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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
549pub struct ExportEdge {
550 pub edge_type: String,
551 pub src: String,
552 pub dst: String,
553 pub derived: bool,
554 /// Rule name that created this edge, if derived. `None` for manual edges.
555 pub rule: Option<String>,
556}
557
558/// Construct the standard write-query result set (columns: created, properties_set, deleted).
559fn write_result_set() -> ResultSet {
560 ResultSet::new(vec![
561 "created".into(),
562 "properties_set".into(),
563 "deleted".into(),
564 ])
565}
566
567fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
568 match op {
569 Operand::Lit(v) => Ok(v.clone()),
570 Operand::Param(name) => params
571 .get(name)
572 .cloned()
573 .ok_or_else(|| GraphError::QueryError {
574 detail: format!("missing parameter `{name}`"),
575 }),
576 _ => Err(GraphError::QueryError {
577 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
578 }),
579 }
580}
581
582fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
583 match op {
584 Operand::Prop { var, .. } | Operand::Var(var) => {
585 if !out.contains(var) {
586 out.push(var.clone());
587 }
588 }
589 Operand::FuncCall { args, .. } => {
590 for arg in args {
591 operand_node_vars(arg, out);
592 }
593 }
594 Operand::BinArith { left, right, .. } => {
595 operand_node_vars(left, out);
596 operand_node_vars(right, out);
597 }
598 Operand::Case { branches, default } => {
599 // Branch conditions reference vars already bound (and mask-filtered)
600 // by the MATCH phase, so collecting from the value operands + ELSE
601 // is sufficient for RETURN-projection var discovery.
602 for (_, value) in branches {
603 operand_node_vars(value, out);
604 }
605 if let Some(d) = default {
606 operand_node_vars(d, out);
607 }
608 }
609 Operand::Lit(_) | Operand::Param(_) => {}
610 }
611}
612
613fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
614 let mut out = Vec::new();
615 for item in items {
616 match &item.value {
617 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
618 if !out.contains(v) {
619 out.push(v.clone());
620 }
621 }
622 RetVal::FuncCall { args, .. } => {
623 for arg in args {
624 operand_node_vars(arg, &mut out);
625 }
626 }
627 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
628 RetVal::Agg { .. } => {}
629 }
630 }
631 out
632}
633
634fn add_var(out: &mut Vec<String>, v: &str) {
635 if !out.iter().any(|x| x == v) {
636 out.push(v.to_string());
637 }
638}
639
640fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
641 let mut out = Vec::new();
642 for p in pats {
643 if let Some(v) = &p.start.var {
644 add_var(&mut out, v);
645 }
646 for (_, dest) in &p.chain {
647 if let Some(v) = &dest.var {
648 add_var(&mut out, v);
649 }
650 }
651 }
652 out
653}
654
655fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
656 let mut out = Vec::new();
657 for p in pats {
658 for (rel, _) in &p.chain {
659 if rel.hops.is_none() {
660 if let Some(v) = &rel.var {
661 add_var(&mut out, v);
662 }
663 }
664 }
665 }
666 out
667}
668
669fn rel_type_alias(var: &str) -> String {
670 format!("__rt_{var}")
671}
672
673fn ret_column_name(item: &RetItem) -> String {
674 if let Some(alias) = &item.alias {
675 return alias.clone();
676 }
677 match &item.value {
678 RetVal::Var(v) => v.clone(),
679 RetVal::Prop { var, field } => format!("{var}.{field}"),
680 RetVal::FuncCall { name, args } => {
681 let arg_strs: Vec<String> = args
682 .iter()
683 .map(|a| match a {
684 Operand::Var(v) => v.clone(),
685 Operand::Prop { var, field } => format!("{var}.{field}"),
686 Operand::Lit(_) => "<lit>".to_string(),
687 Operand::Param(p) => format!("${p}"),
688 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
689 Operand::BinArith { .. } => "<arith>".to_string(),
690 Operand::Case { .. } => "<case>".to_string(),
691 })
692 .collect();
693 format!("{name}({})", arg_strs.join(", "))
694 }
695 RetVal::ScalarExpr(_) => "<expr>".to_string(),
696 RetVal::Agg { .. } => "<agg>".to_string(),
697 }
698}
699
700fn eval_set_return_operand<F: Fs>(
701 db: &GraphDb<F>,
702 match_rs: &ResultSet,
703 row: usize,
704 rel_vars: &[String],
705 op: &Operand,
706 params: &BTreeMap<String, Value>,
707) -> Result<Option<Value>> {
708 match op {
709 Operand::Lit(v) => Ok(Some(v.clone())),
710 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
711 detail: format!("missing parameter `{name}`"),
712 }).map(Some),
713 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
714 detail: format!(
715 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
716 ),
717 }),
718 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
719 Operand::Prop { var, field } => {
720 if rel_vars.iter().any(|r| r == var) {
721 return Ok(None);
722 }
723 let Some(Value::Str(key)) = match_rs.get(row, var) else {
724 return Ok(None);
725 };
726 Ok(db.get_prop(key, field))
727 }
728 Operand::FuncCall { name, args } => {
729 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
730 }
731 Operand::BinArith { op, left, right } => {
732 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
733 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
734 eval_set_return_arith(op, lv, rv)
735 }
736 // CASE is supported in read-query RETURN; in a write-statement RETURN
737 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
738 Operand::Case { .. } => Err(GraphError::QueryError {
739 detail: "CASE is not supported in a write-statement RETURN projection; \
740 use a read query"
741 .into(),
742 }),
743 }
744}
745
746fn eval_set_return_arith(
747 op: &ArithOp,
748 lv: Option<Value>,
749 rv: Option<Value>,
750) -> Result<Option<Value>> {
751 match (lv, rv) {
752 (None, _) | (_, None) => Ok(None),
753 (Some(Value::Int(a)), Some(Value::Int(b))) => {
754 let result = match op {
755 ArithOp::Sub => a.saturating_sub(b),
756 ArithOp::Mul => a.saturating_mul(b),
757 ArithOp::Add => a.saturating_add(b),
758 ArithOp::Div => {
759 if b == 0 {
760 return Err(GraphError::QueryError {
761 detail: "division by zero".into(),
762 });
763 }
764 a.checked_div(b).unwrap_or(i64::MAX)
765 }
766 };
767 Ok(Some(Value::Int(result)))
768 }
769 (Some(lv), Some(rv)) => {
770 let a = match &lv {
771 Value::Float(f) => *f,
772 Value::Int(i) => *i as f64,
773 _ => {
774 return Err(GraphError::QueryError {
775 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
776 })
777 }
778 };
779 let b = match &rv {
780 Value::Float(f) => *f,
781 Value::Int(i) => *i as f64,
782 _ => {
783 return Err(GraphError::QueryError {
784 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
785 })
786 }
787 };
788 let result = match op {
789 ArithOp::Sub => a - b,
790 ArithOp::Mul => a * b,
791 ArithOp::Add => a + b,
792 ArithOp::Div => {
793 if b == 0.0 {
794 return Err(GraphError::QueryError {
795 detail: "division by zero".into(),
796 });
797 }
798 a / b
799 }
800 };
801 Ok(Some(Value::Float(result)))
802 }
803 }
804}
805
806fn eval_set_return_func<F: Fs>(
807 db: &GraphDb<F>,
808 match_rs: &ResultSet,
809 row: usize,
810 rel_vars: &[String],
811 name: &str,
812 args: &[Operand],
813 params: &BTreeMap<String, Value>,
814) -> Result<Option<Value>> {
815 let norm = name.to_ascii_lowercase();
816 if norm == "type" {
817 if args.len() != 1 {
818 return Err(GraphError::QueryError {
819 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
820 });
821 }
822 let Operand::Var(rel) = &args[0] else {
823 return Err(GraphError::QueryError {
824 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
825 });
826 };
827 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
828 }
829 let mut vals = Vec::with_capacity(args.len());
830 for arg in args {
831 vals.push(eval_set_return_operand(
832 db, match_rs, row, rel_vars, arg, params,
833 )?);
834 }
835 match norm.as_str() {
836 "tolower" => {
837 if vals.len() != 1 {
838 return Err(GraphError::QueryError {
839 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
840 });
841 }
842 Ok(vals[0].clone().map(|val| match val {
843 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
844 other => other,
845 }))
846 }
847 "toupper" => {
848 if vals.len() != 1 {
849 return Err(GraphError::QueryError {
850 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
851 });
852 }
853 Ok(vals[0].clone().map(|val| match val {
854 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
855 other => other,
856 }))
857 }
858 "size" => match vals.first().cloned().flatten() {
859 None => Ok(None),
860 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
861 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
862 Some(_) => Ok(None),
863 },
864 "coalesce" => Ok(vals.into_iter().flatten().next()),
865 "abs" => match vals.first().cloned().flatten() {
866 None => Ok(None),
867 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
868 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
869 Some(_) => Ok(None),
870 },
871 "round" => match vals.first().cloned().flatten() {
872 None => Ok(None),
873 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
874 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
875 Some(_) => Ok(None),
876 },
877 "decay" => {
878 if vals.len() != 3 {
879 return Err(GraphError::QueryError {
880 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
881 });
882 }
883 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
884 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
885 (Some(b), Some(a), Some(h)) => {
886 let numeric = |v: Value| -> Result<f64> {
887 match v {
888 Value::Int(n) => Ok(n as f64),
889 Value::Float(f) => Ok(f),
890 other => Err(GraphError::QueryError {
891 detail: format!(
892 "decay() requires numeric arguments, got {other:?}"
893 ),
894 }),
895 }
896 };
897 let b = numeric(b)?;
898 let a = numeric(a)?;
899 let h = numeric(h)?;
900 if h <= 0.0 {
901 return Err(GraphError::QueryError {
902 detail: "decay() requires halflife > 0".into(),
903 });
904 }
905 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
906 }
907 }
908 }
909 _ => Err(GraphError::QueryError {
910 detail: format!(
911 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay"
912 ),
913 }),
914 }
915}
916
917fn eval_set_return_item<F: Fs>(
918 db: &GraphDb<F>,
919 match_rs: &ResultSet,
920 row: usize,
921 rel_vars: &[String],
922 item: &RetItem,
923 params: &BTreeMap<String, Value>,
924) -> Result<Option<Value>> {
925 match &item.value {
926 RetVal::Var(v) => eval_set_return_operand(
927 db,
928 match_rs,
929 row,
930 rel_vars,
931 &Operand::Var(v.clone()),
932 params,
933 ),
934 RetVal::Prop { var, field } => eval_set_return_operand(
935 db,
936 match_rs,
937 row,
938 rel_vars,
939 &Operand::Prop {
940 var: var.clone(),
941 field: field.clone(),
942 },
943 params,
944 ),
945 RetVal::FuncCall { name, args } => {
946 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
947 }
948 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
949 RetVal::Agg { .. } => Err(GraphError::QueryError {
950 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
951 }),
952 }
953}
954
955/// Project user RETURN from original MATCH rows after SET. No rematch.
956fn project_set_return_rows<F: Fs>(
957 db: &GraphDb<F>,
958 rel_vars: &[String],
959 match_rs: &ResultSet,
960 returns: &[RetItem],
961 params: &BTreeMap<String, Value>,
962) -> Result<ResultSet> {
963 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
964 let mut out = ResultSet::new(columns);
965 for row in 0..match_rs.len() {
966 let mut cells = Vec::with_capacity(returns.len());
967 for item in returns {
968 cells.push(eval_set_return_item(
969 db, match_rs, row, rel_vars, item, params,
970 )?);
971 }
972 out.push_row(cells);
973 }
974 Ok(out)
975}
976
977/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
978/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
979/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
980/// Returns `None` for non-list values or lists with non-numeric elements.
981fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
982 match v {
983 Value::List(items) => items
984 .iter()
985 .map(|item| match item {
986 Value::Float(f) => Some(*f),
987 Value::Int(i) => Some(*i as f64),
988 _ => None,
989 })
990 .collect(),
991 _ => None,
992 }
993}
994
995fn make_graph_mut<'a>(
996 ids: &'a IdMap,
997 syms: &'a mut Interner,
998 labels: &'a [u32],
999 props: core_storage::v8::seam::ColumnsView<'a>,
1000 topo: &'a mut Topology,
1001 edge_props: &'a mut EdgeProps,
1002) -> GraphMut<'a> {
1003 GraphMut {
1004 ids,
1005 syms,
1006 labels,
1007 props,
1008 topo,
1009 edge_props,
1010 }
1011}
1012
1013/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1014///
1015/// Takes explicit field references rather than `&self` so the caller can hold
1016/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1017fn build_props_view<'a>(
1018 props: &'a ColumnStore,
1019 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1020) -> core_storage::v8::seam::ColumnsView<'a> {
1021 match base {
1022 None => core_storage::v8::seam::ColumnsView::owned(props),
1023 Some(b) => {
1024 let archived = b
1025 .columns()
1026 .expect("base columns section bounds validated at open");
1027 core_storage::v8::seam::ColumnsView::with_base(props, archived)
1028 }
1029 }
1030}
1031
1032fn build_topo_view<'a>(
1033 overlay: &'a Topology,
1034 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1035) -> core_storage::v8::seam::TopologyView<'a> {
1036 match base {
1037 None => core_storage::v8::seam::TopologyView::owned(overlay),
1038 Some(b) => {
1039 let archived_csr = b
1040 .topology()
1041 .expect("base topology section bounds validated at open");
1042 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1043 }
1044 }
1045}
1046
1047/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1048///
1049/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1050/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1051/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1052/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1053/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1054#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1055pub enum FsyncPolicy {
1056 /// Every WAL commit calls `fs.sync` (today's behavior).
1057 #[default]
1058 Strict,
1059 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1060 /// this policy is set on the database.
1061 Batched,
1062 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1063 Relaxed,
1064}
1065
1066/// A precondition for a compare-and-set batch write.
1067///
1068/// All preconditions in a [`GraphDb::write_batch_cas`] or
1069/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1070/// any operation in the batch is applied. If any precondition fails, the
1071/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1072/// is written.
1073///
1074/// # Touch definition
1075///
1076/// A node's last-change commit (`last_changed`) is updated when any of the
1077/// following state-changing WAL records touch it:
1078///
1079/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1080/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1081/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1082/// endpoints (an edge change touches both sides).
1083/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1084/// for deleted keys so the pre-deletion entry is never observed.
1085///
1086/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1087/// state no-ops. The underlying mutation that triggered rule firing already
1088/// updated the relevant nodes' last-change entries. Rule-management records
1089/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1090/// do not touch any node's last-change.
1091#[derive(Debug, Clone, PartialEq, Eq)]
1092pub enum Precondition {
1093 /// The node's last-change commit must equal `expected`.
1094 ///
1095 /// Fails with [`GraphError::CasConflict`] when:
1096 /// - The node does not exist (`last_changed` returns `None`), or
1097 /// - The recorded commit seq does not match `expected`.
1098 NodeUnchangedSince { key: String, expected: u64 },
1099 /// The node must not exist (not inserted, or already deleted).
1100 ///
1101 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1102 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1103 NodeAbsent { key: String },
1104}
1105
1106pub struct GraphDb<F: Fs> {
1107 fs: F,
1108 ids: IdMap,
1109 syms: Interner,
1110 topo: Topology,
1111 props: ColumnStore,
1112 labels: Vec<u32>, // node id -> label symbol
1113 edge_props: EdgeProps,
1114 engine: RuleEngine,
1115 view_store: ViewStore,
1116 /// Incremental inverted index for full-text-lite search.
1117 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1118 fulltext: FulltextIndex,
1119 /// Opt-in equality index over scalar node properties.
1120 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1121 /// open end (mirrors `fulltext`).
1122 prop_index: PropertyIndex,
1123 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1124 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1125 fsync: FsyncPolicy,
1126 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1127 /// call increments this once; all events emitted from that call share the same
1128 /// `commit_seq` value.
1129 commit_seq: u64,
1130 /// RBAC role definitions loaded from `roles.json` at open.
1131 ///
1132 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1133 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1134 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1135 roles: Option<Vec<RoleDef>>,
1136 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1137 /// distribute_events call.
1138 subscriptions: Vec<SubEntry>,
1139 /// Live query subscriptions. Re-executed on every commit when non-empty.
1140 /// Dead `Weak` entries are pruned inside `distribute_events`.
1141 query_subscriptions: Vec<QuerySubEntry>,
1142 /// Queue capacity for new subscriptions created by this db. Default is
1143 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1144 /// to test Lagged behaviour with small queues.
1145 sub_capacity: usize,
1146 /// True for as-of instances opened via [`GraphDb::open_at`].
1147 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1148 /// when this flag is set.
1149 read_only: bool,
1150 /// Total WAL commit count at the time [`open_at`] was called.
1151 /// 0 for normal (non-as-of) instances.
1152 total_wal_commits: u64,
1153 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1154 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1155 /// reads go through a merged `TopologyView`. `self.props` is always
1156 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1157 base: Option<Arc<core_storage::v8::MappedBase>>,
1158 // ── MVCC epoch reader state ───────────────────────────────────────────────
1159 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1160 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1161 /// `None` only between struct creation and the first fold.
1162 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1163 /// Per-commit deltas accumulated since the last fold.
1164 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1165 /// How many commits have occurred since the last fold.
1166 commits_since_fold: usize,
1167 /// When true, `log_then_apply_with` buffers event notifications instead of
1168 /// firing them immediately. Used by the group-commit drain thread to defer
1169 /// events until after the group fsync (R2: durability before notification).
1170 /// Cleared to false once the drain thread flushes or discards the buffer.
1171 defer_events: bool,
1172 /// Buffered events accumulated while `defer_events` is true.
1173 deferred_events: Vec<DeferredEvent>,
1174 /// Set to true by the group-commit drain thread when a group fsync fails
1175 /// after WAL truncation. All subsequent mutation attempts return an IO
1176 /// error until the database is reopened.
1177 degraded: bool,
1178 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1179 /// HNSW, and IVF sections from the mmap base into the engine's retained
1180 /// fields. `false` on all opens until first use; always `true` for non-V8
1181 /// opens (base is None, fast-path sets flag immediately).
1182 v8_sections_loaded: std::sync::atomic::AtomicBool,
1183 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1184 v8_sections_mutex: std::sync::Mutex<()>,
1185 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1186 /// the node was last modified by commit `seq`.
1187 ///
1188 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1189 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1190 /// nodes return `None` from `last_changed` until they are next mutated.
1191 ///
1192 /// See [`Precondition`] for the full touch definition.
1193 last_change: HashMap<u32, u64>,
1194 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1195 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1196 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1197 wal_archive_retention: Option<u32>,
1198 /// Global frame index of the first commit that is still reachable through
1199 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1200 /// Default 0 = all history reachable.
1201 wal_horizon_floor: u64,
1202 /// True when the surviving archive chain forms a continuous WAL history
1203 /// starting from the store's first commit (the genesis chain).
1204 ///
1205 /// `open_at` may replay archive-resident commits from empty state only when
1206 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1207 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1208 /// already exist (breaks the chain for subsequent archives), or
1209 /// - any archive is pruned (floor advances past zero).
1210 ///
1211 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1212 archive_genesis_chain: bool,
1213 /// Transient write-authz context set by `write_batch_authz` /
1214 /// `query_write_authz` for the duration of ONE mutation call.
1215 /// Always `None` at rest. Never serialized, never WAL-replayed.
1216 pending_write_authz: Option<WriteAuthz>,
1217 /// Slow-query threshold in milliseconds. 0 = disabled.
1218 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1219 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1220 /// — env vars are process-global and race parallel test threads).
1221 slow_query_threshold_ms: u64,
1222 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1223 /// can record entries without requiring `&mut self`).
1224 slow_queries: std::sync::Mutex<SlowQueryLog>,
1225 /// Instant at which the database was opened (used by `/metrics` uptime).
1226 started_at: std::time::Instant,
1227}
1228
1229/// One group of deferred event notifications, held until the group fsync
1230/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1231struct DeferredEvent {
1232 rec: core_storage::WalRecord,
1233 engine_deltas: Vec<EngineEdgeDelta>,
1234 seq: u64,
1235 ingest: Option<(String, usize)>,
1236}
1237
1238/// Options for [`GraphDb::open_with_options`].
1239#[derive(Clone, Copy, Debug)]
1240pub struct OpenOptions {
1241 /// Rewrite an old-format snapshot to the current VERSION after a
1242 /// successful load (default `true`). The old snapshot is kept as
1243 /// `snapshot.bin.bak` until the next clean open at the current version,
1244 /// at which point the `.bak` is deleted.
1245 ///
1246 /// Set to `false` to open a store without touching any on-disk files
1247 /// (useful for read-only inspection of a store at an older format).
1248 pub auto_migrate: bool,
1249
1250 /// Write the valid WAL prefix back over a torn tail on open (default
1251 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1252 ///
1253 /// Set to `false` for an unattended reader. The valid prefix is still
1254 /// decoded and replayed in memory, but nothing is written: the store has
1255 /// no cross-process lock, so a reader that opens while another process is
1256 /// mid-append would otherwise discard a frame that writer believes
1257 /// durable. `mushroomdb recall`, which runs on every prompt, passes
1258 /// `false` for exactly this reason.
1259 pub repair_wal: bool,
1260}
1261
1262impl Default for OpenOptions {
1263 fn default() -> Self {
1264 Self {
1265 auto_migrate: true,
1266 repair_wal: true,
1267 }
1268 }
1269}
1270
1271/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1272///
1273/// `None` at the call site = full authority (today's zero-cost behavior).
1274/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1275/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1276/// record is built. A denial returns an error with no WAL frame written.
1277///
1278/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1279/// hidden-node existence to callers.
1280#[derive(Clone, Debug)]
1281pub struct WriteAuthz {
1282 pub role: String,
1283 pub scope: WriteScope,
1284 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1285 /// Always `Omit`-mode — never `Stub`.
1286 pub mask: crate::mask::NodeMask,
1287}
1288
1289/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1290///
1291/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1292/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1293/// syncs the directory entry. This is the only correct path for writing the
1294/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1295/// the directory sync.
1296pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1297 use core_storage::fs::{FileId, Fs as _};
1298 RealFs::new(dir)
1299 .map_err(core_storage::GraphError::Io)?
1300 .write_atomic(FileId::SnapshotBak, bytes)
1301 .map_err(core_storage::GraphError::Io)
1302}
1303
1304/// Return the on-disk snapshot format version without decoding the full snapshot.
1305///
1306/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1307/// snapshot file exists (WAL-only store). Returns an error if the header is
1308/// malformed.
1309pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1310 use std::io::Read as _;
1311 let path = dir.join("snapshot.bin");
1312 let mut header = [0u8; 6];
1313 let n = match std::fs::File::open(&path) {
1314 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1315 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1316 Err(e) => return Err(core_storage::GraphError::Io(e)),
1317 };
1318 core_storage::snapshot::peek_version(&header[..n])
1319}
1320
1321/// Options for [`GraphDb::snapshot_with`].
1322#[derive(Debug, Clone, Default)]
1323pub struct SnapshotOptions {
1324 /// When `true`, the WAL is preserved after the snapshot write.
1325 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1326 /// When `false` (the default), the WAL is truncated to a minimal
1327 /// baseline so cold-start replay stays fast.
1328 pub keep_wal: bool,
1329 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1330 /// before a fresh WAL baseline is written (history-preserving snapshot).
1331 ///
1332 /// This is the feature opt-in: `false` (the default) leaves the existing
1333 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1334 /// precedence over `keep_wal` when both are set.
1335 ///
1336 /// Archives can be scanned by [`GraphDb::node_history`],
1337 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1338 /// [`GraphDb::open_at`], extending the reachable history horizon across
1339 /// snapshot boundaries.
1340 pub archive_wal: bool,
1341}
1342
1343/// Derive the scan-label sym for the commit-skip fast-path.
1344///
1345/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1346/// or `IndexIntersect`) with a concrete label string, then interns it.
1347///
1348/// Returns `None` in all cases where skipping is unsafe:
1349/// - Any `Expand` op is present (edge traversal; edges change results regardless
1350/// of node labels).
1351/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1352/// - No recognizable leading scan op is found.
1353///
1354/// This is the conservative v0.4.3 boundary. The caller stores the result in
1355/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1356fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1357 // Any Expand → must always re-execute (edges can change join results).
1358 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1359 return None;
1360 }
1361 for op in ops {
1362 match op {
1363 PlanOp::ScanLabel {
1364 label: Some(label), ..
1365 } => return Some(syms.intern(label)),
1366 PlanOp::IndexScan {
1367 label: Some(label), ..
1368 } => return Some(syms.intern(label)),
1369 PlanOp::IndexIntersect {
1370 label: Some(label), ..
1371 } => return Some(syms.intern(label)),
1372 _ => {}
1373 }
1374 }
1375 None
1376}
1377
1378impl GraphDb<RealFs> {
1379 /// Open the database at `dir` with default options.
1380 ///
1381 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1382 /// Old-format snapshots (V5, V6) are automatically migrated to the
1383 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1384 pub fn open(dir: &std::path::Path) -> Result<Self> {
1385 Self::open_with_options(dir, OpenOptions::default())
1386 }
1387
1388 /// Open the database at `dir` with explicit options.
1389 ///
1390 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1391 /// snapshot is an older format version, this function:
1392 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1393 /// + fsynced) before any modification.
1394 /// 2. Rewrites `snapshot.bin` at the current format version via
1395 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1396 ///
1397 /// If migration fails the error is returned and the original files are
1398 /// intact (the `.bak` was written before the new snapshot was attempted).
1399 ///
1400 /// A clean open that finds the snapshot already at the current version
1401 /// deletes any leftover `.bak` file.
1402 ///
1403 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1404 ///
1405 /// `opts.repair_wal` controls the other write this function can make; see
1406 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1407 /// no file on disk.
1408 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1409 // Header-only peek — 6 bytes, no full decode.
1410 let snap_version = snapshot_version_at(dir)?;
1411
1412 // Full load: decode snapshot + replay WAL + rebuild indexes.
1413 let mut db = Self::open_with_repair(RealFs::new(dir)?, opts.repair_wal)?;
1414
1415 if opts.auto_migrate {
1416 match snap_version {
1417 Some(ver) if ver < core_storage::snapshot::VERSION => {
1418 let _tm = std::time::Instant::now();
1419 // Copy the original snapshot to .bak at OS level — no in-memory
1420 // buffer required for a 2+ GiB file.
1421 //
1422 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1423 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1424 // A torn .bak on crash is acceptable because the original
1425 // snapshot.bin is the authoritative source until after the rename.
1426 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1427 .map_err(core_storage::GraphError::Io)?;
1428 trace_migrate!("bak copy done", _tm);
1429 // Rewrite snapshot at current version; keep WAL intact.
1430 db.snapshot_with(SnapshotOptions {
1431 keep_wal: true,
1432 ..SnapshotOptions::default()
1433 })?;
1434 trace_migrate!("snapshot_with done", _tm);
1435 }
1436 Some(_) => {
1437 // Already current version: remove any leftover .bak.
1438 let bak = dir.join("snapshot.bin.bak");
1439 if bak.exists() {
1440 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1441 }
1442 }
1443 None => {
1444 // WAL-only store — nothing to migrate on open.
1445 }
1446 }
1447 }
1448
1449 Ok(db)
1450 }
1451
1452 /// Open a read-only view of the database as it existed after `commit`.
1453 ///
1454 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1455 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1456 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1457 ///
1458 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1459 /// so as-of can only reach commits recorded in the current WAL (those
1460 /// written after the most recent snapshot, or all commits if no snapshot
1461 /// was ever taken). Commit 0 in `open_at` always refers to the first
1462 /// frame in the WAL that exists on disk, not the first ever write to the
1463 /// database. When the on-disk snapshot recorded that it truncated the
1464 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1465 /// before frame replay, so the as-of view includes all pre-snapshot data.
1466 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1467 /// are ignored and replay is WAL-only, as before.
1468 ///
1469 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1470 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1471 /// `stats()` work normally.
1472 ///
1473 /// # Errors
1474 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1475 /// when the WAL is empty after a snapshot).
1476 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1477 Self::open_at_with(RealFs::new(dir)?, commit)
1478 }
1479
1480 /// Run a **read-only** Cypher query against the graph as it existed at
1481 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1482 /// of this store's directory at that commit and executes the read there.
1483 ///
1484 /// The current instance is unaffected. Write statements are rejected (the
1485 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1486 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1487 /// state. Prefer this over holding many historical instances open.
1488 ///
1489 /// # Errors
1490 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1491 /// - A query error for a malformed or write query.
1492 pub fn query_at(
1493 &self,
1494 commit: u64,
1495 cypher: &str,
1496 params: &std::collections::BTreeMap<String, Value>,
1497 ) -> Result<ResultSet> {
1498 let dir = self.fs.dir().to_path_buf();
1499 let temporal = Self::open_at(&dir, commit)?;
1500 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1501 detail: format!("lex: {e}"),
1502 })?) {
1503 return Err(GraphError::QueryError {
1504 detail: "query_at is read-only: write statements are not permitted in a \
1505 time-travel query"
1506 .into(),
1507 });
1508 }
1509 temporal.query(cypher, params)
1510 }
1511}
1512
1513impl<F: Fs> GraphDb<F> {
1514 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1515 pub fn open_with(fs: F) -> Result<Self> {
1516 Self::open_with_repair(fs, true)
1517 }
1518
1519 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1520 /// prefix without writing the truncation back. See
1521 /// [`OpenOptions::repair_wal`].
1522 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1523 let mut db = Self {
1524 fs,
1525 ids: IdMap::new(),
1526 syms: Interner::new(),
1527 topo: Topology::new(),
1528 props: ColumnStore::new(),
1529 labels: Vec::new(),
1530 edge_props: EdgeProps::new(),
1531 engine: RuleEngine::new(),
1532 view_store: ViewStore::new(),
1533 fulltext: FulltextIndex::new(),
1534 prop_index: PropertyIndex::new(),
1535 event_sink: None,
1536 fsync: FsyncPolicy::Strict,
1537 commit_seq: 0,
1538 roles: Some(vec![]),
1539 subscriptions: Vec::new(),
1540 query_subscriptions: Vec::new(),
1541 sub_capacity: DEFAULT_SUB_CAPACITY,
1542 read_only: false,
1543 total_wal_commits: 0,
1544 base: None,
1545 fold_overlay: None,
1546 delta_tail: Vec::new(),
1547 commits_since_fold: 0,
1548 defer_events: false,
1549 deferred_events: Vec::new(),
1550 degraded: false,
1551 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1552 v8_sections_mutex: std::sync::Mutex::new(()),
1553 last_change: HashMap::new(),
1554 wal_archive_retention: None,
1555 wal_horizon_floor: 0,
1556 archive_genesis_chain: false,
1557 pending_write_authz: None,
1558 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1559 .ok()
1560 .and_then(|v| v.parse().ok())
1561 .unwrap_or(100),
1562 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1563 entries: std::collections::VecDeque::new(),
1564 total: 0,
1565 }),
1566 started_at: std::time::Instant::now(),
1567 };
1568 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1569 db.archive_genesis_chain = db.fs.has_genesis_marker();
1570 // Opening cleanup: remove orphaned archives — archives whose frames all
1571 // fall below the horizon floor. Orphans arise when a crash interrupted
1572 // the retention-prune sequence after the floor was written but before
1573 // all surplus archives were deleted. Safe to delete: floor already
1574 // accounts for their frames.
1575 db.cleanup_orphaned_archives()?;
1576 let _t0 = std::time::Instant::now();
1577 // Peek 6 bytes to determine snapshot version without reading the full
1578 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1579 // default impl reads all bytes and truncates (still correct).
1580 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1581 let is_v8 = snap_header.len() >= 6
1582 && &snap_header[0..4] == b"GDB1"
1583 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1584 == core_storage::snapshot::VERSION_8;
1585 if is_v8 {
1586 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1587 // No 2.4GB heap Vec is allocated on RealFs.
1588 let mapped = Arc::new(
1589 if let Some(snap_path) = db.fs.snapshot_path() {
1590 core_storage::v8::MappedBase::map(&snap_path)
1591 } else {
1592 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1593 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1594 }
1595 .map_err(|e| GraphError::Corrupt {
1596 detail: format!("v8: mmap open: {e:?}"),
1597 })?,
1598 );
1599 db.restore_v8_base(Arc::clone(&mapped))?;
1600 trace_open!("restore_v8_base", _t0);
1601 db.base = Some(mapped);
1602 trace_open!("base assigned", _t0);
1603 } else if !snap_header.is_empty() {
1604 // Legacy V5-V7: full read required for decode.
1605 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1606 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1607 db.restore_snapshot_state(state)?;
1608 }
1609 }
1610 // else: snap_header is empty = no snapshot file, fresh store.
1611 //
1612 // Seed commit_seq from the highest seq persisted in last_change so that
1613 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1614 // already stored in the snapshot. Without this, a db with one snapshot
1615 // commit would save last_change["a"]=1, then on reopen the first WAL
1616 // frame would replay at seq=1 again — colliding and making WAL-tail
1617 // mutations indistinguishable from the snapshot baseline.
1618 //
1619 // Safety invariant (seq-recycling):
1620 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1621 // last_change because they belonged to a previous db lifetime — a new
1622 // db starts at commit_seq=0 with an empty last_change. Therefore no
1623 // CAS precondition can carry a recycled seq as its `expected` value
1624 // and accidentally match a live node's last_change entry.
1625 //
1626 // `expected:0` on a deleted-then-reinserted node:
1627 // After deletion, last_changed() returns None; callers that call
1628 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1629 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1630 // expected=0 correctly conflicts. The only way to observe actual=0 in
1631 // a CasConflict would be a caller that invented expected=0 without ever
1632 // calling last_changed() — unreachable via the documented API contract.
1633 if let Some(&max_seq) = db.last_change.values().max() {
1634 db.commit_seq = db.commit_seq.max(max_seq);
1635 }
1636 let bytes = db.fs.read(FileId::Wal)?;
1637 let (records, valid_len) = decode_all(&bytes);
1638 // The valid prefix is replayed either way; `repair_wal` only decides
1639 // whether the truncation is written back. A reader that races a live
1640 // appender must not persist a truncation the writer never asked for.
1641 if valid_len < bytes.len() && repair_wal {
1642 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1643 }
1644 // WAL-present path: build indexes eagerly BEFORE replay so that the
1645 // first replayed record does not trigger the lazy-init guard (which
1646 // would call reindex_all_load_ivf on an empty graph, defeating the
1647 // point of restoring IVF/HNSW blobs from the snapshot).
1648 if !records.is_empty() {
1649 db.ensure_v8_base_sections_loaded();
1650 trace_open!("lazy sections loaded (WAL path)", _t0);
1651 db.engine.consume_retained_state_eager(
1652 &db.ids,
1653 &db.syms,
1654 &db.labels,
1655 build_props_view(&db.props, &db.base),
1656 );
1657 }
1658 for rec in records {
1659 db.apply(&rec)?;
1660 // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1661 // No subscriber exists yet; discard is correct.
1662 let _ = db.engine.drain_deltas();
1663 // Track commit_seq during replay so last_change entries are
1664 // consistent with the seqs assigned by log_then_apply_with on
1665 // subsequent live commits. After N replayed frames, commit_seq=N;
1666 // live commits begin at N+1.
1667 db.commit_seq += 1;
1668 let replay_seq = db.commit_seq;
1669 db.update_last_change_from_rec(&rec, replay_seq);
1670 }
1671 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1672 // this assert catches the regression in debug builds immediately.
1673 debug_assert_eq!(
1674 db.engine.pending_delta_count(),
1675 0,
1676 "pending_deltas non-empty after replay — \
1677 per-frame drain must run inside the loop to keep memory O(1)"
1678 );
1679 // T2 note: the per-frame drain IS the suppression seam for replay.
1680 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1681 // replaying subscribers; the mechanism is already in place.
1682 let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1683 trace_open!("wal replay done", _t0);
1684 // Rebuild view values after WAL replay only when there is no V8 base.
1685 // With a V8 base, view values are correct in the snapshot and are updated
1686 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1687 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1688 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1689 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1690 // base value).
1691 if db.base.is_none() {
1692 let topo_view = TopologyView::owned(&db.topo);
1693 db.view_store
1694 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1695 }
1696 // Rebuild full-text index after WAL replay. Corrects drift from
1697 // per-record incremental apply during replay.
1698 db.fulltext.rebuild_all(
1699 &db.ids,
1700 &db.labels,
1701 &db.syms,
1702 build_props_view(&db.props, &db.base),
1703 );
1704 db.prop_index.rebuild_all(
1705 &db.ids,
1706 &db.labels,
1707 &db.syms,
1708 build_props_view(&db.props, &db.base),
1709 );
1710 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1711 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1712 db.roles = Self::load_roles_from_fs(&db.fs)?;
1713 // Capture the initial MVCC fold so reader() is ready immediately.
1714 db.fold_now();
1715 trace_open!("open_with complete", _t0);
1716 Ok(db)
1717 }
1718
1719 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1720 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1721 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
1722 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1723 /// Restore all persisted state from a decoded snapshot. Shared by
1724 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1725 fn restore_snapshot_state(
1726 &mut self,
1727 state: core_storage::snapshot::SnapshotState,
1728 ) -> Result<()> {
1729 self.ids = state.ids;
1730 self.syms = state.syms;
1731 self.topo = state.topo;
1732 self.props = state.props;
1733 self.labels = state.labels;
1734 self.edge_props = state.edge_props;
1735 // Cross-section label integrity for V5/V7 snapshots: same invariants as
1736 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
1737 // out-of-range sym ids, or a sentinel label on a live node would otherwise
1738 // open successfully and panic later in `NodeRef::label()` or
1739 // `neighborhood_masked()`. Catching it here turns those into typed
1740 // `GraphError::Corrupt` at open time.
1741 {
1742 let ids_len = self.ids.len();
1743 if self.labels.len() != ids_len {
1744 return Err(GraphError::Corrupt {
1745 detail: format!(
1746 "snapshot: labels vec has {} entries but id table has {} total slots",
1747 self.labels.len(),
1748 ids_len,
1749 ),
1750 });
1751 }
1752 let syms_len = self.syms.len() as u32;
1753 for (i, &sym) in self.labels.iter().enumerate() {
1754 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1755 if sym == u32::MAX {
1756 if !is_tombstoned {
1757 return Err(GraphError::Corrupt {
1758 detail: format!(
1759 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1760 ),
1761 });
1762 }
1763 } else if sym >= syms_len {
1764 return Err(GraphError::Corrupt {
1765 detail: format!(
1766 "snapshot: label at id slot {i} references sym {sym} \
1767 which is out of interner range ({syms_len})"
1768 ),
1769 });
1770 }
1771 }
1772 }
1773 let defs: Vec<RuleDef> = state
1774 .rule_defs
1775 .iter()
1776 .map(|b| {
1777 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1778 detail: format!("snapshot rule_def deserialize: {e}"),
1779 })
1780 })
1781 .collect::<Result<Vec<_>>>()?;
1782 self.engine =
1783 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1784 // Candidate indexes are rebuilt lazily on the first mutation (see
1785 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
1786 // snapshot are retained without deserializing so that:
1787 // - clean-open (empty WAL): indexes stay empty; blobs load on first
1788 // ANN query via ensure_hnsw_loaded, or on first mutation via the
1789 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1790 // - WAL-present: open_with calls consume_retained_state_eager before
1791 // replay so HNSW/IVF are live before any record fires the hooks.
1792 let ivf_bytes = if state.ivf_state.is_empty() {
1793 Vec::new()
1794 } else {
1795 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1796 };
1797 // Store blobs without eagerly deserializing them.
1798 self.engine
1799 .store_snapshot_state(state.hnsw_state, ivf_bytes);
1800 // Restore view defs from snapshot (V5).
1801 // The ColumnStore already contains view values from the snapshot;
1802 // use restore_view (no collision check, no backfill) so the store
1803 // is aware of the definitions. rebuild_all runs after WAL replay.
1804 for def_bytes in &state.view_defs {
1805 let def: ViewDef =
1806 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1807 detail: format!("snapshot view_def deserialize: {e}"),
1808 })?;
1809 self.view_store
1810 .restore_view(def)
1811 .map_err(|e| GraphError::Corrupt {
1812 detail: format!("snapshot view restore: {e}"),
1813 })?;
1814 }
1815 Ok(())
1816 }
1817
1818 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1819 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1820 ///
1821 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1822 /// deserialization and view rebuild have access to all column data.
1823 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1824 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1825 detail: format!("v8: ids section: {e:?}"),
1826 })?);
1827 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1828 detail: format!("v8: syms section: {e:?}"),
1829 })?);
1830
1831 // C1: self.props is left as an empty overlay. Column reads go through
1832 // props_view() (ColumnsView::with_base), which consults the archived base
1833 // section zero-copy. This avoids the O(columns) heap copy at every open.
1834
1835 // self.topo deliberately left as Topology::new() — overlay path.
1836
1837 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1838 detail: format!("v8: meta section: {e:?}"),
1839 })?)
1840 .map_err(|e| GraphError::Corrupt {
1841 detail: format!("v8: meta decode: {e:?}"),
1842 })?;
1843 self.labels = meta.labels;
1844 // Cross-section label integrity: labels must cover every id slot (live
1845 // and tombstoned), every non-sentinel sym must be within the interner's
1846 // bound, and no live (non-tombstoned) node may carry the u32::MAX
1847 // sentinel label. Without this check, a crafted snapshot where the META
1848 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1849 // sym ids, or a sentinel label on a live node, would open successfully
1850 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1851 // related read paths. Catching the inconsistency here converts those
1852 // panics into typed `GraphError::Corrupt` at open time.
1853 {
1854 let ids_len = self.ids.len();
1855 if self.labels.len() != ids_len {
1856 return Err(GraphError::Corrupt {
1857 detail: format!(
1858 "v8: labels section has {} entries but id table has {} total slots",
1859 self.labels.len(),
1860 ids_len,
1861 ),
1862 });
1863 }
1864 let syms_len = self.syms.len() as u32;
1865 for (i, &sym) in self.labels.iter().enumerate() {
1866 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1867 if sym == u32::MAX {
1868 // Sentinel is only valid for tombstoned slots.
1869 if !is_tombstoned {
1870 return Err(GraphError::Corrupt {
1871 detail: format!(
1872 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1873 ),
1874 });
1875 }
1876 } else if sym >= syms_len {
1877 return Err(GraphError::Corrupt {
1878 detail: format!(
1879 "v8: label at id slot {i} references sym {sym} \
1880 which is out of interner range ({syms_len})"
1881 ),
1882 });
1883 }
1884 }
1885 }
1886 // C3: self.edge_props stays as an empty overlay. Reads go through
1887 // edge_props_view() which consults the mmap'd base section zero-copy
1888 // via EdgePropsView::with_base. No heap decode at open time.
1889
1890 // Restore rule engine.
1891 let (rule_def_bytes, rule_tripped, rule_fires) =
1892 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1893 GraphError::Corrupt {
1894 detail: format!("v8: rules_meta section: {e:?}"),
1895 }
1896 })?);
1897 let defs: Vec<RuleDef> = rule_def_bytes
1898 .iter()
1899 .map(|b| {
1900 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1901 detail: format!("v8: rule_def deserialize: {e}"),
1902 })
1903 })
1904 .collect::<Result<Vec<_>>>()?;
1905 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1906 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1907 // `ensure_v8_base_sections_loaded` reads them on first use from
1908 // `self.base` (set by the caller immediately after this returns).
1909 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1910
1911 // Restore view definitions.
1912 let view_defs =
1913 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1914 detail: format!("v8: views section: {e:?}"),
1915 })?);
1916 for def_bytes in &view_defs {
1917 let def: ViewDef =
1918 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1919 detail: format!("v8: view_def deserialize: {e}"),
1920 })?;
1921 self.view_store
1922 .restore_view(def)
1923 .map_err(|e| GraphError::Corrupt {
1924 detail: format!("v8: view restore: {e}"),
1925 })?;
1926 }
1927 // Load the last-change map from section 11 (small section; load eagerly).
1928 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1929 // in that case and `decode_last_change_bytes` returns an empty map.
1930 let last_change_raw = mapped
1931 .last_change_bytes()
1932 .map_err(|e| GraphError::Corrupt {
1933 detail: format!("v8: last_change section: {e:?}"),
1934 })?;
1935 self.last_change = decode_last_change_bytes(last_change_raw);
1936
1937 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1938 // the file. Pure bounds check — no bytes read, no page faults triggered.
1939 // Catches truncated snapshots at open time before the lazy deferred reads.
1940 mapped.validate_section_bounds().map_err(|e| match e {
1941 GraphError::Corrupt { detail } => GraphError::Corrupt {
1942 detail: format!("v8: section bounds: {detail}"),
1943 },
1944 other => other,
1945 })?;
1946 Ok(())
1947 }
1948
1949 /// Read provenance, HNSW, and IVF sections from the mmap base into the
1950 /// engine's retained fields on first call. Subsequent calls are a no-op
1951 /// (AtomicBool fast-path).
1952 ///
1953 /// Must be called before any code path that reads or mutates engine
1954 /// provenance, HNSW, or IVF state:
1955 /// - WAL replay (before `consume_retained_state_eager`)
1956 /// - First mutation (`log_then_apply_with`)
1957 /// - Read-only paths (`stats`, `explain`, `node_edges`)
1958 /// - Snapshot (`snapshot_with`)
1959 ///
1960 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1961 fn ensure_v8_base_sections_loaded(&self) {
1962 use std::sync::atomic::Ordering;
1963 if self.v8_sections_loaded.load(Ordering::Acquire) {
1964 return;
1965 }
1966 let _guard = self
1967 .v8_sections_mutex
1968 .lock()
1969 .expect("v8 sections mutex poisoned");
1970 if self.v8_sections_loaded.load(Ordering::Acquire) {
1971 return; // another caller populated while we waited
1972 }
1973 let _t = std::time::Instant::now();
1974 if let Some(base) = &self.base {
1975 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1976 // Bounds are already validated at open time (restore_v8_base →
1977 // validate_section_bounds) — unreachable post-validate_section_bounds;
1978 // unwrap_or_default is a safety belt against impossible errors.
1979 let prov_bytes = base
1980 .provenance_raw_bytes()
1981 .map(|b| b.to_vec())
1982 .unwrap_or_default();
1983 self.engine.store_provenance_bytes(prov_bytes);
1984 // HNSW: decode rkyv blobs into owned map.
1985 let hnsw_state = base
1986 .hnsw_section()
1987 .map(archived_hnsw_to_owned)
1988 .unwrap_or_default();
1989 // IVF: raw bincode bytes; deserialized on first mutation/query.
1990 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1991 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1992 }
1993 self.v8_sections_loaded.store(true, Ordering::Release);
1994 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1995 eprintln!(
1996 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1997 _t.elapsed()
1998 );
1999 }
2000 }
2001
2002 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2003 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2004 /// the full merged topology without going through `self.view()`.
2005 fn topo_view(&self) -> TopologyView<'_> {
2006 match self.base {
2007 None => TopologyView::owned(&self.topo),
2008 Some(ref base) => {
2009 // SAFETY: base lives as long as self; section bounds validated at open.
2010 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2011 let archived = base
2012 .topology()
2013 .expect("base topology section bounds validated at open");
2014 TopologyView::with_base(&self.topo, archived)
2015 }
2016 }
2017 }
2018
2019 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2020 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2021 /// overlay first, then fall through to the archived base section zero-copy.
2022 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2023 match self.base {
2024 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2025 Some(ref base) => {
2026 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2027 let archived = base
2028 .columns()
2029 .expect("base columns section bounds validated at open");
2030 core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
2031 }
2032 }
2033 }
2034
2035 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2036 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2037 ///
2038 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2039 /// through to the archived base section zero-copy. Tombstones in the
2040 /// overlay mask deleted-from-base entries.
2041 fn edge_props_view(&self) -> EdgePropsView<'_> {
2042 match self.base {
2043 None => EdgePropsView::owned(&self.edge_props),
2044 Some(ref base) => {
2045 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2046 let archived = base
2047 .edge_props_section()
2048 .expect("base edge_props section bounds validated at open");
2049 EdgePropsView::with_base(&self.edge_props, archived)
2050 }
2051 }
2052 }
2053
2054 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2055 let mut db = Self {
2056 fs,
2057 ids: IdMap::new(),
2058 syms: Interner::new(),
2059 topo: Topology::new(),
2060 props: ColumnStore::new(),
2061 labels: Vec::new(),
2062 edge_props: EdgeProps::new(),
2063 engine: RuleEngine::new(),
2064 view_store: ViewStore::new(),
2065 fulltext: FulltextIndex::new(),
2066 prop_index: PropertyIndex::new(),
2067 event_sink: None,
2068 fsync: FsyncPolicy::Strict,
2069 commit_seq: 0,
2070 roles: Some(vec![]),
2071 subscriptions: Vec::new(),
2072 query_subscriptions: Vec::new(),
2073 sub_capacity: DEFAULT_SUB_CAPACITY,
2074 read_only: false, // set to true after replay
2075 total_wal_commits: 0,
2076 base: None,
2077 fold_overlay: None,
2078 delta_tail: Vec::new(),
2079 commits_since_fold: 0,
2080 defer_events: false,
2081 deferred_events: Vec::new(),
2082 degraded: false,
2083 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2084 v8_sections_mutex: std::sync::Mutex::new(()),
2085 last_change: HashMap::new(),
2086 wal_archive_retention: None,
2087 wal_horizon_floor: 0,
2088 archive_genesis_chain: false,
2089 pending_write_authz: None,
2090 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2091 .ok()
2092 .and_then(|v| v.parse().ok())
2093 .unwrap_or(100),
2094 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2095 entries: std::collections::VecDeque::new(),
2096 total: 0,
2097 }),
2098 started_at: std::time::Instant::now(),
2099 };
2100 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2101 db.archive_genesis_chain = db.fs.has_genesis_marker();
2102 // Same orphaned-archive cleanup as open_with: floor was written first
2103 // during pruning, so a crash may have left stale archives below floor.
2104 db.cleanup_orphaned_archives()?;
2105 // Collect archive frames (oldest-first) and live WAL frames.
2106 // Archives represent pre-snapshot history; the snapshot captures the
2107 // cumulative state at the time of archiving. Crash-window guarantee:
2108 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2109 // B: crash after rename, before new WAL → archive present, WAL
2110 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2111 // C: crash after new baseline WAL written → normal post-archive.
2112 let archive_ns = db.fs.list_archives()?;
2113 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2114 for n in &archive_ns {
2115 let arc_bytes = db.fs.read_archive(*n)?;
2116 let (arc_frames, _) = decode_all(&arc_bytes);
2117 archive_frames_all.extend(arc_frames);
2118 }
2119 let total_archive_frames = archive_frames_all.len() as u64;
2120
2121 let live_bytes = db.fs.read(FileId::Wal)?;
2122 let (live_records, _valid_len) = decode_all(&live_bytes);
2123 let total_surviving = total_archive_frames + live_records.len() as u64;
2124 // Global total including any pruned history below the horizon floor.
2125 let total = db.wal_horizon_floor + total_surviving;
2126
2127 // Horizon and range check.
2128 if commit < db.wal_horizon_floor {
2129 return Err(GraphError::CommitOutOfRange { commit, total });
2130 }
2131 if commit >= total {
2132 return Err(GraphError::CommitOutOfRange { commit, total });
2133 }
2134
2135 // Local index into surviving frames (0 = first frame of oldest archive).
2136 let local = commit - db.wal_horizon_floor;
2137
2138 if local < total_archive_frames {
2139 // Target commit is in an archive. Correct replay from empty state
2140 // is only possible when the archive chain is an uninterrupted
2141 // genesis chain (first archive taken from a fresh store, no prior
2142 // WAL truncation) and no archives have been pruned (floor == 0).
2143 //
2144 // If either condition is violated the prefix needed to reconstruct
2145 // the requested state is gone; refuse rather than return wrong data.
2146 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2147 return Err(GraphError::CommitOutOfRange { commit, total });
2148 }
2149 // Replay all archive frames up to and including the target commit
2150 // from an empty database state. Archives must be replayed in order
2151 // so that dense-id intern tables are built up correctly.
2152 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2153 db.apply(&rec)?;
2154 let _ = db.engine.drain_deltas();
2155 }
2156 } else {
2157 // Target commit is in the live WAL: load snapshot as base, then
2158 // replay the needed live WAL prefix.
2159 //
2160 // Base state: a truncating snapshot (wal_truncated=true) compacts
2161 // all pre-truncation / pre-archive commits. Dense-id records in
2162 // the live WAL reference ids/interns that the snapshot provides.
2163 // Peek 6 bytes (same pattern as open_with).
2164 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2165 let is_v8 = snap_header.len() >= 6
2166 && &snap_header[0..4] == b"GDB1"
2167 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2168 == core_storage::snapshot::VERSION_8;
2169 if is_v8 {
2170 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2171 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2172 GraphError::Corrupt {
2173 detail: format!("v8: open_at mmap: {e:?}"),
2174 }
2175 })?;
2176 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2177 } else {
2178 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2179 core_storage::snapshot::decode(&snap_bytes)?
2180 };
2181 if let Some(state) = state {
2182 if state.wal_truncated {
2183 db.restore_snapshot_state(state)?;
2184 }
2185 }
2186 } else if !snap_header.is_empty() {
2187 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2188 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2189 if state.wal_truncated {
2190 db.restore_snapshot_state(state)?;
2191 }
2192 }
2193 }
2194 // else: snap_header empty = no snapshot file.
2195 let live_local = local - total_archive_frames;
2196 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2197 db.apply(&rec)?;
2198 let _ = db.engine.drain_deltas();
2199 }
2200 }
2201 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2202 // post-loop assert in open_with.
2203 debug_assert_eq!(
2204 db.engine.pending_delta_count(),
2205 0,
2206 "pending_deltas non-empty after open_at replay — \
2207 per-frame drain must run inside the loop to keep memory O(1)"
2208 );
2209 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2210 // Rebuild view values after WAL replay so derived-edge-driven views
2211 // reflect the as-of state. open_at always uses the legacy path (no V8
2212 // base), so topo_view is always owned.
2213 {
2214 let topo_view = TopologyView::owned(&db.topo);
2215 db.view_store
2216 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2217 }
2218 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2219 db.fulltext.rebuild_all(
2220 &db.ids,
2221 &db.labels,
2222 &db.syms,
2223 build_props_view(&db.props, &db.base),
2224 );
2225 db.prop_index.rebuild_all(
2226 &db.ids,
2227 &db.labels,
2228 &db.syms,
2229 build_props_view(&db.props, &db.base),
2230 );
2231 // Load roles sidecar (current roles, not point-in-time).
2232 db.roles = Self::load_roles_from_fs(&db.fs)?;
2233 db.read_only = true;
2234 db.total_wal_commits = total;
2235 // Capture initial fold so reader() is immediately usable.
2236 db.fold_now();
2237 Ok(db)
2238 }
2239
2240 /// Whether this instance is a read-only as-of view.
2241 pub fn is_read_only(&self) -> bool {
2242 self.read_only
2243 }
2244
2245 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2246
2247 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2248 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2249 /// the end of `open_with` / `open_at_with` to prime the reader.
2250 fn fold_now(&mut self) {
2251 let frozen = crate::reader::FrozenOverlay {
2252 ids: self.ids.clone(),
2253 syms: self.syms.clone(),
2254 topo: self.topo.clone(),
2255 props: self.props.clone(),
2256 labels: self.labels.clone(),
2257 edge_props: self.edge_props.clone(),
2258 roles: self.roles.clone(),
2259 fulltext: self.fulltext.clone(),
2260 };
2261 self.fold_overlay = Some(Arc::new(frozen));
2262 self.delta_tail.clear();
2263 self.commits_since_fold = 0;
2264 }
2265
2266 /// Capture a lock-free reader snapshot of the current db state.
2267 ///
2268 /// The read lock is held only for the duration of this call (to clone a
2269 /// handful of `Arc` handles). Subsequent query operations run without any
2270 /// lock.
2271 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2272 crate::reader::ReaderSnapshot::new(
2273 self.fold_overlay
2274 .clone()
2275 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2276 self.base.clone(),
2277 self.delta_tail.clone(),
2278 )
2279 }
2280
2281 /// Total number of WAL commits at the time [`open_at`] was called.
2282 /// Returns 0 for normal (non-as-of) instances.
2283 pub fn total_wal_commits(&self) -> u64 {
2284 self.total_wal_commits
2285 }
2286
2287 /// Apply a record to in-memory state. Used by both live writes and replay,
2288 /// so replay is definitionally identical to the original execution.
2289 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2290 match rec {
2291 WalRecord::InsertNode { label, key, props } => {
2292 let id = self.ids.try_insert(key)?;
2293 let sym = self.syms.intern(label);
2294 if self.labels.len() <= id as usize {
2295 // gap slots are sentinels, never valid label symbols
2296 self.labels.resize(id as usize + 1, u32::MAX);
2297 }
2298 self.labels[id as usize] = sym;
2299 for (field, value) in props {
2300 self.props.set(id, field, value.clone());
2301 }
2302 // Initialize view values for the new node before the engine runs so
2303 // delta-based increments start from a known zero baseline.
2304 self.view_store
2305 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2306 // Fire rules for the newly inserted node.
2307 let cursor = self.engine.pending_delta_count();
2308 let mut eng = std::mem::take(&mut self.engine);
2309 {
2310 let mut gm = make_graph_mut(
2311 &self.ids,
2312 &mut self.syms,
2313 &self.labels,
2314 build_props_view(&self.props, &self.base),
2315 &mut self.topo,
2316 &mut self.edge_props,
2317 );
2318 eng.on_node_changed(id, None, &mut gm);
2319 }
2320 self.engine = eng;
2321 // Process derived-edge deltas for view maintenance.
2322 // Fast path: skip the O(delta_count) allocation when no views exist.
2323 if !self.view_store.is_empty() {
2324 #[cfg(test)]
2325 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2326 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2327 for d in &new_deltas {
2328 self.view_store.on_edge_changed(
2329 d.etype_sym,
2330 d.src_id,
2331 d.dst_id,
2332 d.fired,
2333 &mut self.props,
2334 &build_topo_view(&self.topo, &self.base),
2335 &self.ids,
2336 &self.syms,
2337 &self.labels,
2338 self.base.as_ref().map(|b| {
2339 b.columns()
2340 .expect("base columns section bounds validated at open")
2341 }),
2342 );
2343 }
2344 }
2345 // Full-text index maintenance: index enabled fields for this label.
2346 if self.fulltext.has_label(label) {
2347 for (field, value) in props {
2348 if self.fulltext.is_enabled(label, field) {
2349 self.fulltext.add_tokens(id, field, value);
2350 }
2351 }
2352 }
2353 // Property (equality) index maintenance.
2354 if self.prop_index.has_label(label) {
2355 for (field, value) in props {
2356 self.prop_index.set(label, field, id, value);
2357 }
2358 }
2359 }
2360 WalRecord::InsertEdge {
2361 edge_type,
2362 src_key,
2363 dst_key,
2364 } => {
2365 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2366 detail: format!("wal replay references unknown key {src_key}"),
2367 })?;
2368 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2369 detail: format!("wal replay references unknown key {dst_key}"),
2370 })?;
2371 let etype = self.syms.intern(edge_type);
2372 // Skip if the edge is already visible in the merged base+overlay
2373 // view. This keeps WAL replay idempotent when the WAL contains
2374 // pre-snapshot records that are already encoded in a V8 base
2375 // (keep_wal=true opens and crash-before-truncation scenarios).
2376 if self.base.is_some()
2377 && self
2378 .topo_view()
2379 .neighbors(etype, Direction::Out, src)
2380 .contains(&dst)
2381 {
2382 return Ok(());
2383 }
2384 self.topo.add_edge(etype, src, dst);
2385 // View maintenance for manual edge insert.
2386 self.view_store.on_edge_changed(
2387 etype,
2388 src,
2389 dst,
2390 true,
2391 &mut self.props,
2392 &build_topo_view(&self.topo, &self.base),
2393 &self.ids,
2394 &self.syms,
2395 &self.labels,
2396 self.base.as_ref().map(|b| {
2397 b.columns()
2398 .expect("base columns section bounds validated at open")
2399 }),
2400 );
2401 // Rule engine: via-hop rules must update when user edges change.
2402 let cursor = self.engine.pending_delta_count();
2403 let mut eng = std::mem::take(&mut self.engine);
2404 {
2405 let mut gm = make_graph_mut(
2406 &self.ids,
2407 &mut self.syms,
2408 &self.labels,
2409 build_props_view(&self.props, &self.base),
2410 &mut self.topo,
2411 &mut self.edge_props,
2412 );
2413 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2414 }
2415 self.engine = eng;
2416 if !self.view_store.is_empty() {
2417 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2418 for d in &new_deltas {
2419 self.view_store.on_edge_changed(
2420 d.etype_sym,
2421 d.src_id,
2422 d.dst_id,
2423 d.fired,
2424 &mut self.props,
2425 &build_topo_view(&self.topo, &self.base),
2426 &self.ids,
2427 &self.syms,
2428 &self.labels,
2429 self.base.as_ref().map(|b| {
2430 b.columns()
2431 .expect("base columns section bounds validated at open")
2432 }),
2433 );
2434 }
2435 }
2436 }
2437 WalRecord::SetProp { key, field, value } => {
2438 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2439 detail: format!("wal replay references unknown key {key}"),
2440 })?;
2441 let old_value = build_props_view(&self.props, &self.base)
2442 .get(id, field)
2443 .map(|vr| vr.into_value());
2444 self.props.set(id, field, value.clone());
2445 // Fire rules for the changed field.
2446 let cursor = self.engine.pending_delta_count();
2447 let mut eng = std::mem::take(&mut self.engine);
2448 {
2449 let mut gm = make_graph_mut(
2450 &self.ids,
2451 &mut self.syms,
2452 &self.labels,
2453 build_props_view(&self.props, &self.base),
2454 &mut self.topo,
2455 &mut self.edge_props,
2456 );
2457 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2458 }
2459 self.engine = eng;
2460 // Derived-edge deltas → view updates.
2461 if !self.view_store.is_empty() {
2462 #[cfg(test)]
2463 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2464 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2465 for d in &new_deltas {
2466 self.view_store.on_edge_changed(
2467 d.etype_sym,
2468 d.src_id,
2469 d.dst_id,
2470 d.fired,
2471 &mut self.props,
2472 &build_topo_view(&self.topo, &self.base),
2473 &self.ids,
2474 &self.syms,
2475 &self.labels,
2476 self.base.as_ref().map(|b| {
2477 b.columns()
2478 .expect("base columns section bounds validated at open")
2479 }),
2480 );
2481 }
2482 }
2483 // Neighbor-aggregate views that read `field` must also update.
2484 self.view_store.on_prop_changed(
2485 id,
2486 field,
2487 &mut self.props,
2488 &build_topo_view(&self.topo, &self.base),
2489 &self.ids,
2490 &self.syms,
2491 &self.labels,
2492 self.base.as_ref().map(|b| {
2493 b.columns()
2494 .expect("base columns section bounds validated at open")
2495 }),
2496 );
2497 // Full-text index maintenance: update tokens for this field if indexed.
2498 if self.fulltext.field_indexed(field) {
2499 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2500 if sym == u32::MAX {
2501 None
2502 } else {
2503 self.syms.resolve(sym)
2504 }
2505 });
2506 if let Some(label) = label_opt {
2507 if self.fulltext.is_enabled(label, field) {
2508 self.fulltext.remove_node_field(id, field);
2509 self.fulltext.add_tokens(id, field, value);
2510 }
2511 }
2512 }
2513 // Property (equality) index maintenance: re-key this node's value.
2514 if self.prop_index.field_indexed(field) {
2515 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2516 if sym == u32::MAX {
2517 None
2518 } else {
2519 self.syms.resolve(sym)
2520 }
2521 });
2522 if let Some(label) = label_opt {
2523 self.prop_index.set(label, field, id, value);
2524 }
2525 }
2526 }
2527 WalRecord::Intern { id, text } => {
2528 if let Some(existing) = self.syms.get(text) {
2529 if existing != *id {
2530 return Err(GraphError::Corrupt {
2531 detail: format!(
2532 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2533 ),
2534 });
2535 }
2536 } else {
2537 let got = self.syms.intern(text);
2538 if got != *id {
2539 return Err(GraphError::Corrupt {
2540 detail: format!(
2541 "wal intern assigned {got} for {text:?}, record wanted {id}"
2542 ),
2543 });
2544 }
2545 }
2546 }
2547 WalRecord::InsertNodeId { label, key, props } => {
2548 let id = self.ids.try_insert(key)?;
2549 if self.labels.len() <= id as usize {
2550 self.labels.resize(id as usize + 1, u32::MAX);
2551 }
2552 self.labels[id as usize] = *label;
2553 let label_str = self
2554 .syms
2555 .resolve(*label)
2556 .ok_or_else(|| GraphError::Corrupt {
2557 detail: format!("wal InsertNodeId unknown label intern {label}"),
2558 })?
2559 .to_string();
2560 for (field_sym, value) in props {
2561 let field =
2562 self.syms
2563 .resolve(*field_sym)
2564 .ok_or_else(|| GraphError::Corrupt {
2565 detail: format!(
2566 "wal InsertNodeId unknown field intern {field_sym}"
2567 ),
2568 })?;
2569 self.props.set(id, field, value.clone());
2570 }
2571 self.view_store
2572 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2573 let cursor = self.engine.pending_delta_count();
2574 let mut eng = std::mem::take(&mut self.engine);
2575 {
2576 let mut gm = make_graph_mut(
2577 &self.ids,
2578 &mut self.syms,
2579 &self.labels,
2580 build_props_view(&self.props, &self.base),
2581 &mut self.topo,
2582 &mut self.edge_props,
2583 );
2584 eng.on_node_changed(id, None, &mut gm);
2585 }
2586 self.engine = eng;
2587 if !self.view_store.is_empty() {
2588 #[cfg(test)]
2589 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2590 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2591 for d in &new_deltas {
2592 self.view_store.on_edge_changed(
2593 d.etype_sym,
2594 d.src_id,
2595 d.dst_id,
2596 d.fired,
2597 &mut self.props,
2598 &build_topo_view(&self.topo, &self.base),
2599 &self.ids,
2600 &self.syms,
2601 &self.labels,
2602 self.base.as_ref().map(|b| {
2603 b.columns()
2604 .expect("base columns section bounds validated at open")
2605 }),
2606 );
2607 }
2608 }
2609 if self.fulltext.has_label(&label_str) {
2610 for (field_sym, value) in props {
2611 let Some(field) = self.syms.resolve(*field_sym) else {
2612 continue;
2613 };
2614 if self.fulltext.is_enabled(&label_str, field) {
2615 self.fulltext.add_tokens(id, field, value);
2616 }
2617 }
2618 }
2619 if self.prop_index.has_label(&label_str) {
2620 for (field_sym, value) in props {
2621 let Some(field) = self.syms.resolve(*field_sym) else {
2622 continue;
2623 };
2624 self.prop_index.set(&label_str, field, id, value);
2625 }
2626 }
2627 }
2628 WalRecord::InsertEdgeId { etype, src, dst } => {
2629 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2630 // already be tombstoned. Skip rather than attaching edges to
2631 // dead ids (DeleteNode keys the live re-insert, not the old id).
2632 if self.ids.is_tombstoned(*src)
2633 || self.ids.is_tombstoned(*dst)
2634 || self.ids.key_of(*src).is_none()
2635 || self.ids.key_of(*dst).is_none()
2636 {
2637 return Ok(());
2638 }
2639 // Skip if already visible in the merged view (same idempotency
2640 // guard as InsertEdge above: prevents double-counting when
2641 // pre-snapshot WAL records are replayed over a V8 base).
2642 if self.base.is_some()
2643 && self
2644 .topo_view()
2645 .neighbors(*etype, Direction::Out, *src)
2646 .contains(dst)
2647 {
2648 return Ok(());
2649 }
2650 self.topo.add_edge(*etype, *src, *dst);
2651 self.view_store.on_edge_changed(
2652 *etype,
2653 *src,
2654 *dst,
2655 true,
2656 &mut self.props,
2657 &build_topo_view(&self.topo, &self.base),
2658 &self.ids,
2659 &self.syms,
2660 &self.labels,
2661 self.base.as_ref().map(|b| {
2662 b.columns()
2663 .expect("base columns section bounds validated at open")
2664 }),
2665 );
2666 // Rule engine: via-hop rules fire when user via-edges are inserted.
2667 // Resolve etype back to string so on_edge_changed can match rules by name.
2668 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2669 let cursor = self.engine.pending_delta_count();
2670 let mut eng = std::mem::take(&mut self.engine);
2671 {
2672 let mut gm = make_graph_mut(
2673 &self.ids,
2674 &mut self.syms,
2675 &self.labels,
2676 build_props_view(&self.props, &self.base),
2677 &mut self.topo,
2678 &mut self.edge_props,
2679 );
2680 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2681 }
2682 self.engine = eng;
2683 if !self.view_store.is_empty() {
2684 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2685 for d in &new_deltas {
2686 self.view_store.on_edge_changed(
2687 d.etype_sym,
2688 d.src_id,
2689 d.dst_id,
2690 d.fired,
2691 &mut self.props,
2692 &build_topo_view(&self.topo, &self.base),
2693 &self.ids,
2694 &self.syms,
2695 &self.labels,
2696 self.base.as_ref().map(|b| {
2697 b.columns()
2698 .expect("base columns section bounds validated at open")
2699 }),
2700 );
2701 }
2702 }
2703 }
2704 }
2705 WalRecord::SetPropId { id, field, value } => {
2706 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2707 return Ok(());
2708 }
2709 let field_str = self
2710 .syms
2711 .resolve(*field)
2712 .ok_or_else(|| GraphError::Corrupt {
2713 detail: format!("wal SetPropId unknown field intern {field}"),
2714 })?
2715 .to_string();
2716 let old_value = build_props_view(&self.props, &self.base)
2717 .get(*id, &field_str)
2718 .map(|vr| vr.into_value());
2719 self.props.set(*id, &field_str, value.clone());
2720 let cursor = self.engine.pending_delta_count();
2721 let mut eng = std::mem::take(&mut self.engine);
2722 {
2723 let mut gm = make_graph_mut(
2724 &self.ids,
2725 &mut self.syms,
2726 &self.labels,
2727 build_props_view(&self.props, &self.base),
2728 &mut self.topo,
2729 &mut self.edge_props,
2730 );
2731 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2732 }
2733 self.engine = eng;
2734 if !self.view_store.is_empty() {
2735 #[cfg(test)]
2736 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2737 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2738 for d in &new_deltas {
2739 self.view_store.on_edge_changed(
2740 d.etype_sym,
2741 d.src_id,
2742 d.dst_id,
2743 d.fired,
2744 &mut self.props,
2745 &build_topo_view(&self.topo, &self.base),
2746 &self.ids,
2747 &self.syms,
2748 &self.labels,
2749 self.base.as_ref().map(|b| {
2750 b.columns()
2751 .expect("base columns section bounds validated at open")
2752 }),
2753 );
2754 }
2755 }
2756 self.view_store.on_prop_changed(
2757 *id,
2758 &field_str,
2759 &mut self.props,
2760 &build_topo_view(&self.topo, &self.base),
2761 &self.ids,
2762 &self.syms,
2763 &self.labels,
2764 self.base.as_ref().map(|b| {
2765 b.columns()
2766 .expect("base columns section bounds validated at open")
2767 }),
2768 );
2769 if self.fulltext.field_indexed(&field_str) {
2770 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2771 if sym == u32::MAX {
2772 None
2773 } else {
2774 self.syms.resolve(sym)
2775 }
2776 });
2777 if let Some(label) = label_opt {
2778 if self.fulltext.is_enabled(label, &field_str) {
2779 self.fulltext.remove_node_field(*id, &field_str);
2780 self.fulltext.add_tokens(*id, &field_str, value);
2781 }
2782 }
2783 }
2784 if self.prop_index.field_indexed(&field_str) {
2785 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2786 if sym == u32::MAX {
2787 None
2788 } else {
2789 self.syms.resolve(sym)
2790 }
2791 });
2792 if let Some(label) = label_opt {
2793 self.prop_index.set(label, &field_str, *id, value);
2794 }
2795 }
2796 }
2797 WalRecord::CreateRule { def_bytes } => {
2798 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2799 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2800 })?;
2801 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2802 // so the engine already has it; silently skip to avoid a spurious
2803 // RuleInvalid error in the crash window between snapshot write and WAL
2804 // truncation.
2805 if self.engine.rules().any(|r| r.name == def.name) {
2806 return Ok(());
2807 }
2808 let cursor = self.engine.pending_delta_count();
2809 let mut eng = std::mem::take(&mut self.engine);
2810 let result = {
2811 let mut gm = make_graph_mut(
2812 &self.ids,
2813 &mut self.syms,
2814 &self.labels,
2815 build_props_view(&self.props, &self.base),
2816 &mut self.topo,
2817 &mut self.edge_props,
2818 );
2819 eng.create_rule(def, &mut gm)
2820 };
2821 self.engine = eng;
2822 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2823 // Derived-edge fires from backfill → view updates.
2824 // Fast path: skip O(edge_count) allocation when no views exist.
2825 if !self.view_store.is_empty() {
2826 #[cfg(test)]
2827 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2828 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2829 for d in &new_deltas {
2830 self.view_store.on_edge_changed(
2831 d.etype_sym,
2832 d.src_id,
2833 d.dst_id,
2834 d.fired,
2835 &mut self.props,
2836 &build_topo_view(&self.topo, &self.base),
2837 &self.ids,
2838 &self.syms,
2839 &self.labels,
2840 self.base.as_ref().map(|b| {
2841 b.columns()
2842 .expect("base columns section bounds validated at open")
2843 }),
2844 );
2845 }
2846 }
2847 }
2848 WalRecord::DeleteRule { name } => {
2849 // Replay-over-snapshot idempotency: the snapshot already captured the
2850 // post-delete state so the rule is absent; silently skip to avoid a
2851 // spurious RuleNotFound error in the crash window between snapshot write
2852 // and WAL truncation.
2853 if !self.engine.rules().any(|r| r.name == *name) {
2854 return Ok(());
2855 }
2856 let cursor = self.engine.pending_delta_count();
2857 let mut eng = std::mem::take(&mut self.engine);
2858 let result = {
2859 let mut gm = make_graph_mut(
2860 &self.ids,
2861 &mut self.syms,
2862 &self.labels,
2863 build_props_view(&self.props, &self.base),
2864 &mut self.topo,
2865 &mut self.edge_props,
2866 );
2867 eng.delete_rule(name, &mut gm)
2868 };
2869 self.engine = eng;
2870 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2871 // Derived-edge retractions → view updates.
2872 if !self.view_store.is_empty() {
2873 #[cfg(test)]
2874 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2875 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2876 for d in &new_deltas {
2877 self.view_store.on_edge_changed(
2878 d.etype_sym,
2879 d.src_id,
2880 d.dst_id,
2881 d.fired,
2882 &mut self.props,
2883 &build_topo_view(&self.topo, &self.base),
2884 &self.ids,
2885 &self.syms,
2886 &self.labels,
2887 self.base.as_ref().map(|b| {
2888 b.columns()
2889 .expect("base columns section bounds validated at open")
2890 }),
2891 );
2892 }
2893 }
2894 }
2895 WalRecord::RemoveProp { key, field } => {
2896 // Recovery-safe: unknown key or already-absent field is a
2897 // clean no-op. Crash-window replay over a snapshot that
2898 // already applied this record must not Err.
2899 let Some(id) = self.ids.get(key) else {
2900 return Ok(());
2901 };
2902 // Read old value through the seam for rule retraction.
2903 let old = build_props_view(&self.props, &self.base)
2904 .get(id, field)
2905 .map(|vr| vr.into_value());
2906 self.props.remove(id, field);
2907 // If the base still supplies the value after the overlay removal,
2908 // record a tombstone so ColumnsView::get does not resurrect it.
2909 // This covers both the base-only case AND the both-resident case:
2910 // base-only (in_overlay=false): old prop was only in base, remove
2911 // is a no-op on overlay, base still visible → tombstone needed.
2912 // both-resident (in_overlay=true): overlay had v2, base has v1;
2913 // removing overlay uncovers v1 → tombstone needed.
2914 // Idempotent on double-replay: second pass sees the tombstone →
2915 // get() returns None → condition is false → no duplicate tombstone.
2916 if build_props_view(&self.props, &self.base)
2917 .get(id, field)
2918 .is_some()
2919 {
2920 self.props.record_prop_tombstone(id, field);
2921 }
2922 let cursor = self.engine.pending_delta_count();
2923 let mut eng = std::mem::take(&mut self.engine);
2924 {
2925 let mut gm = make_graph_mut(
2926 &self.ids,
2927 &mut self.syms,
2928 &self.labels,
2929 build_props_view(&self.props, &self.base),
2930 &mut self.topo,
2931 &mut self.edge_props,
2932 );
2933 eng.on_node_changed(id, Some((field, old)), &mut gm);
2934 }
2935 self.engine = eng;
2936 // Derived-edge deltas → view updates.
2937 if !self.view_store.is_empty() {
2938 #[cfg(test)]
2939 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2940 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2941 for d in &new_deltas {
2942 self.view_store.on_edge_changed(
2943 d.etype_sym,
2944 d.src_id,
2945 d.dst_id,
2946 d.fired,
2947 &mut self.props,
2948 &build_topo_view(&self.topo, &self.base),
2949 &self.ids,
2950 &self.syms,
2951 &self.labels,
2952 self.base.as_ref().map(|b| {
2953 b.columns()
2954 .expect("base columns section bounds validated at open")
2955 }),
2956 );
2957 }
2958 }
2959 // Neighbor-aggregate views that read `field` must also update.
2960 self.view_store.on_prop_changed(
2961 id,
2962 field,
2963 &mut self.props,
2964 &build_topo_view(&self.topo, &self.base),
2965 &self.ids,
2966 &self.syms,
2967 &self.labels,
2968 self.base.as_ref().map(|b| {
2969 b.columns()
2970 .expect("base columns section bounds validated at open")
2971 }),
2972 );
2973 // Full-text index maintenance: remove tokens for this field.
2974 if self.fulltext.field_indexed(field) {
2975 self.fulltext.remove_node_field(id, field);
2976 }
2977 // Property (equality) index maintenance: drop this node's entry.
2978 if self.prop_index.field_indexed(field) {
2979 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2980 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2981 }) {
2982 self.prop_index.remove_node(label, field, id);
2983 }
2984 }
2985 }
2986 WalRecord::DeleteEdge {
2987 edge_type,
2988 src_key,
2989 dst_key,
2990 } => {
2991 // Recovery-safe: unknown keys, unknown etype, or already-
2992 // absent edge is a clean no-op (remove_edge returns false).
2993 let Some(src) = self.ids.get(src_key) else {
2994 return Ok(());
2995 };
2996 let Some(dst) = self.ids.get(dst_key) else {
2997 return Ok(());
2998 };
2999 let Some(etype) = self.syms.get(edge_type) else {
3000 return Ok(());
3001 };
3002 // I3: phantom-tombstone guard. When a V8 base is present, a
3003 // DeleteEdge WAL record for an edge that was already absorbed into
3004 // the new base (i.e. neither in overlay nor in base) must be skipped.
3005 // Without this guard, remove_edge records a tombstone for an edge
3006 // that no longer exists, incorrectly understating edge_count.
3007 if self.base.is_some()
3008 && !self
3009 .topo_view()
3010 .neighbors(etype, core_storage::topology::Direction::Out, src)
3011 .contains(&dst)
3012 {
3013 return Ok(());
3014 }
3015 self.topo.remove_edge(etype, src, dst);
3016 self.edge_props.remove_edge(etype, src, dst);
3017 // View maintenance for manual edge delete (topo already updated above).
3018 self.view_store.on_edge_changed(
3019 etype,
3020 src,
3021 dst,
3022 false,
3023 &mut self.props,
3024 &build_topo_view(&self.topo, &self.base),
3025 &self.ids,
3026 &self.syms,
3027 &self.labels,
3028 self.base.as_ref().map(|b| {
3029 b.columns()
3030 .expect("base columns section bounds validated at open")
3031 }),
3032 );
3033 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3034 let cursor = self.engine.pending_delta_count();
3035 let mut eng = std::mem::take(&mut self.engine);
3036 {
3037 let mut gm = make_graph_mut(
3038 &self.ids,
3039 &mut self.syms,
3040 &self.labels,
3041 build_props_view(&self.props, &self.base),
3042 &mut self.topo,
3043 &mut self.edge_props,
3044 );
3045 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3046 }
3047 self.engine = eng;
3048 if !self.view_store.is_empty() {
3049 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3050 for d in &new_deltas {
3051 self.view_store.on_edge_changed(
3052 d.etype_sym,
3053 d.src_id,
3054 d.dst_id,
3055 d.fired,
3056 &mut self.props,
3057 &build_topo_view(&self.topo, &self.base),
3058 &self.ids,
3059 &self.syms,
3060 &self.labels,
3061 self.base.as_ref().map(|b| {
3062 b.columns()
3063 .expect("base columns section bounds validated at open")
3064 }),
3065 );
3066 }
3067 }
3068 }
3069 WalRecord::DeleteNode { key } => {
3070 // Recovery-safe: already-tombstoned / unknown key is a clean
3071 // no-op. Crash-window replay over a snapshot that already
3072 // applied this record cannot recover the retired id from the
3073 // key (`IdMap::get` is None), so every subsequent step is
3074 // skipped. Each step is independently idempotent if invoked
3075 // twice on a still-live id: retraction is a no-op on empty
3076 // provenance, `remove_edge` returns false, `remove_all` is a
3077 // no-op, `ids.delete` returns None, label sentinel is sticky.
3078 let Some(n) = self.ids.get(key) else {
3079 return Ok(());
3080 };
3081
3082 // (1) Retract derived edges + de-index while props/labels live.
3083 let cursor = self.engine.pending_delta_count();
3084 let mut eng = std::mem::take(&mut self.engine);
3085 {
3086 let mut gm = make_graph_mut(
3087 &self.ids,
3088 &mut self.syms,
3089 &self.labels,
3090 build_props_view(&self.props, &self.base),
3091 &mut self.topo,
3092 &mut self.edge_props,
3093 );
3094 eng.on_node_removed(n, &mut gm);
3095 }
3096 self.engine = eng;
3097 // Derived-edge retractions → view updates for neighbors.
3098 if !self.view_store.is_empty() {
3099 #[cfg(test)]
3100 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3101 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3102 for d in &new_deltas {
3103 self.view_store.on_edge_changed(
3104 d.etype_sym,
3105 d.src_id,
3106 d.dst_id,
3107 d.fired,
3108 &mut self.props,
3109 &build_topo_view(&self.topo, &self.base),
3110 &self.ids,
3111 &self.syms,
3112 &self.labels,
3113 self.base.as_ref().map(|b| {
3114 b.columns()
3115 .expect("base columns section bounds validated at open")
3116 }),
3117 );
3118 }
3119 }
3120
3121 // (2) Sweep ALL remaining edges incident to n, both directions,
3122 // every etype. This cascade is intentionally mask-independent:
3123 // topology integrity requires removing every edge touching the
3124 // deleted node regardless of the caller's visibility scope.
3125 // (The mask limits which nodes a role's read phase can return;
3126 // the WAL delete always executes with full storage authority.)
3127 // Collect then remove so neighbor slices stay valid during
3128 // iteration. Remove from topo first, then call view maintenance
3129 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3130 let etypes: Vec<u32> = self.topo.etypes().collect();
3131 let mut doomed = Vec::new();
3132 for et in &etypes {
3133 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3134 doomed.push((*et, n, dst));
3135 }
3136 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3137 doomed.push((*et, src, n));
3138 }
3139 }
3140 for (et, s, d) in doomed {
3141 self.topo.remove_edge(et, s, d);
3142 self.edge_props.remove_edge(et, s, d);
3143 // View maintenance: n's own view values will be cleared by
3144 // remove_all below; only update surviving neighbors.
3145 self.view_store.on_edge_changed(
3146 et,
3147 s,
3148 d,
3149 false,
3150 &mut self.props,
3151 &build_topo_view(&self.topo, &self.base),
3152 &self.ids,
3153 &self.syms,
3154 &self.labels,
3155 self.base.as_ref().map(|b| {
3156 b.columns()
3157 .expect("base columns section bounds validated at open")
3158 }),
3159 );
3160 }
3161
3162 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3163 self.props.remove_all(n);
3164 // Full-text index maintenance: remove all tokens for this node.
3165 self.fulltext.remove_node(n);
3166 // Property (equality) index maintenance: drop all entries for n.
3167 self.prop_index.remove_node_all(n);
3168
3169 // (4) Retire the dense id and stamp the label sentinel.
3170 self.ids.delete(key);
3171 if let Some(slot) = self.labels.get_mut(n as usize) {
3172 *slot = u32::MAX;
3173 }
3174 }
3175 WalRecord::Batch(inner) => {
3176 // Apply each inner record in order through the same apply path.
3177 // Inner records are validated free of nested Batch by encode_record.
3178 for rec in inner {
3179 self.apply(rec)?;
3180 }
3181 }
3182 WalRecord::RebuildRule { name } => {
3183 // Replay-over-snapshot idempotency: the snapshot may already
3184 // reflect a later delete_rule, so the rule is absent; skip.
3185 if !self.engine.rules().any(|r| r.name == *name) {
3186 return Ok(());
3187 }
3188 let cursor = self.engine.pending_delta_count();
3189 let mut eng = std::mem::take(&mut self.engine);
3190 let result = {
3191 let mut gm = make_graph_mut(
3192 &self.ids,
3193 &mut self.syms,
3194 &self.labels,
3195 build_props_view(&self.props, &self.base),
3196 &mut self.topo,
3197 &mut self.edge_props,
3198 );
3199 eng.rebuild(name, &mut gm)
3200 };
3201 self.engine = eng;
3202 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3203 // Derived-edge delta changes → view updates.
3204 if !self.view_store.is_empty() {
3205 #[cfg(test)]
3206 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3207 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3208 for d in &new_deltas {
3209 self.view_store.on_edge_changed(
3210 d.etype_sym,
3211 d.src_id,
3212 d.dst_id,
3213 d.fired,
3214 &mut self.props,
3215 &build_topo_view(&self.topo, &self.base),
3216 &self.ids,
3217 &self.syms,
3218 &self.labels,
3219 self.base.as_ref().map(|b| {
3220 b.columns()
3221 .expect("base columns section bounds validated at open")
3222 }),
3223 );
3224 }
3225 }
3226 }
3227 WalRecord::CreateView { def_bytes } => {
3228 let def: ViewDef =
3229 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3230 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3231 })?;
3232 // Replay-over-snapshot idempotency: view already present → skip.
3233 if self.view_store.has_view(&def.name) {
3234 return Ok(());
3235 }
3236 self.view_store
3237 .create_view(
3238 def,
3239 &mut self.props,
3240 &build_topo_view(&self.topo, &self.base),
3241 &self.ids,
3242 &self.syms,
3243 &self.labels,
3244 )
3245 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3246 }
3247 WalRecord::DeleteView { name } => {
3248 // Replay-over-snapshot idempotency: view already absent → skip.
3249 if !self.view_store.has_view(name) {
3250 return Ok(());
3251 }
3252 self.view_store
3253 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3254 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3255 }
3256 WalRecord::EnableFulltext { label, field } => {
3257 // Replay-over-snapshot idempotency: already enabled → skip.
3258 if self.fulltext.is_enabled(label, field) {
3259 return Ok(());
3260 }
3261 self.fulltext.enable(label, field);
3262 // Backfill: index all live nodes of this label that have the field.
3263 let n = self.ids.len() as u32;
3264 for id in 0..n {
3265 let Some(&sym) = self.labels.get(id as usize) else {
3266 continue;
3267 };
3268 if sym == u32::MAX {
3269 continue; // tombstoned
3270 }
3271 let Some(lbl) = self.syms.resolve(sym) else {
3272 continue;
3273 };
3274 if lbl != label {
3275 continue;
3276 }
3277 if let Some(value) = build_props_view(&self.props, &self.base)
3278 .get(id, field)
3279 .map(|vr| vr.into_value())
3280 {
3281 self.fulltext.add_tokens(id, field, &value);
3282 }
3283 }
3284 }
3285 WalRecord::DisableFulltext { label, field } => {
3286 // Replay-over-snapshot idempotency: already disabled → skip.
3287 if !self.fulltext.is_enabled(label, field) {
3288 return Ok(());
3289 }
3290 // If another label still indexes this field, the postings column
3291 // is kept — but it must not contain node_ids from the now-disabled
3292 // label. Remove them before calling disable() so the field_indexed
3293 // guard inside disable() sees the correct post-removal state.
3294 if self.fulltext.field_indexed_by_other(label, field) {
3295 if let Some(label_sym) = self.syms.get(label) {
3296 for (node_id, &lsym) in self.labels.iter().enumerate() {
3297 if lsym == label_sym {
3298 self.fulltext.remove_node_field(node_id as u32, field);
3299 }
3300 }
3301 }
3302 }
3303 self.fulltext.disable(label, field);
3304 }
3305 WalRecord::EnableIndex { label, field } => {
3306 // Replay-over-snapshot idempotency: already enabled → skip.
3307 if self.prop_index.is_enabled(label, field) {
3308 return Ok(());
3309 }
3310 self.prop_index.enable(label, field);
3311 // Backfill: index all live nodes of this label that have the field.
3312 let n = self.ids.len() as u32;
3313 for id in 0..n {
3314 let Some(&sym) = self.labels.get(id as usize) else {
3315 continue;
3316 };
3317 if sym == u32::MAX {
3318 continue; // tombstoned
3319 }
3320 let Some(lbl) = self.syms.resolve(sym) else {
3321 continue;
3322 };
3323 if lbl != label {
3324 continue;
3325 }
3326 if let Some(value) = build_props_view(&self.props, &self.base)
3327 .get(id, field)
3328 .map(|vr| vr.into_value())
3329 {
3330 self.prop_index.set(label, field, id, &value);
3331 }
3332 }
3333 }
3334 WalRecord::DisableIndex { label, field } => {
3335 self.prop_index.disable(label, field);
3336 }
3337 // History markers carry no replay state — rules re-derive edges
3338 // deterministically on open/replay. Skip unconditionally.
3339 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3340 // ── rename_node ──────────────────────────────────────────────────
3341 WalRecord::RenameNode { old_key, new_key } => {
3342 // Recovery-safe: if old_key is already gone (key was renamed
3343 // by a snapshot or a prior replay frame), skip cleanly.
3344 if self.ids.get(old_key).is_none() {
3345 return Ok(());
3346 }
3347 // The rename only updates the key-table; the dense id, all
3348 // topo edges, props, labels, and rule state are id-indexed and
3349 // require no change.
3350 self.ids
3351 .rename(old_key, new_key)
3352 .map_err(|e| GraphError::Corrupt {
3353 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3354 })?;
3355 }
3356 }
3357 Ok(())
3358 }
3359
3360 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3361 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3362 /// idempotent when the string is already bound. Always emit: after
3363 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3364 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3365 let id = if let Some(id) = self.syms.get(s) {
3366 id
3367 } else {
3368 self.syms.intern(s)
3369 };
3370 (
3371 id,
3372 WalRecord::Intern {
3373 id,
3374 text: s.to_string(),
3375 },
3376 )
3377 }
3378
3379 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3380 /// state is left mutated: speculative interns made while building the
3381 /// output are rolled back, so a later successful mutation cannot log an
3382 /// `Intern` record whose id replay would never reproduce.
3383 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3384 let syms_checkpoint = self.syms.len();
3385 let result = self.rewrite_wal_dense_inner(recs);
3386 if result.is_err() {
3387 self.syms.truncate(syms_checkpoint);
3388 }
3389 result
3390 }
3391
3392 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3393 let mut out = Vec::with_capacity(recs.len());
3394 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3395 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3396 let mut interned = std::collections::HashSet::<u32>::new();
3397 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3398 detail: "id space exhausted".into(),
3399 })?;
3400 let lookup = |ids: &IdMap,
3401 pending: &std::collections::HashMap<String, u32>,
3402 key: &str|
3403 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3404 for rec in recs {
3405 match rec {
3406 WalRecord::InsertNode { label, key, props } => {
3407 let (label_id, intern) = self.intern_wal(&label);
3408 if interned.insert(label_id) {
3409 out.push(intern);
3410 }
3411 let mut props_id = Vec::with_capacity(props.len());
3412 for (field, value) in props {
3413 let (field_id, intern) = self.intern_wal(&field);
3414 if interned.insert(field_id) {
3415 out.push(intern);
3416 }
3417 props_id.push((field_id, value));
3418 }
3419 if lookup(&self.ids, &pending, &key).is_none() {
3420 pending.insert(key.clone(), next);
3421 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3422 detail: "id space exhausted".into(),
3423 })?;
3424 }
3425 out.push(WalRecord::InsertNodeId {
3426 label: label_id,
3427 key,
3428 props: props_id,
3429 });
3430 }
3431 WalRecord::SetProp { key, field, value } => {
3432 let id =
3433 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3434 detail: format!("dense WAL rewrite missing key {key}"),
3435 })?;
3436 let (field_id, intern) = self.intern_wal(&field);
3437 if interned.insert(field_id) {
3438 out.push(intern);
3439 }
3440 out.push(WalRecord::SetPropId {
3441 id,
3442 field: field_id,
3443 value,
3444 });
3445 }
3446 WalRecord::InsertEdge {
3447 edge_type,
3448 src_key,
3449 dst_key,
3450 } => {
3451 let (etype, intern) = self.intern_wal(&edge_type);
3452 if interned.insert(etype) {
3453 out.push(intern);
3454 }
3455 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3456 GraphError::Corrupt {
3457 detail: format!("dense WAL rewrite missing src {src_key}"),
3458 }
3459 })?;
3460 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3461 GraphError::Corrupt {
3462 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3463 }
3464 })?;
3465 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3466 }
3467 WalRecord::RenameNode {
3468 ref old_key,
3469 ref new_key,
3470 } => {
3471 // Track the rename in `pending` so subsequent InsertEdge /
3472 // SetProp records in this batch can resolve the new key.
3473 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3474 GraphError::Corrupt {
3475 detail: format!(
3476 "dense WAL rewrite: RenameNode old key {old_key} not found"
3477 ),
3478 }
3479 })?;
3480 pending.remove(old_key.as_str());
3481 pending.insert(new_key.clone(), id);
3482 out.push(rec);
3483 }
3484 // # Symbol-order invariant (load-bearing)
3485 //
3486 // Write-time and replay-time symbol assignment must agree: every
3487 // symbol in a `Batch` frame has to receive the same dense id when
3488 // the frame's records are replayed in order as it received when
3489 // the frame was written.
3490 //
3491 // A rule's backfill interns its `edge_type` lazily
3492 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3493 // site), and that backfill runs from `apply` — during the
3494 // `CreateRule` record itself, and again from any later
3495 // `InsertNodeId` in the same frame that makes the rule fire. At
3496 // write time the whole batch is rewritten before any of it is
3497 // applied, so a later `InsertEdge` in the same batch would win the
3498 // lower id for its edge type; on replay the rule's lazy intern
3499 // gets there first and steals it, and the `Intern` record fails at
3500 // the `wal intern assigned …` check in `apply`.
3501 //
3502 // Pre-interning the rule's `edge_type` here, and emitting its
3503 // `Intern` record ahead of the `CreateRule` record, makes both
3504 // orders identical. `weight_prop` needs no pre-intern:
3505 // `EdgeProps::set` keys props by `String`, never through the
3506 // interner. `via_edge` needs none either: via-hop rules resolve it
3507 // with `syms.get` and skip when it is absent.
3508 //
3509 // `RebuildRule` and `DeleteRule` need no such handling here:
3510 // `RebuildRule` has no `BatchOp` variant, so it never appears
3511 // inside a `Batch` today — it is only ever issued as its own
3512 // standalone commit (`rebuild_rule`, or the auto-rebuild path
3513 // that logs it as a second commit after the triggering op).
3514 // `DeleteRule` does have a `BatchOp` variant and can appear
3515 // inside a `Batch`, but it carries only a rule `name` — no
3516 // `edge_type` or other symbol that needs pre-interning — so
3517 // only `CreateRule` needs this arm.
3518 WalRecord::CreateRule { ref def_bytes } => {
3519 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3520 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3521 })?;
3522 let (etype, intern) = self.intern_wal(&def.edge_type);
3523 if interned.insert(etype) {
3524 out.push(intern);
3525 }
3526 out.push(rec);
3527 }
3528 other => out.push(other),
3529 }
3530 }
3531 Ok(out)
3532 }
3533
3534 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3535 let recs = self.rewrite_wal_dense(recs)?;
3536 match recs.len() {
3537 0 => Ok(()),
3538 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3539 _ => self.log_then_apply(WalRecord::Batch(recs)),
3540 }
3541 }
3542
3543 /// Durable write, then notify the event sink. Replay (`apply` during
3544 /// `open`) never enters this function, so it is the replay-silent seam.
3545 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3546 self.log_then_apply_with(rec, None, self.fsync)
3547 }
3548
3549 /// Whether this frame must fsync under `policy`.
3550 ///
3551 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3552 /// mutations do not. The dense rewrite wraps a single mutation in a
3553 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3554 /// from the count — removing that filter would make every single-op write
3555 /// fsync under Batched (or, if the threshold were raised instead, skip a
3556 /// needed fsync for real two-op batches).
3557 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3558 match policy {
3559 FsyncPolicy::Relaxed => false,
3560 FsyncPolicy::Strict => true,
3561 FsyncPolicy::Batched => match rec {
3562 // Intern + one mutation is the single-op rewrite, not a user batch.
3563 WalRecord::Batch(inner) => {
3564 inner
3565 .iter()
3566 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3567 .count()
3568 > 1
3569 }
3570 _ => false,
3571 },
3572 }
3573 }
3574
3575 /// # Apply-infallibility invariant (load-bearing)
3576 ///
3577 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3578 /// for a `Batch` frame after a successful WAL write, the WAL would contain
3579 /// the full frame while in-memory state would reflect only the ops before
3580 /// the failure. On reopen, WAL replay would then apply the entire batch —
3581 /// diverging permanently from what the pre-crash process had in memory.
3582 ///
3583 /// For `Batch` frames this situation cannot arise because:
3584 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3585 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
3586 /// use, with no concurrent mutation between validation exit and apply entry.
3587 /// - Every `apply` arm for a validated op is either infallible by construction
3588 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3589 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3590 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3591 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3592 ///
3593 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3594 /// `Err` for a `Batch` frame, making any future regression immediately visible
3595 /// in tests rather than silently diverging crash-recovery behaviour.
3596 fn log_then_apply_with(
3597 &mut self,
3598 rec: WalRecord,
3599 ingest: Option<(String, usize)>,
3600 policy: FsyncPolicy,
3601 ) -> Result<()> {
3602 // Read-only guard: as-of instances must never write the WAL.
3603 if self.read_only {
3604 return Err(GraphError::ReadOnly);
3605 }
3606 // Degraded guard: fsync failure left WAL truncated; in-memory state
3607 // is ahead of the on-disk WAL, so further mutations would deepen the
3608 // divergence. Reopen the database to recover.
3609 if self.degraded {
3610 return Err(GraphError::Io(std::io::Error::other(
3611 "database degraded after group-commit fsync failure; reopen required",
3612 )));
3613 }
3614 // Ensure retained provenance bytes are decoded into the live mutable
3615 // fields before any mutation touches self.engine.provenance. This is a
3616 // no-op if provenance was never stored (fresh store) or has already been
3617 // consumed (subsequent mutations). WAL replay calls apply() directly
3618 // and is covered by consume_retained_state_eager before replay.
3619 self.ensure_v8_base_sections_loaded();
3620 self.engine.ensure_provenance_loaded_mut();
3621 // Invariant (I-1): no stale deltas may enter from a previous apply.
3622 // If any engine method ever accumulates deltas before erroring, they would
3623 // contaminate the *next* commit's event stream. This assert fires in debug
3624 // builds, making any future regression visible at the earliest point.
3625 debug_assert_eq!(
3626 self.engine.pending_delta_count(),
3627 0,
3628 "stale engine deltas at log_then_apply_with entry — \
3629 a previous apply arm may have accumulated deltas before erroring; \
3630 the caller must drain_deltas() on any error path before returning"
3631 );
3632 self.fs.append(FileId::Wal, &encode_record(&rec))?;
3633 if Self::wal_needs_sync(policy, &rec) {
3634 self.fs.sync(FileId::Wal)?;
3635 }
3636 // Marker writing always needs the engine deltas, but the engine only
3637 // accumulates them when emit_deltas is true (normally gated on subscribers
3638 // or views being present). Enable emission for this apply if it is
3639 // currently off, then restore the original state unconditionally via an
3640 // RAII guard — this prevents a panic in apply() from leaking the flag.
3641 // The same guard resets the engine's transient chaining state. A panic
3642 // unwinding out of a rule hook would otherwise leave `chain_depth`
3643 // non-zero, which makes every later `begin_chain` decide chaining is
3644 // already running and silently switch it off for good.
3645 struct RestoreEmitDeltas(*mut RuleEngine, bool);
3646 impl Drop for RestoreEmitDeltas {
3647 fn drop(&mut self) {
3648 // SAFETY: pointer into self (GraphDb); guard is dropped within
3649 // this frame before log_then_apply_with returns.
3650 unsafe {
3651 (*self.0).set_emit_deltas(self.1);
3652 (*self.0).reset_chain_state();
3653 }
3654 }
3655 }
3656 let original_emit = self.engine.emit_deltas();
3657 if !original_emit {
3658 self.engine.set_emit_deltas(true);
3659 }
3660 // SAFETY: raw pointer into self; guard dropped within this frame.
3661 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3662
3663 let apply_result = self.apply(&rec);
3664 // For Batch frames, post-validation apply must be infallible (see above).
3665 // A debug_assert here catches any future change that makes apply fallible
3666 // before the caller notices via silent WAL/memory divergence.
3667 if matches!(&rec, WalRecord::Batch(_)) {
3668 debug_assert!(
3669 apply_result.is_ok(),
3670 "Batch apply returned Err after successful WAL write — \
3671 the validate-then-apply invariant has been violated; \
3672 see log_then_apply_with invariant doc"
3673 );
3674 }
3675 if apply_result.is_err() {
3676 // Discard any partial deltas accumulated by the failed apply.
3677 // They must not ride the next commit's event stream (I-1).
3678 // _emit_guard restores emit_deltas on drop automatically.
3679 let _ = self.engine.drain_deltas();
3680 let _ = self.engine.take_rebuild_needed();
3681 apply_result?;
3682 }
3683 self.commit_seq += 1;
3684 let seq = self.commit_seq;
3685 // Update per-node last-change map for the committed record.
3686 // Must happen after commit_seq is incremented so the seq is correct.
3687 self.update_last_change_from_rec(&rec, seq);
3688 // Drain engine deltas and distribute to subscribers before the existing
3689 // MutationEvent sink fires — both happen post-fsync, post-apply.
3690 // _emit_guard restores emit_deltas after this line when it drops.
3691 let engine_deltas = self.engine.drain_deltas();
3692
3693 // Append history-marker WAL records for any derived-edge changes so
3694 // that `edge_history` and `was_linked` can surface rule-attributed
3695 // events. Markers are STATE NO-OPS during replay; they are written
3696 // without an additional fsync (the triggering commit's sync already
3697 // happened; the next commit's sync covers these lazily).
3698 if !engine_deltas.is_empty() {
3699 let markers: Vec<WalRecord> = engine_deltas
3700 .iter()
3701 .map(|d| {
3702 if d.fired {
3703 WalRecord::DerivedEdgeAdded {
3704 rule: d.rule.clone(),
3705 edge_type: d.edge_type.clone(),
3706 src_key: d.src_key.clone(),
3707 dst_key: d.dst_key.clone(),
3708 }
3709 } else {
3710 WalRecord::DerivedEdgeRetracted {
3711 rule: d.rule.clone(),
3712 edge_type: d.edge_type.clone(),
3713 src_key: d.src_key.clone(),
3714 dst_key: d.dst_key.clone(),
3715 }
3716 }
3717 })
3718 .collect();
3719 let marker_frame = if markers.len() == 1 {
3720 markers.into_iter().next().unwrap()
3721 } else {
3722 WalRecord::Batch(markers)
3723 };
3724 // Ignore append errors: markers are best-effort history
3725 // annotations. Losing them does not affect state correctness.
3726 let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3727 }
3728
3729 // Record MVCC CommitDelta for the epoch reader. The WAL record is
3730 // stored as-is (including any nested Batch / Intern records); the
3731 // ReaderSnapshot's apply_one function handles all variants.
3732 {
3733 let derived_inserts = engine_deltas
3734 .iter()
3735 .filter(|d| d.fired)
3736 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3737 .collect();
3738 let derived_deletes = engine_deltas
3739 .iter()
3740 .filter(|d| !d.fired)
3741 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3742 .collect();
3743 let delta = Arc::new(crate::reader::CommitDelta {
3744 records: vec![rec.clone()],
3745 derived_inserts,
3746 derived_deletes,
3747 });
3748 self.delta_tail.push(delta);
3749 self.commits_since_fold += 1;
3750 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3751 self.fold_now();
3752 }
3753 }
3754
3755 if self.defer_events {
3756 // Group-commit drain thread: hold events until after the group
3757 // fsync so subscribers only observe durable data (R2).
3758 self.deferred_events.push(DeferredEvent {
3759 rec: rec.clone(),
3760 engine_deltas,
3761 seq,
3762 ingest,
3763 });
3764 } else {
3765 self.distribute_events(&rec, &engine_deltas, seq);
3766 self.emit_committed(&rec, ingest);
3767 }
3768 // Drift is only known after apply, so auto-rebuild cannot join the
3769 // triggering op's WAL frame. Issue RebuildRule as a second commit.
3770 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3771 // retrigger loop is impossible if the fit succeeded, but we still
3772 // drain the flag so a leftover cannot re-enter.
3773 let rebuilds = self.engine.take_rebuild_needed();
3774 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3775 let mut failed = Vec::new();
3776 for name in rebuilds {
3777 if self.engine.rules().any(|r| r.name == name) {
3778 // User op is already durable. A failed second commit must
3779 // not surface as the caller's error.
3780 if let Err(e) =
3781 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3782 {
3783 eprintln!(
3784 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3785 );
3786 failed.push(name);
3787 }
3788 }
3789 }
3790 for name in failed {
3791 self.engine.queue_rebuild_needed(name);
3792 }
3793 }
3794 Ok(())
3795 }
3796
3797 /// Install a post-commit hook. Replaces any previous sink.
3798 ///
3799 /// The sink runs inside `log_then_apply` after a successful
3800 /// durable commit, while the caller still holds `&mut self`. When this
3801 /// database is behind a [`crate::SharedDb`], that means the **write
3802 /// guard is held**. The sink must never call `read` / `write` (or any
3803 /// other method) on the same `SharedDb` — the `RwLock` is not
3804 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3805 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3806 /// Intended examples: `std::sync::mpsc::SyncSender`,
3807 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3808 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3809 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3810 self.event_sink = Some(sink);
3811 }
3812
3813 /// Whether a post-commit event sink is currently installed.
3814 pub fn has_event_sink(&self) -> bool {
3815 self.event_sink.is_some()
3816 }
3817
3818 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3819 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3820 self.fsync = p;
3821 }
3822
3823 /// Return the current WAL fsync cadence.
3824 pub fn fsync_policy(&self) -> FsyncPolicy {
3825 self.fsync
3826 }
3827
3828 // ── Group-commit event deferral ───────────────────────────────────────────
3829
3830 /// Enable or disable deferred event mode.
3831 ///
3832 /// When `true`, event notifications (subscription `DbEvent`s and legacy
3833 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3834 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3835 /// or [`discard_deferred_events`] if the fsync failed and the group must
3836 /// be treated as lost.
3837 pub fn set_deferred_events_mode(&mut self, defer: bool) {
3838 self.defer_events = defer;
3839 }
3840
3841 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3842 /// was set to true. Clears the buffer.
3843 ///
3844 /// Called by the drain thread AFTER a successful group fsync, so
3845 /// subscribers observe only data that is durably on disk.
3846 pub fn flush_deferred_events(&mut self) {
3847 let events = std::mem::take(&mut self.deferred_events);
3848 for de in events {
3849 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3850 self.emit_committed(&de.rec, de.ingest);
3851 }
3852 }
3853
3854 /// Discard all buffered events without firing them.
3855 ///
3856 /// Called by the drain thread when a group fsync fails: the WAL has been
3857 /// truncated back to the pre-group offset, so the committed-but-unsynced
3858 /// ops must not be observable to subscribers.
3859 pub fn discard_deferred_events(&mut self) {
3860 self.deferred_events.clear();
3861 }
3862
3863 // ── Degraded state ────────────────────────────────────────────────────────
3864
3865 /// Mark this database as degraded.
3866 ///
3867 /// Called by the group-commit drain thread after a group fsync failure and
3868 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3869 /// further mutations would deepen the divergence. All subsequent calls to
3870 /// [`log_then_apply_with`] return `Err` until the database is reopened.
3871 pub fn set_degraded(&mut self) {
3872 self.degraded = true;
3873 }
3874
3875 fn emit(&self, ev: MutationEvent) {
3876 if let Some(sink) = &self.event_sink {
3877 sink(ev);
3878 }
3879 }
3880
3881 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3882 match rec {
3883 WalRecord::Batch(inner) => {
3884 for r in inner {
3885 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3886 self.emit(ev);
3887 }
3888 }
3889 match ingest {
3890 Some((label, inserted)) => {
3891 self.emit(MutationEvent::Ingested { label, inserted })
3892 }
3893 None => {
3894 let ops = inner
3895 .iter()
3896 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3897 .count();
3898 if ops > 1 {
3899 self.emit(MutationEvent::BatchApplied { ops });
3900 }
3901 }
3902 }
3903 }
3904 other => {
3905 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3906 self.emit(ev);
3907 }
3908 }
3909 }
3910 }
3911
3912 // -----------------------------------------------------------------------
3913 // Subscription API
3914 // -----------------------------------------------------------------------
3915
3916 /// Distribute post-commit events to all live subscribers.
3917 ///
3918 /// Build a row-key → row-data map from a [`ResultSet`].
3919 ///
3920 /// Each row is serialized to JSON to form its key; a debug fallback is used
3921 /// if serialization fails. Used by both the initial-seed path in
3922 /// [`Self::subscribe_query`] and the per-commit diff path in
3923 /// [`Self::distribute_events`] to keep the two in sync.
3924 fn result_to_row_map(
3925 result: &core_query::ResultSet,
3926 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3927 (0..result.len())
3928 .map(|i| {
3929 let row = result.row(i).to_vec();
3930 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3931 (key, row)
3932 })
3933 .collect()
3934 }
3935
3936 /// Collect the set of label syms touched by a WAL record.
3937 ///
3938 /// Returns `Some(set)` when every record in this commit can be attributed to
3939 /// a known label sym. Returns `None` when the commit must not be skipped:
3940 /// edge records, unresolvable key→label lookups, or any record type not in
3941 /// the explicit handled set.
3942 ///
3943 /// Handled record types and their actions:
3944 /// - `InsertNode` → look up label in interner (fails → None)
3945 /// - `InsertNodeId` → label sym is carried directly
3946 /// - `SetProp` → resolve key→id→label (fails → None)
3947 /// - `DeleteNode` → resolve key→id→label (fails → None)
3948 /// - `Batch` → recurse into every inner record
3949 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3950 /// - everything else → None (conservative)
3951 fn commit_touched_labels(
3952 rec: &WalRecord,
3953 syms: &Interner,
3954 ids: &IdMap,
3955 labels: &[u32],
3956 ) -> Option<BTreeSet<u32>> {
3957 let mut out = BTreeSet::new();
3958 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3959 Some(out)
3960 } else {
3961 None
3962 }
3963 }
3964
3965 fn collect_touched_labels(
3966 rec: &WalRecord,
3967 syms: &Interner,
3968 ids: &IdMap,
3969 labels: &[u32],
3970 out: &mut BTreeSet<u32>,
3971 ) -> bool {
3972 match rec {
3973 // String-key insert: the dense rewrite converts this to
3974 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3975 // records written before the dense path was added.
3976 WalRecord::InsertNode { label, .. } => {
3977 if let Some(sym) = syms.get(label) {
3978 out.insert(sym);
3979 true
3980 } else {
3981 false
3982 }
3983 }
3984 // Dense-id insert (produced by rewrite_wal_dense for every
3985 // insert_node call in the current codebase).
3986 WalRecord::InsertNodeId { label, .. } => {
3987 out.insert(*label);
3988 true
3989 }
3990 // String-key prop set: dense path converts to [Intern, SetPropId].
3991 WalRecord::SetProp { key, .. } => {
3992 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3993 out.insert(sym);
3994 true
3995 } else {
3996 false
3997 }
3998 }
3999 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4000 WalRecord::SetPropId { id, .. } => {
4001 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4002 out.insert(sym);
4003 true
4004 } else {
4005 false
4006 }
4007 }
4008 WalRecord::DeleteNode { key } => {
4009 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4010 out.insert(sym);
4011 true
4012 } else {
4013 false
4014 }
4015 }
4016 WalRecord::Batch(inner) => inner
4017 .iter()
4018 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4019 // Intern is a pure metadata record — it does not touch any node's
4020 // label and is safe to skip for the label-skip predicate.
4021 WalRecord::Intern { .. } => true,
4022 // Edge records: always re-execute (edges can change join results).
4023 WalRecord::InsertEdge { .. }
4024 | WalRecord::DeleteEdge { .. }
4025 | WalRecord::InsertEdgeId { .. } => false,
4026 _ => false,
4027 }
4028 }
4029
4030 /// Resolve a node key to its label sym via the dense id table.
4031 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4032 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4033 let id = ids.get(key)?;
4034 let sym = labels.get(id as usize).copied()?;
4035 (sym != u32::MAX).then_some(sym)
4036 }
4037
4038 /// Distribute post-commit events to all live subscribers.
4039 ///
4040 /// Called from `log_then_apply_with` after apply + fsync, before the
4041 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4042 ///
4043 /// Query subscriptions (subscribe_query) re-execute their plan on every
4044 /// call and diff the result against the previous run. Zero overhead when
4045 /// no query subscriptions are active.
4046 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4047 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4048 return;
4049 }
4050
4051 if !self.subscriptions.is_empty() {
4052 // Build write events from the WAL record.
4053 let write_events: Vec<DbEvent> =
4054 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4055
4056 // Build edge events from engine deltas. Weight is looked up from
4057 // edge_props at distribution time (after apply), so it's always fresh.
4058 let edge_events: Vec<DbEvent> = engine_deltas
4059 .iter()
4060 .map(|d| {
4061 if d.fired {
4062 // The score lives under the rule's declared weight_prop,
4063 // which is not always the literal "weight".
4064 let prop = self
4065 .engine
4066 .rules()
4067 .find(|r| r.name == d.rule)
4068 .and_then(|r| r.weight_prop.as_deref());
4069 let weight = prop.and_then(|p| {
4070 self.edge_props
4071 .get(d.etype_sym, d.src_id, d.dst_id, p)
4072 .and_then(|v| {
4073 if let core_storage::Value::Float(f) = v {
4074 Some(*f)
4075 } else {
4076 None
4077 }
4078 })
4079 });
4080 DbEvent::EdgeFired {
4081 rule: d.rule.clone(),
4082 src_key: d.src_key.clone(),
4083 dst_key: d.dst_key.clone(),
4084 edge_type: d.edge_type.clone(),
4085 weight,
4086 commit_seq: seq,
4087 }
4088 } else {
4089 DbEvent::EdgeRetracted {
4090 rule: d.rule.clone(),
4091 src_key: d.src_key.clone(),
4092 dst_key: d.dst_key.clone(),
4093 edge_type: d.edge_type.clone(),
4094 commit_seq: seq,
4095 }
4096 }
4097 })
4098 .collect();
4099
4100 // Prune dead entries; push matching events to live ones.
4101 self.subscriptions.retain(|entry| {
4102 let Some(inner) = entry.inner.upgrade() else {
4103 return false;
4104 };
4105 for ev in &write_events {
4106 if event_matches(ev, &entry.filter) {
4107 inner.push(ev.clone());
4108 }
4109 }
4110 for ev in &edge_events {
4111 if event_matches(ev, &entry.filter) {
4112 inner.push(ev.clone());
4113 }
4114 }
4115 true
4116 });
4117
4118 // Turn off delta accumulation if all subscribers dropped and no views remain.
4119 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4120 self.engine.set_emit_deltas(false);
4121 }
4122 }
4123
4124 // Query subscriptions: full re-run per commit, then diff rows.
4125 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4126 // Differential evaluation is roadmap / Phase 5.
4127 if !self.query_subscriptions.is_empty() {
4128 // Take the list out so we can call self.view() without borrow conflict.
4129 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4130 let empty_params = BTreeMap::new();
4131 query_subs.retain_mut(|entry| {
4132 let Some(inner) = entry.inner.upgrade() else {
4133 return false; // subscriber dropped — prune
4134 };
4135 // Label-skip: if the plan has a known scan label and this commit
4136 // can be proven to touch only different labels (and no rule-derived
4137 // edge deltas fired), the result set cannot have changed — skip.
4138 if let Some(scan_sym) = entry.scan_label {
4139 if engine_deltas.is_empty() {
4140 let touched =
4141 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4142 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4143 return true; // safe to skip — result set unchanged
4144 }
4145 }
4146 }
4147 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4148 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4149 Ok(r) => r,
4150 Err(e) => {
4151 // Keep the subscription alive; skip the diff for this commit.
4152 // Re-run errors are transient (e.g., planner change) and
4153 // self-heal when the next commit succeeds.
4154 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4155 return true;
4156 }
4157 };
4158 // Build new row map: serialized-key → row data.
4159 let new_row_map = Self::result_to_row_map(&result);
4160 // Removed rows: in prev but not in new.
4161 for (key, row) in &entry.prev_row_map {
4162 if !new_row_map.contains_key(key) {
4163 inner.push(DbEvent::QueryRowRemoved {
4164 columns: entry.columns.clone(),
4165 row: row.clone(),
4166 });
4167 }
4168 }
4169 // Added rows: in new but not in prev.
4170 for (key, row) in &new_row_map {
4171 if !entry.prev_row_map.contains_key(key) {
4172 inner.push(DbEvent::QueryRowAdded {
4173 columns: entry.columns.clone(),
4174 row: row.clone(),
4175 });
4176 }
4177 }
4178 entry.prev_row_map = new_row_map;
4179 true
4180 });
4181 self.query_subscriptions = query_subs;
4182 }
4183 }
4184
4185 /// Returns `true` if any live subscriber or view definition requires delta
4186 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4187 fn needs_emit_deltas(&self) -> bool {
4188 !self.view_store.is_empty()
4189 || self
4190 .subscriptions
4191 .iter()
4192 .any(|e| e.inner.upgrade().is_some())
4193 }
4194
4195 /// Convert a WAL record into `DbEvent` write events with the given seq.
4196 fn write_events_from_record(
4197 rec: &WalRecord,
4198 seq: u64,
4199 intern: &Interner,
4200 ids: &IdMap,
4201 ) -> Vec<DbEvent> {
4202 match rec {
4203 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4204 label: label.clone(),
4205 key: key.clone(),
4206 commit_seq: seq,
4207 }],
4208 // *Id arms run after a successful apply, so resolution can only
4209 // fail on a programming error. Skip the event rather than emit a
4210 // fabricated "" that clients can't tell from a real empty value
4211 // (mirrors event_from_record returning None).
4212 WalRecord::InsertNodeId { label, key, .. } => intern
4213 .resolve(*label)
4214 .map(|label| DbEvent::NodeInserted {
4215 label: label.to_string(),
4216 key: key.clone(),
4217 commit_seq: seq,
4218 })
4219 .into_iter()
4220 .collect(),
4221 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4222 key: key.clone(),
4223 field: field.clone(),
4224 commit_seq: seq,
4225 }],
4226 WalRecord::SetPropId { id, field, .. } => ids
4227 .key_of(*id)
4228 .zip(intern.resolve(*field))
4229 .map(|(key, field)| DbEvent::PropSet {
4230 key: key.to_string(),
4231 field: field.to_string(),
4232 commit_seq: seq,
4233 })
4234 .into_iter()
4235 .collect(),
4236 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4237 key: key.clone(),
4238 field: field.clone(),
4239 commit_seq: seq,
4240 }],
4241 WalRecord::InsertEdge {
4242 edge_type,
4243 src_key,
4244 dst_key,
4245 } => vec![DbEvent::EdgeInserted {
4246 edge_type: edge_type.clone(),
4247 src: src_key.clone(),
4248 dst: dst_key.clone(),
4249 commit_seq: seq,
4250 }],
4251 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4252 Some(DbEvent::EdgeInserted {
4253 edge_type: intern.resolve(*etype)?.to_string(),
4254 src: ids.key_of(*src)?.to_string(),
4255 dst: ids.key_of(*dst)?.to_string(),
4256 commit_seq: seq,
4257 })
4258 })()
4259 .into_iter()
4260 .collect(),
4261 WalRecord::DeleteEdge {
4262 edge_type,
4263 src_key,
4264 dst_key,
4265 } => vec![DbEvent::EdgeDeleted {
4266 edge_type: edge_type.clone(),
4267 src: src_key.clone(),
4268 dst: dst_key.clone(),
4269 commit_seq: seq,
4270 }],
4271 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4272 key: key.clone(),
4273 commit_seq: seq,
4274 }],
4275 WalRecord::Batch(inner) => inner
4276 .iter()
4277 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4278 .collect(),
4279 WalRecord::CreateRule { .. }
4280 | WalRecord::DeleteRule { .. }
4281 | WalRecord::RebuildRule { .. }
4282 | WalRecord::CreateView { .. }
4283 | WalRecord::DeleteView { .. }
4284 | WalRecord::EnableFulltext { .. }
4285 | WalRecord::DisableFulltext { .. }
4286 | WalRecord::EnableIndex { .. }
4287 | WalRecord::DisableIndex { .. }
4288 | WalRecord::Intern { .. }
4289 // History markers produce no DbEvent — the engine delta already
4290 // fired the EdgeFired/EdgeRetracted subscription events.
4291 | WalRecord::DerivedEdgeAdded { .. }
4292 | WalRecord::DerivedEdgeRetracted { .. }
4293 | WalRecord::RenameNode { .. } => vec![],
4294 }
4295 }
4296
4297 /// Subscribe to edge-fire and edge-retract events for one named rule.
4298 ///
4299 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4300 /// currently registered. Dropping the returned [`Subscription`] handle
4301 /// unregisters the subscriber — no further events are queued, no
4302 /// resources leak.
4303 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4304 if self.read_only {
4305 return Err(core_storage::GraphError::ReadOnly);
4306 }
4307 if !self.engine.rules().any(|r| r.name == rule_name) {
4308 return Err(core_storage::GraphError::RuleNotFound {
4309 name: rule_name.to_string(),
4310 });
4311 }
4312 let inner = SubInner::new(self.sub_capacity());
4313 self.subscriptions.push(SubEntry {
4314 filter: SubFilter::Rule(rule_name.to_string()),
4315 inner: std::sync::Arc::downgrade(&inner),
4316 });
4317 self.engine.set_emit_deltas(true);
4318 Ok(Subscription(inner))
4319 }
4320
4321 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4322 ///
4323 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4324 /// as-of instances never commit, so `distribute_events` never runs and the
4325 /// subscription would never deliver events.
4326 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4327 if self.read_only {
4328 return Err(core_storage::GraphError::ReadOnly);
4329 }
4330 let inner = SubInner::new(self.sub_capacity());
4331 self.subscriptions.push(SubEntry {
4332 filter: SubFilter::AllRules,
4333 inner: std::sync::Arc::downgrade(&inner),
4334 });
4335 self.engine.set_emit_deltas(true);
4336 Ok(Subscription(inner))
4337 }
4338
4339 /// Subscribe to write events: node insert/delete, prop set/remove.
4340 ///
4341 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4342 ///
4343 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4344 /// as-of instances never commit, so `distribute_events` never runs and the
4345 /// subscription would never deliver events.
4346 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4347 if self.read_only {
4348 return Err(core_storage::GraphError::ReadOnly);
4349 }
4350 let inner = SubInner::new(self.sub_capacity());
4351 self.subscriptions.push(SubEntry {
4352 filter: SubFilter::Writes,
4353 inner: std::sync::Arc::downgrade(&inner),
4354 });
4355 self.engine.set_emit_deltas(true);
4356 Ok(Subscription(inner))
4357 }
4358
4359 /// Subscribe to incremental Cypher query results.
4360 ///
4361 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4362 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4363 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4364 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4365 ///
4366 /// SKIP is not supported — it shifts the result window on every commit,
4367 /// causing spurious Added/Removed churn for rows whose data never changed.
4368 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4369 /// widens scope beyond the documented single-scan / single-hop subset.
4370 ///
4371 /// After each successful commit, the plan is **fully re-executed** and the
4372 /// result is diffed against the previous run. Added rows produce
4373 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4374 /// [`DbEvent::QueryRowRemoved`].
4375 ///
4376 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4377 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4378 /// is roadmap / Phase 5.
4379 ///
4380 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4381 /// as-of instances never commit, so `distribute_events` never runs and the
4382 /// subscription would never deliver events.
4383 ///
4384 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4385 /// or if the plan shape is not in the allowlist.
4386 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4387 if self.read_only {
4388 return Err(GraphError::ReadOnly);
4389 }
4390 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4391 detail: format!("lex: {e}"),
4392 })?;
4393 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4394 detail: format!("parse: {e}"),
4395 })?;
4396 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4397 detail: format!("plan: {e}"),
4398 })?;
4399 if !is_subscribable(&ops) {
4400 return Err(GraphError::QueryError {
4401 detail: "subscribe_query only supports allowlisted plan shapes: \
4402 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4403 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4404 Not supported: multi-hop Expand chains, SKIP (creates \
4405 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4406 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4407 Use LIMIT to bound re-execution cost."
4408 .to_string(),
4409 });
4410 }
4411 // Execute once to capture initial state (initial rows are not emitted as
4412 // events — the subscriber learns the baseline via the first query call).
4413 let empty_params = BTreeMap::new();
4414 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4415 GraphError::QueryError {
4416 detail: format!("execute: {e}"),
4417 }
4418 })?;
4419 let columns = initial.columns().to_vec();
4420 let prev_row_map = Self::result_to_row_map(&initial);
4421 let inner = SubInner::new(self.sub_capacity());
4422 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4423 // or unrecognized leading scan → None (always re-execute).
4424 let scan_label = extract_scan_label(&ops, &mut self.syms);
4425 self.query_subscriptions.push(QuerySubEntry {
4426 ops,
4427 columns,
4428 prev_row_map,
4429 inner: std::sync::Arc::downgrade(&inner),
4430 scan_label,
4431 });
4432 Ok(Subscription(inner))
4433 }
4434
4435 /// Queue capacity used for new subscriptions.
4436 fn sub_capacity(&self) -> usize {
4437 self.sub_capacity
4438 }
4439
4440 /// Override per-subscriber queue capacity for subsequently created
4441 /// subscriptions on this db instance.
4442 ///
4443 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4444 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4445 /// generating tens of thousands of events.
4446 ///
4447 /// This is a test-support escape hatch. Calling it in production reduces
4448 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4449 /// to discourage accidental production use.
4450 #[doc(hidden)]
4451 pub fn set_sub_capacity(&mut self, capacity: usize) {
4452 self.sub_capacity = capacity;
4453 }
4454
4455 // -----------------------------------------------------------------------
4456
4457 /// Start an atomic batch.
4458 ///
4459 /// The returned [`BatchBuilder`] borrows `self` mutably until
4460 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4461 /// validation, no WAL I/O. `commit` validates every queued op against
4462 /// live state plus preceding ops in this batch (duplicate key inside
4463 /// the batch is `Err`; an edge between two nodes created earlier in
4464 /// the batch is valid; `delete_node` then insert of the same key is a
4465 /// fresh identity). Validation never mutates the database. Any failure
4466 /// leaves WAL bytes and in-memory state identical to before `commit`.
4467 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4468 /// and each inner record is applied in order so rules fire per record.
4469 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4470 ///
4471 /// **Rule-window limitation:** batch validation cannot see edges that a
4472 /// rule created earlier in the *same* batch will derive at apply time, so
4473 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4474 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4475 /// is unaffected (idempotent apply, provenance intact). Create rules in
4476 /// their own batch, or sequentially, when later ops may touch derived
4477 /// edges.
4478 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4479 BatchBuilder {
4480 db: self,
4481 ops: Vec::new(),
4482 }
4483 }
4484
4485 /// Closure-style atomic write batch.
4486 ///
4487 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4488 /// then committing. All ops queued inside `build` are validated in order and
4489 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4490 /// once per inner record, in order, after commit — semantically identical to
4491 /// sequential single-op writes.
4492 ///
4493 /// **Error semantics — validate-then-apply.** `build` queues ops without
4494 /// touching the database. [`BatchBuilder::commit`] validates every op against
4495 /// live state plus earlier ops in this batch before writing anything. If op N
4496 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4497 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4498 /// changes. The database is identical to its state before `write_batch` was
4499 /// called.
4500 ///
4501 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4502 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4503 /// either fully applied or not at all. However, while applying a committed
4504 /// batch, concurrent readers may observe intermediate states as ops are applied
4505 /// sequentially in memory. There is no interactive transaction isolation in v1.
4506 /// This is documented as "crash-atomic write batches; no interactive
4507 /// transactions or read isolation."
4508 ///
4509 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4510 /// writes zero WAL bytes and returns `(0, 0)`.
4511 ///
4512 /// # Example
4513 ///
4514 /// ```rust,ignore
4515 /// let (nodes, edges) = db.write_batch(|b| {
4516 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4517 /// b.insert_node("Person", "bob", vec![]);
4518 /// b.insert_edge("KNOWS", "alice", "bob");
4519 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4520 /// b.delete_node("old_key");
4521 /// })?;
4522 /// // One fsync; on crash replay: all five ops land or none do.
4523 /// ```
4524 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4525 where
4526 C: FnOnce(&mut BatchBuilder<'_, F>),
4527 {
4528 let mut b = self.batch();
4529 build(&mut b);
4530 b.commit()
4531 }
4532
4533 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4534 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4535 /// inserts, so incremental fire sees the new rules. Per-row key problems
4536 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4537 /// `Err` means nothing was applied.
4538 ///
4539 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4540 /// distinct source labels sharing an FK field each get their own rule.
4541 pub fn ingest(
4542 &mut self,
4543 label: &str,
4544 rows: Vec<BTreeMap<String, Value>>,
4545 opts: &IngestOptions,
4546 ) -> Result<IngestReport> {
4547 self.ingest_with_edges(label, rows, opts, &[])
4548 }
4549
4550 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4551 /// A failing edge rejects the whole request; nothing is applied.
4552 pub fn ingest_with_edges(
4553 &mut self,
4554 label: &str,
4555 rows: Vec<BTreeMap<String, Value>>,
4556 opts: &IngestOptions,
4557 edges: &[(String, String, String)],
4558 ) -> Result<IngestReport> {
4559 crate::ingest::run(self, label, rows, opts, edges)
4560 }
4561
4562 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4563 ///
4564 /// JSON `null` fields are silently omitted (not stored, not a row error).
4565 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4566 /// Parse failures and a top-level value that is not an array of objects
4567 /// return [`GraphError::IngestError`].
4568 pub fn ingest_json(
4569 &mut self,
4570 label: &str,
4571 json: &str,
4572 opts: &IngestOptions,
4573 ) -> Result<IngestReport> {
4574 crate::ingest::run_json(self, label, json, opts)
4575 }
4576
4577 fn commit_logged_batch(
4578 &mut self,
4579 ops: Vec<BatchOp>,
4580 ingest: Option<(String, usize)>,
4581 // Two-source rule: write_batch_authz threads authz here directly (never
4582 // touches pending_write_authz); query_write_authz sets the field instead
4583 // and passes None. Only one source is non-None per call.
4584 param_authz: Option<WriteAuthz>,
4585 ) -> Result<(usize, usize)> {
4586 // Read-only guard: catches empty-batch calls before the early-return
4587 // that skips log_then_apply_with, ensuring all mutation entry points fail.
4588 if self.read_only {
4589 return Err(GraphError::ReadOnly);
4590 }
4591 // Ensure provenance is decoded before MutPreview accesses it
4592 // (note_delete_rule / is_rule_owned may call engine.provenance()).
4593 self.engine.ensure_provenance_loaded_mut();
4594
4595 // ── Authz pre-check ──────────────────────────────────────────────────
4596 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4597 // produces no WAL frame (all-or-nothing at the authz boundary extends
4598 // the existing validate-then-apply contract to role-scope checks).
4599 //
4600 // `batch_created` tracks key→label for nodes created by earlier ops in
4601 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4602 // as visible without needing to call `self.ids.get` on not-yet-committed
4603 // keys (they won't be there yet).
4604 //
4605 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4606 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4607 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4608 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4609 if let Some(ref authz) = authz_opt {
4610 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4611 for op in &ops {
4612 self.check_single_op_authz(authz, op, &batch_created)?;
4613 // Update batch_created after a passing authz check so that
4614 // subsequent ops in this batch see the nodes as "about to exist".
4615 match op {
4616 BatchOp::InsertNode { label, key, .. } => {
4617 // Only track genuinely new nodes (absent from the
4618 // snapshot at authz-check time). A pre-existing visible
4619 // key would be a DuplicateKey — not a real creation —
4620 // so MutPreview handles it. Letting it into batch_created
4621 // would allow a later SetProp to bypass update_labels
4622 // via the "batch-created → always updatable" ruling
4623 // (delete+recreate exploit, fix for I1 review round 2).
4624 //
4625 // Accepted edge: for a delete+recreate-with-different-
4626 // label batch, node_status resolves the pre-delete
4627 // (store) label for any subsequent update checks. This
4628 // grants no net-new capability — a role that can delete+
4629 // create can already place arbitrary props via
4630 // InsertNode's own props field.
4631 if self.ids.get(key.as_str()).is_none() {
4632 batch_created.insert(key.clone(), label.clone());
4633 }
4634 }
4635 BatchOp::InsertEdgeUpsert {
4636 placeholder_label,
4637 src_key,
4638 dst_key,
4639 ..
4640 } => {
4641 // Both endpoints will be created if not already in store.
4642 for ep_key in [src_key, dst_key] {
4643 if self.ids.get(ep_key.as_str()).is_none()
4644 && !batch_created.contains_key(ep_key.as_str())
4645 {
4646 batch_created.insert(ep_key.clone(), placeholder_label.clone());
4647 }
4648 }
4649 }
4650 _ => {}
4651 }
4652 }
4653 }
4654
4655 let recs = {
4656 let mut preview = MutPreview::new(self);
4657 let mut recs = Vec::with_capacity(ops.len());
4658 for op in ops {
4659 match op {
4660 BatchOp::InsertNode { label, key, props } => {
4661 preview.check_insert_node(&key)?;
4662 preview.note_insert_node(&key, &props);
4663 recs.push(WalRecord::InsertNode { label, key, props });
4664 }
4665 BatchOp::InsertEdge {
4666 edge_type,
4667 src_key,
4668 dst_key,
4669 } => {
4670 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4671 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4672 recs.push(WalRecord::InsertEdge {
4673 edge_type,
4674 src_key,
4675 dst_key,
4676 });
4677 }
4678 }
4679 BatchOp::SetProp { key, field, value } => {
4680 preview.check_live_key(&key)?;
4681 preview.note_set_prop(&key, &field, &value);
4682 recs.push(WalRecord::SetProp { key, field, value });
4683 }
4684 BatchOp::RemoveProp { key, field } => {
4685 if preview.prepare_remove_prop(&key, &field)? {
4686 preview.note_remove_prop(&key, &field);
4687 recs.push(WalRecord::RemoveProp { key, field });
4688 }
4689 }
4690 BatchOp::DeleteEdge {
4691 edge_type,
4692 src_key,
4693 dst_key,
4694 } => {
4695 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4696 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4697 recs.push(WalRecord::DeleteEdge {
4698 edge_type,
4699 src_key,
4700 dst_key,
4701 });
4702 }
4703 }
4704 BatchOp::DeleteNode { key } => {
4705 preview.check_live_key(&key)?;
4706 preview.note_delete_node(&key);
4707 recs.push(WalRecord::DeleteNode { key });
4708 }
4709 BatchOp::CreateRule(def) => {
4710 preview.check_create_rule(&def)?;
4711 let def_bytes =
4712 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4713 detail: format!("serialize rule: {e}"),
4714 })?;
4715 preview.note_create_rule(&def);
4716 recs.push(WalRecord::CreateRule { def_bytes });
4717 }
4718 BatchOp::DeleteRule { name } => {
4719 preview.check_delete_rule(&name)?;
4720 preview.note_delete_rule(&name);
4721 recs.push(WalRecord::DeleteRule { name });
4722 }
4723 BatchOp::RenameNode { old_key, new_key } => {
4724 preview.check_rename_node(&old_key, &new_key)?;
4725 preview.note_rename_node(&old_key, &new_key);
4726 recs.push(WalRecord::RenameNode { old_key, new_key });
4727 }
4728 BatchOp::InsertEdgeUpsert {
4729 edge_type,
4730 src_key,
4731 dst_key,
4732 placeholder_label,
4733 } => {
4734 // Auto-create any missing endpoints as plain InsertNode ops.
4735 // Rules fire and last-change is updated for each created node.
4736 for key in [&src_key, &dst_key] {
4737 if !preview.has_key(key) {
4738 preview.check_insert_node(key)?;
4739 preview.note_insert_node(key, &[]);
4740 recs.push(WalRecord::InsertNode {
4741 label: placeholder_label.clone(),
4742 key: key.clone(),
4743 props: vec![],
4744 });
4745 }
4746 }
4747 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4748 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4749 recs.push(WalRecord::InsertEdge {
4750 edge_type,
4751 src_key,
4752 dst_key,
4753 });
4754 }
4755 }
4756 }
4757 }
4758 recs
4759 };
4760 if recs.is_empty() {
4761 return Ok((0, 0));
4762 }
4763 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4764 // *Id form, so only the dense variants can appear in `recs` here.
4765 let recs = self.rewrite_wal_dense(recs)?;
4766 let nodes_inserted = recs
4767 .iter()
4768 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4769 .count();
4770 let edges_inserted = recs
4771 .iter()
4772 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4773 .count();
4774 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4775 // under Strict. Pass self.fsync directly so Strict stays Strict —
4776 // wal_needs_sync(Strict, _) always returns true regardless of op count.
4777 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4778 // short-circuit on single-op batches and silently skip the fsync.
4779 // Batched fsyncs only for multi-op batches; Relaxed always skips.
4780 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4781 Ok((nodes_inserted, edges_inserted))
4782 }
4783
4784 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4785 self.commit_logged_batch(ops, None, None)
4786 }
4787
4788 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4789 /// and the group-commit drain thread, which do a single group fsync later.
4790 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4791 // Restore fsync policy even on panic via a raw-pointer drop guard.
4792 // A panic here would poison the RwLock anyway, but the correct policy
4793 // must be in place if the guard is ever unwrapped.
4794 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4795 impl Drop for RestoreFsync {
4796 fn drop(&mut self) {
4797 // SAFETY: the pointer is valid for the full duration of
4798 // commit_batch_nosync; the guard is dropped before the frame
4799 // returns, and GraphDb outlives this frame.
4800 unsafe {
4801 *self.0 = self.1;
4802 }
4803 }
4804 }
4805 let saved = self.fsync;
4806 // SAFETY: raw pointer into self; guard dropped within this frame.
4807 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4808 self.fsync = FsyncPolicy::Relaxed;
4809 self.commit_logged_batch(ops, None, None)
4810 }
4811
4812 /// Commit multiple op-batches as a **group**: each submission gets its own
4813 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4814 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4815 ///
4816 /// # Durability semantics
4817 ///
4818 /// A crash before the group fsync may lose **all** submissions in the group.
4819 /// A crash after the group fsync preserves all of them. No submission is
4820 /// ever torn: each WAL frame is either fully applied on replay or dropped
4821 /// in its entirety (CRC-protected frame boundaries).
4822 ///
4823 /// Events and subscription notifications fire per-submission immediately
4824 /// after apply, which may be before the group fsync. From a subscriber's
4825 /// perspective this is equivalent to the `Relaxed` durability window.
4826 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4827 /// fsync, so from their perspective durability is fully guaranteed.
4828 ///
4829 /// # MVCC interplay
4830 ///
4831 /// Each submission records its own `CommitDelta`; the fold-every-K counter
4832 /// increments per submission (not per group), preserving existing reader
4833 /// snapshot semantics.
4834 ///
4835 /// # Returns
4836 ///
4837 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4838 /// in order. Failures are per-submission (validation errors); the group
4839 /// fsync error (if any) is returned as the second tuple element.
4840 pub fn commit_group(
4841 &mut self,
4842 groups: Vec<Vec<BatchOp>>,
4843 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4844 let mut results = Vec::with_capacity(groups.len());
4845 for ops in groups {
4846 results.push(self.commit_batch_nosync(ops));
4847 }
4848 let any_ok = results.iter().any(|r| r.is_ok());
4849 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4850 self.fs
4851 .sync(core_storage::fs::FileId::Wal)
4852 .map_err(GraphError::Io)
4853 .err()
4854 } else {
4855 None
4856 };
4857 (results, sync_err)
4858 }
4859
4860 /// Like [`commit_group`] but skips the group fsync entirely.
4861 ///
4862 /// Used by the drain thread to apply submissions under the write lock and
4863 /// then perform the single fsync OUTSIDE the lock (via
4864 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4865 /// to concurrent readers.
4866 pub fn commit_group_nosync(
4867 &mut self,
4868 groups: Vec<Vec<BatchOp>>,
4869 ) -> Vec<Result<(usize, usize)>> {
4870 let mut results = Vec::with_capacity(groups.len());
4871 for ops in groups {
4872 results.push(self.commit_batch_nosync(ops));
4873 }
4874 results
4875 }
4876
4877 pub fn insert_node(
4878 &mut self,
4879 label: &str,
4880 key: &str,
4881 props: Vec<(String, Value)>,
4882 ) -> Result<()> {
4883 if self.read_only {
4884 return Err(GraphError::ReadOnly);
4885 }
4886 MutPreview::new(self).check_insert_node(key)?;
4887 self.log_dense(vec![WalRecord::InsertNode {
4888 label: label.into(),
4889 key: key.into(),
4890 props,
4891 }])
4892 }
4893
4894 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4895 if self.read_only {
4896 return Err(GraphError::ReadOnly);
4897 }
4898 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4899 return Ok(false);
4900 }
4901 self.log_dense(vec![WalRecord::InsertEdge {
4902 edge_type: edge_type.into(),
4903 src_key: src_key.into(),
4904 dst_key: dst_key.into(),
4905 }])?;
4906 Ok(true)
4907 }
4908
4909 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4910 if self.read_only {
4911 return Err(GraphError::ReadOnly);
4912 }
4913 if let Some(view_name) = self.view_store.view_for_prop(field) {
4914 return Err(GraphError::ViewPropReadOnly {
4915 view_name: view_name.to_string(),
4916 });
4917 }
4918 MutPreview::new(self).check_live_key(key)?;
4919 self.log_dense(vec![WalRecord::SetProp {
4920 key: key.into(),
4921 field: field.into(),
4922 value,
4923 }])
4924 }
4925
4926 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4927 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4928 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4929 if self.read_only {
4930 return Err(GraphError::ReadOnly);
4931 }
4932 if let Some(view_name) = self.view_store.view_for_prop(field) {
4933 return Err(GraphError::ViewPropReadOnly {
4934 view_name: view_name.to_string(),
4935 });
4936 }
4937 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4938 return Ok(false);
4939 }
4940 self.log_then_apply(WalRecord::RemoveProp {
4941 key: key.into(),
4942 field: field.into(),
4943 })?;
4944 Ok(true)
4945 }
4946
4947 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4948 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4949 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4950 /// (the rule would just put the edge back; delete or change the rule).
4951 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4952 if self.read_only {
4953 return Err(GraphError::ReadOnly);
4954 }
4955 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4956 return Ok(false);
4957 }
4958 self.log_then_apply(WalRecord::DeleteEdge {
4959 edge_type: edge_type.into(),
4960 src_key: src_key.into(),
4961 dst_key: dst_key.into(),
4962 })?;
4963 Ok(true)
4964 }
4965
4966 /// Delete a live node. Unknown or already-tombstoned keys are
4967 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4968 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4969 /// (crash window) is a clean no-op.
4970 ///
4971 /// Returns a [`DeleteReport`] with counts of manual and derived edges
4972 /// removed (computed from live state before the deletion is applied).
4973 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4974 if self.read_only {
4975 return Err(GraphError::ReadOnly);
4976 }
4977 // Provenance must be loaded before we query provenance_touching.
4978 self.engine.ensure_provenance_loaded_mut();
4979 let id = self
4980 .ids
4981 .get(key)
4982 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4983
4984 // Count edges before the delete is applied so we can report counts.
4985 let derived_set: BTreeSet<(u32, u32, u32)> = self
4986 .engine
4987 .provenance_touching(id)
4988 .map(|(_, etype, src, dst)| (etype, src, dst))
4989 .collect();
4990 let derived_edges = derived_set.len() as u64;
4991
4992 let mut total_topo = 0u64;
4993 let tv = self.topo_view();
4994 for et in tv.etypes() {
4995 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4996 + tv.neighbors(et, Direction::In, id).len() as u64;
4997 }
4998 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4999 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5000 // The subtraction remains correct because both counts include both directions.
5001 let manual_edges = total_topo.saturating_sub(derived_edges);
5002
5003 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5004 Ok(DeleteReport {
5005 manual_edges,
5006 derived_edges,
5007 })
5008 }
5009
5010 /// Rename a live node's key. The dense id (and therefore all edges,
5011 /// props, history, and last-change tracking) is unaffected.
5012 ///
5013 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5014 /// Returns `Err(DuplicateKey)` if `new` is already live.
5015 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5016 if self.read_only {
5017 return Err(GraphError::ReadOnly);
5018 }
5019 MutPreview::new(self).check_rename_node(old, new)?;
5020 self.log_then_apply(WalRecord::RenameNode {
5021 old_key: old.into(),
5022 new_key: new.into(),
5023 })
5024 }
5025
5026 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5027 /// `None` if the rule does not exist or is not approximate.
5028 ///
5029 /// The drift counter increments on IVF insert/remove after the last fit.
5030 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5031 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5032 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5033 // SideIvfExport = (centroids, node→cluster, drift)
5034 self.engine
5035 .export_ivf_state()
5036 .remove(rule)
5037 .map(|(_src, dst)| dst.2)
5038 }
5039
5040 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5041 /// Validation and duplicate-name check run before logging so invalid rules
5042 /// never enter the WAL.
5043 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5044 if self.read_only {
5045 return Err(GraphError::ReadOnly);
5046 }
5047 MutPreview::new(self).check_create_rule(&def)?;
5048 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5049 detail: format!("serialize rule: {e}"),
5050 })?;
5051 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5052 }
5053
5054 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5055 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5056 if self.read_only {
5057 return Err(GraphError::ReadOnly);
5058 }
5059 MutPreview::new(self).check_delete_rule(name)?;
5060 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5061 }
5062
5063 /// Return a snapshot of all registered rules.
5064 pub fn rules(&self) -> Vec<RuleDef> {
5065 self.engine.rules().cloned().collect()
5066 }
5067
5068 // -----------------------------------------------------------------------
5069 // Rule suggestion API
5070 // -----------------------------------------------------------------------
5071
5072 /// Profile the database and suggest linking rules with previewed edge counts.
5073 ///
5074 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5075 /// sampling. Suggestions are sorted by estimated edge count (descending).
5076 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5077 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5078 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5079 }
5080
5081 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5082 /// reproducibility. Same seed + same data = identical output.
5083 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5084 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5085 .suggestions
5086 }
5087
5088 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5089 ///
5090 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5091 /// and a `truncated` flag indicating whether the global budget fired before all
5092 /// candidates were evaluated.
5093 pub fn suggest_rules_with_config(
5094 &self,
5095 config: &core_rules::suggest::SuggestConfig,
5096 seed: u64,
5097 ) -> core_rules::SuggestReport {
5098 use std::collections::BTreeMap;
5099
5100 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5101 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5102 for id in 0..self.ids.len() as u32 {
5103 let Some(key) = self.ids.key_of(id) else {
5104 continue;
5105 };
5106 let Some(&sym) = self.labels.get(id as usize) else {
5107 continue;
5108 };
5109 if sym == u32::MAX {
5110 continue; // tombstoned
5111 }
5112 let Some(label) = self.syms.resolve(sym) else {
5113 continue;
5114 };
5115 label_nodes
5116 .entry(label.to_string())
5117 .or_default()
5118 .push((id, key.to_string()));
5119 }
5120
5121 let existing = self.rules();
5122 let pv = build_props_view(&self.props, &self.base);
5123 let all_fields: Vec<String> = pv.field_names();
5124
5125 core_rules::suggest::suggest_rules(
5126 &label_nodes,
5127 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5128 &all_fields,
5129 &existing,
5130 config,
5131 seed,
5132 )
5133 }
5134
5135 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5136 /// plus later mutations replay identically (rebuild is a pure function
5137 /// of state).
5138 ///
5139 /// Only exit from the tripped latch: if the full desired set fits the
5140 /// budget, it is applied completely and `tripped` clears; if it still
5141 /// exceeds the budget, provenance is left untouched and `tripped` stays
5142 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5143 /// Unknown rule → `RuleNotFound`, nothing logged.
5144 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5145 if self.read_only {
5146 return Err(GraphError::ReadOnly);
5147 }
5148 if !self.engine.rules().any(|r| r.name == name) {
5149 return Err(GraphError::RuleNotFound { name: name.into() });
5150 }
5151 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5152 }
5153
5154 // -----------------------------------------------------------------------
5155 // Materialized view API
5156 // -----------------------------------------------------------------------
5157
5158 /// Register a new materialized property view, backfill its values for all
5159 /// existing nodes, and WAL-log the definition.
5160 ///
5161 /// # Errors
5162 /// - `ReadOnly`: called on an as-of instance.
5163 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5164 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5165 if self.read_only {
5166 return Err(GraphError::ReadOnly);
5167 }
5168 // Pre-validate before WAL write.
5169 def.validate()
5170 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5171 if self.view_store.has_view(&def.name) {
5172 return Err(GraphError::RuleInvalid {
5173 detail: format!("view {:?} already exists", def.name),
5174 });
5175 }
5176 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5177 return Err(GraphError::RuleInvalid {
5178 detail: format!(
5179 "view_prop {:?} is already used by view {:?}",
5180 def.view_prop, existing
5181 ),
5182 });
5183 }
5184 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5185 detail: format!("serialize view: {e}"),
5186 })?;
5187 // Enable delta accumulation before the view is registered so subsequent
5188 // incremental edge events reach view maintenance from this point onward.
5189 // (The backfill inside create_view reads topo directly; it does not rely
5190 // on pending deltas.)
5191 self.engine.set_emit_deltas(true);
5192 self.log_then_apply(WalRecord::CreateView { def_bytes })
5193 }
5194
5195 /// Remove a named view and delete its values from every node.
5196 ///
5197 /// # Errors
5198 /// - `ReadOnly`: called on an as-of instance.
5199 /// - `RuleNotFound`: view does not exist.
5200 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5201 if self.read_only {
5202 return Err(GraphError::ReadOnly);
5203 }
5204 if !self.view_store.has_view(name) {
5205 return Err(GraphError::RuleNotFound { name: name.into() });
5206 }
5207 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5208 // After deletion, disable accumulation if no listeners remain.
5209 if !self.needs_emit_deltas() {
5210 self.engine.set_emit_deltas(false);
5211 }
5212 result
5213 }
5214
5215 /// Snapshot of all registered view definitions.
5216 pub fn views(&self) -> Vec<ViewDef> {
5217 self.view_store.views().cloned().collect()
5218 }
5219
5220 // -----------------------------------------------------------------------
5221 // Full-text-lite API
5222 // -----------------------------------------------------------------------
5223
5224 /// Enable full-text indexing for all nodes of `label` on property `field`.
5225 ///
5226 /// After this call, every subsequent write to `(label, field)` is reflected
5227 /// in the index incrementally. Existing nodes are backfilled immediately.
5228 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5229 /// from scratch on re-open (no snapshot format changes).
5230 ///
5231 /// # Errors
5232 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5233 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5234 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5235 if self.read_only {
5236 return Err(GraphError::ReadOnly);
5237 }
5238 if self.fulltext.is_enabled(label, field) {
5239 return Err(GraphError::RuleInvalid {
5240 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5241 });
5242 }
5243 self.log_then_apply(WalRecord::EnableFulltext {
5244 label: label.into(),
5245 field: field.into(),
5246 })
5247 }
5248
5249 /// Disable full-text indexing for `(label, field)` and drop its postings.
5250 ///
5251 /// # Errors
5252 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5253 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5254 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5255 if self.read_only {
5256 return Err(GraphError::ReadOnly);
5257 }
5258 if !self.fulltext.is_enabled(label, field) {
5259 return Err(GraphError::RuleNotFound {
5260 name: format!("fulltext({label},{field})"),
5261 });
5262 }
5263 self.log_then_apply(WalRecord::DisableFulltext {
5264 label: label.into(),
5265 field: field.into(),
5266 })
5267 }
5268
5269 /// Whether `(label, field)` is currently indexed for full-text search.
5270 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5271 self.fulltext.is_enabled(label, field)
5272 }
5273
5274 /// Every `(label, field)` pair with a live full-text index, sorted.
5275 ///
5276 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5277 /// declares which nodes are *indexed*, so callers that want to search
5278 /// everything indexed should query each distinct field once.
5279 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5280 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5281 v.sort();
5282 v
5283 }
5284
5285 /// Enable an equality index for all nodes of `label` on scalar property
5286 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5287 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5288 /// declaration persists via WAL and the postings rebuild on re-open.
5289 ///
5290 /// # Errors
5291 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5292 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5293 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5294 if self.read_only {
5295 return Err(GraphError::ReadOnly);
5296 }
5297 if self.prop_index.is_enabled(label, field) {
5298 return Err(GraphError::RuleInvalid {
5299 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5300 });
5301 }
5302 self.log_then_apply(WalRecord::EnableIndex {
5303 label: label.into(),
5304 field: field.into(),
5305 })
5306 }
5307
5308 /// Disable the equality index for `(label, field)` and drop its postings.
5309 ///
5310 /// # Errors
5311 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5312 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5313 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5314 if self.read_only {
5315 return Err(GraphError::ReadOnly);
5316 }
5317 if !self.prop_index.is_enabled(label, field) {
5318 return Err(GraphError::RuleNotFound {
5319 name: format!("index({label},{field})"),
5320 });
5321 }
5322 self.log_then_apply(WalRecord::DisableIndex {
5323 label: label.into(),
5324 field: field.into(),
5325 })
5326 }
5327
5328 /// Whether `(label, field)` currently has an equality index.
5329 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5330 self.prop_index.is_enabled(label, field)
5331 }
5332
5333 /// Search a full-text-indexed field.
5334 ///
5335 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5336 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5337 ///
5338 /// **Query syntax:**
5339 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5340 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5341 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5342 /// - `AND` keyword is accepted explicitly and is the default.
5343 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5344 ///
5345 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5346 /// Pin: this is the documented, tested, stable behavior for v1.
5347 ///
5348 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5349 /// in-memory and proportional to total indexed text across all enabled fields.
5350 ///
5351 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5352 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5353 /// key ascending for deterministic tiebreaking.
5354 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5355 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5356 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5357 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5358 // which diverges from key order when nodes were not inserted in key-lex order.
5359 let mut results: Vec<(String, f64)> = self
5360 .fulltext
5361 .search(field, query, 0)
5362 .into_iter()
5363 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5364 .collect();
5365 results.sort_by(|a, b| {
5366 b.1.partial_cmp(&a.1)
5367 .unwrap_or(std::cmp::Ordering::Equal)
5368 .then(a.0.cmp(&b.0))
5369 });
5370 results
5371 }
5372
5373 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5374 ///
5375 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5376 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5377 /// them with RRF using a fixed constant of 60.
5378 ///
5379 /// ```text
5380 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5381 /// ```
5382 ///
5383 /// Returns the top `k` nodes by fused score, ties broken by node key
5384 /// ascending (deterministic).
5385 ///
5386 /// # Vector leg fallback
5387 ///
5388 /// When `query_vec` is empty the vector leg is skipped entirely and
5389 /// results are ranked by the text list alone through the same RRF path
5390 /// (each text result scores `1/(60 + rank)` from that single list).
5391 ///
5392 /// When `label` is `None`, the vector leg **always** returns empty results.
5393 /// Internally `label` is mapped to `""`, which does not match any rule-created
5394 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5395 /// the brute-force fallback finds no nodes with an empty label. The fused
5396 /// ranking is therefore text-only in this case.
5397 pub fn search_hybrid(
5398 &self,
5399 text_field: &str,
5400 query_text: &str,
5401 vector_field: &str,
5402 query_vec: &[f64],
5403 label: Option<&str>,
5404 k: usize,
5405 ) -> Vec<(String, f64)> {
5406 use std::collections::HashMap;
5407
5408 const RRF_K: f64 = 60.0;
5409 let pool = 4 * k;
5410
5411 // Accumulate per-node RRF scores.
5412 let mut scores: HashMap<String, f64> = HashMap::new();
5413
5414 // Text leg.
5415 let text_hits = self.search(text_field, query_text);
5416 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5417 let rank = (rank0 + 1) as f64;
5418 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5419 }
5420
5421 // Vector leg (skipped when query_vec is empty).
5422 if !query_vec.is_empty() {
5423 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5424 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5425 let rank = (rank0 + 1) as f64;
5426 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5427 }
5428 }
5429
5430 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5431 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5432 ranked.sort_by(|a, b| {
5433 b.1.partial_cmp(&a.1)
5434 .unwrap_or(std::cmp::Ordering::Equal)
5435 .then(a.0.cmp(&b.0))
5436 });
5437 ranked.truncate(k);
5438 ranked
5439 }
5440
5441 /// For DST/testing: scratch BM25 search over live nodes without the index.
5442 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5443 /// returns BM25-ranked results.
5444 ///
5445 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5446 /// `scratch_search(field, q)` at every quiescent state.
5447 #[doc(hidden)]
5448 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5449 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5450 use std::collections::BTreeMap;
5451
5452 let groups = parse_query(query);
5453 if groups.is_empty() {
5454 return vec![];
5455 }
5456
5457 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5458 struct NodeData {
5459 key: String,
5460 /// stemmed_token → positions (sorted)
5461 tokens: BTreeMap<String, Vec<u32>>,
5462 dl: u32,
5463 }
5464
5465 let mut nodes: Vec<NodeData> = Vec::new();
5466 for id in 0..self.ids.len() as u32 {
5467 let Some(key) = self.ids.key_of(id) else {
5468 continue;
5469 };
5470 let Some(&sym) = self.labels.get(id as usize) else {
5471 continue;
5472 };
5473 if sym == u32::MAX {
5474 continue;
5475 }
5476 let label = match self.syms.resolve(sym) {
5477 Some(l) => l,
5478 None => continue,
5479 };
5480 if !self.fulltext.is_enabled(label, field) {
5481 continue;
5482 }
5483 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5484 continue;
5485 };
5486 // Use value_tokens_stemmed_with_positions so list elements are
5487 // separated by POSITION_GAP — identical to the index path, which
5488 // prevents phrase queries from matching across element boundaries.
5489 let stemmed_with_pos = match &value {
5490 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5491 _ => continue,
5492 };
5493 let dl = stemmed_with_pos.len() as u32;
5494 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5495 for (tok, pos) in stemmed_with_pos {
5496 tok_map.entry(tok).or_default().push(pos);
5497 }
5498 nodes.push(NodeData {
5499 key: key.to_string(),
5500 tokens: tok_map,
5501 dl,
5502 });
5503 }
5504
5505 if nodes.is_empty() {
5506 return vec![];
5507 }
5508
5509 // --- BM25 corpus stats ---
5510 let n = nodes.len() as f64;
5511 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5512 // df per stemmed token across all live indexed nodes.
5513 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5514 for nd in &nodes {
5515 for tok in nd.tokens.keys() {
5516 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5517 }
5518 }
5519
5520 const K1: f64 = 1.2;
5521 const B: f64 = 0.75;
5522
5523 // --- Pass 2: score each node against each OR-group ---
5524 let mut results: Vec<(String, f64)> = Vec::new();
5525 for nd in &nodes {
5526 let dl = nd.dl as f64;
5527 let mut total_score = 0.0f64;
5528
5529 'group: for group in &groups {
5530 let mut group_score = 0.0f64;
5531
5532 for term in group {
5533 if term.negated {
5534 // Negated: if doc has this stemmed token → group fails.
5535 let present = if term.prefix {
5536 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5537 } else {
5538 nd.tokens.contains_key(term.token.as_str())
5539 };
5540 if present {
5541 continue 'group;
5542 }
5543 continue;
5544 }
5545 if term.prefix {
5546 // Prefix: sum BM25 for all matching stemmed tokens.
5547 let mut prefix_matched = false;
5548 for (tok, positions) in &nd.tokens {
5549 if tok.starts_with(term.token.as_str()) {
5550 let tf = positions.len() as f64;
5551 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5552 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5553 let tf_norm =
5554 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5555 group_score += idf * tf_norm;
5556 prefix_matched = true;
5557 }
5558 }
5559 if !prefix_matched {
5560 continue 'group;
5561 }
5562 } else {
5563 // term.token is already stemmed by parse_query; use directly.
5564 match nd.tokens.get(term.token.as_str()) {
5565 None => continue 'group,
5566 Some(positions) => {
5567 let tf = positions.len() as f64;
5568 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5569 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5570 let tf_norm =
5571 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5572 group_score += idf * tf_norm;
5573 }
5574 }
5575 }
5576 }
5577
5578 if group_score > 0.0 {
5579 total_score += group_score;
5580 }
5581 }
5582
5583 if total_score > 0.0 {
5584 results.push((nd.key.clone(), total_score));
5585 }
5586 }
5587
5588 results.sort_by(|a, b| {
5589 b.1.partial_cmp(&a.1)
5590 .unwrap_or(std::cmp::Ordering::Equal)
5591 .then(a.0.cmp(&b.0))
5592 });
5593 results
5594 }
5595
5596 /// Return the current view-maintained value of `view_prop` for node `key`.
5597 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5598 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5599 let id = self.ids.get(key)?;
5600 self.props_view()
5601 .get(id, view_prop)
5602 .map(|vr| vr.into_value())
5603 }
5604
5605 /// For testing / DST oracle: scratch recompute of a view value for one node.
5606 ///
5607 /// Returns `None` if the node does not exist, the view does not exist, or
5608 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5609 #[doc(hidden)]
5610 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5611 let node = self.ids.get(key)?;
5612 let def = self.view_store.views().find(|v| v.name == view_name)?;
5613 // Use TopologyView so that NeighborAgg sees base + overlay edges
5614 // without materialising a temporary Topology (I1).
5615 let topo_view = self.topo_view();
5616 core_rules::views::compute_view_value(
5617 def,
5618 node,
5619 self.props_view(),
5620 &topo_view,
5621 &self.ids,
5622 &self.syms,
5623 &self.labels,
5624 )
5625 }
5626
5627 // -----------------------------------------------------------------------
5628 // Graph algorithm API
5629 // -----------------------------------------------------------------------
5630
5631 /// Run PageRank over the unified topology (manual + derived edges).
5632 ///
5633 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5634 /// ascending). Set `config.edge_type` to restrict to one edge type.
5635 /// `config.converged` is `true` only when the power iteration converged
5636 /// within `config.max_iters` and within any time budget.
5637 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5638 let topo = build_topo_view(&self.topo, &self.base);
5639 crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5640 }
5641
5642 /// Weakly-connected components over the unified topology (treated as
5643 /// undirected regardless of how edges were inserted).
5644 ///
5645 /// Component IDs are the key of the smallest member in the component
5646 /// (deterministic). Result sorted by (component_id, key).
5647 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5648 let topo = build_topo_view(&self.topo, &self.base);
5649 crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5650 }
5651
5652 /// Degree centrality for every live node.
5653 ///
5654 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5655 /// `AlgoDir::Both` = out + in (total directed degree).
5656 ///
5657 /// For one-shot ranking use this; for a live property updated on every
5658 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5659 pub fn degree_centrality(
5660 &self,
5661 config: &crate::algo::DegreeConfig,
5662 ) -> crate::algo::DegreeReport {
5663 let topo = build_topo_view(&self.topo, &self.base);
5664 crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5665 }
5666
5667 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5668 /// atomically via a single write-batch (one WAL frame, one fsync).
5669 ///
5670 /// # Errors
5671 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5672 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5673 /// (collision check mirrors `create_view`).
5674 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5675 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5676 if self.read_only {
5677 return Err(GraphError::ReadOnly);
5678 }
5679 // Collision check: refuse if prop_name is view-managed.
5680 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5681 return Err(GraphError::RuleInvalid {
5682 detail: format!(
5683 "prop {:?} is managed by view {:?} and cannot be written as scores",
5684 prop_name, view_name
5685 ),
5686 });
5687 }
5688 // Refuse if prop_name is a view name itself (confusing namespace collision).
5689 if self.view_store.has_view(prop_name) {
5690 return Err(GraphError::RuleInvalid {
5691 detail: format!(
5692 "prop_name {:?} collides with an existing view name",
5693 prop_name
5694 ),
5695 });
5696 }
5697 // Write all scores in a single crash-atomic batch.
5698 self.write_batch(|b| {
5699 for (key, score) in scores {
5700 b.set_prop(key, prop_name, Value::Float(*score));
5701 }
5702 })?;
5703 Ok(())
5704 }
5705
5706 /// Return the value of `field` for the node with key `key`, or `None` if
5707 /// the node or field is absent. Reads through the overlay-over-base
5708 /// `ColumnsView`, materialising base values on demand (zero heap cost for
5709 /// overlay hits; one clone per base hit).
5710 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5711 let id = self.ids.get(key)?;
5712 self.props_view().get(id, field).map(|vr| vr.into_value())
5713 }
5714
5715 pub fn has_node(&self, key: &str) -> bool {
5716 self.ids.get(key).is_some()
5717 }
5718
5719 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5720 pub(crate) fn ids(&self) -> &IdMap {
5721 &self.ids
5722 }
5723
5724 // -----------------------------------------------------------------------
5725 // RBAC role resolution
5726 // -----------------------------------------------------------------------
5727
5728 /// Parse `roles.json` bytes from `fs`.
5729 ///
5730 /// Return values:
5731 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5732 /// and valid; in both cases `mask_for_role` uses the
5733 /// list normally (an absent file means no roles defined).
5734 /// `Ok(None)` — file present but corrupt or unrecognised version
5735 /// → poisoned state; `mask_for_role` returns `Err` for
5736 /// any role name until the file is fixed and the DB
5737 /// re-opened (or `apply_schema` is called to repair it).
5738 ///
5739 /// Note: `None` signals corruption, not absence — the opposite of what an
5740 /// optional "file missing" convention would suggest. The open path stores
5741 /// this result on `db.roles` directly.
5742 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5743 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5744 if bytes.is_empty() {
5745 // Empty bytes means either the file is absent or zero-byte — both
5746 // are treated identically as "no roles defined". A zero-byte
5747 // roles.json does NOT widen access: an absent file and a zero-byte
5748 // file both resolve to an empty role list (sees nothing by default).
5749 return Ok(Some(vec![]));
5750 }
5751 match serde_json::from_slice::<RolesFile>(&bytes) {
5752 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5753 // Corrupt or unrecognised version (>2): poison the roles state.
5754 _ => Ok(None),
5755 }
5756 }
5757
5758 /// Resolve a role to a node-visibility mask against the current graph state.
5759 ///
5760 /// Returns `Err` when:
5761 /// - `roles.json` was present but corrupt at open (poisoned state), or
5762 /// - `role` does not match any defined role name.
5763 ///
5764 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5765 /// all live nodes carrying any label in `labels`. Label resolution is live
5766 /// — new nodes of an allowed label are visible without re-applying the
5767 /// schema. An empty union = empty mask = sees nothing.
5768 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5769 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5770 detail:
5771 "roles.json was corrupt at open; fix the file and re-open to restore role access"
5772 .into(),
5773 })?;
5774 let def = roles
5775 .iter()
5776 .find(|r| r.name == role)
5777 .ok_or_else(|| GraphError::KeyNotFound {
5778 key: format!("role:{role}"),
5779 })?;
5780
5781 let mut visible = std::collections::HashSet::new();
5782
5783 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5784 for key in &def.keys {
5785 if let Some(id) = self.ids.get(key) {
5786 visible.insert(id);
5787 }
5788 }
5789
5790 // Label leg: live scan — iterate labels vec for matching symbol.
5791 for label_name in &def.labels {
5792 if let Some(sym) = self.syms.get(label_name) {
5793 for (i, &s) in self.labels.iter().enumerate() {
5794 if s == sym {
5795 visible.insert(i as u32);
5796 }
5797 }
5798 }
5799 }
5800
5801 Ok(crate::mask::NodeMask::from_ids(visible))
5802 }
5803
5804 /// Return the current list of role definitions.
5805 ///
5806 /// Returns an empty list when no roles are defined or when `roles.json`
5807 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5808 /// the fail-loud error in that case).
5809 pub fn roles(&self) -> Vec<RoleDef> {
5810 self.roles.as_deref().unwrap_or(&[]).to_vec()
5811 }
5812
5813 // ── Role-scoped write authz ───────────────────────────────────────────────
5814
5815 /// Execute `ops` with optional role-scoped write authorization.
5816 ///
5817 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5818 /// (zero-cost bypass of all authz checks).
5819 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5820 /// record is built. A denial returns an error with no WAL frame written
5821 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5822 ///
5823 /// See the plan's "authz decision table" section for the full semantics.
5824 pub fn write_batch_authz(
5825 &mut self,
5826 authz: Option<&WriteAuthz>,
5827 ops: Vec<BatchOp>,
5828 ) -> Result<(usize, usize)> {
5829 // Thread authz as a direct parameter — never touches pending_write_authz.
5830 self.commit_logged_batch(ops, None, authz.cloned())
5831 }
5832
5833 /// Execute a Cypher write statement with role-scoped write authorization.
5834 ///
5835 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5836 /// lifetime as execution, satisfying §5 lock discipline). The resolved
5837 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5838 /// call so that all inner `batch.commit()` calls are authz-checked.
5839 ///
5840 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5841 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5842 /// timing-oracle item (hidden ≡ absent for unscoped roles).
5843 ///
5844 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5845 /// "this endpoint is not permitted".
5846 pub fn query_write_authz(
5847 &mut self,
5848 role: &str,
5849 cypher: &str,
5850 params: &BTreeMap<String, Value>,
5851 ) -> Result<ResultSet> {
5852 // Resolve scope (fails fast if role has no write scope).
5853 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5854 let scope =
5855 {
5856 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5857 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5858 })?;
5859 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5860 GraphError::KeyNotFound {
5861 key: format!("role:{role}"),
5862 }
5863 })?;
5864 def.write
5865 .clone()
5866 .ok_or_else(|| GraphError::RoleWriteDenied {
5867 reason: "role-bound token: writes are not permitted".into(),
5868 })?
5869 };
5870 // Resolve mask inside the call (same guard, §5 coherence).
5871 let mask = self.mask_for_role(role)?;
5872 self.pending_write_authz = Some(WriteAuthz {
5873 role: role.into(),
5874 scope,
5875 mask,
5876 });
5877 // RAII guard: always clears pending_write_authz on scope exit, including
5878 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5879 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5880 impl Drop for ClearPendingAuthzOnDrop {
5881 fn drop(&mut self) {
5882 // SAFETY: pointer into the owning GraphDb; guard is dropped
5883 // within this function's frame before it returns.
5884 unsafe { *self.0 = None };
5885 }
5886 }
5887 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5888 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5889 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5890 detail: format!("lex: {e}"),
5891 })?;
5892 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5893 detail: format!("parse: {e}"),
5894 })?;
5895 self.exec_write_stmt(stmt, params)
5896 }
5897
5898 /// Execute `ops` with optional role-scoped write authorization, suppressing
5899 /// fsync (for use inside the group-commit drain thread, which performs one
5900 /// group fsync after releasing the write lock).
5901 ///
5902 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5903 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5904 /// contract established by [`commit_batch_nosync`].
5905 pub(crate) fn write_batch_authz_nosync(
5906 &mut self,
5907 authz: Option<&WriteAuthz>,
5908 ops: Vec<BatchOp>,
5909 ) -> Result<(usize, usize)> {
5910 let saved = self.fsync;
5911 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5912 impl Drop for RestoreFsync {
5913 fn drop(&mut self) {
5914 // SAFETY: pointer into the owning GraphDb; guard is dropped
5915 // within the enclosing function's frame before it returns.
5916 unsafe { *self.0 = self.1 };
5917 }
5918 }
5919 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5920 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5921 self.fsync = FsyncPolicy::Relaxed;
5922 self.commit_logged_batch(ops, None, authz.cloned())
5923 }
5924
5925 /// Execute a `/ingest` request with role-scoped write authorization.
5926 ///
5927 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5928 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5929 /// Sets `pending_write_authz` for the duration of the call so that the
5930 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5931 /// and evaluates the decision table per-op before any WAL write.
5932 ///
5933 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5934 /// denied by the decision table with the appropriate §4.3 scope reason;
5935 /// no special HTTP-layer check is needed.
5936 ///
5937 /// Roles with `write: None` return `RoleWriteDenied` with
5938 /// "writes are not permitted" (byte-identical to v1 blanket 403).
5939 pub fn ingest_with_edges_authz(
5940 &mut self,
5941 role: &str,
5942 label: &str,
5943 rows: Vec<std::collections::BTreeMap<String, Value>>,
5944 opts: &crate::ingest::IngestOptions,
5945 edges: &[(String, String, String)],
5946 ) -> Result<crate::ingest::IngestReport> {
5947 // Resolve scope (fails fast if role has no write scope).
5948 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5949 let scope =
5950 {
5951 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5952 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5953 })?;
5954 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5955 GraphError::KeyNotFound {
5956 key: format!("role:{role}"),
5957 }
5958 })?;
5959 def.write
5960 .clone()
5961 .ok_or_else(|| GraphError::RoleWriteDenied {
5962 reason: "role-bound token: writes are not permitted".into(),
5963 })?
5964 };
5965 let mask = self.mask_for_role(role)?;
5966 self.pending_write_authz = Some(WriteAuthz {
5967 role: role.into(),
5968 scope,
5969 mask,
5970 });
5971 // RAII guard: always clears pending_write_authz on scope exit, including
5972 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5973 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5974 impl Drop for ClearPendingAuthzOnDrop {
5975 fn drop(&mut self) {
5976 // SAFETY: pointer into the owning GraphDb; guard is dropped
5977 // within this function's frame before it returns.
5978 unsafe { *self.0 = None };
5979 }
5980 }
5981 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5982 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5983 self.ingest_with_edges(label, rows, opts, edges)
5984 }
5985
5986 /// Evaluate the write-authz decision table for one `BatchOp`.
5987 ///
5988 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5989 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
5990 /// the remaining ops are not evaluated and no WAL frame is written.
5991 ///
5992 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5993 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
5994 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5995 /// batch creates counts as visible if its label passed the create-class gate").
5996 fn check_single_op_authz(
5997 &self,
5998 authz: &WriteAuthz,
5999 op: &BatchOp,
6000 batch_created: &BTreeMap<String, String>,
6001 ) -> Result<()> {
6002 // Helper: 3-way node status under the authz mask.
6003 //
6004 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6005 // as Visible with their recorded label — their create gate already passed
6006 // and they are not yet in self.ids (not committed). This fixes the
6007 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6008 // the SetProp must not see the node as Absent.
6009 let node_status = |key: &str| -> NodeAuthzStatus {
6010 if let Some(label) = batch_created.get(key) {
6011 return NodeAuthzStatus::Visible(label.clone());
6012 }
6013 match self.ids.get(key) {
6014 None => NodeAuthzStatus::Absent,
6015 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6016 Some(id) => {
6017 let label = self
6018 .labels
6019 .get(id as usize)
6020 .and_then(|&sym| {
6021 if sym == u32::MAX {
6022 None
6023 } else {
6024 self.syms.resolve(sym).map(str::to_string)
6025 }
6026 })
6027 .unwrap_or_default();
6028 NodeAuthzStatus::Visible(label)
6029 }
6030 }
6031 };
6032
6033 // Helper: is an InsertEdgeUpsert endpoint visible?
6034 // A same-batch placeholder counts as visible if its label passed
6035 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6036 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6037 // In store and visible?
6038 if let Some(id) = self.ids.get(ep_key) {
6039 return authz.mask.contains_id(id);
6040 }
6041 // Created by an earlier op in this batch?
6042 if let Some(created_label) = batch_created.get(ep_key) {
6043 return authz.scope.create_labels.contains(created_label);
6044 }
6045 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6046 // must pass the create-class gate.
6047 authz
6048 .scope
6049 .create_labels
6050 .contains(&placeholder_label.to_string())
6051 };
6052
6053 match op {
6054 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6055 // These ops are never routed to role-scoped paths by the HTTP layer,
6056 // but we 403 them here to close any future bypass route.
6057 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6058 return Err(GraphError::RoleWriteDenied {
6059 reason: "role-bound token: this endpoint is not permitted".into(),
6060 });
6061 }
6062
6063 // ── CREATE-class: InsertNode ─────────────────────────────────────
6064 //
6065 // Decision table row 1 (scope-before-lookup): check label in
6066 // create_labels BEFORE any key lookup. This is the structural
6067 // closure of the §6.2 timing-oracle item — the denial fires even
6068 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6069 BatchOp::InsertNode { label, key, .. } => {
6070 if !authz.scope.create_labels.contains(label) {
6071 return Err(GraphError::RoleWriteDenied {
6072 reason: format!(
6073 "role-bound token: label '{}' not in write scope (create_labels)",
6074 label
6075 ),
6076 });
6077 }
6078 // Row 2/3: key lookup.
6079 match self.ids.get(key.as_str()) {
6080 Some(id) if authz.mask.contains_id(id) => {
6081 // Visible: DuplicateKey — let MutPreview handle this.
6082 }
6083 Some(_) => {
6084 // Hidden: indistinguishable from absent to the role.
6085 return Err(GraphError::RoleWriteDenied {
6086 reason: "role-bound token: target node not visible".into(),
6087 });
6088 }
6089 None => {
6090 // Absent: proceed (create).
6091 }
6092 }
6093 }
6094
6095 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6096 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6097 if batch_created.contains_key(key.as_str()) {
6098 // Batch-created node: create gate already passed this batch.
6099 // Updating it in the same batch is always allowed, regardless
6100 // of update_labels (ruling §3.5: "writer just created it").
6101 } else {
6102 let label = match node_status(key) {
6103 NodeAuthzStatus::Visible(lbl) => lbl,
6104 _ => {
6105 return Err(GraphError::RoleWriteDenied {
6106 reason: "role-bound token: target node not visible".into(),
6107 });
6108 }
6109 };
6110 if !authz.scope.update_labels.contains(&label) {
6111 return Err(GraphError::RoleWriteDenied {
6112 reason: format!(
6113 "role-bound token: label '{}' not in write scope (update_labels)",
6114 label
6115 ),
6116 });
6117 }
6118 }
6119 }
6120
6121 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6122 BatchOp::DeleteNode { key } => {
6123 let label = match node_status(key) {
6124 NodeAuthzStatus::Visible(lbl) => lbl,
6125 _ => {
6126 return Err(GraphError::RoleWriteDenied {
6127 reason: "role-bound token: target node not visible".into(),
6128 });
6129 }
6130 };
6131 if !authz.scope.delete_labels.contains(&label) {
6132 return Err(GraphError::RoleWriteDenied {
6133 reason: format!(
6134 "role-bound token: label '{}' not in write scope (delete_labels)",
6135 label
6136 ),
6137 });
6138 }
6139 }
6140
6141 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6142 //
6143 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6144 // check (spec §3.5: "existing derived-edge rejection precedes
6145 // delete_edge_types check").
6146 BatchOp::DeleteEdge {
6147 edge_type,
6148 src_key,
6149 dst_key,
6150 } => {
6151 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6152 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6153 self.ids.get(src_key.as_str()),
6154 self.ids.get(dst_key.as_str()),
6155 self.syms.get(edge_type.as_str()),
6156 ) {
6157 if self.engine.is_owned(et_sym, src_id, dst_id) {
6158 return Err(GraphError::RuleOwned {
6159 detail: format!(
6160 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6161 delete or change the owning rule"
6162 ),
6163 });
6164 }
6165 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6166 let preview = MutPreview::new(self);
6167 if preview.would_derive(edge_type, src_key, dst_key) {
6168 return Err(GraphError::RuleOwned {
6169 detail: format!(
6170 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6171 delete or change the owning rule, or a live rule would \
6172 re-derive it"
6173 ),
6174 });
6175 }
6176 }
6177 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6178 if !authz.scope.delete_edge_types.contains(edge_type) {
6179 return Err(GraphError::RoleWriteDenied {
6180 reason: format!(
6181 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6182 edge_type
6183 ),
6184 });
6185 }
6186 // Both endpoints must be visible.
6187 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6188 match self.ids.get(ep_key) {
6189 None => {
6190 return Err(GraphError::RoleWriteDenied {
6191 reason: "role-bound token: edge endpoint not visible".into(),
6192 });
6193 }
6194 Some(id) if !authz.mask.contains_id(id) => {
6195 return Err(GraphError::RoleWriteDenied {
6196 reason: "role-bound token: edge endpoint not visible".into(),
6197 });
6198 }
6199 _ => {}
6200 }
6201 }
6202 }
6203
6204 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6205 //
6206 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6207 BatchOp::InsertEdge {
6208 edge_type,
6209 src_key,
6210 dst_key,
6211 } => {
6212 if !authz.scope.create_edge_types.contains(edge_type) {
6213 return Err(GraphError::RoleWriteDenied {
6214 reason: format!(
6215 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6216 edge_type
6217 ),
6218 });
6219 }
6220 // Both endpoints must be visible. A node created by an earlier
6221 // InsertNode in the same batch (tracked in batch_created) counts
6222 // as visible if its label passed the create-class gate.
6223 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6224 if batch_created.contains_key(ep_key) {
6225 // Created earlier this batch — already scope-checked.
6226 continue;
6227 }
6228 match self.ids.get(ep_key) {
6229 None => {
6230 return Err(GraphError::RoleWriteDenied {
6231 reason: "role-bound token: edge endpoint not visible".into(),
6232 });
6233 }
6234 Some(id) if !authz.mask.contains_id(id) => {
6235 return Err(GraphError::RoleWriteDenied {
6236 reason: "role-bound token: edge endpoint not visible".into(),
6237 });
6238 }
6239 _ => {}
6240 }
6241 }
6242 }
6243
6244 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6245 //
6246 // Scope check first; then endpoint visibility using same-batch
6247 // placeholder awareness (spec: "a placeholder endpoint the SAME
6248 // batch creates counts as visible if its label passed the
6249 // create-class gate").
6250 BatchOp::InsertEdgeUpsert {
6251 edge_type,
6252 src_key,
6253 dst_key,
6254 placeholder_label,
6255 } => {
6256 if !authz.scope.create_edge_types.contains(edge_type) {
6257 return Err(GraphError::RoleWriteDenied {
6258 reason: format!(
6259 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6260 edge_type
6261 ),
6262 });
6263 }
6264 // Check placeholder label against create_labels (create-class gate).
6265 // This ensures the auto-created endpoints are scope-allowed.
6266 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6267 if !upsert_ep_visible(ep_key, placeholder_label) {
6268 return Err(GraphError::RoleWriteDenied {
6269 reason: "role-bound token: edge endpoint not visible".into(),
6270 });
6271 }
6272 }
6273 }
6274 }
6275 Ok(())
6276 }
6277
6278 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6279 ///
6280 /// Called by `apply_schema` when roles change. Never called on unchanged
6281 /// re-apply — this preserves byte-identical idempotency.
6282 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6283 let file = RolesFile::new_versioned(roles.clone());
6284 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6285 detail: format!("roles serialization: {e}"),
6286 })?;
6287 self.fs
6288 .write_atomic(FileId::Roles, &bytes)
6289 .map_err(GraphError::Io)?;
6290 self.roles = Some(roles);
6291 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6292 // updated role definitions without waiting for the next K-commit fold.
6293 self.fold_now();
6294 Ok(())
6295 }
6296
6297 fn view(&self) -> GraphView<'_> {
6298 GraphView {
6299 ids: &self.ids,
6300 syms: &self.syms,
6301 labels: &self.labels,
6302 props: self.props_view(),
6303 topo: self.topo_view(),
6304 edge_props: self.edge_props_view(),
6305 mask: None,
6306 prop_index: Some(&self.prop_index),
6307 }
6308 }
6309
6310 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6311 GraphView {
6312 ids: &self.ids,
6313 syms: &self.syms,
6314 labels: &self.labels,
6315 props: self.props_view(),
6316 topo: self.topo_view(),
6317 edge_props: self.edge_props_view(),
6318 mask: Some(&mask.visible),
6319 prop_index: Some(&self.prop_index),
6320 }
6321 }
6322
6323 /// Execute a read-only Cypher query with a node visibility mask.
6324 ///
6325 /// Only nodes whose key is in `mask` are accessible: label scans, key
6326 /// lookups, and neighbor expansions all respect the mask. Edges where
6327 /// either endpoint is hidden are silently dropped.
6328 ///
6329 /// Returns `Err` with a "masked queries are read-only" message when
6330 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6331 pub fn query_masked(
6332 &self,
6333 cypher: &str,
6334 params: &std::collections::BTreeMap<String, Value>,
6335 mask: &crate::mask::NodeMask,
6336 ) -> Result<ResultSet> {
6337 // Reject write statements up front.
6338 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6339 detail: format!("lex: {e}"),
6340 })?;
6341 if is_write_tokens(&tokens) {
6342 return Err(GraphError::MaskedReadOnly);
6343 }
6344 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6345 detail: format!("parse: {e}"),
6346 })?;
6347 // Each UNION part executes against the same masked view, so the mask
6348 // applies uniformly across the chain.
6349 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6350 GraphError::QueryError {
6351 detail: format!("execute: {e}"),
6352 }
6353 })
6354 }
6355
6356 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6357 let id = self.ids.get(key)?;
6358 Some(NodeRef { db: self, id })
6359 }
6360
6361 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6362 ///
6363 /// Hidden nodes are never used as traversal intermediaries in either
6364 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6365 /// only through a hidden node will not appear in results.
6366 ///
6367 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6368 /// a visited visible node are appended to the result as stub rows
6369 /// (`label` column is `null`, same key+depth columns as visible rows).
6370 /// They are NOT added to the BFS frontier.
6371 ///
6372 /// Returns `None` when `key` does not exist (caller should 404).
6373 ///
6374 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6375 /// stub rows are never produced on the role path.
6376 pub fn neighborhood_masked(
6377 &self,
6378 key: &str,
6379 depth: u32,
6380 edge_types: Option<&[&str]>,
6381 dir: Dir,
6382 mask: &crate::mask::NodeMask,
6383 ) -> Option<ResultSet> {
6384 let start_id = self.ids.get(key)?;
6385 let view = self.view_masked(mask);
6386 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6387 names
6388 .iter()
6389 .filter_map(|name| view.syms.get(name))
6390 .collect()
6391 });
6392 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6393 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6394 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6395 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6396 visited.push((start_id, 0));
6397 for (nid, d) in &nb.nodes {
6398 let k = view.key_of(*nid);
6399 let label = view
6400 .label_of(*nid)
6401 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6402 rs.push_row(vec![
6403 Some(Value::Str(k.to_string())),
6404 Some(Value::Str(label.to_string())),
6405 Some(Value::Int(*d as i64)),
6406 ]);
6407 visited.push((*nid, *d));
6408 }
6409 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6410 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6411 // frontier, so the BFS never expands through them.
6412 if mask.mode() == crate::mask::MaskMode::Stub {
6413 let raw_view = self.view();
6414 let mut seen: std::collections::HashSet<u32> =
6415 visited.iter().map(|(id, _)| *id).collect();
6416 for (node_id, node_depth) in &visited {
6417 if *node_depth >= depth {
6418 continue;
6419 }
6420 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6421 let nbr = if e.src == *node_id { e.dst } else { e.src };
6422 if !mask.contains_id(nbr) && seen.insert(nbr) {
6423 if let Some(k) = self.ids.key_of(nbr) {
6424 rs.push_row(vec![
6425 Some(Value::Str(k.to_string())),
6426 None,
6427 Some(Value::Int((*node_depth + 1) as i64)),
6428 ]);
6429 }
6430 }
6431 }
6432 }
6433 }
6434 Some(rs)
6435 }
6436
6437 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6438 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6439 let n = self.node_ref(key)?;
6440 Some(NodeInfo {
6441 key: n.key().to_string(),
6442 label: n.label().to_string(),
6443 props: n.props(),
6444 })
6445 }
6446
6447 /// Look up a node with mask awareness.
6448 ///
6449 /// | Key state | Omit mode | Stub mode |
6450 /// |-------------------|-----------------|------------------------|
6451 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6452 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6453 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6454 ///
6455 /// **SECURITY**: only call from client-mask (full-token) paths.
6456 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6457 pub fn node_info_masked(
6458 &self,
6459 key: &str,
6460 mask: &crate::mask::NodeMask,
6461 ) -> Option<MaskedNodeResult> {
6462 let id = self.ids.get(key)?;
6463 if mask.contains_id(id) {
6464 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6465 } else {
6466 match mask.mode() {
6467 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6468 crate::mask::MaskMode::Omit => None,
6469 }
6470 }
6471 }
6472
6473 /// Get edges for `key` with mask-aware hidden-endpoint handling.
6474 ///
6475 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6476 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6477 /// is `true` for each hidden endpoint.
6478 ///
6479 /// Unknown key → [`GraphError::KeyNotFound`].
6480 ///
6481 /// **SECURITY**: only call from client-mask (full-token) paths.
6482 pub fn node_edges_masked(
6483 &self,
6484 key: &str,
6485 mask: &crate::mask::NodeMask,
6486 ) -> Result<Vec<MaskedEdge>> {
6487 self.ensure_v8_base_sections_loaded();
6488 let id = self
6489 .ids
6490 .get(key)
6491 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6492 let derived: BTreeSet<(u32, u32, u32)> = self
6493 .engine
6494 .provenance_touching(id)
6495 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6496 .collect();
6497 let mut edges = Vec::new();
6498 let tv = self.topo_view();
6499 for etype in tv.etypes() {
6500 // etype comes from the archived CSR (access_unchecked, no eager CRC).
6501 // A bit-flip in the large TOPOLOGY section can produce an etype id
6502 // that is not in the interner. Return Corrupt rather than panic.
6503 let edge_type = self
6504 .syms
6505 .resolve(etype)
6506 .ok_or_else(|| GraphError::Corrupt {
6507 detail: format!("v8: topology etype {etype} not in interner"),
6508 })?
6509 .to_string();
6510 for dir in [Direction::Out, Direction::In] {
6511 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6512 let nbr_restricted = !mask.contains_id(nbr);
6513 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6514 continue;
6515 }
6516 let nbr_key = self
6517 .ids
6518 .key_of(nbr)
6519 .ok_or_else(|| GraphError::Corrupt {
6520 detail: format!("topology id {nbr} has no key"),
6521 })?
6522 .to_string();
6523 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6524 match dir {
6525 Direction::Out => {
6526 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6527 }
6528 Direction::In => {
6529 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6530 }
6531 };
6532 edges.push(MaskedEdge {
6533 edge_type: edge_type.clone(),
6534 src_key,
6535 src_restricted,
6536 dst_key,
6537 dst_restricted,
6538 derived: derived.contains(&(etype, src_id, dst_id)),
6539 });
6540 }
6541 }
6542 }
6543 edges.sort_by(|a, b| {
6544 a.edge_type
6545 .cmp(&b.edge_type)
6546 .then(a.src_key.cmp(&b.src_key))
6547 .then(a.dst_key.cmp(&b.dst_key))
6548 });
6549 edges.dedup_by(|a, b| {
6550 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6551 });
6552 Ok(edges)
6553 }
6554
6555 /// Every directed edge incident on `key`, both directions, every etype.
6556 ///
6557 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6558 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6559 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6560 /// Unknown key → [`GraphError::KeyNotFound`].
6561 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6562 self.ensure_v8_base_sections_loaded();
6563 let id = self
6564 .ids
6565 .get(key)
6566 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6567 let derived: BTreeSet<(u32, u32, u32)> = self
6568 .engine
6569 .provenance_touching(id)
6570 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6571 .collect();
6572 let mut edges = Vec::new();
6573 let tv = self.topo_view();
6574 for etype in tv.etypes() {
6575 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6576 let edge_type = self
6577 .syms
6578 .resolve(etype)
6579 .ok_or_else(|| GraphError::Corrupt {
6580 detail: format!("v8: topology etype {etype} not in interner"),
6581 })?
6582 .to_string();
6583 for dir in [Direction::Out, Direction::In] {
6584 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6585 let (src, dst, src_key, dst_key) = match dir {
6586 Direction::Out => (
6587 id,
6588 nbr,
6589 key.to_string(),
6590 self.ids
6591 .key_of(nbr)
6592 .ok_or_else(|| GraphError::Corrupt {
6593 detail: format!("topology id {nbr} has no key"),
6594 })?
6595 .to_string(),
6596 ),
6597 Direction::In => (
6598 nbr,
6599 id,
6600 self.ids
6601 .key_of(nbr)
6602 .ok_or_else(|| GraphError::Corrupt {
6603 detail: format!("topology id {nbr} has no key"),
6604 })?
6605 .to_string(),
6606 key.to_string(),
6607 ),
6608 };
6609 edges.push(EdgeInfo {
6610 edge_type: edge_type.clone(),
6611 src_key,
6612 dst_key,
6613 derived: derived.contains(&(etype, src, dst)),
6614 });
6615 }
6616 }
6617 }
6618 edges.sort_by(|a, b| {
6619 a.edge_type
6620 .cmp(&b.edge_type)
6621 .then(a.src_key.cmp(&b.src_key))
6622 .then(a.dst_key.cmp(&b.dst_key))
6623 });
6624 // Self-loops appear in both Out and In; sort makes the pair adjacent
6625 // (sort key matches PartialEq for this case) so one pass drops the dup.
6626 edges.dedup();
6627 Ok(edges)
6628 }
6629
6630 // ── Backup ────────────────────────────────────────────────────────────────
6631
6632 /// Copy this store to `dest` as a consistent, verified snapshot.
6633 ///
6634 /// Copies every durable file in the database directory — `snapshot.bin`,
6635 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6636 /// `roles.json` — into a freshly created `dest` directory using OS-level
6637 /// `copy` calls (no large in-process buffers).
6638 ///
6639 /// # Consistency guarantee
6640 ///
6641 /// The guarantee is **process-local**: the caller holds `&self`, which
6642 /// prevents any concurrent writer in the **same process** from modifying
6643 /// the files during the copy. Running `mushroomdb backup` against a
6644 /// directory that is **concurrently being written by another process** (e.g.
6645 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
6646 /// `verified: true` result reduces but does not eliminate the risk of a
6647 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6648 /// consistent mid-write snapshot).
6649 ///
6650 /// **The safe path for a live-served store is `POST /backup` on the HTTP
6651 /// server.** That handler acquires the read lock on the shared database
6652 /// before calling this method, which is the correct cross-process
6653 /// synchronisation point because the server is the single process writing
6654 /// the files.
6655 ///
6656 /// After copying, opens the destination read-only and runs the CRC section
6657 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6658 /// `BackupReport::verified` reflects whether both checks passed.
6659 ///
6660 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6661 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6662 // Derive source directory from snapshot_path (RealFs only).
6663 let src_dir = match self.fs.snapshot_path() {
6664 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6665 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6666 })?,
6667 None => {
6668 return Err(GraphError::Io(std::io::Error::other(
6669 "backup_to requires a real filesystem (RealFs)",
6670 )))
6671 }
6672 };
6673
6674 std::fs::create_dir_all(dest)?;
6675
6676 let mut files: Vec<String> = Vec::new();
6677 let mut bytes: u64 = 0;
6678
6679 // Helper: copy src_dir/name → dest/name if the file exists.
6680 let mut try_copy = |name: &str| -> std::io::Result<()> {
6681 let src_path = src_dir.join(name);
6682 if src_path.exists() {
6683 let n = std::fs::copy(&src_path, dest.join(name))?;
6684 bytes += n;
6685 files.push(name.to_string());
6686 }
6687 Ok(())
6688 };
6689
6690 try_copy("snapshot.bin")?;
6691 try_copy("snapshot.bin.bak")?;
6692 try_copy("wal.bin")?;
6693 try_copy("wal.floor")?;
6694 try_copy("wal.genesis")?;
6695 try_copy("roles.json")?;
6696
6697 // Copy WAL archives.
6698 let archives = self.fs.list_archives()?;
6699 for n in &archives {
6700 let name = format!("wal.{n}.archive");
6701 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6702 bytes += n_bytes;
6703 files.push(name);
6704 }
6705
6706 files.sort();
6707
6708 // Post-copy verification: open dest and run CRC checks.
6709 let snap_in_dest = dest.join("snapshot.bin").exists();
6710 let crc_ok = if snap_in_dest {
6711 crate::verify_snapshot(dest)
6712 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6713 .unwrap_or(false)
6714 } else {
6715 true // WAL-only store: nothing to CRC-check in snapshot
6716 };
6717 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6718 let verified = crc_ok && opens_ok;
6719
6720 Ok(BackupReport {
6721 files,
6722 bytes,
6723 verified,
6724 })
6725 }
6726
6727 // ── Export helpers ────────────────────────────────────────────────────────
6728
6729 /// All live nodes, sorted by key (deterministic).
6730 ///
6731 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6732 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6733 self.ensure_v8_base_sections_loaded();
6734 let pv = self.props_view();
6735 let mut nodes = Vec::new();
6736 for id in 0..self.ids.len() as u32 {
6737 let Some(key) = self.ids.key_of(id) else {
6738 continue;
6739 };
6740 let Some(&sym) = self.labels.get(id as usize) else {
6741 continue;
6742 };
6743 if sym == u32::MAX {
6744 continue; // tombstoned
6745 }
6746 let Some(label) = self.syms.resolve(sym) else {
6747 continue;
6748 };
6749 let mut props = BTreeMap::new();
6750 for field in pv.field_names() {
6751 if let Some(vr) = pv.get(id, &field) {
6752 props.insert(field, vr.into_value());
6753 }
6754 }
6755 nodes.push(NodeInfo {
6756 key: key.to_string(),
6757 label: label.to_string(),
6758 props,
6759 });
6760 }
6761 nodes.sort_by(|a, b| a.key.cmp(&b.key));
6762 nodes
6763 }
6764
6765 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6766 ///
6767 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6768 /// Manual edges carry `derived: false` and `rule: None`.
6769 /// Deterministic across runs on the same store state.
6770 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6771 self.ensure_v8_base_sections_loaded();
6772
6773 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6774 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6775 for (rule_name, triples) in self.engine.provenance() {
6776 for &(etype, src, dst) in triples {
6777 prov.insert((etype, src, dst), rule_name.clone());
6778 }
6779 }
6780
6781 let tv = self.topo_view();
6782 let mut edges = Vec::new();
6783
6784 for id in 0..self.ids.len() as u32 {
6785 let Some(key) = self.ids.key_of(id) else {
6786 continue;
6787 };
6788 let Some(&lsym) = self.labels.get(id as usize) else {
6789 continue;
6790 };
6791 if lsym == u32::MAX {
6792 continue; // tombstoned
6793 }
6794
6795 for etype_sym in tv.etypes() {
6796 // etype from archived CSR (access_unchecked, no eager CRC).
6797 // Skip edges whose etype is not in the interner; this can only
6798 // occur with a corrupt large TOPOLOGY section (bit-flip on an
6799 // etype field in the archived data). The function returns Vec,
6800 // not Result, so we continue rather than propagate.
6801 let Some(edge_type) = self.syms.resolve(etype_sym) else {
6802 continue;
6803 };
6804 let edge_type = edge_type.to_string();
6805 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6806 let Some(dst_key) = self.ids.key_of(nbr) else {
6807 continue; // skip corrupt entries
6808 };
6809 let prov_key = (etype_sym, id, nbr);
6810 let rule = prov.get(&prov_key).cloned();
6811 let derived = rule.is_some();
6812 edges.push(ExportEdge {
6813 edge_type: edge_type.clone(),
6814 src: key.to_string(),
6815 dst: dst_key.to_string(),
6816 derived,
6817 rule,
6818 });
6819 }
6820 }
6821 }
6822
6823 edges.sort_by(|a, b| {
6824 a.edge_type
6825 .cmp(&b.edge_type)
6826 .then(a.src.cmp(&b.src))
6827 .then(a.dst.cmp(&b.dst))
6828 });
6829 edges
6830 }
6831
6832 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6833 self.view()
6834 .nodes_with_label(label)
6835 .into_iter()
6836 .map(|id| NodeRef { db: self, id })
6837 .collect()
6838 }
6839
6840 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6841 let view = self.view();
6842 view.nodes_with_label(label)
6843 .into_iter()
6844 .filter(|&id| {
6845 eval_filter(filter, &|field| {
6846 view.prop(id, field).map(|vr| vr.into_value())
6847 })
6848 })
6849 .map(|id| NodeRef { db: self, id })
6850 .collect()
6851 }
6852
6853 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6854 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
6855 /// with `label = None` will use the native ANN path rather than the O(n)
6856 /// brute-force scan.
6857 pub fn has_vector_rule(&self, field: &str) -> bool {
6858 self.engine.hnsw_has_rule(field)
6859 }
6860
6861 /// Find nodes whose `field` vector is most similar to `q` (cosine
6862 /// similarity), returning up to `k` results with similarity ≥ `min`,
6863 /// sorted descending.
6864 ///
6865 /// When `label` is `None` the search spans all labels (via
6866 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6867 /// `Some(lbl)` it restricts to nodes with that label.
6868 ///
6869 /// Uses the HNSW index when one is available (fast path); otherwise falls
6870 /// back to an O(n) brute-force scan.
6871 pub fn find_similar_vector(
6872 &self,
6873 field: &str,
6874 label: Option<&str>,
6875 q: &[f64],
6876 k: usize,
6877 min: f64,
6878 ) -> Vec<(String, f64)> {
6879 // Ensure any HNSW blobs retained from the snapshot are deserialized
6880 // before the first ANN query on a clean-open (no-WAL) path.
6881 self.engine.ensure_hnsw_loaded();
6882 // L2-normalise query for cosine via dot product.
6883 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6884 if norm == 0.0 {
6885 return vec![];
6886 }
6887 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6888
6889 // Try HNSW fast path.
6890 // `None` label searches across all VectorSimilar rules covering `field`
6891 // (merging their results); `Some(lbl)` restricts to rules whose
6892 // dst_label matches. Returns `None` when no populated HNSW index
6893 // covers the request — the O(n) brute-force fallback handles that case.
6894 let hnsw_hits = match label {
6895 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6896 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6897 };
6898 if let Some(hits) = hnsw_hits {
6899 let mut out: Vec<(String, f64)> = hits
6900 .into_iter()
6901 .filter(|&(_, sim)| sim >= min)
6902 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6903 .collect();
6904 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6905 out.truncate(k);
6906 return out;
6907 }
6908
6909 // Brute-force fallback: O(n) scan (only reached when no HNSW index
6910 // covers the request).
6911 let view = self.view();
6912 let candidate_ids: Vec<u32> = match label {
6913 Some(lbl) => view.nodes_with_label(lbl),
6914 None => view.nodes_all(),
6915 };
6916 let mut scored: Vec<(String, f64)> = candidate_ids
6917 .into_iter()
6918 .filter_map(|id| {
6919 let v = view.prop(id, field)?;
6920 let v_owned = v.into_value();
6921 let xs = value_as_float_list(&v_owned)?;
6922 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6923 if v_norm == 0.0 {
6924 return None;
6925 }
6926 let dot: f64 = q_unit
6927 .iter()
6928 .zip(xs.iter())
6929 .map(|(a, b)| a * (b / v_norm))
6930 .sum();
6931 if dot < min {
6932 return None;
6933 }
6934 let key = self.ids.key_of(id)?.to_string();
6935 Some((key, dot))
6936 })
6937 .collect();
6938 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6939 scored.truncate(k);
6940 scored
6941 }
6942
6943 /// Like [`find_similar_vector`] but restricts results to nodes visible in
6944 /// `mask`. Hidden nodes never appear in results; the mask is applied
6945 /// **before** k-truncation so a caller still receives up to `k` visible
6946 /// hits.
6947 ///
6948 /// # HNSW path (over-fetch policy)
6949 ///
6950 /// When an HNSW index covers the request, this function fetches `4 * k`
6951 /// candidates from the index and discards hidden nodes in the post-filter
6952 /// step. If fewer than `k` visible nodes remain after filtering the caller
6953 /// receives whatever is available — we do not re-query the index. The 4×
6954 /// multiplier is a heuristic suited for sparsely masked graphs; callers
6955 /// operating under a very selective mask should register a VectorSimilar
6956 /// rule with a non-approximate index, or use the brute-force path (no HNSW
6957 /// rule) which exhaustively filters through the masked [`GraphView`].
6958 ///
6959 /// # Brute-force path
6960 ///
6961 /// When no HNSW index covers the request the function builds a masked
6962 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6963 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6964 /// fewer than `k` exist).
6965 pub fn find_similar_vector_masked(
6966 &self,
6967 field: &str,
6968 label: Option<&str>,
6969 q: &[f64],
6970 k: usize,
6971 min: f64,
6972 mask: &crate::mask::NodeMask,
6973 ) -> Vec<(String, f64)> {
6974 self.engine.ensure_hnsw_loaded();
6975 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6976 if norm == 0.0 {
6977 return vec![];
6978 }
6979 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6980
6981 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6982 // visible hits. See doc comment above for the policy rationale.
6983 let over_k = k.saturating_mul(4).max(k + 1);
6984 let hnsw_hits = match label {
6985 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6986 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6987 };
6988 if let Some(hits) = hnsw_hits {
6989 let mut out: Vec<(String, f64)> = hits
6990 .into_iter()
6991 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6992 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6993 .collect();
6994 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6995 out.truncate(k);
6996 return out;
6997 }
6998
6999 // Brute-force fallback — masked view ensures only visible nodes are
7000 // enumerated by nodes_all(); nodes_with_label() does not filter by
7001 // mask so we apply view.visible() explicitly for the labeled case.
7002 let view = self.view_masked(mask);
7003 let candidate_ids: Vec<u32> = match label {
7004 Some(lbl) => view
7005 .nodes_with_label(lbl)
7006 .into_iter()
7007 .filter(|&id| view.visible(id))
7008 .collect(),
7009 None => view.nodes_all(),
7010 };
7011 let mut scored: Vec<(String, f64)> = candidate_ids
7012 .into_iter()
7013 .filter_map(|id| {
7014 let v = view.prop(id, field)?;
7015 let v_owned = v.into_value();
7016 let xs = value_as_float_list(&v_owned)?;
7017 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7018 if v_norm == 0.0 {
7019 return None;
7020 }
7021 let dot: f64 = q_unit
7022 .iter()
7023 .zip(xs.iter())
7024 .map(|(a, b)| a * (b / v_norm))
7025 .sum();
7026 if dot < min {
7027 return None;
7028 }
7029 let key = self.ids.key_of(id)?.to_string();
7030 Some((key, dot))
7031 })
7032 .collect();
7033 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7034 scored.truncate(k);
7035 scored
7036 }
7037
7038 /// Read a single property from an edge.
7039 ///
7040 /// Returns `None` when the edge does not exist, the field is absent, or any
7041 /// of the string keys cannot be resolved to interned ids. Only edge props
7042 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7043 /// binding; topology-only edges (no props set) return `None` for every field.
7044 pub fn get_edge_prop(
7045 &self,
7046 edge_type: &str,
7047 src_key: &str,
7048 dst_key: &str,
7049 field: &str,
7050 ) -> Option<Value> {
7051 let etype = self.syms.get(edge_type)?;
7052 let src = self.ids.get(src_key)?;
7053 let dst = self.ids.get(dst_key)?;
7054 self.edge_props_view().get(etype, src, dst, field)
7055 }
7056
7057 /// Lex → parse → plan → execute `cypher` over a read-only view.
7058 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7059 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7060 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7061 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7062 detail: format!("lex: {e}"),
7063 })?;
7064 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7065 detail: format!("parse: {e}"),
7066 })?;
7067 let t0 = std::time::Instant::now();
7068 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7069 GraphError::QueryError {
7070 detail: format!("execute: {e}"),
7071 }
7072 });
7073 let elapsed_ms = t0.elapsed().as_millis() as u64;
7074 let threshold = self.slow_query_threshold_ms;
7075 if threshold > 0 && elapsed_ms >= threshold {
7076 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7077 let entry = SlowQueryEntry {
7078 ms: elapsed_ms,
7079 query: cypher.to_string(),
7080 at_commit: self.commit_seq,
7081 };
7082 if let Ok(mut log) = self.slow_queries.lock() {
7083 if log.entries.len() == SLOW_QUERY_RING_CAP {
7084 log.entries.pop_front();
7085 }
7086 log.entries.push_back(entry);
7087 log.total += 1;
7088 }
7089 }
7090 result
7091 }
7092
7093 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7094 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7095 /// calling [`GraphDb::query`].
7096 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7097 let map: BTreeMap<String, Value> = params
7098 .iter()
7099 .map(|(k, v)| (k.to_string(), v.clone()))
7100 .collect();
7101 self.query(cypher, &map)
7102 }
7103
7104 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7105 ///
7106 /// All mutations flow through the same `insert_node` / `set_prop` /
7107 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7108 /// fires and the WAL captures everything with one fsync per statement.
7109 ///
7110 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7111 /// and `deleted` matching the write-result contract.
7112 ///
7113 /// **Mutation routing**: mutations are collected into a single
7114 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7115 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7116 /// over `self.view()` — the borrow is dropped before the batch is opened.
7117 ///
7118 /// **Limitations (v1)**:
7119 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7120 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7121 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7122 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7123 /// - Deleting a derived edge → named error "cannot delete derived edge".
7124 pub fn query_write(
7125 &mut self,
7126 cypher: &str,
7127 params: &BTreeMap<String, Value>,
7128 ) -> Result<ResultSet> {
7129 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7130 detail: format!("lex: {e}"),
7131 })?;
7132 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7133 detail: format!("parse: {e}"),
7134 })?;
7135 self.exec_write_stmt(stmt, params)
7136 }
7137
7138 fn exec_write_stmt(
7139 &mut self,
7140 stmt: WriteStatement,
7141 params: &BTreeMap<String, Value>,
7142 ) -> Result<ResultSet> {
7143 match stmt {
7144 WriteStatement::Create(s) => self.exec_create(s, params),
7145 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7146 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7147 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7148 WriteStatement::Merge(s) => self.exec_merge(s, params),
7149 }
7150 }
7151
7152 fn exec_create(
7153 &mut self,
7154 stmt: core_query::cypher::CreateStmt,
7155 params: &BTreeMap<String, Value>,
7156 ) -> Result<ResultSet> {
7157 // Extract the node key from props: require a string-valued `id` field.
7158 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7159 for node in &stmt.nodes {
7160 let var = node.var.as_deref().unwrap_or("_cn0");
7161 let key = node
7162 .props
7163 .iter()
7164 .find(|(f, _)| f == "id")
7165 .and_then(|(_, v)| {
7166 if let Value::Str(s) = v {
7167 Some(s.clone())
7168 } else {
7169 None
7170 }
7171 })
7172 .ok_or_else(|| GraphError::QueryError {
7173 detail: format!(
7174 "CREATE node ({}:{}) requires a string 'id' property",
7175 var, node.label
7176 ),
7177 })?;
7178 var_to_key.insert(var.to_string(), key);
7179 }
7180
7181 let mut batch = self.batch();
7182 let mut created: usize = 0;
7183 for node in &stmt.nodes {
7184 let var = node.var.as_deref().unwrap_or("_cn0");
7185 let key = &var_to_key[var];
7186 batch.insert_node(&node.label, key, node.props.clone());
7187 created += 1;
7188 }
7189 for edge in &stmt.edges {
7190 let src_key = var_to_key
7191 .get(&edge.src_var)
7192 .ok_or_else(|| GraphError::QueryError {
7193 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7194 })?;
7195 let dst_key = var_to_key
7196 .get(&edge.dst_var)
7197 .ok_or_else(|| GraphError::QueryError {
7198 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7199 })?;
7200 batch.insert_edge(&edge.etype, src_key, dst_key);
7201 }
7202 batch.commit()?;
7203
7204 // Optional RETURN clause: project created bindings as a read result.
7205 if let Some(returns) = stmt.returns {
7206 // Each created node is looked up by its key via a separate MATCH pattern.
7207 // Multiple single-node patterns cross-join to produce 1 output row with
7208 // all variables bound (each pattern returns exactly 1 row).
7209 let patterns: Vec<Pattern> = stmt
7210 .nodes
7211 .iter()
7212 .map(|node| {
7213 let var = node.var.as_deref().unwrap_or("_cn0");
7214 let key = var_to_key[var].clone();
7215 Pattern {
7216 start: NodePat {
7217 var: Some(var.to_string()),
7218 label: Some(node.label.clone()),
7219 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7220 },
7221 chain: vec![],
7222 shortest: false,
7223 }
7224 })
7225 .collect();
7226 let q = Query {
7227 matches: patterns,
7228 optional_clauses: vec![],
7229 where_expr: None,
7230 unwinds: vec![],
7231 post_unwind_where: None,
7232 stages: vec![],
7233 returns,
7234 distinct: false,
7235 order_by: vec![],
7236 skip: None,
7237 limit: None,
7238 };
7239 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7240 detail: format!("plan: {e}"),
7241 })?;
7242 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7243 GraphError::QueryError {
7244 detail: format!("execute: {e}"),
7245 }
7246 });
7247 }
7248
7249 let mut rs = write_result_set();
7250 rs.push_row(vec![
7251 Some(Value::Int(created as i64)),
7252 Some(Value::Int(0)),
7253 Some(Value::Int(0)),
7254 ]);
7255 Ok(rs)
7256 }
7257
7258 fn exec_match_set(
7259 &mut self,
7260 stmt: core_query::cypher::MatchSetStmt,
7261 params: &BTreeMap<String, Value>,
7262 ) -> Result<ResultSet> {
7263 let project_returns = stmt.returns.clone();
7264 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7265 // so the post-write projection can look them up by key.
7266 let mut set_vars: Vec<String> = Vec::new();
7267 for s in &stmt.sets {
7268 if !set_vars.contains(&s.var) {
7269 set_vars.push(s.var.clone());
7270 }
7271 }
7272 let rel_vars = pattern_rel_vars(&stmt.matches);
7273 let mut lookup_vars = set_vars.clone();
7274 for v in pattern_node_vars(&stmt.matches) {
7275 add_var(&mut lookup_vars, &v);
7276 }
7277 if let Some(ref returns) = project_returns {
7278 for v in ret_node_vars(returns) {
7279 if !rel_vars.iter().any(|r| r == &v) {
7280 add_var(&mut lookup_vars, &v);
7281 }
7282 }
7283 }
7284
7285 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7286 // SET values are projected as ScalarExpr items so that arithmetic expressions
7287 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7288 let mut set_returns: Vec<RetItem> = lookup_vars
7289 .iter()
7290 .map(|v| RetItem {
7291 value: RetVal::Var(v.clone()),
7292 alias: None,
7293 })
7294 .collect();
7295 // One computed column per SET clause; alias is `__sv_<i>`.
7296 let set_val_cols: Vec<String> = stmt
7297 .sets
7298 .iter()
7299 .enumerate()
7300 .map(|(i, _)| format!("__sv_{i}"))
7301 .collect();
7302 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7303 set_returns.push(RetItem {
7304 value: RetVal::ScalarExpr(sc.value.clone()),
7305 alias: Some(col.clone()),
7306 });
7307 }
7308 // Capture relationship types while r is bound; SET does not change them.
7309 for r in &rel_vars {
7310 set_returns.push(RetItem {
7311 value: RetVal::FuncCall {
7312 name: "type".into(),
7313 args: vec![Operand::Var(r.clone())],
7314 },
7315 alias: Some(rel_type_alias(r)),
7316 });
7317 }
7318
7319 let read_q = Query {
7320 matches: stmt.matches.clone(),
7321 optional_clauses: vec![],
7322 where_expr: stmt.where_expr.clone(),
7323 unwinds: vec![],
7324 post_unwind_where: None,
7325 stages: vec![],
7326 returns: set_returns,
7327 distinct: false,
7328 order_by: vec![],
7329 skip: None,
7330 limit: None,
7331 };
7332 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7333 detail: format!("plan: {e}"),
7334 })?;
7335 // MATCH phase is read-only; borrow ends before batch opens.
7336 //
7337 // When a role-scoped write is in flight, run the MATCH read through
7338 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7339 // zero-rows (no SetProp ops generated, no existence-oracle 403).
7340 // Full-authority writes (pending_write_authz=None) keep view().
7341 let match_rs = {
7342 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7343 if let Some(ref mask) = mask_opt {
7344 execute(&self.view_masked(mask), &ops, &Params(params))
7345 } else {
7346 execute(&self.view(), &ops, &Params(params))
7347 }
7348 }
7349 .map_err(|e| GraphError::QueryError {
7350 detail: format!("execute: {e}"),
7351 })?;
7352
7353 // Collect (key, field, value) for each matched row × each SET clause.
7354 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7355 for row_i in 0..match_rs.len() {
7356 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7357 let key = match match_rs.get(row_i, &sc.var) {
7358 Some(Value::Str(k)) => k.clone(),
7359 _ => {
7360 return Err(GraphError::QueryError {
7361 detail: format!(
7362 "SET variable '{}' did not resolve to a node key",
7363 sc.var
7364 ),
7365 })
7366 }
7367 };
7368 // The SET value was already evaluated by the executor.
7369 let value = match match_rs.get(row_i, col) {
7370 Some(v) => v.clone(),
7371 None => {
7372 return Err(GraphError::QueryError {
7373 detail: format!(
7374 "SET value for {}.{} evaluated to null",
7375 sc.var, sc.field
7376 ),
7377 })
7378 }
7379 };
7380 set_ops.push((key, sc.field.clone(), value));
7381 }
7382 }
7383
7384 // Apply as one atomic batch.
7385 let props_set = set_ops.len();
7386 let mut batch = self.batch();
7387 for (key, field, value) in set_ops {
7388 batch.set_prop(&key, &field, value);
7389 }
7390 batch.commit()?;
7391
7392 if let Some(returns) = project_returns {
7393 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7394 }
7395
7396 let mut rs = write_result_set();
7397 rs.push_row(vec![
7398 Some(Value::Int(0)),
7399 Some(Value::Int(props_set as i64)),
7400 Some(Value::Int(0)),
7401 ]);
7402 Ok(rs)
7403 }
7404
7405 fn exec_match_delete(
7406 &mut self,
7407 stmt: core_query::cypher::MatchDeleteStmt,
7408 params: &BTreeMap<String, Value>,
7409 ) -> Result<ResultSet> {
7410 // Collect unique node vars needed to identify edge endpoints.
7411 let mut node_vars: Vec<String> = Vec::new();
7412 for ed in &stmt.deletes {
7413 if !node_vars.contains(&ed.src_var) {
7414 node_vars.push(ed.src_var.clone());
7415 }
7416 if !node_vars.contains(&ed.dst_var) {
7417 node_vars.push(ed.dst_var.clone());
7418 }
7419 }
7420
7421 // Synthesize read query.
7422 let returns: Vec<RetItem> = node_vars
7423 .iter()
7424 .map(|v| RetItem {
7425 value: RetVal::Var(v.clone()),
7426 alias: None,
7427 })
7428 .collect();
7429 let read_q = Query {
7430 matches: stmt.matches,
7431 optional_clauses: vec![],
7432 where_expr: stmt.where_expr,
7433 unwinds: vec![],
7434 post_unwind_where: None,
7435 stages: vec![],
7436 returns,
7437 distinct: false,
7438 order_by: vec![],
7439 skip: None,
7440 limit: None,
7441 };
7442 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7443 detail: format!("plan: {e}"),
7444 })?;
7445 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7446 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7447 let match_rs = {
7448 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7449 if let Some(ref mask) = mask_opt {
7450 execute(&self.view_masked(mask), &ops, &Params(params))
7451 } else {
7452 execute(&self.view(), &ops, &Params(params))
7453 }
7454 }
7455 .map_err(|e| GraphError::QueryError {
7456 detail: format!("execute: {e}"),
7457 })?;
7458
7459 // Collect (etype, src_key, dst_key) for each row × each delete target.
7460 let mut del_ops: Vec<(String, String, String)> = Vec::new();
7461 for row_i in 0..match_rs.len() {
7462 for ed in &stmt.deletes {
7463 let src_key = match match_rs.get(row_i, &ed.src_var) {
7464 Some(Value::Str(k)) => k.clone(),
7465 _ => {
7466 return Err(GraphError::QueryError {
7467 detail: format!(
7468 "DELETE src variable '{}' did not resolve to a node key",
7469 ed.src_var
7470 ),
7471 })
7472 }
7473 };
7474 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7475 Some(Value::Str(k)) => k.clone(),
7476 _ => {
7477 return Err(GraphError::QueryError {
7478 detail: format!(
7479 "DELETE dst variable '{}' did not resolve to a node key",
7480 ed.dst_var
7481 ),
7482 })
7483 }
7484 };
7485 del_ops.push((ed.etype.clone(), src_key, dst_key));
7486 }
7487 }
7488
7489 // Apply as one atomic batch.
7490 let deleted = del_ops.len();
7491 let mut batch = self.batch();
7492 for (etype, src_key, dst_key) in del_ops {
7493 batch.delete_edge(&etype, &src_key, &dst_key);
7494 }
7495 batch.commit().map_err(|e| match e {
7496 GraphError::RuleOwned { .. } => GraphError::QueryError {
7497 detail: "cannot delete derived edge; retract via the rule or change the property"
7498 .to_string(),
7499 },
7500 other => other,
7501 })?;
7502
7503 let mut rs = write_result_set();
7504 rs.push_row(vec![
7505 Some(Value::Int(0)),
7506 Some(Value::Int(0)),
7507 Some(Value::Int(deleted as i64)),
7508 ]);
7509 Ok(rs)
7510 }
7511
7512 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7513 ///
7514 /// Collects the matching node keys via an ephemeral read query, then calls
7515 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
7516 /// the executor first checks that the node has no incident edges; if any
7517 /// remain it returns a named error matching openCypher semantics.
7518 fn exec_match_delete_node(
7519 &mut self,
7520 stmt: MatchDeleteNodeStmt,
7521 params: &BTreeMap<String, Value>,
7522 ) -> Result<ResultSet> {
7523 // Build a read query returning only the node keys we need.
7524 let returns: Vec<RetItem> = stmt
7525 .node_vars
7526 .iter()
7527 .map(|v| RetItem {
7528 value: RetVal::Var(v.clone()),
7529 alias: None,
7530 })
7531 .collect();
7532 let read_q = Query {
7533 matches: stmt.matches,
7534 optional_clauses: vec![],
7535 where_expr: stmt.where_expr,
7536 unwinds: vec![],
7537 post_unwind_where: None,
7538 stages: vec![],
7539 returns,
7540 distinct: false,
7541 order_by: vec![],
7542 skip: None,
7543 limit: None,
7544 };
7545 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7546 detail: format!("plan: {e}"),
7547 })?;
7548 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7549 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7550 let match_rs = {
7551 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7552 if let Some(ref mask) = mask_opt {
7553 execute(&self.view_masked(mask), &ops, &Params(params))
7554 } else {
7555 execute(&self.view(), &ops, &Params(params))
7556 }
7557 }
7558 .map_err(|e| GraphError::QueryError {
7559 detail: format!("execute: {e}"),
7560 })?;
7561
7562 // Collect unique node keys to delete (deduplicate across rows × vars).
7563 let mut keys: Vec<String> = Vec::new();
7564 for row_i in 0..match_rs.len() {
7565 for var in &stmt.node_vars {
7566 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7567 if !keys.contains(k) {
7568 keys.push(k.clone());
7569 }
7570 }
7571 }
7572 }
7573
7574 if !stmt.detach {
7575 // openCypher bare DELETE: error if any matched node has incident edges.
7576 for key in &keys {
7577 if let Some(id) = self.ids.get(key) {
7578 let tv = self.topo_view();
7579 let has_edges = tv.etypes().any(|et| {
7580 !tv.neighbors(et, Direction::Out, id).is_empty()
7581 || !tv.neighbors(et, Direction::In, id).is_empty()
7582 });
7583 if has_edges {
7584 return Err(GraphError::QueryError {
7585 detail: format!(
7586 "Cannot delete node `{key}` because it still has incident edges. \
7587 Use DETACH DELETE to remove the node and all its edges."
7588 ),
7589 });
7590 }
7591 }
7592 }
7593 }
7594
7595 let mut nodes_deleted = 0i64;
7596 let mut edges_deleted = 0i64;
7597 for key in keys {
7598 match self.delete_node(&key) {
7599 Ok(report) => {
7600 nodes_deleted += 1;
7601 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7602 }
7603 Err(GraphError::KeyNotFound { .. }) => {
7604 // Node may have been deleted by an earlier iteration (e.g., via
7605 // multiple MATCH rows for the same node). Safe to skip.
7606 }
7607 Err(e) => return Err(e),
7608 }
7609 }
7610
7611 let mut rs = write_result_set();
7612 rs.push_row(vec![
7613 Some(Value::Int(0)),
7614 Some(Value::Int(0)),
7615 Some(Value::Int(nodes_deleted + edges_deleted)),
7616 ]);
7617 Ok(rs)
7618 }
7619
7620 fn exec_merge(
7621 &mut self,
7622 stmt: core_query::cypher::MergeStmt,
7623 params: &BTreeMap<String, Value>,
7624 ) -> Result<ResultSet> {
7625 // MERGE: check if a node with the given key already exists.
7626 let key = match &stmt.key_value {
7627 Value::Str(s) => s.clone(),
7628 _ => {
7629 return Err(GraphError::QueryError {
7630 detail: format!(
7631 "MERGE key value must be a string (got {:?})",
7632 stmt.key_value
7633 ),
7634 })
7635 }
7636 };
7637
7638 if let Some(var) = stmt.var.as_deref() {
7639 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7640 if sc.var != var {
7641 return Err(GraphError::QueryError {
7642 detail: format!(
7643 "SET variable '{}' does not match MERGE variable '{var}'",
7644 sc.var
7645 ),
7646 });
7647 }
7648 }
7649 }
7650
7651 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7652 //
7653 // MERGE scope precondition: check create OR update scope for the
7654 // declared label BEFORE calling `has_node` (timing-oracle closure,
7655 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7656 // unscoped roles — the scope denial fires without touching the key store).
7657 //
7658 // Clone to avoid holding a borrow on `self.pending_write_authz` while
7659 // also calling `self.ids.get(key)`.
7660 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7661 let has_create = authz.scope.create_labels.contains(&stmt.label);
7662 let has_update = authz.scope.update_labels.contains(&stmt.label);
7663 if !has_create && !has_update {
7664 // Scope-before-lookup: 403 without has_node call (timing oracle
7665 // closure — see test_merge_unscoped_no_key_lookup).
7666 return Err(GraphError::RoleWriteDenied {
7667 reason: format!(
7668 "role-bound token: label '{}' not in write scope (create_labels)",
7669 stmt.label
7670 ),
7671 });
7672 }
7673 // Key lookup under mask.
7674 match self.ids.get(key.as_str()) {
7675 Some(id) if authz.mask.contains_id(id) => {
7676 // Visible: must have update scope to proceed to match arm.
7677 if !has_update {
7678 return Err(GraphError::RoleWriteDenied {
7679 reason: format!(
7680 "role-bound token: label '{}' not in write scope (update_labels)",
7681 stmt.label
7682 ),
7683 });
7684 }
7685 true // existed = true → match arm
7686 }
7687 Some(_) => {
7688 // Hidden: same error as absent to the role (spec §3.1/§3.3).
7689 return Err(GraphError::RoleWriteDenied {
7690 reason: "role-bound token: target node not visible".into(),
7691 });
7692 }
7693 None => {
7694 // Absent: must have create scope to proceed to the create arm.
7695 //
7696 // Update-only roles (create_labels empty, update_labels set):
7697 // return the SAME "not visible" error as the hidden-key branch
7698 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7699 // "confirm existence of hidden nodes: No").
7700 //
7701 // Create-scoped roles (has_create=true): absent → create arm
7702 // as before. The accepted structural key-existence disclosure
7703 // (§THREAT-MODEL) applies only when the role holds create scope.
7704 if !has_create {
7705 return Err(GraphError::RoleWriteDenied {
7706 reason: "role-bound token: target node not visible".into(),
7707 });
7708 }
7709 false // existed = false → create arm
7710 }
7711 }
7712 } else {
7713 // Full authority: use the existing non-masked has_node check.
7714 self.has_node(&key)
7715 };
7716
7717 let existed = merge_existed;
7718 let mut created = 0i64;
7719 if !existed || !stmt.on_match.is_empty() {
7720 let mut batch = self.batch();
7721 if !existed {
7722 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7723 batch.insert_node(&stmt.label, &key, props);
7724 for sc in &stmt.on_create {
7725 let value = resolve_merge_set_value(&sc.value, params)?;
7726 batch.set_prop(&key, &sc.field, value);
7727 }
7728 created = 1;
7729 } else {
7730 for sc in &stmt.on_match {
7731 let value = resolve_merge_set_value(&sc.value, params)?;
7732 batch.set_prop(&key, &sc.field, value);
7733 }
7734 }
7735 batch.commit()?;
7736 }
7737
7738 // Refresh the role mask so the just-created node is visible to this
7739 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7740 // (apply_schema subset rule), so the new node's label is already in the
7741 // role's read scope — this never widens beyond the role's declared labels.
7742 if !existed {
7743 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7744 let new_mask = self.mask_for_role(&role)?;
7745 if let Some(a) = self.pending_write_authz.as_mut() {
7746 a.mask = new_mask;
7747 }
7748 }
7749 }
7750
7751 // Optional RETURN clause: project the node (created or matched) as a read result.
7752 if let Some(returns) = stmt.returns {
7753 let var = stmt.var.as_deref().unwrap_or("_mn0");
7754 let q = Query {
7755 matches: vec![Pattern {
7756 start: NodePat {
7757 var: Some(var.to_string()),
7758 label: Some(stmt.label.clone()),
7759 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7760 },
7761 chain: vec![],
7762 shortest: false,
7763 }],
7764 optional_clauses: vec![],
7765 where_expr: None,
7766 unwinds: vec![],
7767 post_unwind_where: None,
7768 stages: vec![],
7769 returns,
7770 distinct: false,
7771 order_by: vec![],
7772 skip: None,
7773 limit: None,
7774 };
7775 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7776 detail: format!("plan: {e}"),
7777 })?;
7778 // Use view_masked when a role-scoped write is in flight so the
7779 // post-merge projection is consistent with the masked read phase.
7780 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7781 return (if let Some(ref mask) = mask_opt {
7782 execute(&self.view_masked(mask), &ops, &Params(params))
7783 } else {
7784 execute(&self.view(), &ops, &Params(params))
7785 })
7786 .map_err(|e| GraphError::QueryError {
7787 detail: format!("execute: {e}"),
7788 });
7789 }
7790
7791 let mut rs = write_result_set();
7792 rs.push_row(vec![
7793 Some(Value::Int(created)),
7794 Some(Value::Int(0)),
7795 Some(Value::Int(0)),
7796 ]);
7797 Ok(rs)
7798 }
7799
7800 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7801 /// annotated with rule name, edge type, direction, and weight.
7802 /// Results are sorted by (rule, edge_type).
7803 /// Returns `Err(KeyNotFound)` if either key is unknown.
7804 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7805 self.ensure_v8_base_sections_loaded();
7806 let id_a = self
7807 .ids
7808 .get(key_a)
7809 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7810 let id_b = self
7811 .ids
7812 .get(key_b)
7813 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7814
7815 let mut results = Vec::new();
7816
7817 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7818 // rather than O(total provenance).
7819 let scan = if self.engine.provenance_touching_len(id_a)
7820 <= self.engine.provenance_touching_len(id_b)
7821 {
7822 id_a
7823 } else {
7824 id_b
7825 };
7826 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7827 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7828 continue;
7829 }
7830 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7831 continue;
7832 };
7833 let edge_type = match self.syms.resolve(etype) {
7834 Some(s) => s.to_string(),
7835 None => continue,
7836 };
7837 // Provenance (src, dst) ids come from the archived PROVENANCE section
7838 // (large, no eager CRC). A corrupt section can produce ids that are
7839 // out of range; return Corrupt rather than panic.
7840 let src_key = self
7841 .ids
7842 .key_of(src)
7843 .ok_or_else(|| GraphError::Corrupt {
7844 detail: format!("v8: provenance src id {src} not in id table"),
7845 })?
7846 .to_string();
7847 let dst_key = self
7848 .ids
7849 .key_of(dst)
7850 .ok_or_else(|| GraphError::Corrupt {
7851 detail: format!("v8: provenance dst id {dst} not in id table"),
7852 })?
7853 .to_string();
7854 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
7855 self.edge_props_view()
7856 .get(etype, src, dst, prop)
7857 .and_then(|v| {
7858 if let Value::Float(f) = v {
7859 Some(f)
7860 } else {
7861 None
7862 }
7863 })
7864 });
7865 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
7866 // still have a score: recompute it from the predicate so explain
7867 // never reports "no score" for an edge the engine scored. Via-hop
7868 // rules score over their via set, not over (src, dst), so leave
7869 // those None rather than report a number the rule did not produce.
7870 let weight = stored.or_else(|| {
7871 if rule_def.via_edge.is_some() {
7872 return None;
7873 }
7874 let props_view = build_props_view(&self.props, &self.base);
7875 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
7876 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
7877 let src_view = NodeView {
7878 key: &src_key,
7879 props: &src_get,
7880 };
7881 let dst_view = NodeView {
7882 key: &dst_key,
7883 props: &dst_get,
7884 };
7885 evaluate(&rule_def.predicate, &src_view, &dst_view)
7886 });
7887 results.push(Explanation {
7888 rule: rule_name.to_string(),
7889 edge_type,
7890 src_key,
7891 dst_key,
7892 weight,
7893 predicate: PredicateSummary {
7894 approximate: rule_def.approximate,
7895 ..PredicateSummary::from(&rule_def.predicate)
7896 },
7897 via_edge: rule_def.via_edge.clone(),
7898 });
7899 }
7900
7901 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7902 Ok(results)
7903 }
7904
7905 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7906 let id = self
7907 .ids
7908 .get(key)
7909 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7910 let Some(sym) = self.syms.get(edge_type) else {
7911 return Ok(Vec::new());
7912 };
7913 self.topo_view()
7914 .neighbors(sym, dir, id)
7915 .iter()
7916 .map(|&n| {
7917 self.ids
7918 .key_of(n)
7919 .map(|k| k.to_string())
7920 .ok_or_else(|| GraphError::Corrupt {
7921 detail: format!("topology id {n} has no key"),
7922 })
7923 })
7924 .collect::<Result<Vec<_>>>()
7925 }
7926
7927 /// Return the last-change commit sequence for `key`, or `None` if the node
7928 /// does not exist or has never been mutated since the last V5-V7 snapshot
7929 /// (horizon-bounded for legacy stores).
7930 ///
7931 /// The returned sequence is a monotonically increasing counter that starts
7932 /// at 1 for the first commit after `open` and increments with every
7933 /// successful write. WAL replay at open also assigns sequences (1..N for N
7934 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7935 ///
7936 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7937 /// in the snapshot but not touched by any WAL frame will return `None`
7938 /// (horizon-bounded: CAS against such nodes is only safe after the first
7939 /// V8 snapshot or after the node is next mutated).
7940 pub fn last_changed(&self, key: &str) -> Option<u64> {
7941 let id = self.ids.get(key)?;
7942 self.last_change.get(&id).copied()
7943 }
7944
7945 /// The current commit sequence (number of successful commits since open,
7946 /// including WAL replay frames). Useful for recording a baseline before
7947 /// a read-modify-write cycle.
7948 pub fn commit_seq(&self) -> u64 {
7949 self.commit_seq
7950 }
7951
7952 /// Check that all `preconds` are satisfied against the current db state.
7953 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7954 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7955 for precond in preconds {
7956 match precond {
7957 Precondition::NodeUnchangedSince { key, expected } => {
7958 // Missing entry means the node predates the WAL window or
7959 // does not exist; treat as 0 (before any commit).
7960 let actual = self.last_changed(key).unwrap_or_default();
7961 if actual != *expected {
7962 return Err(GraphError::CasConflict {
7963 key: key.clone(),
7964 expected: *expected,
7965 actual,
7966 });
7967 }
7968 }
7969 Precondition::NodeAbsent { key } => {
7970 // Node must not exist (not live).
7971 if self.ids.get(key).is_some() {
7972 let actual = self.last_changed(key).unwrap_or(0);
7973 return Err(GraphError::CasConflict {
7974 key: key.clone(),
7975 expected: u64::MAX,
7976 actual,
7977 });
7978 }
7979 }
7980 }
7981 }
7982 Ok(())
7983 }
7984
7985 /// Apply a batch of mutations with compare-and-set preconditions.
7986 ///
7987 /// All preconditions are checked atomically before any operation is applied.
7988 /// If any precondition fails, the entire batch is rejected with
7989 /// [`GraphError::CasConflict`] and no WAL frame is written.
7990 ///
7991 /// # Returns
7992 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7993 ///
7994 /// # Errors
7995 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7996 /// - Any error that [`write_batch`] would return for the ops themselves.
7997 pub fn write_batch_cas(
7998 &mut self,
7999 preconds: Vec<Precondition>,
8000 ops: Vec<BatchOp>,
8001 ) -> Result<(usize, usize)> {
8002 self.check_preconditions(&preconds)?;
8003 self.commit_logged_batch(ops, None, None)
8004 }
8005
8006 /// Update the per-node last-change map for a WAL record at commit `seq`.
8007 ///
8008 /// Called after a successful apply to record which nodes were touched.
8009 /// For replay, called with the WAL-frame's replayed seq.
8010 ///
8011 /// Touch definition (see [`Precondition`] doc):
8012 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8013 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8014 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8015 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8016 /// - Batch → recurse into inner records.
8017 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8018 match rec {
8019 WalRecord::InsertNode { key, .. }
8020 | WalRecord::SetProp { key, .. }
8021 | WalRecord::RemoveProp { key, .. } => {
8022 if let Some(id) = self.ids.get(key) {
8023 self.last_change.insert(id, seq);
8024 }
8025 }
8026 WalRecord::InsertNodeId { key, .. } => {
8027 if let Some(id) = self.ids.get(key) {
8028 self.last_change.insert(id, seq);
8029 }
8030 }
8031 WalRecord::SetPropId { id, .. } => {
8032 self.last_change.insert(*id, seq);
8033 }
8034 WalRecord::InsertEdge {
8035 src_key, dst_key, ..
8036 }
8037 | WalRecord::DeleteEdge {
8038 src_key, dst_key, ..
8039 } => {
8040 if let Some(src_id) = self.ids.get(src_key) {
8041 self.last_change.insert(src_id, seq);
8042 }
8043 if let Some(dst_id) = self.ids.get(dst_key) {
8044 self.last_change.insert(dst_id, seq);
8045 }
8046 }
8047 WalRecord::InsertEdgeId { src, dst, .. } => {
8048 self.last_change.insert(*src, seq);
8049 self.last_change.insert(*dst, seq);
8050 }
8051 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8052 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8053 // History markers: state no-ops; the underlying mutation already
8054 // touched the relevant nodes' last_change entries.
8055 WalRecord::DeleteNode { .. }
8056 | WalRecord::DerivedEdgeAdded { .. }
8057 | WalRecord::DerivedEdgeRetracted { .. }
8058 | WalRecord::Intern { .. }
8059 | WalRecord::CreateRule { .. }
8060 | WalRecord::DeleteRule { .. }
8061 | WalRecord::RebuildRule { .. }
8062 | WalRecord::CreateView { .. }
8063 | WalRecord::DeleteView { .. }
8064 | WalRecord::EnableFulltext { .. }
8065 | WalRecord::DisableFulltext { .. }
8066 | WalRecord::EnableIndex { .. }
8067 | WalRecord::DisableIndex { .. } => {}
8068 // RenameNode: node id is stable; update last_change via the new key.
8069 // Called after apply(), so ids already reflects new_key.
8070 WalRecord::RenameNode { new_key, .. } => {
8071 if let Some(id) = self.ids.get(new_key) {
8072 self.last_change.insert(id, seq);
8073 }
8074 }
8075 WalRecord::Batch(inner) => {
8076 for inner_rec in inner {
8077 self.update_last_change_from_rec(inner_rec, seq);
8078 }
8079 }
8080 }
8081 }
8082
8083 pub fn node_count(&self) -> usize {
8084 self.ids.len()
8085 }
8086
8087 /// Configure archive retention: keep the `N` newest WAL archives at each
8088 /// [`snapshot_with`] call when `archive_wal: true`.
8089 ///
8090 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8091 /// `Some(0)` or `None` → unlimited (no pruning).
8092 ///
8093 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8094 /// stores the policy. Archives below the retention limit are deleted
8095 /// oldest-first. The horizon floor is updated so that
8096 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8097 /// in pruned archives rather than silently returning wrong data.
8098 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8099 self.wal_archive_retention = keep;
8100 }
8101
8102 /// Delete any WAL archives that are fully below the current horizon floor.
8103 ///
8104 /// Orphaned archives arise when the floor is written first during retention
8105 /// pruning and then a crash interrupts the archive-delete sequence. The
8106 /// opening cleanup ensures no subsequent read path sees stale data.
8107 ///
8108 /// Under the monotonic naming scheme, the archive name N equals the
8109 /// cumulative end-frame index of the archive in global commit space (i.e.
8110 /// the archive covers global frames `[prev_n, N)`). An archive is
8111 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8112 /// below the floor and have already been counted in it.
8113 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8114 if self.wal_horizon_floor == 0 {
8115 // Floor at 0 means no pruning has ever occurred; nothing to clean.
8116 return Ok(());
8117 }
8118 let archive_ns = self.fs.list_archives()?;
8119 for n in archive_ns {
8120 if n <= self.wal_horizon_floor {
8121 // Archive N ends at global frame N; all its frames are below
8122 // the floor (floor already accounts for them) → orphaned.
8123 self.fs.delete_archive(n).map_err(GraphError::Io)?;
8124 } else {
8125 // Archives are sorted ascending; first one above floor stops scan.
8126 break;
8127 }
8128 }
8129 Ok(())
8130 }
8131
8132 /// Collect all WAL frames from surviving archives (oldest-first) then the
8133 /// live WAL into one flat list, and return the total along with the number
8134 /// of archive frames at the front of the list.
8135 ///
8136 /// Commit indices into the returned list are LOCAL (0 = first frame of
8137 /// oldest surviving archive). To obtain the GLOBAL index add
8138 /// `self.wal_horizon_floor`.
8139 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8140 let archive_ns = self.fs.list_archives()?;
8141 let mut all: Vec<WalRecord> = Vec::new();
8142 for n in archive_ns {
8143 let bytes = self.fs.read_archive(n)?;
8144 let (frames, _) = decode_all(&bytes);
8145 all.extend(frames);
8146 }
8147 let archive_count = all.len() as u64;
8148 let live_bytes = self.fs.read(FileId::Wal)?;
8149 let (live_frames, _) = decode_all(&live_bytes);
8150 all.extend(live_frames);
8151 Ok((all, archive_count))
8152 }
8153
8154 /// Return the total number of committed WAL frames visible in the current
8155 /// horizon window, including frames in surviving WAL archives.
8156 ///
8157 /// This is the exclusive upper bound for valid `at_commit` indices in
8158 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8159 ///
8160 /// Returns the horizon floor when all surviving history is empty.
8161 pub fn wal_total_commits(&self) -> Result<u64> {
8162 let (frames, _) = self.all_frames()?;
8163 Ok(self.wal_horizon_floor + frames.len() as u64)
8164 }
8165
8166 /// The global frame index of the first commit reachable through surviving
8167 /// archives (0 when no archives have been pruned).
8168 pub fn wal_horizon_floor(&self) -> u64 {
8169 self.wal_horizon_floor
8170 }
8171
8172 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8173 ///
8174 /// ## Horizon
8175 ///
8176 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8177 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8178 /// zero-cost contract; a durable history log is out of scope.
8179 ///
8180 /// ## Derived edges
8181 ///
8182 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8183 /// history. Only edges written directly by the application are recorded.
8184 ///
8185 /// ## Deleted nodes
8186 ///
8187 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8188 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8189 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8190 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8191 ///
8192 /// ## Dense-id edge entries and tombstoned partners
8193 ///
8194 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8195 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8196 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8197 /// Build commit-bounded alias intervals for `queried_key`.
8198 ///
8199 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8200 /// A record written under `key` at commit `c` matches the queried identity iff
8201 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8202 ///
8203 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8204 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8205 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8206 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8207 /// only identity-2's events (commits 7–9 under "a") are in scope.
8208 ///
8209 /// Only **forward aliasing**: querying the *new* key surfaces events written
8210 /// under the *old* key. The reverse direction is not supported.
8211 fn build_key_alias_intervals(
8212 &self,
8213 frames: &[core_storage::wal::WalRecord],
8214 queried_key: &str,
8215 ) -> Vec<(String, u64, Option<u64>)> {
8216 use core_storage::wal::WalRecord;
8217
8218 // Pre-pass: build reverse_rename and key_starts maps.
8219 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8220 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8221
8222 for (local_i, frame) in frames.iter().enumerate() {
8223 let commit = self.wal_horizon_floor + local_i as u64;
8224 let records: &[WalRecord] = match frame {
8225 WalRecord::Batch(inner) => inner.as_slice(),
8226 single => std::slice::from_ref(single),
8227 };
8228 for rec in records {
8229 match rec {
8230 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8231 key_starts.entry(key.clone()).or_default().push(commit);
8232 }
8233 WalRecord::RenameNode { old_key, new_key } => {
8234 // new_key came into existence at this commit.
8235 key_starts.entry(new_key.clone()).or_default().push(commit);
8236 // Record the reverse rename: new_key was introduced by renaming old_key.
8237 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8238 }
8239 _ => {}
8240 }
8241 }
8242 }
8243
8244 // Build alias intervals by following the reverse rename chain.
8245 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8246 let mut current_key = queried_key.to_string();
8247 let mut current_valid_until: Option<u64> = None;
8248
8249 loop {
8250 // valid_from: the most recent commit where current_key was assigned to this
8251 // identity. For aliases (valid_until = Some(vu)), find the last start event
8252 // for the key strictly before vu — this is where the alias's occupancy by
8253 // this identity began, correctly excluding prior identities that reused the key.
8254 let valid_from = if let Some(vu) = current_valid_until {
8255 key_starts
8256 .get(¤t_key)
8257 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8258 .unwrap_or(self.wal_horizon_floor)
8259 } else {
8260 // Queried key — no upper bound; may have been introduced at any commit.
8261 self.wal_horizon_floor
8262 };
8263
8264 result.push((current_key.clone(), valid_from, current_valid_until));
8265
8266 match reverse_rename.get(¤t_key) {
8267 Some((old_key, rename_commit)) => {
8268 current_valid_until = Some(*rename_commit);
8269 current_key = old_key.clone();
8270 }
8271 None => break,
8272 }
8273 }
8274
8275 result
8276 }
8277
8278 /// Returns true if `record_key` matches any alias interval that covers `commit`.
8279 fn aliases_match(
8280 intervals: &[(String, u64, Option<u64>)],
8281 record_key: &str,
8282 commit: u64,
8283 ) -> bool {
8284 intervals
8285 .iter()
8286 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8287 }
8288
8289 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8290 use crate::history::{HistoryChange, HistoryEntry};
8291 use core_storage::wal::WalRecord;
8292
8293 let (frames, _) = self.all_frames()?;
8294
8295 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8296 let alias_intervals = self.build_key_alias_intervals(&frames, key);
8297
8298 let mut out: Vec<HistoryEntry> = Vec::new();
8299
8300 for (local_i, frame) in frames.iter().enumerate() {
8301 let commit = self.wal_horizon_floor + local_i as u64;
8302 // Collect the inner records to process — Batch is one commit, single records are one commit.
8303 let records: &[WalRecord] = match frame {
8304 WalRecord::Batch(inner) => inner.as_slice(),
8305 single => std::slice::from_ref(single),
8306 };
8307
8308 for rec in records {
8309 let change = match rec {
8310 WalRecord::InsertNode { label, key: k, .. }
8311 if Self::aliases_match(&alias_intervals, k, commit) =>
8312 {
8313 Some(HistoryChange::NodeInserted {
8314 label: label.clone(),
8315 })
8316 }
8317 WalRecord::InsertNodeId { label, key: k, .. }
8318 if Self::aliases_match(&alias_intervals, k, commit) =>
8319 {
8320 let label_str = match self.syms.resolve(*label) {
8321 Some(s) => s.to_string(),
8322 None => continue,
8323 };
8324 Some(HistoryChange::NodeInserted { label: label_str })
8325 }
8326 WalRecord::SetProp {
8327 key: k,
8328 field,
8329 value,
8330 } if Self::aliases_match(&alias_intervals, k, commit) => {
8331 Some(HistoryChange::PropSet {
8332 field: field.clone(),
8333 value: value.clone(),
8334 })
8335 }
8336 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8337 // key_of returns the current (post-rename) key; compare to queried key.
8338 Some(resolved) if resolved == key => {
8339 let field_str = match self.syms.resolve(*field) {
8340 Some(s) => s.to_string(),
8341 None => continue,
8342 };
8343 Some(HistoryChange::PropSet {
8344 field: field_str,
8345 value: value.clone(),
8346 })
8347 }
8348 _ => None,
8349 },
8350 WalRecord::RemoveProp { key: k, field }
8351 if Self::aliases_match(&alias_intervals, k, commit) =>
8352 {
8353 Some(HistoryChange::PropRemoved {
8354 field: field.clone(),
8355 })
8356 }
8357 WalRecord::InsertEdge {
8358 edge_type,
8359 src_key,
8360 dst_key,
8361 } => {
8362 if Self::aliases_match(&alias_intervals, src_key, commit) {
8363 Some(HistoryChange::EdgeAdded {
8364 edge_type: edge_type.clone(),
8365 other: dst_key.clone(),
8366 outgoing: true,
8367 })
8368 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8369 Some(HistoryChange::EdgeAdded {
8370 edge_type: edge_type.clone(),
8371 other: src_key.clone(),
8372 outgoing: false,
8373 })
8374 } else {
8375 None
8376 }
8377 }
8378 WalRecord::InsertEdgeId { etype, src, dst } => {
8379 let etype_str = match self.syms.resolve(*etype) {
8380 Some(s) => s.to_string(),
8381 None => continue,
8382 };
8383 let src_key = self.ids.key_of(*src);
8384 let dst_key = self.ids.key_of(*dst);
8385 if src_key == Some(key) {
8386 let other = match dst_key {
8387 Some(s) => s.to_string(),
8388 None => continue,
8389 };
8390 Some(HistoryChange::EdgeAdded {
8391 edge_type: etype_str,
8392 other,
8393 outgoing: true,
8394 })
8395 } else if dst_key == Some(key) {
8396 let other = match src_key {
8397 Some(s) => s.to_string(),
8398 None => continue,
8399 };
8400 Some(HistoryChange::EdgeAdded {
8401 edge_type: etype_str,
8402 other,
8403 outgoing: false,
8404 })
8405 } else {
8406 None
8407 }
8408 }
8409 WalRecord::DeleteEdge {
8410 edge_type,
8411 src_key,
8412 dst_key,
8413 } => {
8414 if Self::aliases_match(&alias_intervals, src_key, commit) {
8415 Some(HistoryChange::EdgeRemoved {
8416 edge_type: edge_type.clone(),
8417 other: dst_key.clone(),
8418 outgoing: true,
8419 })
8420 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8421 Some(HistoryChange::EdgeRemoved {
8422 edge_type: edge_type.clone(),
8423 other: src_key.clone(),
8424 outgoing: false,
8425 })
8426 } else {
8427 None
8428 }
8429 }
8430 WalRecord::DeleteNode { key: k }
8431 if Self::aliases_match(&alias_intervals, k, commit) =>
8432 {
8433 Some(HistoryChange::NodeDeleted)
8434 }
8435 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8436 _ => None,
8437 };
8438
8439 if let Some(change) = change {
8440 out.push(HistoryEntry { commit, change });
8441 }
8442 }
8443 }
8444
8445 Ok(out)
8446 }
8447
8448 /// Return the per-edge change history between nodes `a` and `b` by scanning
8449 /// the on-disk WAL.
8450 ///
8451 /// ## Horizon
8452 ///
8453 /// History reaches back only to the last WAL-truncating snapshot, exactly
8454 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8455 /// `total_commits` (= number of WAL frames), which is the exclusive upper
8456 /// bound for valid commit indices.
8457 ///
8458 /// ## Derived edges
8459 ///
8460 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8461 /// WAL markers written by `log_then_apply_with` after each rule-firing
8462 /// mutation. The `rule` field of those events carries the rule name.
8463 ///
8464 /// ## DeleteNode
8465 ///
8466 /// When a node is deleted, its manual incident edges are swept inline without
8467 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8468 /// events for either endpoint and synthesises `Retracted(rule:None)` events
8469 /// for each manual edge that was active at that point. Derived edges active at
8470 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8471 /// the engine appends immediately after the `DeleteNode` record; those events
8472 /// carry correct rule attribution and are emitted by the marker arm, not the
8473 /// synthetic sweep.
8474 ///
8475 /// ## Masks
8476 ///
8477 /// Like `node_history`, this method has no mask parameter and returns WAL
8478 /// history regardless of any role mask. For masked history semantics, apply
8479 /// the mask at the caller level.
8480 pub fn edge_history(
8481 &self,
8482 a: &str,
8483 b: &str,
8484 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8485 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8486 use core_storage::wal::WalRecord;
8487
8488 let (frames, _) = self.all_frames()?;
8489 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8490
8491 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8492 // Intervals are commit-bounded so recycled keys don't contaminate histories.
8493 let alias_a = self.build_key_alias_intervals(&frames, a);
8494 let alias_b = self.build_key_alias_intervals(&frames, b);
8495
8496 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8497 // The is_derived flag is used by the DeleteNode sweep: manual edges are
8498 // swept with a synthetic Retracted(rule:None); derived edges are skipped
8499 // because the engine writes a DerivedEdgeRetracted marker immediately after
8500 // the DeleteNode record, which carries the correct rule attribution.
8501 let mut active: Vec<(String, String, String, bool)> = Vec::new();
8502 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8503
8504 for (local_i, frame) in frames.iter().enumerate() {
8505 let commit = self.wal_horizon_floor + local_i as u64;
8506 let records: &[WalRecord] = match frame {
8507 WalRecord::Batch(inner) => inner.as_slice(),
8508 single => std::slice::from_ref(single),
8509 };
8510
8511 for rec in records {
8512 match rec {
8513 WalRecord::InsertEdge {
8514 edge_type,
8515 src_key,
8516 dst_key,
8517 } => {
8518 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8519 && Self::aliases_match(&alias_b, dst_key, commit);
8520 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8521 && Self::aliases_match(&alias_a, dst_key, commit);
8522 if is_ab || is_ba {
8523 active.push((
8524 edge_type.clone(),
8525 src_key.clone(),
8526 dst_key.clone(),
8527 false,
8528 ));
8529 out.push(EdgeHistoryEvent {
8530 edge_type: edge_type.clone(),
8531 commit,
8532 event: EdgeEvent::Added,
8533 rule: None,
8534 });
8535 }
8536 }
8537 WalRecord::InsertEdgeId { etype, src, dst } => {
8538 let etype_str = match self.syms.resolve(*etype) {
8539 Some(s) => s.to_string(),
8540 None => continue,
8541 };
8542 // Use key_of_historical so tombstoned nodes (deleted
8543 // later in the WAL) still resolve during the scan.
8544 let src_key = self.ids.key_of_historical(*src);
8545 let dst_key = self.ids.key_of_historical(*dst);
8546 let is_ab = src_key == Some(a) && dst_key == Some(b);
8547 let is_ba = src_key == Some(b) && dst_key == Some(a);
8548 if is_ab || is_ba {
8549 let src_str = src_key.unwrap().to_string();
8550 let dst_str = dst_key.unwrap().to_string();
8551 active.push((etype_str.clone(), src_str, dst_str, false));
8552 out.push(EdgeHistoryEvent {
8553 edge_type: etype_str,
8554 commit,
8555 event: EdgeEvent::Added,
8556 rule: None,
8557 });
8558 }
8559 }
8560 WalRecord::DeleteEdge {
8561 edge_type,
8562 src_key,
8563 dst_key,
8564 } => {
8565 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8566 && Self::aliases_match(&alias_b, dst_key, commit);
8567 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8568 && Self::aliases_match(&alias_a, dst_key, commit);
8569 if is_ab || is_ba {
8570 // Remove the first matching active entry (flag ignored).
8571 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8572 et == edge_type && s == src_key && d == dst_key
8573 }) {
8574 active.remove(pos);
8575 }
8576 out.push(EdgeHistoryEvent {
8577 edge_type: edge_type.clone(),
8578 commit,
8579 event: EdgeEvent::Retracted,
8580 rule: None,
8581 });
8582 }
8583 }
8584 WalRecord::DeleteNode { key: k }
8585 if Self::aliases_match(&alias_a, k, commit)
8586 || Self::aliases_match(&alias_b, k, commit) =>
8587 {
8588 // Sweep: implicitly retract only MANUAL active edges.
8589 // Derived active edges are skipped here because the rule
8590 // engine appends a DerivedEdgeRetracted marker immediately
8591 // after this DeleteNode record; that marker produces the
8592 // single correctly-attributed Retracted event. Derived
8593 // entries are dropped from `active` (the marker arm's
8594 // idempotent retain finds nothing to remove).
8595 for (et, _, _, is_derived) in active.drain(..) {
8596 if !is_derived {
8597 out.push(EdgeHistoryEvent {
8598 edge_type: et,
8599 commit,
8600 event: EdgeEvent::Retracted,
8601 rule: None,
8602 });
8603 }
8604 // Derived: drop silently; marker carries the Retracted event.
8605 }
8606 }
8607 WalRecord::DerivedEdgeAdded {
8608 rule,
8609 edge_type: et,
8610 src_key,
8611 dst_key,
8612 } => {
8613 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8614 && Self::aliases_match(&alias_b, dst_key, commit);
8615 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8616 && Self::aliases_match(&alias_a, dst_key, commit);
8617 if is_ab || is_ba {
8618 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8619 out.push(EdgeHistoryEvent {
8620 edge_type: et.clone(),
8621 commit,
8622 event: EdgeEvent::Added,
8623 rule: Some(rule.clone()),
8624 });
8625 }
8626 }
8627 WalRecord::DerivedEdgeRetracted {
8628 rule,
8629 edge_type: et,
8630 src_key,
8631 dst_key,
8632 } => {
8633 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8634 && Self::aliases_match(&alias_b, dst_key, commit);
8635 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8636 && Self::aliases_match(&alias_a, dst_key, commit);
8637 if is_ab || is_ba {
8638 // Push unconditionally: a derived edge whose Added marker
8639 // predates the history horizon has no `active` entry, but
8640 // the retraction is still a real in-window event.
8641 // Remove from active idempotently if present.
8642 active.retain(|(aet, s, d, _)| {
8643 !(aet == et && s == src_key && d == dst_key)
8644 });
8645 out.push(EdgeHistoryEvent {
8646 edge_type: et.clone(),
8647 commit,
8648 event: EdgeEvent::Retracted,
8649 rule: Some(rule.clone()),
8650 });
8651 }
8652 }
8653 // All other records (InsertNode, SetProp, CreateRule, etc.)
8654 // do not affect edges between a and b.
8655 _ => {}
8656 }
8657 }
8658 }
8659
8660 Ok(HistoryResult {
8661 items: out,
8662 total_commits,
8663 })
8664 }
8665
8666 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8667 /// (in either direction) at the WAL commit `at_commit`.
8668 ///
8669 /// ## Horizon
8670 ///
8671 /// Valid commit indices are `0..total_commits` where `total_commits` is the
8672 /// number of WAL frames. An `at_commit >= total_commits` is outside the
8673 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8674 ///
8675 /// ## Derived edges
8676 ///
8677 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8678 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8679 /// and therefore includes derived edges in its point-in-time evaluation,
8680 /// matching `edge_history`'s fidelity.
8681 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8682 use core_storage::wal::WalRecord;
8683
8684 let (frames, _) = self.all_frames()?;
8685 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8686
8687 // Horizon floor: commits in pruned archives are unreachable.
8688 if at_commit < self.wal_horizon_floor {
8689 return Err(GraphError::CommitOutOfRange {
8690 commit: at_commit,
8691 total: total_commits,
8692 });
8693 }
8694 if at_commit >= total_commits {
8695 return Err(GraphError::CommitOutOfRange {
8696 commit: at_commit,
8697 total: total_commits,
8698 });
8699 }
8700
8701 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8702 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8703 let alias_a = self.build_key_alias_intervals(&frames, a);
8704 let alias_b = self.build_key_alias_intervals(&frames, b);
8705
8706 // Local index into surviving frames (0 = first frame of oldest archive).
8707 let local_commit = at_commit - self.wal_horizon_floor;
8708
8709 // Replay local frames 0..=local_commit, tracking active edges.
8710 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8711
8712 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8713 let commit = self.wal_horizon_floor + local_i as u64;
8714 let records: &[WalRecord] = match frame {
8715 WalRecord::Batch(inner) => inner.as_slice(),
8716 single => std::slice::from_ref(single),
8717 };
8718
8719 for rec in records {
8720 match rec {
8721 WalRecord::InsertEdge {
8722 edge_type: et,
8723 src_key,
8724 dst_key,
8725 } => {
8726 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8727 && Self::aliases_match(&alias_b, dst_key, commit);
8728 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8729 && Self::aliases_match(&alias_a, dst_key, commit);
8730 if is_ab || is_ba {
8731 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8732 }
8733 }
8734 WalRecord::InsertEdgeId { etype, src, dst } => {
8735 let etype_str = match self.syms.resolve(*etype) {
8736 Some(s) => s.to_string(),
8737 None => continue,
8738 };
8739 // Use key_of_historical so tombstoned nodes resolve.
8740 let src_key = self.ids.key_of_historical(*src);
8741 let dst_key = self.ids.key_of_historical(*dst);
8742 let is_ab = src_key == Some(a) && dst_key == Some(b);
8743 let is_ba = src_key == Some(b) && dst_key == Some(a);
8744 if is_ab || is_ba {
8745 active.insert((
8746 etype_str,
8747 src_key.unwrap().to_string(),
8748 dst_key.unwrap().to_string(),
8749 ));
8750 }
8751 }
8752 WalRecord::DeleteEdge {
8753 edge_type: et,
8754 src_key,
8755 dst_key,
8756 } => {
8757 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8758 && Self::aliases_match(&alias_b, dst_key, commit);
8759 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8760 && Self::aliases_match(&alias_a, dst_key, commit);
8761 if is_ab || is_ba {
8762 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8763 }
8764 }
8765 WalRecord::DeleteNode { key: k }
8766 if Self::aliases_match(&alias_a, k, commit)
8767 || Self::aliases_match(&alias_b, k, commit) =>
8768 {
8769 // All edges touching the deleted node are gone.
8770 active.retain(|(_, s, d)| s != k && d != k);
8771 }
8772 WalRecord::DerivedEdgeAdded {
8773 edge_type: et,
8774 src_key,
8775 dst_key,
8776 ..
8777 } => {
8778 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8779 && Self::aliases_match(&alias_b, dst_key, commit);
8780 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8781 && Self::aliases_match(&alias_a, dst_key, commit);
8782 if is_ab || is_ba {
8783 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8784 }
8785 }
8786 WalRecord::DerivedEdgeRetracted {
8787 edge_type: et,
8788 src_key,
8789 dst_key,
8790 ..
8791 } => {
8792 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8793 && Self::aliases_match(&alias_b, dst_key, commit);
8794 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8795 && Self::aliases_match(&alias_a, dst_key, commit);
8796 if is_ab || is_ba {
8797 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8798 }
8799 }
8800 _ => {}
8801 }
8802 }
8803 }
8804
8805 Ok(active.iter().any(|(et, _, _)| et == edge_type))
8806 }
8807
8808 pub fn edge_count(&self) -> u64 {
8809 self.topo_view().edge_count()
8810 }
8811
8812 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8813 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8814 pub fn stats(&self) -> Stats {
8815 self.ensure_v8_base_sections_loaded();
8816 let rules: Vec<RuleStats> = self
8817 .engine
8818 .rules()
8819 .map(|r| RuleStats {
8820 name: r.name.clone(),
8821 edges: self
8822 .engine
8823 .provenance()
8824 .get(&r.name)
8825 .map(|s| s.len() as u64)
8826 .unwrap_or(0),
8827 tripped: self.engine.is_tripped(&r.name),
8828 fires: self.engine.fire_count(&r.name),
8829 approximate: r.approximate,
8830 })
8831 .collect();
8832 Stats {
8833 nodes_live: self.ids.live_len(),
8834 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8835 edges: self.topo_view().edge_count(),
8836 rules,
8837 chain_truncations: self.engine.chain_truncations(),
8838 }
8839 }
8840
8841 /// On-disk size of the WAL file in bytes.
8842 ///
8843 /// Reads file metadata without loading WAL contents. Returns `Err` for
8844 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8845 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8846 let path = self.fs.wal_path().ok_or_else(|| {
8847 std::io::Error::new(
8848 std::io::ErrorKind::Unsupported,
8849 "wal_path not available for this Fs implementation",
8850 )
8851 })?;
8852 Ok(std::fs::metadata(path)?.len())
8853 }
8854
8855 /// Set the slow-query threshold. Queries whose execution time equals or
8856 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
8857 ///
8858 /// Use this setter in tests — the environment variable
8859 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8860 /// threads.
8861 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8862 self.slow_query_threshold_ms = ms;
8863 }
8864
8865 /// Snapshot of the slow-query ring buffer and lifetime counter.
8866 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8867 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8868 SlowQuerySnapshot {
8869 threshold_ms: self.slow_query_threshold_ms,
8870 count: log.total,
8871 last: log.entries.iter().cloned().collect(),
8872 }
8873 }
8874
8875 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
8876 /// to compute uptime.
8877 pub fn started_at(&self) -> std::time::Instant {
8878 self.started_at
8879 }
8880
8881 /// On-disk snapshot format version this binary writes and reads.
8882 pub fn format_version() -> u16 {
8883 core_storage::snapshot::VERSION
8884 }
8885
8886 /// Test-support: total bytes appended (SimFs only usage).
8887 pub fn fs_total_appended(&self) -> usize
8888 where
8889 F: FsIntrospect,
8890 {
8891 self.fs.total_appended()
8892 }
8893
8894 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8895 pub fn fs_sync_count(&self) -> usize
8896 where
8897 F: FsIntrospect,
8898 {
8899 self.fs.sync_count()
8900 }
8901
8902 /// Consume the db, returning its fs (for crash simulation).
8903 pub fn into_fs(self) -> F {
8904 self.fs
8905 }
8906
8907 pub fn snapshot(&mut self) -> Result<()> {
8908 self.snapshot_with(SnapshotOptions::default())
8909 }
8910
8911 /// Snapshot with explicit options.
8912 ///
8913 /// # `keep_wal`
8914 ///
8915 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8916 /// - The WAL is replaced with a minimal baseline containing one
8917 /// `EnableFulltext` record per active declaration. All pre-snapshot
8918 /// history is discarded; `open_at` can only reach post-snapshot commits.
8919 ///
8920 /// When `keep_wal` is `true`:
8921 /// - The WAL is left intact. All pre-snapshot commits remain reachable
8922 /// via `open_at`. The existing WAL already contains the original
8923 /// `EnableFulltext` records, so no baseline re-write is needed; the
8924 /// recovery guards in `apply()` silently skip any duplicate records on
8925 /// replay.
8926 /// - Crash window: a crash after the snapshot write but before the next
8927 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
8928 /// snapshot is loaded and the WAL replayed idempotently over it — safe
8929 /// because every `apply()` arm is idempotent when replayed over an
8930 /// already-current snapshot.
8931 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8932 if self.read_only {
8933 return Err(GraphError::ReadOnly);
8934 }
8935 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8936 // Used by the archive path's conservative genesis-chain check: if a prior
8937 // snapshot exists but wal.truncated does not, we cannot distinguish a
8938 // legacy store (may have been truncated in an older code version) from a
8939 // new store that only used keep_wal=true. Conservative: refuse genesis in
8940 // both cases. Must be sampled here, before the snapshot write below.
8941 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8942 self.ensure_v8_base_sections_loaded();
8943 // Ensure provenance is decoded before to_persist() clones it.
8944 self.engine.ensure_provenance_loaded_mut();
8945 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8946 let rule_defs = rule_defs_typed
8947 .iter()
8948 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8949 .collect();
8950 // Collect HNSW state and IVF state. When indexes are not yet
8951 // populated (clean open, no mutation since open), pass the retained
8952 // raw bytes through directly so that migrate/snapshot does not
8953 // silently discard fitted approximate-rule indexes.
8954 let hnsw_state = self.engine.export_hnsw_state_passthrough();
8955 let ivf_bytes = if !self.engine.indexes_populated() {
8956 // Pass retained IVF bytes through unchanged (no re-encode).
8957 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8958 } else {
8959 // Indexes live: encode from current state.
8960 let raw_ivf = self.engine.export_ivf_state();
8961 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8962 .into_iter()
8963 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8964 (
8965 name,
8966 core_storage::snapshot::PerRuleIvfState {
8967 src: core_storage::snapshot::SideIvfState {
8968 centroids: sc,
8969 clusters: sa,
8970 drift: sd,
8971 },
8972 dst: core_storage::snapshot::SideIvfState {
8973 centroids: dc,
8974 clusters: da,
8975 drift: dd,
8976 },
8977 },
8978 )
8979 })
8980 .collect();
8981 if ivf_state_map.is_empty() {
8982 Vec::new()
8983 } else {
8984 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8985 }
8986 };
8987 let view_defs: Vec<Vec<u8>> = self
8988 .view_store
8989 .views()
8990 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8991 .collect();
8992 if self.base.is_some() {
8993 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8994 // write it atomically, remap it as the new base, then clear the overlay.
8995 let meta = V8Meta {
8996 labels: self.labels.clone(),
8997 edge_props: self.edge_props.clone(),
8998 rule_defs,
8999 provenance,
9000 rule_tripped,
9001 rule_fires,
9002 ivf_bytes,
9003 view_defs,
9004 wal_truncated: !opts.keep_wal,
9005 hnsw: hnsw_state,
9006 last_change: self.last_change.clone(),
9007 };
9008 let mut buf: Vec<u8> = Vec::new();
9009 {
9010 // Clone the Arc so the old base stays alive while we encode.
9011 // The borrow of archived_csr (into old_base's mmap) is released
9012 // at the end of this block, before we replace self.base.
9013 let old_base = self.base.clone().expect("is_some checked above");
9014 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9015 detail: format!("v8 snapshot: topology section: {e:?}"),
9016 })?;
9017 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9018 detail: format!("v8 snapshot: columns section: {e:?}"),
9019 })?;
9020 let archived_edge_props =
9021 old_base
9022 .edge_props_section()
9023 .map_err(|e| GraphError::Corrupt {
9024 detail: format!("v8 snapshot: edge_props section: {e:?}"),
9025 })?;
9026 let edge_props_raw =
9027 old_base
9028 .edge_props_raw_bytes()
9029 .map_err(|e| GraphError::Corrupt {
9030 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9031 })?;
9032 let prov_raw =
9033 old_base
9034 .provenance_raw_bytes()
9035 .map_err(|e| GraphError::Corrupt {
9036 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9037 })?;
9038 encode_v8(
9039 Some(archived_csr),
9040 Some(archived_cols),
9041 Some((archived_edge_props, edge_props_raw)),
9042 Some(prov_raw),
9043 &self.topo,
9044 &self.props,
9045 &self.ids,
9046 &self.syms,
9047 &meta,
9048 &mut buf,
9049 )?;
9050 }
9051 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9052 // Remap the freshly-written snapshot as the new base.
9053 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9054 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9055 core_storage::v8::MappedBase::map(&snap_path)
9056 } else {
9057 core_storage::v8::MappedBase::from_bytes(buf)
9058 }
9059 .map_err(|e| GraphError::Corrupt {
9060 detail: format!("v8 snapshot: remap new base: {e:?}"),
9061 })?;
9062 self.base = Some(Arc::new(new_base));
9063 // Clear the overlay and prop tombstones — all data is now in the new base.
9064 self.topo = Topology::new();
9065 self.props = core_storage::columns::ColumnStore::new();
9066 } else {
9067 // Legacy path (V5–V7 stores without a V8 base).
9068 //
9069 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9070 // clone and no encode_v8_from_state intermediate clones. The big
9071 // structures (self.topo, self.props) are borrowed, not cloned.
9072 // self.edge_props is moved (not cloned) because we immediately clear it
9073 // when we remap the new V8 snapshot as self.base (see below).
9074 //
9075 // Eliminates from peak RSS vs. the old SnapshotState path:
9076 // • self.topo.clone() (~topology HashMap footprint)
9077 // • self.props.clone() (~column-store footprint)
9078 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9079 let meta = V8Meta {
9080 labels: self.labels.clone(),
9081 wal_truncated: !opts.keep_wal,
9082 // Move edge_props out so the large overlay is freed when meta
9083 // drops at end of this block (self.edge_props is now empty; reads
9084 // after base assignment go through the mmap'd base section).
9085 edge_props: std::mem::take(&mut self.edge_props),
9086 rule_defs,
9087 provenance,
9088 rule_tripped,
9089 rule_fires,
9090 ivf_bytes,
9091 view_defs,
9092 hnsw: hnsw_state,
9093 last_change: self.last_change.clone(),
9094 };
9095 let mut buf = Vec::new();
9096 encode_v8(
9097 None,
9098 None,
9099 None,
9100 None,
9101 &self.topo,
9102 &self.props,
9103 &self.ids,
9104 &self.syms,
9105 &meta,
9106 &mut buf,
9107 )?;
9108 // meta (and the moved edge_props inside it) is no longer needed;
9109 // drop it before the write to keep the peak window narrow.
9110 drop(meta);
9111 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9112 // Remap the freshly-written V8 snapshot as self.base.
9113 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9114 // On SimFs (tests): pass buf to from_bytes.
9115 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9116 drop(buf);
9117 core_storage::v8::MappedBase::map(&snap_path)
9118 } else {
9119 core_storage::v8::MappedBase::from_bytes(buf)
9120 }
9121 .map_err(|e| GraphError::Corrupt {
9122 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9123 })?;
9124 self.base = Some(Arc::new(new_base));
9125 // Free the large heap-allocated decoded state — all data is now in the
9126 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
9127 // self.edge_props was already moved into meta and is effectively empty.
9128 self.topo = Topology::new();
9129 self.props = core_storage::columns::ColumnStore::new();
9130 }
9131
9132 if opts.archive_wal {
9133 // History-preserving snapshot (Task 4):
9134 // 1. Snapshot already written above (write_atomic → fsynced).
9135 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
9136 // Crash window B: crash here leaves archive present, WAL
9137 // absent. Reopen: snapshot loaded (full state), no WAL
9138 // replay. Archive is NOT replayed into live state — it is
9139 // pre-snapshot by construction. Safe.
9140 // 3. Optionally write genesis marker (first archive only, no
9141 // prior WAL truncation).
9142 // 4. Prune old archives (retention), update horizon floor.
9143 // Pruning invalidates the genesis chain; delete marker.
9144 // 5. Write new minimal baseline WAL (write_atomic).
9145 // Crash window C: crash here leaves new archive plus no live
9146 // WAL. Same as window B — handled above.
9147 //
9148 // Sample existing archives BEFORE the rename so we can detect
9149 // whether this is the first archive.
9150 let existing_archives = self.fs.list_archives()?;
9151 let is_first_archive = existing_archives.is_empty();
9152
9153 // Compute a globally-monotonic archive name: the name equals the
9154 // cumulative end-frame index of the archive in global commit space.
9155 //
9156 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9157 // commit_seq is seeded from max(last_change), which underestimates
9158 // the WAL depth when trailing commits (e.g. insert_edge) do not
9159 // update last_change. A session-2 archive could then receive a name
9160 // ≤ the session-1 archive, causing incorrect sort order or collision.
9161 //
9162 // Instead: read and decode the live WAL here (before the rename) to
9163 // get its exact frame count, then add it to the last known global
9164 // end-frame index (the name of the most recent existing archive, or
9165 // wal_horizon_floor if no archives exist). This is O(WAL size) but
9166 // snapshot is already serialising the full graph state, so the cost
9167 // is dominated.
9168 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9169 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9170 let archive_n = existing_archives
9171 .last()
9172 .copied()
9173 .unwrap_or(self.wal_horizon_floor)
9174 + live_frames_for_name.len() as u64;
9175 self.fs.archive_wal(archive_n)?;
9176
9177 // Genesis marker: written once when the first archive is taken
9178 // from a store that has never undergone a WAL-truncating snapshot.
9179 // When present, `open_at` may replay archive-resident commits from
9180 // empty state (the archive chain covers from global index 0).
9181 //
9182 // Two conditions must ALL hold:
9183 // 1. This is the first archive (existing_archives was empty).
9184 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9185 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9186 // before truncating the WAL, so if any prior truncating snapshot was taken
9187 // — even in a previous session — snapshot.bin is present and this condition
9188 // is false. This subsumes the cross-session truncation case without
9189 // requiring a separate wal.truncated sidecar file.
9190 // For legacy stores (snapshot.bin written by an older code version that
9191 // may have truncated the WAL), the same conservative refusal applies:
9192 // we cannot prove the chain is complete, so we refuse genesis (cost =
9193 // no as-of-through-archives; never silent wrong data).
9194 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9195 // so SimFs always passes this check.
9196 if is_first_archive && !had_prior_snapshot {
9197 self.fs.write_genesis_marker()?;
9198 self.archive_genesis_chain = true;
9199 }
9200
9201 // Retention pruning: keep newest `keep` archives; delete oldest.
9202 // Pruning is the ONLY deletion site for archives.
9203 //
9204 // Crash-safety ordering (C1 fix):
9205 // 1. Count frames in surplus archives (reads only — no mutation).
9206 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
9207 // rename (atomic). A crash after this point leaves orphaned
9208 // archives on disk, but the floor is correct. The opening
9209 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
9210 // the next open, so the store is always safe to reopen.
9211 // 3. Delete the genesis marker (floor > 0 already blocks open_at
9212 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
9213 // 4. Delete surplus archives. A crash between any two deletes
9214 // leaves the floor committed and orphaned archives cleaned at
9215 // next open — never a stale floor with a missing archive prefix.
9216 if let Some(keep) = self.wal_archive_retention {
9217 if keep > 0 {
9218 let archives = self.fs.list_archives()?;
9219 // archives is sorted ascending (oldest first)
9220 if archives.len() as u32 > keep {
9221 let surplus = archives.len() - keep as usize;
9222 // Step 1: count pruned frames (reads, no mutation).
9223 let mut pruned_frames = 0u64;
9224 for &n in &archives[..surplus] {
9225 let bytes = self.fs.read_archive(n)?;
9226 let (frames, _) = decode_all(&bytes);
9227 pruned_frames += frames.len() as u64;
9228 }
9229 // Step 2: advance and persist floor FIRST.
9230 self.wal_horizon_floor += pruned_frames;
9231 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9232 // Step 3: delete genesis marker (floor > 0 already
9233 // blocks open_at; this is belt-and-suspenders cleanup).
9234 if pruned_frames > 0 && self.archive_genesis_chain {
9235 self.fs.delete_genesis_marker()?;
9236 self.archive_genesis_chain = false;
9237 }
9238 // Step 4: delete surplus archives. Crash here →
9239 // orphaned archives; cleaned at next open.
9240 for &n in &archives[..surplus] {
9241 self.fs.delete_archive(n)?;
9242 }
9243 }
9244 }
9245 }
9246
9247 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9248 let mut baseline_wal: Vec<u8> = Vec::new();
9249 for (label, field) in self.fulltext.enabled_pairs() {
9250 let rec = WalRecord::EnableFulltext {
9251 label: label.clone(),
9252 field: field.clone(),
9253 };
9254 baseline_wal.extend_from_slice(&encode_record(&rec));
9255 }
9256 for (label, field) in self.prop_index.enabled_pairs() {
9257 let rec = WalRecord::EnableIndex {
9258 label: label.clone(),
9259 field: field.clone(),
9260 };
9261 baseline_wal.extend_from_slice(&encode_record(&rec));
9262 }
9263 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9264 } else if opts.keep_wal {
9265 // keep_wal=true: WAL is left untouched. The existing WAL already
9266 // contains the EnableFulltext records from the original enable calls;
9267 // replay is idempotent (guards in apply() skip already-live entries).
9268 // No baseline re-write is needed or safe here — the full WAL history
9269 // must remain intact for open_at to reach pre-snapshot commits.
9270 } else {
9271 // keep_wal=false (default): truncate by replacing the WAL with a
9272 // minimal baseline of one EnableFulltext record per active pair.
9273 //
9274 // Crash-ordering: write_atomic is atomic.
9275 // • Crash before snapshot write → WAL unchanged. Safe.
9276 // • Crash after snapshot write but before this WAL write → full
9277 // pre-snapshot WAL still present; open_with replays idempotently.
9278 // • Crash after both writes → normal post-snapshot state.
9279 //
9280 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9281 // for any archives taken AFTER this point (their WAL slices would
9282 // not start at genesis). Delete any existing genesis marker so that
9283 // open_at refuses archive-resident commits. Future sessions are
9284 // covered by had_prior_snapshot: snapshot.bin written here persists
9285 // across sessions and prevents a later archiving session from
9286 // incorrectly claiming a complete genesis chain.
9287 if self.archive_genesis_chain {
9288 self.fs.delete_genesis_marker()?;
9289 self.archive_genesis_chain = false;
9290 }
9291 let mut baseline_wal: Vec<u8> = Vec::new();
9292 for (label, field) in self.fulltext.enabled_pairs() {
9293 let rec = WalRecord::EnableFulltext {
9294 label: label.clone(),
9295 field: field.clone(),
9296 };
9297 baseline_wal.extend_from_slice(&encode_record(&rec));
9298 }
9299 for (label, field) in self.prop_index.enabled_pairs() {
9300 let rec = WalRecord::EnableIndex {
9301 label: label.clone(),
9302 field: field.clone(),
9303 };
9304 baseline_wal.extend_from_slice(&encode_record(&rec));
9305 }
9306 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9307 }
9308 // After snapshot the overlay may have changed (V8 merge path clears
9309 // self.topo and self.props). Refresh the MVCC fold so future readers
9310 // see the post-snapshot state rather than stale overlay data.
9311 self.fold_now();
9312 Ok(())
9313 }
9314}
9315
9316/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9317///
9318/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9319/// callers can build a set of mutations without holding `&mut GraphDb` and
9320/// hand them off to the group-committing writer for durable, batched I/O.
9321pub enum BatchOp {
9322 InsertNode {
9323 label: String,
9324 key: String,
9325 props: Vec<(String, Value)>,
9326 },
9327 InsertEdge {
9328 edge_type: String,
9329 src_key: String,
9330 dst_key: String,
9331 },
9332 SetProp {
9333 key: String,
9334 field: String,
9335 value: Value,
9336 },
9337 RemoveProp {
9338 key: String,
9339 field: String,
9340 },
9341 DeleteEdge {
9342 edge_type: String,
9343 src_key: String,
9344 dst_key: String,
9345 },
9346 DeleteNode {
9347 key: String,
9348 },
9349 CreateRule(RuleDef),
9350 DeleteRule {
9351 name: String,
9352 },
9353 /// Rename a node's key. Validated: old must exist, new must not.
9354 RenameNode {
9355 old_key: String,
9356 new_key: String,
9357 },
9358 /// Insert an edge, auto-creating any missing endpoint as a plain node with
9359 /// `placeholder_label` and no props. Rules fire and last-change is updated
9360 /// for each created endpoint (normal InsertNode semantics in the batch frame).
9361 InsertEdgeUpsert {
9362 edge_type: String,
9363 src_key: String,
9364 dst_key: String,
9365 placeholder_label: String,
9366 },
9367}
9368
9369/// Three-way node visibility status used by `check_single_op_authz`.
9370enum NodeAuthzStatus {
9371 /// Node exists in the store and is in the role's read mask.
9372 Visible(String), // carries the node's label
9373 /// Node exists in the store but is NOT in the role's read mask.
9374 Hidden,
9375 /// Node does not exist in the store.
9376 Absent,
9377}
9378
9379/// Overlay of ops already accepted earlier in the same batch. Never written
9380/// back to the database — validation only.
9381#[derive(Default)]
9382struct Overlay {
9383 extra_keys: BTreeSet<String>,
9384 deleted_keys: BTreeSet<String>,
9385 extra_props: BTreeMap<(String, String), Value>,
9386 removed_props: BTreeSet<(String, String)>,
9387 extra_edges: BTreeSet<(String, String, String)>,
9388 deleted_edges: BTreeSet<(String, String, String)>,
9389 extra_rules: BTreeSet<String>,
9390 deleted_rules: BTreeSet<String>,
9391 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
9392 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
9393 /// sees only the rules already committed to the engine. Keyed by name so a
9394 /// later `DeleteRule` in the same batch drops the arc with the rule.
9395 extra_rule_arcs: BTreeMap<String, (String, String)>,
9396}
9397
9398/// Read-only view of live db state plus a batch overlay. Shared by single-op
9399/// public methods (empty overlay) and `commit_batch`.
9400struct MutPreview<'a, F: Fs> {
9401 db: &'a GraphDb<F>,
9402 overlay: Overlay,
9403}
9404
9405/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
9406/// `None` if `target` is unreachable.
9407///
9408/// Used for rule-chain cycle detection, where an arc is "a rule hops over
9409/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
9410/// reported path is stable for a given rule set, and iterative so a pathological
9411/// rule graph cannot overflow the stack.
9412fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
9413 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
9414 for (from, to) in arcs {
9415 adj.entry(from.as_str()).or_default().insert(to.as_str());
9416 }
9417 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
9418 let mut visited: BTreeSet<&str> = BTreeSet::new();
9419 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
9420 visited.insert(start);
9421 queue.push_back(start);
9422 while let Some(node) = queue.pop_front() {
9423 if node == target {
9424 let mut path = vec![node.to_string()];
9425 let mut cur = node;
9426 while let Some(&p) = parent.get(cur) {
9427 path.push(p.to_string());
9428 cur = p;
9429 }
9430 path.reverse();
9431 return Some(path);
9432 }
9433 for &next in adj.get(node).into_iter().flatten() {
9434 if visited.insert(next) {
9435 parent.insert(next, node);
9436 queue.push_back(next);
9437 }
9438 }
9439 }
9440 None
9441}
9442
9443impl<'a, F: Fs> MutPreview<'a, F> {
9444 fn new(db: &'a GraphDb<F>) -> Self {
9445 Self {
9446 db,
9447 overlay: Overlay::default(),
9448 }
9449 }
9450
9451 fn has_key(&self, key: &str) -> bool {
9452 if self.overlay.extra_keys.contains(key) {
9453 return true;
9454 }
9455 if self.overlay.deleted_keys.contains(key) {
9456 return false;
9457 }
9458 self.db.ids.get(key).is_some()
9459 }
9460
9461 fn has_prop(&self, key: &str, field: &str) -> bool {
9462 if !self.has_key(key) {
9463 return false;
9464 }
9465 let k = (key.to_string(), field.to_string());
9466 if self.overlay.removed_props.contains(&k) {
9467 return false;
9468 }
9469 if self.overlay.extra_props.contains_key(&k) {
9470 return true;
9471 }
9472 // Fresh identity (first insert in this batch, or delete+reinsert):
9473 // ignore props still sitting on the soon-to-be-tombstoned slot.
9474 if self.overlay.extra_keys.contains(key) {
9475 return false;
9476 }
9477 self.db.get_prop(key, field).is_some()
9478 }
9479
9480 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9481 let k = (
9482 edge_type.to_string(),
9483 src_key.to_string(),
9484 dst_key.to_string(),
9485 );
9486 if self.overlay.deleted_edges.contains(&k) {
9487 return false;
9488 }
9489 if self.overlay.extra_edges.contains(&k) {
9490 return true;
9491 }
9492 // A key created in this batch (including reinsert) has no db edges.
9493 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9494 return false;
9495 }
9496 if self.overlay.deleted_keys.contains(src_key)
9497 || self.overlay.deleted_keys.contains(dst_key)
9498 {
9499 return false;
9500 }
9501 let Some(src) = self.db.ids.get(src_key) else {
9502 return false;
9503 };
9504 let Some(dst) = self.db.ids.get(dst_key) else {
9505 return false;
9506 };
9507 let Some(sym) = self.db.syms.get(edge_type) else {
9508 return false;
9509 };
9510 self.db
9511 .topo_view()
9512 .neighbors(sym, Direction::Out, src)
9513 .binary_search(&dst)
9514 .is_ok()
9515 }
9516
9517 fn has_rule(&self, name: &str) -> bool {
9518 if self.overlay.extra_rules.contains(name) {
9519 return true;
9520 }
9521 if self.overlay.deleted_rules.contains(name) {
9522 return false;
9523 }
9524 self.db.engine.rules().any(|r| r.name == name)
9525 }
9526
9527 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9528 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9529 return false;
9530 }
9531 if self.overlay.deleted_keys.contains(src_key)
9532 || self.overlay.deleted_keys.contains(dst_key)
9533 {
9534 return false;
9535 }
9536 let Some(src) = self.db.ids.get(src_key) else {
9537 return false;
9538 };
9539 let Some(dst) = self.db.ids.get(dst_key) else {
9540 return false;
9541 };
9542 let Some(et) = self.db.syms.get(edge_type) else {
9543 return false;
9544 };
9545 // extra_rules is deliberately not consulted: a CreateRule earlier in
9546 // this batch has not fired, so it contributes no provenance. That is
9547 // the documented rule-window gap (see GraphDb::batch).
9548 if self.overlay.deleted_rules.is_empty() {
9549 return self.db.engine.is_owned(et, src, dst);
9550 }
9551 for (rule, triples) in self.db.engine.provenance() {
9552 if self.overlay.deleted_rules.contains(rule) {
9553 continue;
9554 }
9555 if triples.contains(&(et, src, dst)) {
9556 return true;
9557 }
9558 }
9559 false
9560 }
9561
9562 fn check_insert_node(&self, key: &str) -> Result<()> {
9563 if self.has_key(key) {
9564 Err(GraphError::DuplicateKey { key: key.into() })
9565 } else {
9566 Ok(())
9567 }
9568 }
9569
9570 fn check_live_key(&self, key: &str) -> Result<()> {
9571 if self.has_key(key) {
9572 Ok(())
9573 } else {
9574 Err(GraphError::KeyNotFound { key: key.into() })
9575 }
9576 }
9577
9578 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9579 for k in [src_key, dst_key] {
9580 if !self.has_key(k) {
9581 return Err(GraphError::KeyNotFound { key: k.into() });
9582 }
9583 }
9584 if self.is_rule_owned(edge_type, src_key, dst_key) {
9585 return Err(GraphError::RuleOwned {
9586 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9587 });
9588 }
9589 Ok(!self.has_edge(edge_type, src_key, dst_key))
9590 }
9591
9592 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9593 self.check_live_key(key)?;
9594 Ok(self.has_prop(key, field))
9595 }
9596
9597 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9598 for k in [src_key, dst_key] {
9599 if !self.has_key(k) {
9600 return Err(GraphError::KeyNotFound { key: k.into() });
9601 }
9602 }
9603 // Provenance-owned OR a live rule would derive this pair. User-first
9604 // edges that a later rule matches are not in `owned`, but deleting
9605 // them would leave a hole `rebuild_rule` immediately fills.
9606 if self.is_rule_owned(edge_type, src_key, dst_key) {
9607 return Err(GraphError::RuleOwned {
9608 detail: format!(
9609 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9610 delete or change the owning rule"
9611 ),
9612 });
9613 }
9614 if self.would_derive(edge_type, src_key, dst_key) {
9615 return Err(GraphError::RuleOwned {
9616 detail: format!(
9617 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9618 delete or change the owning rule, or a live rule would re-derive it"
9619 ),
9620 });
9621 }
9622 Ok(self.has_edge(edge_type, src_key, dst_key))
9623 }
9624
9625 /// True if any live rule (minus overlay-deleted names) would derive
9626 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9627 /// CreateRule names in `extra_rules` are ignored — same documented
9628 /// same-batch rule-window as [`Self::is_rule_owned`].
9629 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9630 if src_key == dst_key {
9631 return false;
9632 }
9633 let Some(src_label) = self.label_of(src_key) else {
9634 return false;
9635 };
9636 let Some(dst_label) = self.label_of(dst_key) else {
9637 return false;
9638 };
9639 for rule in self.db.engine.rules() {
9640 if self.overlay.deleted_rules.contains(&rule.name) {
9641 continue;
9642 }
9643 if rule.edge_type != edge_type {
9644 continue;
9645 }
9646 if rule.src_label != src_label || rule.dst_label != dst_label {
9647 continue;
9648 }
9649 let src_props = |f: &str| self.prop_value(src_key, f);
9650 let dst_props = |f: &str| self.prop_value(dst_key, f);
9651 let src_view = NodeView {
9652 key: src_key,
9653 props: &src_props,
9654 };
9655 let dst_view = NodeView {
9656 key: dst_key,
9657 props: &dst_props,
9658 };
9659 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9660 return true;
9661 }
9662 }
9663 false
9664 }
9665
9666 fn label_of(&self, key: &str) -> Option<String> {
9667 if self.overlay.deleted_keys.contains(key) {
9668 return None;
9669 }
9670 // Fresh identities created in this batch have no stored label in the
9671 // overlay; they cannot be provenance-owned yet either.
9672 let id = self.db.ids.get(key)?;
9673 let sym = self.db.labels.get(id as usize).copied()?;
9674 if sym == u32::MAX {
9675 return None;
9676 }
9677 self.db.syms.resolve(sym).map(str::to_string)
9678 }
9679
9680 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9681 if !self.has_key(key) {
9682 return None;
9683 }
9684 let k = (key.to_string(), field.to_string());
9685 if self.overlay.removed_props.contains(&k) {
9686 return None;
9687 }
9688 if let Some(v) = self.overlay.extra_props.get(&k) {
9689 return Some(v.clone());
9690 }
9691 if self.overlay.extra_keys.contains(key) {
9692 return None;
9693 }
9694 self.db.get_prop(key, field)
9695 }
9696
9697 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9698 def.validate()
9699 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9700 if self.has_rule(&def.name) {
9701 return Err(GraphError::RuleInvalid {
9702 detail: format!("rule {:?} already exists", def.name),
9703 });
9704 }
9705 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
9706 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
9707 // `edge_type`". A cycle in that graph is a rule set that would re-fire
9708 // itself forever; the engine's depth cap would silently truncate it
9709 // instead, leaving an arbitrary partial result. Reject it here, the one
9710 // place that sees the whole rule set.
9711 //
9712 // Rules accepted earlier in the same batch count too: the overlay
9713 // carries their arcs, so a cycle cannot be assembled one op at a time.
9714 if let Some(via) = def.via_edge.as_deref() {
9715 if via == def.edge_type {
9716 return Err(GraphError::RuleInvalid {
9717 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
9718 });
9719 }
9720 let mut arcs: Vec<(String, String)> = self
9721 .db
9722 .engine
9723 .rules()
9724 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
9725 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
9726 .collect();
9727 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
9728 arcs.push((via.to_string(), def.edge_type.clone()));
9729 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
9730 return Err(GraphError::RuleInvalid {
9731 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
9732 });
9733 }
9734 }
9735 Ok(())
9736 }
9737
9738 fn check_delete_rule(&self, name: &str) -> Result<()> {
9739 if self.has_rule(name) {
9740 Ok(())
9741 } else {
9742 Err(GraphError::RuleNotFound { name: name.into() })
9743 }
9744 }
9745
9746 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9747 self.overlay.deleted_keys.remove(key);
9748 self.overlay.extra_keys.insert(key.to_string());
9749 self.overlay.extra_props.retain(|(k, _), _| k != key);
9750 self.overlay.removed_props.retain(|(k, _)| k != key);
9751 for (field, value) in props {
9752 self.overlay
9753 .extra_props
9754 .insert((key.to_string(), field.clone()), value.clone());
9755 }
9756 }
9757
9758 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9759 let k = (
9760 edge_type.to_string(),
9761 src_key.to_string(),
9762 dst_key.to_string(),
9763 );
9764 self.overlay.deleted_edges.remove(&k);
9765 self.overlay.extra_edges.insert(k);
9766 }
9767
9768 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9769 let k = (key.to_string(), field.to_string());
9770 self.overlay.removed_props.remove(&k);
9771 self.overlay.extra_props.insert(k, value.clone());
9772 }
9773
9774 fn note_remove_prop(&mut self, key: &str, field: &str) {
9775 let k = (key.to_string(), field.to_string());
9776 self.overlay.extra_props.remove(&k);
9777 self.overlay.removed_props.insert(k);
9778 }
9779
9780 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9781 let k = (
9782 edge_type.to_string(),
9783 src_key.to_string(),
9784 dst_key.to_string(),
9785 );
9786 self.overlay.extra_edges.remove(&k);
9787 self.overlay.deleted_edges.insert(k);
9788 }
9789
9790 fn note_delete_node(&mut self, key: &str) {
9791 self.overlay.extra_keys.remove(key);
9792 self.overlay.deleted_keys.insert(key.to_string());
9793 self.overlay.extra_props.retain(|(k, _), _| k != key);
9794 self.overlay.removed_props.retain(|(k, _)| k != key);
9795 self.overlay
9796 .extra_edges
9797 .retain(|(_, s, d)| s != key && d != key);
9798 self.overlay
9799 .deleted_edges
9800 .retain(|(_, s, d)| s != key && d != key);
9801 }
9802
9803 fn note_create_rule(&mut self, def: &RuleDef) {
9804 self.overlay.deleted_rules.remove(&def.name);
9805 self.overlay.extra_rules.insert(def.name.clone());
9806 // Rules accepted earlier in this batch are not in the engine yet, so
9807 // the cycle check would not see their arcs. Keep the arc, not just the
9808 // name, so a batch cannot smuggle in a cycle one op at a time.
9809 if let Some(via) = def.via_edge.clone() {
9810 self.overlay
9811 .extra_rule_arcs
9812 .insert(def.name.clone(), (via, def.edge_type.clone()));
9813 }
9814 }
9815
9816 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9817 if !self.has_key(old) {
9818 return Err(GraphError::KeyNotFound { key: old.into() });
9819 }
9820 if self.has_key(new) {
9821 return Err(GraphError::DuplicateKey { key: new.into() });
9822 }
9823 Ok(())
9824 }
9825
9826 fn note_rename_node(&mut self, old: &str, new: &str) {
9827 // Mark old as deleted so subsequent batch ops cannot reference it.
9828 self.overlay.extra_keys.remove(old);
9829 self.overlay.deleted_keys.insert(old.to_string());
9830 // Mark new as extra so subsequent batch ops can reference it.
9831 self.overlay.deleted_keys.remove(new);
9832 self.overlay.extra_keys.insert(new.to_string());
9833 // Migrate any overlay props from old key to new key.
9834 let new_str = new.to_string();
9835 let transferred: Vec<((String, String), Value)> = self
9836 .overlay
9837 .extra_props
9838 .iter()
9839 .filter(|((k, _), _)| k.as_str() == old)
9840 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9841 .collect();
9842 self.overlay
9843 .extra_props
9844 .retain(|(k, _), _| k.as_str() != old);
9845 for (k, v) in transferred {
9846 self.overlay.extra_props.insert(k, v);
9847 }
9848 // Migrate removed_props.
9849 let transferred_removed: Vec<(String, String)> = self
9850 .overlay
9851 .removed_props
9852 .iter()
9853 .filter(|(k, _)| k.as_str() == old)
9854 .map(|(_, f)| (new_str.clone(), f.clone()))
9855 .collect();
9856 self.overlay
9857 .removed_props
9858 .retain(|(k, _)| k.as_str() != old);
9859 for k in transferred_removed {
9860 self.overlay.removed_props.insert(k);
9861 }
9862 }
9863
9864 fn note_delete_rule(&mut self, name: &str) {
9865 self.overlay.extra_rules.remove(name);
9866 // Drop its chain arc too: a rule created and then deleted in the same
9867 // batch must not make a later, legal rule look like a cycle.
9868 self.overlay.extra_rule_arcs.remove(name);
9869 self.overlay.deleted_rules.insert(name.to_string());
9870 // Treat the deleted rule's current provenance as gone so a later
9871 // delete_edge of those triples is a no-op (matches sequential).
9872 if let Some(triples) = self.db.engine.provenance().get(name) {
9873 for &(et, s, d) in triples {
9874 let Some(etype) = self.db.syms.resolve(et) else {
9875 continue;
9876 };
9877 let Some(src) = self.db.ids.key_of(s) else {
9878 continue;
9879 };
9880 let Some(dst) = self.db.ids.key_of(d) else {
9881 continue;
9882 };
9883 let k = (etype.to_string(), src.to_string(), dst.to_string());
9884 self.overlay.extra_edges.remove(&k);
9885 self.overlay.deleted_edges.insert(k);
9886 }
9887 }
9888 }
9889}
9890
9891/// Collects mutations and commits them as one WAL `Batch` frame.
9892///
9893/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9894/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9895/// See [`GraphDb::batch`] for validation and atomicity rules.
9896pub struct BatchBuilder<'a, F: Fs> {
9897 db: &'a mut GraphDb<F>,
9898 ops: Vec<BatchOp>,
9899}
9900
9901impl<'a, F: Fs> BatchBuilder<'a, F> {
9902 pub fn insert_node(
9903 &mut self,
9904 label: &str,
9905 key: &str,
9906 props: Vec<(String, Value)>,
9907 ) -> &mut Self {
9908 self.ops.push(BatchOp::InsertNode {
9909 label: label.into(),
9910 key: key.into(),
9911 props,
9912 });
9913 self
9914 }
9915
9916 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9917 self.ops.push(BatchOp::InsertEdge {
9918 edge_type: edge_type.into(),
9919 src_key: src_key.into(),
9920 dst_key: dst_key.into(),
9921 });
9922 self
9923 }
9924
9925 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9926 self.ops.push(BatchOp::SetProp {
9927 key: key.into(),
9928 field: field.into(),
9929 value,
9930 });
9931 self
9932 }
9933
9934 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9935 self.ops.push(BatchOp::RemoveProp {
9936 key: key.into(),
9937 field: field.into(),
9938 });
9939 self
9940 }
9941
9942 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9943 self.ops.push(BatchOp::DeleteEdge {
9944 edge_type: edge_type.into(),
9945 src_key: src_key.into(),
9946 dst_key: dst_key.into(),
9947 });
9948 self
9949 }
9950
9951 pub fn delete_node(&mut self, key: &str) -> &mut Self {
9952 self.ops.push(BatchOp::DeleteNode { key: key.into() });
9953 self
9954 }
9955
9956 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9957 self.ops.push(BatchOp::CreateRule(def));
9958 self
9959 }
9960
9961 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9962 self.ops.push(BatchOp::DeleteRule { name: name.into() });
9963 self
9964 }
9965
9966 /// Queue a node-rename in this batch.
9967 ///
9968 /// Validation (old exists, new not taken) runs at commit time.
9969 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9970 self.ops.push(BatchOp::RenameNode {
9971 old_key: old_key.into(),
9972 new_key: new_key.into(),
9973 });
9974 self
9975 }
9976
9977 /// Queue an edge insert with endpoint auto-creation.
9978 ///
9979 /// Any missing endpoint is created as a plain node `{key, label:
9980 /// placeholder_label, no props}` inside this batch frame. Rules fire and
9981 /// last-change is updated for each auto-created node.
9982 pub fn insert_edge_upsert(
9983 &mut self,
9984 edge_type: &str,
9985 src_key: &str,
9986 dst_key: &str,
9987 placeholder_label: &str,
9988 ) -> &mut Self {
9989 self.ops.push(BatchOp::InsertEdgeUpsert {
9990 edge_type: edge_type.into(),
9991 src_key: src_key.into(),
9992 dst_key: dst_key.into(),
9993 placeholder_label: placeholder_label.into(),
9994 });
9995 self
9996 }
9997
9998 /// Validate every queued op, then log one `Batch` frame and apply.
9999 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10000 /// A second `commit()` after a successful one is an empty-batch no-op
10001 /// (queued ops were taken).
10002 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10003 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10004 ///
10005 /// **Rule-window limitation:** batch validation cannot see edges that a
10006 /// rule created earlier in the *same* batch will derive at apply time, so
10007 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10008 /// where sequential calls would return `Err(RuleOwned)`. State integrity
10009 /// is unaffected (idempotent apply, provenance intact). Create rules in
10010 /// their own batch, or sequentially, when later ops may touch derived
10011 /// edges.
10012 /// Validate every queued op and commit atomically.
10013 ///
10014 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10015 /// WAL records actually written (duplicate edges are silent no-ops and are
10016 /// NOT counted). Both are 0 when the batch is empty or all-noop.
10017 pub fn commit(&mut self) -> Result<(usize, usize)> {
10018 let ops = std::mem::take(&mut self.ops);
10019 self.db.commit_batch(ops)
10020 }
10021
10022 /// Same as [`commit`](Self::commit) but tail the inner events with
10023 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10024 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10025 let ops = std::mem::take(&mut self.ops);
10026 self.db
10027 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10028 }
10029}
10030
10031pub struct NodeRef<'a, F: Fs> {
10032 db: &'a GraphDb<F>,
10033 id: u32,
10034}
10035
10036impl<'a, F: Fs> NodeRef<'a, F> {
10037 pub fn key(&self) -> &str {
10038 self.db.ids.key_of(self.id).expect("dense ids")
10039 }
10040
10041 pub fn label(&self) -> &str {
10042 let sym = self
10043 .db
10044 .labels
10045 .get(self.id as usize)
10046 .copied()
10047 .filter(|&s| s != u32::MAX)
10048 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10049 self.db.syms.resolve(sym).expect("interned label symbol")
10050 }
10051
10052 pub fn prop(&self, field: &str) -> Option<Value> {
10053 self.db
10054 .props_view()
10055 .get(self.id, field)
10056 .map(|vr| vr.into_value())
10057 }
10058
10059 /// All stored fields for this node, sorted by field name.
10060 ///
10061 /// Reads from the full base+overlay view so that props stored only in the
10062 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10063 pub fn props(&self) -> BTreeMap<String, Value> {
10064 let mut out = BTreeMap::new();
10065 let pv = self.db.props_view();
10066 for field in pv.field_names() {
10067 if let Some(vr) = pv.get(self.id, &field) {
10068 out.insert(field, vr.into_value());
10069 }
10070 }
10071 out
10072 }
10073
10074 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10075 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10076 let view = self.db.view();
10077 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10078 names
10079 .iter()
10080 .filter_map(|name| view.syms.get(name))
10081 .collect()
10082 });
10083 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10084 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10085 for (nid, d) in nb.nodes {
10086 let key = view.key_of(nid);
10087 let label = view
10088 .label_of(nid)
10089 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10090 rs.push_row(vec![
10091 Some(Value::Str(key.to_string())),
10092 Some(Value::Str(label.to_string())),
10093 Some(Value::Int(d as i64)),
10094 ]);
10095 }
10096 rs
10097 }
10098
10099 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10100 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10101 let view = self.db.view();
10102 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10103 for e in expand(&view, self.id, None, Dir::Both) {
10104 // Skip edges with unknown etypes (only possible from corrupt large
10105 // TOPOLOGY section; function returns BTreeMap not Result).
10106 let Some(etype) = view.syms.resolve(e.etype) else {
10107 continue;
10108 };
10109 let etype = etype.to_string();
10110 let nbr = if e.src == self.id { e.dst } else { e.src };
10111 groups
10112 .entry(etype)
10113 .or_default()
10114 .insert(view.key_of(nbr).to_string());
10115 }
10116 groups
10117 .into_iter()
10118 .map(|(k, v)| (k, v.into_iter().collect()))
10119 .collect()
10120 }
10121}
10122
10123#[cfg(test)]
10124mod tests {
10125 use super::*;
10126 use core_rules::Predicate;
10127
10128 fn tmp_dir(name: &str) -> std::path::PathBuf {
10129 let d =
10130 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10131 let _ = std::fs::remove_dir_all(&d);
10132 d
10133 }
10134
10135 fn fk_rule() -> RuleDef {
10136 RuleDef {
10137 name: "works_at".into(),
10138 src_label: "Person".into(),
10139 dst_label: "Org".into(),
10140 predicate: Predicate::KeyMatch {
10141 field: "org_id".into(),
10142 },
10143 edge_type: "WORKS_AT".into(),
10144 weight_prop: None,
10145 max_edges: None,
10146 approximate: false,
10147 via_label: None,
10148 via_edge: None,
10149 via_dir: None,
10150 }
10151 }
10152
10153 /// Regression guard for the no-views delta-copy fast path.
10154 ///
10155 /// When no views are defined, `pending_deltas_since().to_vec()` must never
10156 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10157 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10158 /// a count of 0 after the entire sequence proves the guard fires correctly.
10159 #[test]
10160 fn no_delta_copy_when_no_views() {
10161 DELTA_COPY_COUNT.with(|c| c.set(0));
10162 let dir = tmp_dir("no-delta-copy");
10163 {
10164 let mut db = GraphDb::open(&dir).unwrap();
10165 // Insert 50 Org + 50 Person nodes with FK links.
10166 for i in 0..50u32 {
10167 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10168 }
10169 for i in 0..50u32 {
10170 db.insert_node(
10171 "Person",
10172 &format!("p{i}"),
10173 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10174 )
10175 .unwrap();
10176 }
10177 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10178 db.create_rule(fk_rule()).unwrap();
10179
10180 // Counter must stay 0 — no views, no copies.
10181 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10182 assert_eq!(
10183 copies, 0,
10184 "pending_deltas_since().to_vec() called despite no views"
10185 );
10186
10187 // Derived edges must still be correct (the guard skips only the
10188 // empty delta propagation loop, not the rule application itself).
10189 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10190 assert_eq!(
10191 nbrs,
10192 vec!["o0"],
10193 "rule must derive edges even with no views"
10194 );
10195 }
10196 let _ = std::fs::remove_dir_all(&dir);
10197 }
10198
10199 /// Gating regression: subscribe AFTER a backfill must see no stale events.
10200 /// subscribe BEFORE a backfill must see every edge-fire event.
10201 #[test]
10202 fn subscribe_after_backfill_no_stale_events() {
10203 let dir = tmp_dir("sub-after-backfill");
10204 {
10205 let mut db = GraphDb::open(&dir).unwrap();
10206 for i in 0..10u32 {
10207 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10208 db.insert_node(
10209 "Person",
10210 &format!("p{i}"),
10211 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10212 )
10213 .unwrap();
10214 }
10215 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10216 db.create_rule(fk_rule()).unwrap();
10217
10218 // Subscribe AFTER the backfill — queue must be empty (no stale events).
10219 let sub = db.subscribe_all_rules().unwrap();
10220 // No events should have queued for the prior backfill.
10221 assert!(
10222 sub.try_recv().is_none(),
10223 "subscribe after backfill must see no stale events"
10224 );
10225
10226 // Inserting a new node now should fire an event (emit_deltas is now true).
10227 db.insert_node("Org", "o_new", vec![]).unwrap();
10228 db.insert_node(
10229 "Person",
10230 "p_new",
10231 vec![("org_id".into(), Value::Str("o_new".into()))],
10232 )
10233 .unwrap();
10234 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10235 assert!(
10236 ev.is_some(),
10237 "edge-fire event must arrive after subscribe (emit_deltas=true)"
10238 );
10239 }
10240 let _ = std::fs::remove_dir_all(&dir);
10241 }
10242
10243 /// Gating regression: subscribe BEFORE a backfill → events flow.
10244 #[test]
10245 fn subscribe_before_backfill_events_flow() {
10246 let dir = tmp_dir("sub-before-backfill");
10247 {
10248 let mut db = GraphDb::open(&dir).unwrap();
10249 // Subscribe FIRST — emit_deltas becomes true.
10250 let sub = db.subscribe_all_rules().unwrap();
10251
10252 for i in 0..5u32 {
10253 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10254 db.insert_node(
10255 "Person",
10256 &format!("p{i}"),
10257 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10258 )
10259 .unwrap();
10260 }
10261 // Backfill fires with emit_deltas=true → events queued.
10262 db.create_rule(fk_rule()).unwrap();
10263
10264 // Should receive at least one edge-fired event from the backfill.
10265 let mut received = 0usize;
10266 while sub.try_recv().is_some() {
10267 received += 1;
10268 }
10269 assert!(
10270 received > 0,
10271 "subscribe before backfill must receive edge-fire events (got 0)"
10272 );
10273 }
10274 let _ = std::fs::remove_dir_all(&dir);
10275 }
10276
10277 /// Companion: when a view IS defined, the delta path fires and view values update.
10278 #[test]
10279 fn delta_copy_fires_when_view_exists() {
10280 use core_rules::ViewSource;
10281 DELTA_COPY_COUNT.with(|c| c.set(0));
10282 let dir = tmp_dir("delta-copy-with-view");
10283 {
10284 let mut db = GraphDb::open(&dir).unwrap();
10285 db.insert_node("Org", "o1", vec![]).unwrap();
10286 db.insert_node(
10287 "Person",
10288 "p1",
10289 vec![("org_id".into(), Value::Str("o1".into()))],
10290 )
10291 .unwrap();
10292 // Declare a Degree view so is_empty() returns false.
10293 db.create_view(ViewDef {
10294 name: "degree_out".into(),
10295 label: "Person".into(),
10296 view_prop: "degree_out".into(),
10297 source: ViewSource::Degree {
10298 edge_type: "WORKS_AT".into(),
10299 direction: Direction::Out,
10300 },
10301 })
10302 .unwrap();
10303 db.create_rule(fk_rule()).unwrap();
10304
10305 // At least one delta copy should have happened (CreateRule backfill).
10306 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10307 assert!(
10308 copies > 0,
10309 "expected delta copy to fire when a view is defined"
10310 );
10311
10312 // View value should be computed: p1 has one WORKS_AT out-edge.
10313 let info = db.node_info("p1").unwrap();
10314 let degree = info.props.get("degree_out");
10315 assert!(
10316 degree.is_some(),
10317 "view prop should be written to node props"
10318 );
10319 }
10320 let _ = std::fs::remove_dir_all(&dir);
10321 }
10322
10323 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10324 /// derived-edge-driven view values reflect the as-of state rather than just
10325 /// the initial backfill written at `CreateView` time.
10326 ///
10327 /// Base WAL frames (indices 0..=5 before history markers):
10328 /// 0: insert Org "o1"
10329 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10330 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10331 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
10332 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10333 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
10334 ///
10335 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10336 /// no-op), so the total commit count is higher than the base frame count.
10337 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10338 ///
10339 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10340 /// initial backfill value (0) instead of reflecting the replayed derived edges.
10341 #[test]
10342 fn open_at_derived_edge_view_values_correct() {
10343 use core_rules::ViewSource;
10344 let dir = tmp_dir("open-at-view-rebuild");
10345 {
10346 let mut db = GraphDb::open(&dir).unwrap();
10347 // frame 0
10348 db.insert_node("Org", "o1", vec![]).unwrap();
10349 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10350 db.create_view(ViewDef {
10351 name: "employee_count".into(),
10352 label: "Org".into(),
10353 view_prop: "emp".into(),
10354 source: ViewSource::Degree {
10355 edge_type: "WORKS_AT".into(),
10356 direction: Direction::In,
10357 },
10358 })
10359 .unwrap();
10360 // frame 2: create rule — no Persons yet; backfill is a no-op
10361 db.create_rule(fk_rule()).unwrap();
10362 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10363 db.insert_node(
10364 "Person",
10365 "p1",
10366 vec![("org_id".into(), Value::Str("o1".into()))],
10367 )
10368 .unwrap();
10369 // frame 4: p2 — degree = 2
10370 db.insert_node(
10371 "Person",
10372 "p2",
10373 vec![("org_id".into(), Value::Str("o1".into()))],
10374 )
10375 .unwrap();
10376 // frame 5: p3 — degree = 3
10377 db.insert_node(
10378 "Person",
10379 "p3",
10380 vec![("org_id".into(), Value::Str("o1".into()))],
10381 )
10382 .unwrap();
10383 // Sanity: normal open sees degree = 3.
10384 assert_eq!(
10385 db.get_view_prop("o1", "emp"),
10386 Some(Value::Int(3)),
10387 "normal db must show degree 3 after 3 derived edges"
10388 );
10389 } // WAL flushed
10390
10391 // Re-open normally to get the authoritative reference value.
10392 let normal_db = GraphDb::open(&dir).unwrap();
10393 let normal_emp = normal_db.get_view_prop("o1", "emp");
10394 assert_eq!(
10395 normal_emp,
10396 Some(Value::Int(3)),
10397 "re-opened normal db must show degree 3"
10398 );
10399
10400 // Latest as-of (last WAL commit): must match the normal open.
10401 // History-marker frames are appended after each rule-fire, so the total
10402 // commit count is computed dynamically rather than hardcoded.
10403 let total = crate::wal_commit_count_at(&dir).unwrap();
10404 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10405 assert_eq!(
10406 aof_latest.get_view_prop("o1", "emp"),
10407 normal_emp,
10408 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10409 );
10410
10411 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10412 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10413 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10414 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10415 assert_eq!(
10416 aof_mid.get_view_prop("o1", "emp"),
10417 Some(Value::Int(1)),
10418 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10419 );
10420
10421 let _ = std::fs::remove_dir_all(&dir);
10422 }
10423
10424 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10425 /// as-of instances never commit, so distribute_events never runs and any
10426 /// subscription would wait forever.
10427 #[test]
10428 fn subscribe_on_as_of_returns_read_only_error() {
10429 let dir = tmp_dir("sub-as-of-read-only");
10430 {
10431 let mut db = GraphDb::open(&dir).unwrap();
10432 db.insert_node("Org", "o1", vec![]).unwrap();
10433 db.create_rule(fk_rule()).unwrap();
10434 }
10435 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10436
10437 assert!(
10438 matches!(
10439 aof.subscribe_all_rules(),
10440 Err(core_storage::GraphError::ReadOnly)
10441 ),
10442 "subscribe_all_rules on as-of must return ReadOnly"
10443 );
10444 assert!(
10445 matches!(
10446 aof.subscribe_writes(),
10447 Err(core_storage::GraphError::ReadOnly)
10448 ),
10449 "subscribe_writes on as-of must return ReadOnly"
10450 );
10451 assert!(
10452 matches!(
10453 aof.subscribe_rule("works_at"),
10454 Err(core_storage::GraphError::ReadOnly)
10455 ),
10456 "subscribe_rule on as-of must return ReadOnly"
10457 );
10458 let _ = std::fs::remove_dir_all(&dir);
10459 }
10460
10461 /// Regression: a failed dense WAL rewrite must not leave speculative
10462 /// interns in `syms`. If it does, the next successful mutation logs an
10463 /// `Intern` record with an inflated id; replay (which never saw the
10464 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10465 #[test]
10466 fn dense_rewrite_error_rolls_back_speculative_interns() {
10467 let dir = tmp_dir("dense-rewrite-rollback");
10468 {
10469 let mut db = GraphDb::open(&dir).unwrap();
10470 db.insert_node("Person", "a", vec![]).unwrap();
10471
10472 // Bypass MutPreview validation to hit the rewrite's own error path
10473 // (same shape as an id-exhaustion failure mid-rewrite). The
10474 // InsertEdge arm interns the edge type before it resolves keys.
10475 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10476 edge_type: "ORPHAN_TYPE".into(),
10477 src_key: "missing".into(),
10478 dst_key: "a".into(),
10479 }]);
10480 assert!(err.is_err(), "rewrite of a missing src key must fail");
10481 assert_eq!(
10482 db.syms.get("ORPHAN_TYPE"),
10483 None,
10484 "failed rewrite must roll back speculative interns"
10485 );
10486
10487 // A later successful mutation must produce a replayable WAL.
10488 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10489 }
10490 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10491 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10492 let _ = std::fs::remove_dir_all(&dir);
10493 }
10494}