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 if norm == "key" {
830 if args.len() != 1 {
831 return Err(GraphError::QueryError {
832 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
833 });
834 }
835 let Operand::Var(var) = &args[0] else {
836 return Err(GraphError::QueryError {
837 detail: "key() argument must be a node variable (e.g. key(n))".into(),
838 });
839 };
840 if rel_vars.iter().any(|r| r == var) {
841 return Err(GraphError::QueryError {
842 detail: format!("key() argument `{var}` is a relationship, not a node"),
843 });
844 }
845 // MATCH rows bind node variables to their key string, so the column
846 // value *is* the key.
847 return Ok(match_rs.get(row, var).cloned());
848 }
849 let mut vals = Vec::with_capacity(args.len());
850 for arg in args {
851 vals.push(eval_set_return_operand(
852 db, match_rs, row, rel_vars, arg, params,
853 )?);
854 }
855 match norm.as_str() {
856 "tolower" => {
857 if vals.len() != 1 {
858 return Err(GraphError::QueryError {
859 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
860 });
861 }
862 Ok(vals[0].clone().map(|val| match val {
863 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
864 other => other,
865 }))
866 }
867 "toupper" => {
868 if vals.len() != 1 {
869 return Err(GraphError::QueryError {
870 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
871 });
872 }
873 Ok(vals[0].clone().map(|val| match val {
874 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
875 other => other,
876 }))
877 }
878 "size" => match vals.first().cloned().flatten() {
879 None => Ok(None),
880 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
881 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
882 Some(_) => Ok(None),
883 },
884 "coalesce" => Ok(vals.into_iter().flatten().next()),
885 "abs" => match vals.first().cloned().flatten() {
886 None => Ok(None),
887 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
888 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
889 Some(_) => Ok(None),
890 },
891 "round" => match vals.first().cloned().flatten() {
892 None => Ok(None),
893 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
894 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
895 Some(_) => Ok(None),
896 },
897 "decay" => {
898 if vals.len() != 3 {
899 return Err(GraphError::QueryError {
900 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
901 });
902 }
903 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
904 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
905 (Some(b), Some(a), Some(h)) => {
906 let numeric = |v: Value| -> Result<f64> {
907 match v {
908 Value::Int(n) => Ok(n as f64),
909 Value::Float(f) => Ok(f),
910 other => Err(GraphError::QueryError {
911 detail: format!(
912 "decay() requires numeric arguments, got {other:?}"
913 ),
914 }),
915 }
916 };
917 let b = numeric(b)?;
918 let a = numeric(a)?;
919 let h = numeric(h)?;
920 if h <= 0.0 {
921 return Err(GraphError::QueryError {
922 detail: "decay() requires halflife > 0".into(),
923 });
924 }
925 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
926 }
927 }
928 }
929 _ => Err(GraphError::QueryError {
930 detail: format!(
931 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
932 ),
933 }),
934 }
935}
936
937fn eval_set_return_item<F: Fs>(
938 db: &GraphDb<F>,
939 match_rs: &ResultSet,
940 row: usize,
941 rel_vars: &[String],
942 item: &RetItem,
943 params: &BTreeMap<String, Value>,
944) -> Result<Option<Value>> {
945 match &item.value {
946 RetVal::Var(v) => eval_set_return_operand(
947 db,
948 match_rs,
949 row,
950 rel_vars,
951 &Operand::Var(v.clone()),
952 params,
953 ),
954 RetVal::Prop { var, field } => eval_set_return_operand(
955 db,
956 match_rs,
957 row,
958 rel_vars,
959 &Operand::Prop {
960 var: var.clone(),
961 field: field.clone(),
962 },
963 params,
964 ),
965 RetVal::FuncCall { name, args } => {
966 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
967 }
968 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
969 RetVal::Agg { .. } => Err(GraphError::QueryError {
970 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
971 }),
972 }
973}
974
975/// Project user RETURN from original MATCH rows after SET. No rematch.
976fn project_set_return_rows<F: Fs>(
977 db: &GraphDb<F>,
978 rel_vars: &[String],
979 match_rs: &ResultSet,
980 returns: &[RetItem],
981 params: &BTreeMap<String, Value>,
982) -> Result<ResultSet> {
983 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
984 let mut out = ResultSet::new(columns);
985 for row in 0..match_rs.len() {
986 let mut cells = Vec::with_capacity(returns.len());
987 for item in returns {
988 cells.push(eval_set_return_item(
989 db, match_rs, row, rel_vars, item, params,
990 )?);
991 }
992 out.push_row(cells);
993 }
994 Ok(out)
995}
996
997/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
998/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
999/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1000/// Returns `None` for non-list values or lists with non-numeric elements.
1001fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1002 match v {
1003 Value::List(items) => items
1004 .iter()
1005 .map(|item| match item {
1006 Value::Float(f) => Some(*f),
1007 Value::Int(i) => Some(*i as f64),
1008 _ => None,
1009 })
1010 .collect(),
1011 _ => None,
1012 }
1013}
1014
1015fn make_graph_mut<'a>(
1016 ids: &'a IdMap,
1017 syms: &'a mut Interner,
1018 labels: &'a [u32],
1019 props: core_storage::v8::seam::ColumnsView<'a>,
1020 topo: &'a mut Topology,
1021 edge_props: &'a mut EdgeProps,
1022) -> GraphMut<'a> {
1023 GraphMut {
1024 ids,
1025 syms,
1026 labels,
1027 props,
1028 topo,
1029 edge_props,
1030 }
1031}
1032
1033/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1034///
1035/// Takes explicit field references rather than `&self` so the caller can hold
1036/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1037fn build_props_view<'a>(
1038 props: &'a ColumnStore,
1039 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1040) -> core_storage::v8::seam::ColumnsView<'a> {
1041 match base {
1042 None => core_storage::v8::seam::ColumnsView::owned(props),
1043 Some(b) => {
1044 let archived = b
1045 .columns()
1046 .expect("base columns section bounds validated at open");
1047 core_storage::v8::seam::ColumnsView::with_base(props, archived)
1048 }
1049 }
1050}
1051
1052fn build_topo_view<'a>(
1053 overlay: &'a Topology,
1054 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1055) -> core_storage::v8::seam::TopologyView<'a> {
1056 match base {
1057 None => core_storage::v8::seam::TopologyView::owned(overlay),
1058 Some(b) => {
1059 let archived_csr = b
1060 .topology()
1061 .expect("base topology section bounds validated at open");
1062 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1063 }
1064 }
1065}
1066
1067/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1068///
1069/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1070/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1071/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1072/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1073/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1074#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1075pub enum FsyncPolicy {
1076 /// Every WAL commit calls `fs.sync` (today's behavior).
1077 #[default]
1078 Strict,
1079 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1080 /// this policy is set on the database.
1081 Batched,
1082 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1083 Relaxed,
1084}
1085
1086/// A precondition for a compare-and-set batch write.
1087///
1088/// All preconditions in a [`GraphDb::write_batch_cas`] or
1089/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1090/// any operation in the batch is applied. If any precondition fails, the
1091/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1092/// is written.
1093///
1094/// # Touch definition
1095///
1096/// A node's last-change commit (`last_changed`) is updated when any of the
1097/// following state-changing WAL records touch it:
1098///
1099/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1100/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1101/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1102/// endpoints (an edge change touches both sides).
1103/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1104/// for deleted keys so the pre-deletion entry is never observed.
1105///
1106/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1107/// state no-ops. The underlying mutation that triggered rule firing already
1108/// updated the relevant nodes' last-change entries. Rule-management records
1109/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1110/// do not touch any node's last-change.
1111#[derive(Debug, Clone, PartialEq, Eq)]
1112pub enum Precondition {
1113 /// The node's last-change commit must equal `expected`.
1114 ///
1115 /// Fails with [`GraphError::CasConflict`] when:
1116 /// - The node does not exist (`last_changed` returns `None`), or
1117 /// - The recorded commit seq does not match `expected`.
1118 NodeUnchangedSince { key: String, expected: u64 },
1119 /// The node must not exist (not inserted, or already deleted).
1120 ///
1121 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1122 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1123 NodeAbsent { key: String },
1124}
1125
1126pub struct GraphDb<F: Fs> {
1127 fs: F,
1128 ids: IdMap,
1129 syms: Interner,
1130 topo: Topology,
1131 props: ColumnStore,
1132 labels: Vec<u32>, // node id -> label symbol
1133 edge_props: EdgeProps,
1134 engine: RuleEngine,
1135 view_store: ViewStore,
1136 /// Incremental inverted index for full-text-lite search.
1137 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1138 fulltext: FulltextIndex,
1139 /// Opt-in equality index over scalar node properties.
1140 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1141 /// open end (mirrors `fulltext`).
1142 prop_index: PropertyIndex,
1143 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1144 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1145 fsync: FsyncPolicy,
1146 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1147 /// call increments this once; all events emitted from that call share the same
1148 /// `commit_seq` value.
1149 commit_seq: u64,
1150 /// RBAC role definitions loaded from `roles.json` at open.
1151 ///
1152 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1153 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1154 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1155 roles: Option<Vec<RoleDef>>,
1156 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1157 /// distribute_events call.
1158 subscriptions: Vec<SubEntry>,
1159 /// Live query subscriptions. Re-executed on every commit when non-empty.
1160 /// Dead `Weak` entries are pruned inside `distribute_events`.
1161 query_subscriptions: Vec<QuerySubEntry>,
1162 /// Queue capacity for new subscriptions created by this db. Default is
1163 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1164 /// to test Lagged behaviour with small queues.
1165 sub_capacity: usize,
1166 /// True for as-of instances opened via [`GraphDb::open_at`].
1167 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1168 /// when this flag is set.
1169 read_only: bool,
1170 /// Total WAL commit count at the time [`open_at`] was called.
1171 /// 0 for normal (non-as-of) instances.
1172 total_wal_commits: u64,
1173 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1174 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1175 /// reads go through a merged `TopologyView`. `self.props` is always
1176 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1177 base: Option<Arc<core_storage::v8::MappedBase>>,
1178 // ── MVCC epoch reader state ───────────────────────────────────────────────
1179 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1180 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1181 /// `None` only between struct creation and the first fold.
1182 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1183 /// Per-commit deltas accumulated since the last fold.
1184 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1185 /// How many commits have occurred since the last fold.
1186 commits_since_fold: usize,
1187 /// When true, `log_then_apply_with` buffers event notifications instead of
1188 /// firing them immediately. Used by the group-commit drain thread to defer
1189 /// events until after the group fsync (R2: durability before notification).
1190 /// Cleared to false once the drain thread flushes or discards the buffer.
1191 defer_events: bool,
1192 /// Buffered events accumulated while `defer_events` is true.
1193 deferred_events: Vec<DeferredEvent>,
1194 /// Set to true by the group-commit drain thread when a group fsync fails
1195 /// after WAL truncation. All subsequent mutation attempts return an IO
1196 /// error until the database is reopened.
1197 degraded: bool,
1198 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1199 /// HNSW, and IVF sections from the mmap base into the engine's retained
1200 /// fields. `false` on all opens until first use; always `true` for non-V8
1201 /// opens (base is None, fast-path sets flag immediately).
1202 v8_sections_loaded: std::sync::atomic::AtomicBool,
1203 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1204 v8_sections_mutex: std::sync::Mutex<()>,
1205 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1206 /// the node was last modified by commit `seq`.
1207 ///
1208 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1209 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1210 /// nodes return `None` from `last_changed` until they are next mutated.
1211 ///
1212 /// See [`Precondition`] for the full touch definition.
1213 last_change: HashMap<u32, u64>,
1214 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1215 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1216 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1217 wal_archive_retention: Option<u32>,
1218 /// Global frame index of the first commit that is still reachable through
1219 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1220 /// Default 0 = all history reachable.
1221 wal_horizon_floor: u64,
1222 /// True when the surviving archive chain forms a continuous WAL history
1223 /// starting from the store's first commit (the genesis chain).
1224 ///
1225 /// `open_at` may replay archive-resident commits from empty state only when
1226 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1227 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1228 /// already exist (breaks the chain for subsequent archives), or
1229 /// - any archive is pruned (floor advances past zero).
1230 ///
1231 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1232 archive_genesis_chain: bool,
1233 /// Transient write-authz context set by `write_batch_authz` /
1234 /// `query_write_authz` for the duration of ONE mutation call.
1235 /// Always `None` at rest. Never serialized, never WAL-replayed.
1236 pending_write_authz: Option<WriteAuthz>,
1237 /// Slow-query threshold in milliseconds. 0 = disabled.
1238 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1239 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1240 /// — env vars are process-global and race parallel test threads).
1241 slow_query_threshold_ms: u64,
1242 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1243 /// can record entries without requiring `&mut self`).
1244 slow_queries: std::sync::Mutex<SlowQueryLog>,
1245 /// Instant at which the database was opened (used by `/metrics` uptime).
1246 started_at: std::time::Instant,
1247}
1248
1249/// One group of deferred event notifications, held until the group fsync
1250/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1251struct DeferredEvent {
1252 rec: core_storage::WalRecord,
1253 engine_deltas: Vec<EngineEdgeDelta>,
1254 seq: u64,
1255 ingest: Option<(String, usize)>,
1256}
1257
1258/// Options for [`GraphDb::open_with_options`].
1259#[derive(Clone, Copy, Debug)]
1260pub struct OpenOptions {
1261 /// Rewrite an old-format snapshot to the current VERSION after a
1262 /// successful load (default `true`). The old snapshot is kept as
1263 /// `snapshot.bin.bak` until the next clean open at the current version,
1264 /// at which point the `.bak` is deleted.
1265 ///
1266 /// Set to `false` to open a store without touching any on-disk files
1267 /// (useful for read-only inspection of a store at an older format).
1268 pub auto_migrate: bool,
1269
1270 /// Write the valid WAL prefix back over a torn tail on open (default
1271 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1272 ///
1273 /// Set to `false` for an unattended reader. The valid prefix is still
1274 /// decoded and replayed in memory, but nothing is written: the store has
1275 /// no cross-process lock, so a reader that opens while another process is
1276 /// mid-append would otherwise discard a frame that writer believes
1277 /// durable. `mushroomdb recall`, which runs on every prompt, passes
1278 /// `false` for exactly this reason.
1279 pub repair_wal: bool,
1280}
1281
1282impl Default for OpenOptions {
1283 fn default() -> Self {
1284 Self {
1285 auto_migrate: true,
1286 repair_wal: true,
1287 }
1288 }
1289}
1290
1291/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1292///
1293/// `None` at the call site = full authority (today's zero-cost behavior).
1294/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1295/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1296/// record is built. A denial returns an error with no WAL frame written.
1297///
1298/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1299/// hidden-node existence to callers.
1300#[derive(Clone, Debug)]
1301pub struct WriteAuthz {
1302 pub role: String,
1303 pub scope: WriteScope,
1304 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1305 /// Always `Omit`-mode — never `Stub`.
1306 pub mask: crate::mask::NodeMask,
1307}
1308
1309/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1310///
1311/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1312/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1313/// syncs the directory entry. This is the only correct path for writing the
1314/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1315/// the directory sync.
1316pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1317 use core_storage::fs::{FileId, Fs as _};
1318 RealFs::new(dir)
1319 .map_err(core_storage::GraphError::Io)?
1320 .write_atomic(FileId::SnapshotBak, bytes)
1321 .map_err(core_storage::GraphError::Io)
1322}
1323
1324/// Return the on-disk snapshot format version without decoding the full snapshot.
1325///
1326/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1327/// snapshot file exists (WAL-only store). Returns an error if the header is
1328/// malformed.
1329pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1330 use std::io::Read as _;
1331 let path = dir.join("snapshot.bin");
1332 let mut header = [0u8; 6];
1333 let n = match std::fs::File::open(&path) {
1334 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1335 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1336 Err(e) => return Err(core_storage::GraphError::Io(e)),
1337 };
1338 core_storage::snapshot::peek_version(&header[..n])
1339}
1340
1341/// Options for [`GraphDb::snapshot_with`].
1342#[derive(Debug, Clone, Default)]
1343pub struct SnapshotOptions {
1344 /// When `true`, the WAL is preserved after the snapshot write.
1345 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1346 /// When `false` (the default), the WAL is truncated to a minimal
1347 /// baseline so cold-start replay stays fast.
1348 pub keep_wal: bool,
1349 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1350 /// before a fresh WAL baseline is written (history-preserving snapshot).
1351 ///
1352 /// This is the feature opt-in: `false` (the default) leaves the existing
1353 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1354 /// precedence over `keep_wal` when both are set.
1355 ///
1356 /// Archives can be scanned by [`GraphDb::node_history`],
1357 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1358 /// [`GraphDb::open_at`], extending the reachable history horizon across
1359 /// snapshot boundaries.
1360 pub archive_wal: bool,
1361}
1362
1363/// Derive the scan-label sym for the commit-skip fast-path.
1364///
1365/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1366/// or `IndexIntersect`) with a concrete label string, then interns it.
1367///
1368/// Returns `None` in all cases where skipping is unsafe:
1369/// - Any `Expand` op is present (edge traversal; edges change results regardless
1370/// of node labels).
1371/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1372/// - No recognizable leading scan op is found.
1373///
1374/// This is the conservative v0.4.3 boundary. The caller stores the result in
1375/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1376fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1377 // Any Expand → must always re-execute (edges can change join results).
1378 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1379 return None;
1380 }
1381 for op in ops {
1382 match op {
1383 PlanOp::ScanLabel {
1384 label: Some(label), ..
1385 } => return Some(syms.intern(label)),
1386 PlanOp::IndexScan {
1387 label: Some(label), ..
1388 } => return Some(syms.intern(label)),
1389 PlanOp::IndexIntersect {
1390 label: Some(label), ..
1391 } => return Some(syms.intern(label)),
1392 _ => {}
1393 }
1394 }
1395 None
1396}
1397
1398impl GraphDb<RealFs> {
1399 /// Open the database at `dir` with default options.
1400 ///
1401 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1402 /// Old-format snapshots (V5, V6) are automatically migrated to the
1403 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1404 pub fn open(dir: &std::path::Path) -> Result<Self> {
1405 Self::open_with_options(dir, OpenOptions::default())
1406 }
1407
1408 /// Open the database at `dir` with explicit options.
1409 ///
1410 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1411 /// snapshot is an older format version, this function:
1412 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1413 /// + fsynced) before any modification.
1414 /// 2. Rewrites `snapshot.bin` at the current format version via
1415 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1416 ///
1417 /// If migration fails the error is returned and the original files are
1418 /// intact (the `.bak` was written before the new snapshot was attempted).
1419 ///
1420 /// A clean open that finds the snapshot already at the current version
1421 /// deletes any leftover `.bak` file.
1422 ///
1423 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1424 ///
1425 /// `opts.repair_wal` controls the other write this function can make; see
1426 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1427 /// no file on disk.
1428 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1429 // Header-only peek — 6 bytes, no full decode.
1430 let snap_version = snapshot_version_at(dir)?;
1431
1432 // Full load: decode snapshot + replay WAL + rebuild indexes.
1433 let mut db = Self::open_with_repair(RealFs::new(dir)?, opts.repair_wal)?;
1434
1435 if opts.auto_migrate {
1436 match snap_version {
1437 Some(ver) if ver < core_storage::snapshot::VERSION => {
1438 let _tm = std::time::Instant::now();
1439 // Copy the original snapshot to .bak at OS level — no in-memory
1440 // buffer required for a 2+ GiB file.
1441 //
1442 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1443 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1444 // A torn .bak on crash is acceptable because the original
1445 // snapshot.bin is the authoritative source until after the rename.
1446 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1447 .map_err(core_storage::GraphError::Io)?;
1448 trace_migrate!("bak copy done", _tm);
1449 // Rewrite snapshot at current version; keep WAL intact.
1450 db.snapshot_with(SnapshotOptions {
1451 keep_wal: true,
1452 ..SnapshotOptions::default()
1453 })?;
1454 trace_migrate!("snapshot_with done", _tm);
1455 }
1456 Some(_) => {
1457 // Already current version: remove any leftover .bak.
1458 let bak = dir.join("snapshot.bin.bak");
1459 if bak.exists() {
1460 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1461 }
1462 }
1463 None => {
1464 // WAL-only store — nothing to migrate on open.
1465 }
1466 }
1467 }
1468
1469 Ok(db)
1470 }
1471
1472 /// Open a read-only view of the database as it existed after `commit`.
1473 ///
1474 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1475 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1476 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1477 ///
1478 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1479 /// so as-of can only reach commits recorded in the current WAL (those
1480 /// written after the most recent snapshot, or all commits if no snapshot
1481 /// was ever taken). Commit 0 in `open_at` always refers to the first
1482 /// frame in the WAL that exists on disk, not the first ever write to the
1483 /// database. When the on-disk snapshot recorded that it truncated the
1484 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1485 /// before frame replay, so the as-of view includes all pre-snapshot data.
1486 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1487 /// are ignored and replay is WAL-only, as before.
1488 ///
1489 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1490 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1491 /// `stats()` work normally.
1492 ///
1493 /// # Errors
1494 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1495 /// when the WAL is empty after a snapshot).
1496 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1497 Self::open_at_with(RealFs::new(dir)?, commit)
1498 }
1499
1500 /// Run a **read-only** Cypher query against the graph as it existed at
1501 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1502 /// of this store's directory at that commit and executes the read there.
1503 ///
1504 /// The current instance is unaffected. Write statements are rejected (the
1505 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1506 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1507 /// state. Prefer this over holding many historical instances open.
1508 ///
1509 /// # Errors
1510 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1511 /// - A query error for a malformed or write query.
1512 pub fn query_at(
1513 &self,
1514 commit: u64,
1515 cypher: &str,
1516 params: &std::collections::BTreeMap<String, Value>,
1517 ) -> Result<ResultSet> {
1518 let dir = self.fs.dir().to_path_buf();
1519 let temporal = Self::open_at(&dir, commit)?;
1520 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1521 detail: format!("lex: {e}"),
1522 })?) {
1523 return Err(GraphError::QueryError {
1524 detail: "query_at is read-only: write statements are not permitted in a \
1525 time-travel query"
1526 .into(),
1527 });
1528 }
1529 temporal.query(cypher, params)
1530 }
1531}
1532
1533impl<F: Fs> GraphDb<F> {
1534 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1535 pub fn open_with(fs: F) -> Result<Self> {
1536 Self::open_with_repair(fs, true)
1537 }
1538
1539 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1540 /// prefix without writing the truncation back. See
1541 /// [`OpenOptions::repair_wal`].
1542 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1543 let mut db = Self {
1544 fs,
1545 ids: IdMap::new(),
1546 syms: Interner::new(),
1547 topo: Topology::new(),
1548 props: ColumnStore::new(),
1549 labels: Vec::new(),
1550 edge_props: EdgeProps::new(),
1551 engine: RuleEngine::new(),
1552 view_store: ViewStore::new(),
1553 fulltext: FulltextIndex::new(),
1554 prop_index: PropertyIndex::new(),
1555 event_sink: None,
1556 fsync: FsyncPolicy::Strict,
1557 commit_seq: 0,
1558 roles: Some(vec![]),
1559 subscriptions: Vec::new(),
1560 query_subscriptions: Vec::new(),
1561 sub_capacity: DEFAULT_SUB_CAPACITY,
1562 read_only: false,
1563 total_wal_commits: 0,
1564 base: None,
1565 fold_overlay: None,
1566 delta_tail: Vec::new(),
1567 commits_since_fold: 0,
1568 defer_events: false,
1569 deferred_events: Vec::new(),
1570 degraded: false,
1571 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1572 v8_sections_mutex: std::sync::Mutex::new(()),
1573 last_change: HashMap::new(),
1574 wal_archive_retention: None,
1575 wal_horizon_floor: 0,
1576 archive_genesis_chain: false,
1577 pending_write_authz: None,
1578 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1579 .ok()
1580 .and_then(|v| v.parse().ok())
1581 .unwrap_or(100),
1582 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1583 entries: std::collections::VecDeque::new(),
1584 total: 0,
1585 }),
1586 started_at: std::time::Instant::now(),
1587 };
1588 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1589 db.archive_genesis_chain = db.fs.has_genesis_marker();
1590 // Opening cleanup: remove orphaned archives — archives whose frames all
1591 // fall below the horizon floor. Orphans arise when a crash interrupted
1592 // the retention-prune sequence after the floor was written but before
1593 // all surplus archives were deleted. Safe to delete: floor already
1594 // accounts for their frames.
1595 db.cleanup_orphaned_archives()?;
1596 let _t0 = std::time::Instant::now();
1597 // Peek 6 bytes to determine snapshot version without reading the full
1598 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1599 // default impl reads all bytes and truncates (still correct).
1600 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1601 let is_v8 = snap_header.len() >= 6
1602 && &snap_header[0..4] == b"GDB1"
1603 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1604 == core_storage::snapshot::VERSION_8;
1605 if is_v8 {
1606 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1607 // No 2.4GB heap Vec is allocated on RealFs.
1608 let mapped = Arc::new(
1609 if let Some(snap_path) = db.fs.snapshot_path() {
1610 core_storage::v8::MappedBase::map(&snap_path)
1611 } else {
1612 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1613 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1614 }
1615 .map_err(|e| GraphError::Corrupt {
1616 detail: format!("v8: mmap open: {e:?}"),
1617 })?,
1618 );
1619 db.restore_v8_base(Arc::clone(&mapped))?;
1620 trace_open!("restore_v8_base", _t0);
1621 db.base = Some(mapped);
1622 trace_open!("base assigned", _t0);
1623 } else if !snap_header.is_empty() {
1624 // Legacy V5-V7: full read required for decode.
1625 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1626 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1627 db.restore_snapshot_state(state)?;
1628 }
1629 }
1630 // else: snap_header is empty = no snapshot file, fresh store.
1631 //
1632 // Seed commit_seq from the highest seq persisted in last_change so that
1633 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1634 // already stored in the snapshot. Without this, a db with one snapshot
1635 // commit would save last_change["a"]=1, then on reopen the first WAL
1636 // frame would replay at seq=1 again — colliding and making WAL-tail
1637 // mutations indistinguishable from the snapshot baseline.
1638 //
1639 // Safety invariant (seq-recycling):
1640 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1641 // last_change because they belonged to a previous db lifetime — a new
1642 // db starts at commit_seq=0 with an empty last_change. Therefore no
1643 // CAS precondition can carry a recycled seq as its `expected` value
1644 // and accidentally match a live node's last_change entry.
1645 //
1646 // `expected:0` on a deleted-then-reinserted node:
1647 // After deletion, last_changed() returns None; callers that call
1648 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1649 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1650 // expected=0 correctly conflicts. The only way to observe actual=0 in
1651 // a CasConflict would be a caller that invented expected=0 without ever
1652 // calling last_changed() — unreachable via the documented API contract.
1653 if let Some(&max_seq) = db.last_change.values().max() {
1654 db.commit_seq = db.commit_seq.max(max_seq);
1655 }
1656 let bytes = db.fs.read(FileId::Wal)?;
1657 let (records, valid_len) = decode_all(&bytes);
1658 // The valid prefix is replayed either way; `repair_wal` only decides
1659 // whether the truncation is written back. A reader that races a live
1660 // appender must not persist a truncation the writer never asked for.
1661 if valid_len < bytes.len() && repair_wal {
1662 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1663 }
1664 // WAL-present path: build indexes eagerly BEFORE replay so that the
1665 // first replayed record does not trigger the lazy-init guard (which
1666 // would call reindex_all_load_ivf on an empty graph, defeating the
1667 // point of restoring IVF/HNSW blobs from the snapshot).
1668 if !records.is_empty() {
1669 db.ensure_v8_base_sections_loaded();
1670 trace_open!("lazy sections loaded (WAL path)", _t0);
1671 db.engine.consume_retained_state_eager(
1672 &db.ids,
1673 &db.syms,
1674 &db.labels,
1675 build_props_view(&db.props, &db.base),
1676 );
1677 }
1678 for rec in records {
1679 db.apply(&rec)?;
1680 // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1681 // No subscriber exists yet; discard is correct.
1682 let _ = db.engine.drain_deltas();
1683 // Track commit_seq during replay so last_change entries are
1684 // consistent with the seqs assigned by log_then_apply_with on
1685 // subsequent live commits. After N replayed frames, commit_seq=N;
1686 // live commits begin at N+1.
1687 db.commit_seq += 1;
1688 let replay_seq = db.commit_seq;
1689 db.update_last_change_from_rec(&rec, replay_seq);
1690 }
1691 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1692 // this assert catches the regression in debug builds immediately.
1693 debug_assert_eq!(
1694 db.engine.pending_delta_count(),
1695 0,
1696 "pending_deltas non-empty after replay — \
1697 per-frame drain must run inside the loop to keep memory O(1)"
1698 );
1699 // T2 note: the per-frame drain IS the suppression seam for replay.
1700 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1701 // replaying subscribers; the mechanism is already in place.
1702 let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1703 trace_open!("wal replay done", _t0);
1704 // Rebuild view values after WAL replay only when there is no V8 base.
1705 // With a V8 base, view values are correct in the snapshot and are updated
1706 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1707 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1708 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1709 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1710 // base value).
1711 if db.base.is_none() {
1712 let topo_view = TopologyView::owned(&db.topo);
1713 db.view_store
1714 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1715 }
1716 // Rebuild full-text index after WAL replay. Corrects drift from
1717 // per-record incremental apply during replay.
1718 db.fulltext.rebuild_all(
1719 &db.ids,
1720 &db.labels,
1721 &db.syms,
1722 build_props_view(&db.props, &db.base),
1723 );
1724 db.prop_index.rebuild_all(
1725 &db.ids,
1726 &db.labels,
1727 &db.syms,
1728 build_props_view(&db.props, &db.base),
1729 );
1730 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1731 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1732 db.roles = Self::load_roles_from_fs(&db.fs)?;
1733 // Capture the initial MVCC fold so reader() is ready immediately.
1734 db.fold_now();
1735 trace_open!("open_with complete", _t0);
1736 Ok(db)
1737 }
1738
1739 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1740 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1741 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
1742 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1743 /// Restore all persisted state from a decoded snapshot. Shared by
1744 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1745 fn restore_snapshot_state(
1746 &mut self,
1747 state: core_storage::snapshot::SnapshotState,
1748 ) -> Result<()> {
1749 self.ids = state.ids;
1750 self.syms = state.syms;
1751 self.topo = state.topo;
1752 self.props = state.props;
1753 self.labels = state.labels;
1754 self.edge_props = state.edge_props;
1755 // Cross-section label integrity for V5/V7 snapshots: same invariants as
1756 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
1757 // out-of-range sym ids, or a sentinel label on a live node would otherwise
1758 // open successfully and panic later in `NodeRef::label()` or
1759 // `neighborhood_masked()`. Catching it here turns those into typed
1760 // `GraphError::Corrupt` at open time.
1761 {
1762 let ids_len = self.ids.len();
1763 if self.labels.len() != ids_len {
1764 return Err(GraphError::Corrupt {
1765 detail: format!(
1766 "snapshot: labels vec has {} entries but id table has {} total slots",
1767 self.labels.len(),
1768 ids_len,
1769 ),
1770 });
1771 }
1772 let syms_len = self.syms.len() as u32;
1773 for (i, &sym) in self.labels.iter().enumerate() {
1774 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1775 if sym == u32::MAX {
1776 if !is_tombstoned {
1777 return Err(GraphError::Corrupt {
1778 detail: format!(
1779 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1780 ),
1781 });
1782 }
1783 } else if sym >= syms_len {
1784 return Err(GraphError::Corrupt {
1785 detail: format!(
1786 "snapshot: label at id slot {i} references sym {sym} \
1787 which is out of interner range ({syms_len})"
1788 ),
1789 });
1790 }
1791 }
1792 }
1793 let defs: Vec<RuleDef> = state
1794 .rule_defs
1795 .iter()
1796 .map(|b| {
1797 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1798 detail: format!("snapshot rule_def deserialize: {e}"),
1799 })
1800 })
1801 .collect::<Result<Vec<_>>>()?;
1802 self.engine =
1803 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1804 // Candidate indexes are rebuilt lazily on the first mutation (see
1805 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
1806 // snapshot are retained without deserializing so that:
1807 // - clean-open (empty WAL): indexes stay empty; blobs load on first
1808 // ANN query via ensure_hnsw_loaded, or on first mutation via the
1809 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1810 // - WAL-present: open_with calls consume_retained_state_eager before
1811 // replay so HNSW/IVF are live before any record fires the hooks.
1812 let ivf_bytes = if state.ivf_state.is_empty() {
1813 Vec::new()
1814 } else {
1815 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1816 };
1817 // Store blobs without eagerly deserializing them.
1818 self.engine
1819 .store_snapshot_state(state.hnsw_state, ivf_bytes);
1820 // Restore view defs from snapshot (V5).
1821 // The ColumnStore already contains view values from the snapshot;
1822 // use restore_view (no collision check, no backfill) so the store
1823 // is aware of the definitions. rebuild_all runs after WAL replay.
1824 for def_bytes in &state.view_defs {
1825 let def: ViewDef =
1826 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1827 detail: format!("snapshot view_def deserialize: {e}"),
1828 })?;
1829 self.view_store
1830 .restore_view(def)
1831 .map_err(|e| GraphError::Corrupt {
1832 detail: format!("snapshot view restore: {e}"),
1833 })?;
1834 }
1835 Ok(())
1836 }
1837
1838 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1839 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1840 ///
1841 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1842 /// deserialization and view rebuild have access to all column data.
1843 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1844 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1845 detail: format!("v8: ids section: {e:?}"),
1846 })?);
1847 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1848 detail: format!("v8: syms section: {e:?}"),
1849 })?);
1850
1851 // C1: self.props is left as an empty overlay. Column reads go through
1852 // props_view() (ColumnsView::with_base), which consults the archived base
1853 // section zero-copy. This avoids the O(columns) heap copy at every open.
1854
1855 // self.topo deliberately left as Topology::new() — overlay path.
1856
1857 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1858 detail: format!("v8: meta section: {e:?}"),
1859 })?)
1860 .map_err(|e| GraphError::Corrupt {
1861 detail: format!("v8: meta decode: {e:?}"),
1862 })?;
1863 self.labels = meta.labels;
1864 // Cross-section label integrity: labels must cover every id slot (live
1865 // and tombstoned), every non-sentinel sym must be within the interner's
1866 // bound, and no live (non-tombstoned) node may carry the u32::MAX
1867 // sentinel label. Without this check, a crafted snapshot where the META
1868 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1869 // sym ids, or a sentinel label on a live node, would open successfully
1870 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1871 // related read paths. Catching the inconsistency here converts those
1872 // panics into typed `GraphError::Corrupt` at open time.
1873 {
1874 let ids_len = self.ids.len();
1875 if self.labels.len() != ids_len {
1876 return Err(GraphError::Corrupt {
1877 detail: format!(
1878 "v8: labels section has {} entries but id table has {} total slots",
1879 self.labels.len(),
1880 ids_len,
1881 ),
1882 });
1883 }
1884 let syms_len = self.syms.len() as u32;
1885 for (i, &sym) in self.labels.iter().enumerate() {
1886 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1887 if sym == u32::MAX {
1888 // Sentinel is only valid for tombstoned slots.
1889 if !is_tombstoned {
1890 return Err(GraphError::Corrupt {
1891 detail: format!(
1892 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1893 ),
1894 });
1895 }
1896 } else if sym >= syms_len {
1897 return Err(GraphError::Corrupt {
1898 detail: format!(
1899 "v8: label at id slot {i} references sym {sym} \
1900 which is out of interner range ({syms_len})"
1901 ),
1902 });
1903 }
1904 }
1905 }
1906 // C3: self.edge_props stays as an empty overlay. Reads go through
1907 // edge_props_view() which consults the mmap'd base section zero-copy
1908 // via EdgePropsView::with_base. No heap decode at open time.
1909
1910 // Restore rule engine.
1911 let (rule_def_bytes, rule_tripped, rule_fires) =
1912 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1913 GraphError::Corrupt {
1914 detail: format!("v8: rules_meta section: {e:?}"),
1915 }
1916 })?);
1917 let defs: Vec<RuleDef> = rule_def_bytes
1918 .iter()
1919 .map(|b| {
1920 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1921 detail: format!("v8: rule_def deserialize: {e}"),
1922 })
1923 })
1924 .collect::<Result<Vec<_>>>()?;
1925 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1926 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1927 // `ensure_v8_base_sections_loaded` reads them on first use from
1928 // `self.base` (set by the caller immediately after this returns).
1929 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1930
1931 // Restore view definitions.
1932 let view_defs =
1933 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1934 detail: format!("v8: views section: {e:?}"),
1935 })?);
1936 for def_bytes in &view_defs {
1937 let def: ViewDef =
1938 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1939 detail: format!("v8: view_def deserialize: {e}"),
1940 })?;
1941 self.view_store
1942 .restore_view(def)
1943 .map_err(|e| GraphError::Corrupt {
1944 detail: format!("v8: view restore: {e}"),
1945 })?;
1946 }
1947 // Load the last-change map from section 11 (small section; load eagerly).
1948 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1949 // in that case and `decode_last_change_bytes` returns an empty map.
1950 let last_change_raw = mapped
1951 .last_change_bytes()
1952 .map_err(|e| GraphError::Corrupt {
1953 detail: format!("v8: last_change section: {e:?}"),
1954 })?;
1955 self.last_change = decode_last_change_bytes(last_change_raw);
1956
1957 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1958 // the file. Pure bounds check — no bytes read, no page faults triggered.
1959 // Catches truncated snapshots at open time before the lazy deferred reads.
1960 mapped.validate_section_bounds().map_err(|e| match e {
1961 GraphError::Corrupt { detail } => GraphError::Corrupt {
1962 detail: format!("v8: section bounds: {detail}"),
1963 },
1964 other => other,
1965 })?;
1966 Ok(())
1967 }
1968
1969 /// Read provenance, HNSW, and IVF sections from the mmap base into the
1970 /// engine's retained fields on first call. Subsequent calls are a no-op
1971 /// (AtomicBool fast-path).
1972 ///
1973 /// Must be called before any code path that reads or mutates engine
1974 /// provenance, HNSW, or IVF state:
1975 /// - WAL replay (before `consume_retained_state_eager`)
1976 /// - First mutation (`log_then_apply_with`)
1977 /// - Read-only paths (`stats`, `explain`, `node_edges`)
1978 /// - Snapshot (`snapshot_with`)
1979 ///
1980 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1981 fn ensure_v8_base_sections_loaded(&self) {
1982 use std::sync::atomic::Ordering;
1983 if self.v8_sections_loaded.load(Ordering::Acquire) {
1984 return;
1985 }
1986 let _guard = self
1987 .v8_sections_mutex
1988 .lock()
1989 .expect("v8 sections mutex poisoned");
1990 if self.v8_sections_loaded.load(Ordering::Acquire) {
1991 return; // another caller populated while we waited
1992 }
1993 let _t = std::time::Instant::now();
1994 if let Some(base) = &self.base {
1995 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1996 // Bounds are already validated at open time (restore_v8_base →
1997 // validate_section_bounds) — unreachable post-validate_section_bounds;
1998 // unwrap_or_default is a safety belt against impossible errors.
1999 let prov_bytes = base
2000 .provenance_raw_bytes()
2001 .map(|b| b.to_vec())
2002 .unwrap_or_default();
2003 self.engine.store_provenance_bytes(prov_bytes);
2004 // HNSW: decode rkyv blobs into owned map.
2005 let hnsw_state = base
2006 .hnsw_section()
2007 .map(archived_hnsw_to_owned)
2008 .unwrap_or_default();
2009 // IVF: raw bincode bytes; deserialized on first mutation/query.
2010 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2011 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2012 }
2013 self.v8_sections_loaded.store(true, Ordering::Release);
2014 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2015 eprintln!(
2016 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2017 _t.elapsed()
2018 );
2019 }
2020 }
2021
2022 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2023 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2024 /// the full merged topology without going through `self.view()`.
2025 fn topo_view(&self) -> TopologyView<'_> {
2026 match self.base {
2027 None => TopologyView::owned(&self.topo),
2028 Some(ref base) => {
2029 // SAFETY: base lives as long as self; section bounds validated at open.
2030 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2031 let archived = base
2032 .topology()
2033 .expect("base topology section bounds validated at open");
2034 TopologyView::with_base(&self.topo, archived)
2035 }
2036 }
2037 }
2038
2039 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2040 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2041 /// overlay first, then fall through to the archived base section zero-copy.
2042 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2043 match self.base {
2044 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2045 Some(ref base) => {
2046 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2047 let archived = base
2048 .columns()
2049 .expect("base columns section bounds validated at open");
2050 core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
2051 }
2052 }
2053 }
2054
2055 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2056 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2057 ///
2058 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2059 /// through to the archived base section zero-copy. Tombstones in the
2060 /// overlay mask deleted-from-base entries.
2061 fn edge_props_view(&self) -> EdgePropsView<'_> {
2062 match self.base {
2063 None => EdgePropsView::owned(&self.edge_props),
2064 Some(ref base) => {
2065 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2066 let archived = base
2067 .edge_props_section()
2068 .expect("base edge_props section bounds validated at open");
2069 EdgePropsView::with_base(&self.edge_props, archived)
2070 }
2071 }
2072 }
2073
2074 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2075 let mut db = Self {
2076 fs,
2077 ids: IdMap::new(),
2078 syms: Interner::new(),
2079 topo: Topology::new(),
2080 props: ColumnStore::new(),
2081 labels: Vec::new(),
2082 edge_props: EdgeProps::new(),
2083 engine: RuleEngine::new(),
2084 view_store: ViewStore::new(),
2085 fulltext: FulltextIndex::new(),
2086 prop_index: PropertyIndex::new(),
2087 event_sink: None,
2088 fsync: FsyncPolicy::Strict,
2089 commit_seq: 0,
2090 roles: Some(vec![]),
2091 subscriptions: Vec::new(),
2092 query_subscriptions: Vec::new(),
2093 sub_capacity: DEFAULT_SUB_CAPACITY,
2094 read_only: false, // set to true after replay
2095 total_wal_commits: 0,
2096 base: None,
2097 fold_overlay: None,
2098 delta_tail: Vec::new(),
2099 commits_since_fold: 0,
2100 defer_events: false,
2101 deferred_events: Vec::new(),
2102 degraded: false,
2103 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2104 v8_sections_mutex: std::sync::Mutex::new(()),
2105 last_change: HashMap::new(),
2106 wal_archive_retention: None,
2107 wal_horizon_floor: 0,
2108 archive_genesis_chain: false,
2109 pending_write_authz: None,
2110 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2111 .ok()
2112 .and_then(|v| v.parse().ok())
2113 .unwrap_or(100),
2114 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2115 entries: std::collections::VecDeque::new(),
2116 total: 0,
2117 }),
2118 started_at: std::time::Instant::now(),
2119 };
2120 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2121 db.archive_genesis_chain = db.fs.has_genesis_marker();
2122 // Same orphaned-archive cleanup as open_with: floor was written first
2123 // during pruning, so a crash may have left stale archives below floor.
2124 db.cleanup_orphaned_archives()?;
2125 // Collect archive frames (oldest-first) and live WAL frames.
2126 // Archives represent pre-snapshot history; the snapshot captures the
2127 // cumulative state at the time of archiving. Crash-window guarantee:
2128 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2129 // B: crash after rename, before new WAL → archive present, WAL
2130 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2131 // C: crash after new baseline WAL written → normal post-archive.
2132 let archive_ns = db.fs.list_archives()?;
2133 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2134 for n in &archive_ns {
2135 let arc_bytes = db.fs.read_archive(*n)?;
2136 let (arc_frames, _) = decode_all(&arc_bytes);
2137 archive_frames_all.extend(arc_frames);
2138 }
2139 let total_archive_frames = archive_frames_all.len() as u64;
2140
2141 let live_bytes = db.fs.read(FileId::Wal)?;
2142 let (live_records, _valid_len) = decode_all(&live_bytes);
2143 let total_surviving = total_archive_frames + live_records.len() as u64;
2144 // Global total including any pruned history below the horizon floor.
2145 let total = db.wal_horizon_floor + total_surviving;
2146
2147 // Horizon and range check.
2148 if commit < db.wal_horizon_floor {
2149 return Err(GraphError::CommitOutOfRange { commit, total });
2150 }
2151 if commit >= total {
2152 return Err(GraphError::CommitOutOfRange { commit, total });
2153 }
2154
2155 // Local index into surviving frames (0 = first frame of oldest archive).
2156 let local = commit - db.wal_horizon_floor;
2157
2158 if local < total_archive_frames {
2159 // Target commit is in an archive. Correct replay from empty state
2160 // is only possible when the archive chain is an uninterrupted
2161 // genesis chain (first archive taken from a fresh store, no prior
2162 // WAL truncation) and no archives have been pruned (floor == 0).
2163 //
2164 // If either condition is violated the prefix needed to reconstruct
2165 // the requested state is gone; refuse rather than return wrong data.
2166 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2167 return Err(GraphError::CommitOutOfRange { commit, total });
2168 }
2169 // Replay all archive frames up to and including the target commit
2170 // from an empty database state. Archives must be replayed in order
2171 // so that dense-id intern tables are built up correctly.
2172 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2173 db.apply(&rec)?;
2174 let _ = db.engine.drain_deltas();
2175 }
2176 } else {
2177 // Target commit is in the live WAL: load snapshot as base, then
2178 // replay the needed live WAL prefix.
2179 //
2180 // Base state: a truncating snapshot (wal_truncated=true) compacts
2181 // all pre-truncation / pre-archive commits. Dense-id records in
2182 // the live WAL reference ids/interns that the snapshot provides.
2183 // Peek 6 bytes (same pattern as open_with).
2184 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2185 let is_v8 = snap_header.len() >= 6
2186 && &snap_header[0..4] == b"GDB1"
2187 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2188 == core_storage::snapshot::VERSION_8;
2189 if is_v8 {
2190 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2191 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2192 GraphError::Corrupt {
2193 detail: format!("v8: open_at mmap: {e:?}"),
2194 }
2195 })?;
2196 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2197 } else {
2198 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2199 core_storage::snapshot::decode(&snap_bytes)?
2200 };
2201 if let Some(state) = state {
2202 if state.wal_truncated {
2203 db.restore_snapshot_state(state)?;
2204 }
2205 }
2206 } else if !snap_header.is_empty() {
2207 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2208 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2209 if state.wal_truncated {
2210 db.restore_snapshot_state(state)?;
2211 }
2212 }
2213 }
2214 // else: snap_header empty = no snapshot file.
2215 let live_local = local - total_archive_frames;
2216 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2217 db.apply(&rec)?;
2218 let _ = db.engine.drain_deltas();
2219 }
2220 }
2221 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2222 // post-loop assert in open_with.
2223 debug_assert_eq!(
2224 db.engine.pending_delta_count(),
2225 0,
2226 "pending_deltas non-empty after open_at replay — \
2227 per-frame drain must run inside the loop to keep memory O(1)"
2228 );
2229 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2230 // Rebuild view values after WAL replay so derived-edge-driven views
2231 // reflect the as-of state. open_at always uses the legacy path (no V8
2232 // base), so topo_view is always owned.
2233 {
2234 let topo_view = TopologyView::owned(&db.topo);
2235 db.view_store
2236 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2237 }
2238 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2239 db.fulltext.rebuild_all(
2240 &db.ids,
2241 &db.labels,
2242 &db.syms,
2243 build_props_view(&db.props, &db.base),
2244 );
2245 db.prop_index.rebuild_all(
2246 &db.ids,
2247 &db.labels,
2248 &db.syms,
2249 build_props_view(&db.props, &db.base),
2250 );
2251 // Load roles sidecar (current roles, not point-in-time).
2252 db.roles = Self::load_roles_from_fs(&db.fs)?;
2253 db.read_only = true;
2254 db.total_wal_commits = total;
2255 // Capture initial fold so reader() is immediately usable.
2256 db.fold_now();
2257 Ok(db)
2258 }
2259
2260 /// Whether this instance is a read-only as-of view.
2261 pub fn is_read_only(&self) -> bool {
2262 self.read_only
2263 }
2264
2265 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2266
2267 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2268 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2269 /// the end of `open_with` / `open_at_with` to prime the reader.
2270 fn fold_now(&mut self) {
2271 let frozen = crate::reader::FrozenOverlay {
2272 ids: self.ids.clone(),
2273 syms: self.syms.clone(),
2274 topo: self.topo.clone(),
2275 props: self.props.clone(),
2276 labels: self.labels.clone(),
2277 edge_props: self.edge_props.clone(),
2278 roles: self.roles.clone(),
2279 fulltext: self.fulltext.clone(),
2280 };
2281 self.fold_overlay = Some(Arc::new(frozen));
2282 self.delta_tail.clear();
2283 self.commits_since_fold = 0;
2284 }
2285
2286 /// Capture a lock-free reader snapshot of the current db state.
2287 ///
2288 /// The read lock is held only for the duration of this call (to clone a
2289 /// handful of `Arc` handles). Subsequent query operations run without any
2290 /// lock.
2291 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2292 crate::reader::ReaderSnapshot::new(
2293 self.fold_overlay
2294 .clone()
2295 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2296 self.base.clone(),
2297 self.delta_tail.clone(),
2298 )
2299 }
2300
2301 /// Total number of WAL commits at the time [`open_at`] was called.
2302 /// Returns 0 for normal (non-as-of) instances.
2303 pub fn total_wal_commits(&self) -> u64 {
2304 self.total_wal_commits
2305 }
2306
2307 /// Apply a record to in-memory state. Used by both live writes and replay,
2308 /// so replay is definitionally identical to the original execution.
2309 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2310 match rec {
2311 WalRecord::InsertNode { label, key, props } => {
2312 let id = self.ids.try_insert(key)?;
2313 let sym = self.syms.intern(label);
2314 if self.labels.len() <= id as usize {
2315 // gap slots are sentinels, never valid label symbols
2316 self.labels.resize(id as usize + 1, u32::MAX);
2317 }
2318 self.labels[id as usize] = sym;
2319 for (field, value) in props {
2320 self.props.set(id, field, value.clone());
2321 }
2322 // Initialize view values for the new node before the engine runs so
2323 // delta-based increments start from a known zero baseline.
2324 self.view_store
2325 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2326 // Fire rules for the newly inserted node.
2327 let cursor = self.engine.pending_delta_count();
2328 let mut eng = std::mem::take(&mut self.engine);
2329 {
2330 let mut gm = make_graph_mut(
2331 &self.ids,
2332 &mut self.syms,
2333 &self.labels,
2334 build_props_view(&self.props, &self.base),
2335 &mut self.topo,
2336 &mut self.edge_props,
2337 );
2338 eng.on_node_changed(id, None, &mut gm);
2339 }
2340 self.engine = eng;
2341 // Process derived-edge deltas for view maintenance.
2342 // Fast path: skip the O(delta_count) allocation when no views exist.
2343 if !self.view_store.is_empty() {
2344 #[cfg(test)]
2345 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2346 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2347 for d in &new_deltas {
2348 self.view_store.on_edge_changed(
2349 d.etype_sym,
2350 d.src_id,
2351 d.dst_id,
2352 d.fired,
2353 &mut self.props,
2354 &build_topo_view(&self.topo, &self.base),
2355 &self.ids,
2356 &self.syms,
2357 &self.labels,
2358 self.base.as_ref().map(|b| {
2359 b.columns()
2360 .expect("base columns section bounds validated at open")
2361 }),
2362 );
2363 }
2364 }
2365 // Full-text index maintenance: index enabled fields for this label.
2366 if self.fulltext.has_label(label) {
2367 for (field, value) in props {
2368 if self.fulltext.is_enabled(label, field) {
2369 self.fulltext.add_tokens(id, field, value);
2370 }
2371 }
2372 }
2373 // Property (equality) index maintenance.
2374 if self.prop_index.has_label(label) {
2375 for (field, value) in props {
2376 self.prop_index.set(label, field, id, value);
2377 }
2378 }
2379 }
2380 WalRecord::InsertEdge {
2381 edge_type,
2382 src_key,
2383 dst_key,
2384 } => {
2385 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2386 detail: format!("wal replay references unknown key {src_key}"),
2387 })?;
2388 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2389 detail: format!("wal replay references unknown key {dst_key}"),
2390 })?;
2391 let etype = self.syms.intern(edge_type);
2392 // Skip if the edge is already visible in the merged base+overlay
2393 // view. This keeps WAL replay idempotent when the WAL contains
2394 // pre-snapshot records that are already encoded in a V8 base
2395 // (keep_wal=true opens and crash-before-truncation scenarios).
2396 if self.base.is_some()
2397 && self
2398 .topo_view()
2399 .neighbors(etype, Direction::Out, src)
2400 .contains(&dst)
2401 {
2402 return Ok(());
2403 }
2404 self.topo.add_edge(etype, src, dst);
2405 // View maintenance for manual edge insert.
2406 self.view_store.on_edge_changed(
2407 etype,
2408 src,
2409 dst,
2410 true,
2411 &mut self.props,
2412 &build_topo_view(&self.topo, &self.base),
2413 &self.ids,
2414 &self.syms,
2415 &self.labels,
2416 self.base.as_ref().map(|b| {
2417 b.columns()
2418 .expect("base columns section bounds validated at open")
2419 }),
2420 );
2421 // Rule engine: via-hop rules must update when user edges change.
2422 let cursor = self.engine.pending_delta_count();
2423 let mut eng = std::mem::take(&mut self.engine);
2424 {
2425 let mut gm = make_graph_mut(
2426 &self.ids,
2427 &mut self.syms,
2428 &self.labels,
2429 build_props_view(&self.props, &self.base),
2430 &mut self.topo,
2431 &mut self.edge_props,
2432 );
2433 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2434 }
2435 self.engine = eng;
2436 if !self.view_store.is_empty() {
2437 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2438 for d in &new_deltas {
2439 self.view_store.on_edge_changed(
2440 d.etype_sym,
2441 d.src_id,
2442 d.dst_id,
2443 d.fired,
2444 &mut self.props,
2445 &build_topo_view(&self.topo, &self.base),
2446 &self.ids,
2447 &self.syms,
2448 &self.labels,
2449 self.base.as_ref().map(|b| {
2450 b.columns()
2451 .expect("base columns section bounds validated at open")
2452 }),
2453 );
2454 }
2455 }
2456 }
2457 WalRecord::SetProp { key, field, value } => {
2458 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2459 detail: format!("wal replay references unknown key {key}"),
2460 })?;
2461 let old_value = build_props_view(&self.props, &self.base)
2462 .get(id, field)
2463 .map(|vr| vr.into_value());
2464 self.props.set(id, field, value.clone());
2465 // Fire rules for the changed field.
2466 let cursor = self.engine.pending_delta_count();
2467 let mut eng = std::mem::take(&mut self.engine);
2468 {
2469 let mut gm = make_graph_mut(
2470 &self.ids,
2471 &mut self.syms,
2472 &self.labels,
2473 build_props_view(&self.props, &self.base),
2474 &mut self.topo,
2475 &mut self.edge_props,
2476 );
2477 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2478 }
2479 self.engine = eng;
2480 // Derived-edge deltas → view updates.
2481 if !self.view_store.is_empty() {
2482 #[cfg(test)]
2483 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2484 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2485 for d in &new_deltas {
2486 self.view_store.on_edge_changed(
2487 d.etype_sym,
2488 d.src_id,
2489 d.dst_id,
2490 d.fired,
2491 &mut self.props,
2492 &build_topo_view(&self.topo, &self.base),
2493 &self.ids,
2494 &self.syms,
2495 &self.labels,
2496 self.base.as_ref().map(|b| {
2497 b.columns()
2498 .expect("base columns section bounds validated at open")
2499 }),
2500 );
2501 }
2502 }
2503 // Neighbor-aggregate views that read `field` must also update.
2504 self.view_store.on_prop_changed(
2505 id,
2506 field,
2507 &mut self.props,
2508 &build_topo_view(&self.topo, &self.base),
2509 &self.ids,
2510 &self.syms,
2511 &self.labels,
2512 self.base.as_ref().map(|b| {
2513 b.columns()
2514 .expect("base columns section bounds validated at open")
2515 }),
2516 );
2517 // Full-text index maintenance: update tokens for this field if indexed.
2518 if self.fulltext.field_indexed(field) {
2519 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2520 if sym == u32::MAX {
2521 None
2522 } else {
2523 self.syms.resolve(sym)
2524 }
2525 });
2526 if let Some(label) = label_opt {
2527 if self.fulltext.is_enabled(label, field) {
2528 self.fulltext.remove_node_field(id, field);
2529 self.fulltext.add_tokens(id, field, value);
2530 }
2531 }
2532 }
2533 // Property (equality) index maintenance: re-key this node's value.
2534 if self.prop_index.field_indexed(field) {
2535 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2536 if sym == u32::MAX {
2537 None
2538 } else {
2539 self.syms.resolve(sym)
2540 }
2541 });
2542 if let Some(label) = label_opt {
2543 self.prop_index.set(label, field, id, value);
2544 }
2545 }
2546 }
2547 WalRecord::Intern { id, text } => {
2548 if let Some(existing) = self.syms.get(text) {
2549 if existing != *id {
2550 return Err(GraphError::Corrupt {
2551 detail: format!(
2552 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2553 ),
2554 });
2555 }
2556 } else {
2557 let got = self.syms.intern(text);
2558 if got != *id {
2559 return Err(GraphError::Corrupt {
2560 detail: format!(
2561 "wal intern assigned {got} for {text:?}, record wanted {id}"
2562 ),
2563 });
2564 }
2565 }
2566 }
2567 WalRecord::InsertNodeId { label, key, props } => {
2568 let id = self.ids.try_insert(key)?;
2569 if self.labels.len() <= id as usize {
2570 self.labels.resize(id as usize + 1, u32::MAX);
2571 }
2572 self.labels[id as usize] = *label;
2573 let label_str = self
2574 .syms
2575 .resolve(*label)
2576 .ok_or_else(|| GraphError::Corrupt {
2577 detail: format!("wal InsertNodeId unknown label intern {label}"),
2578 })?
2579 .to_string();
2580 for (field_sym, value) in props {
2581 let field =
2582 self.syms
2583 .resolve(*field_sym)
2584 .ok_or_else(|| GraphError::Corrupt {
2585 detail: format!(
2586 "wal InsertNodeId unknown field intern {field_sym}"
2587 ),
2588 })?;
2589 self.props.set(id, field, value.clone());
2590 }
2591 self.view_store
2592 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2593 let cursor = self.engine.pending_delta_count();
2594 let mut eng = std::mem::take(&mut self.engine);
2595 {
2596 let mut gm = make_graph_mut(
2597 &self.ids,
2598 &mut self.syms,
2599 &self.labels,
2600 build_props_view(&self.props, &self.base),
2601 &mut self.topo,
2602 &mut self.edge_props,
2603 );
2604 eng.on_node_changed(id, None, &mut gm);
2605 }
2606 self.engine = eng;
2607 if !self.view_store.is_empty() {
2608 #[cfg(test)]
2609 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2610 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2611 for d in &new_deltas {
2612 self.view_store.on_edge_changed(
2613 d.etype_sym,
2614 d.src_id,
2615 d.dst_id,
2616 d.fired,
2617 &mut self.props,
2618 &build_topo_view(&self.topo, &self.base),
2619 &self.ids,
2620 &self.syms,
2621 &self.labels,
2622 self.base.as_ref().map(|b| {
2623 b.columns()
2624 .expect("base columns section bounds validated at open")
2625 }),
2626 );
2627 }
2628 }
2629 if self.fulltext.has_label(&label_str) {
2630 for (field_sym, value) in props {
2631 let Some(field) = self.syms.resolve(*field_sym) else {
2632 continue;
2633 };
2634 if self.fulltext.is_enabled(&label_str, field) {
2635 self.fulltext.add_tokens(id, field, value);
2636 }
2637 }
2638 }
2639 if self.prop_index.has_label(&label_str) {
2640 for (field_sym, value) in props {
2641 let Some(field) = self.syms.resolve(*field_sym) else {
2642 continue;
2643 };
2644 self.prop_index.set(&label_str, field, id, value);
2645 }
2646 }
2647 }
2648 WalRecord::InsertEdgeId { etype, src, dst } => {
2649 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2650 // already be tombstoned. Skip rather than attaching edges to
2651 // dead ids (DeleteNode keys the live re-insert, not the old id).
2652 if self.ids.is_tombstoned(*src)
2653 || self.ids.is_tombstoned(*dst)
2654 || self.ids.key_of(*src).is_none()
2655 || self.ids.key_of(*dst).is_none()
2656 {
2657 return Ok(());
2658 }
2659 // Skip if already visible in the merged view (same idempotency
2660 // guard as InsertEdge above: prevents double-counting when
2661 // pre-snapshot WAL records are replayed over a V8 base).
2662 if self.base.is_some()
2663 && self
2664 .topo_view()
2665 .neighbors(*etype, Direction::Out, *src)
2666 .contains(dst)
2667 {
2668 return Ok(());
2669 }
2670 self.topo.add_edge(*etype, *src, *dst);
2671 self.view_store.on_edge_changed(
2672 *etype,
2673 *src,
2674 *dst,
2675 true,
2676 &mut self.props,
2677 &build_topo_view(&self.topo, &self.base),
2678 &self.ids,
2679 &self.syms,
2680 &self.labels,
2681 self.base.as_ref().map(|b| {
2682 b.columns()
2683 .expect("base columns section bounds validated at open")
2684 }),
2685 );
2686 // Rule engine: via-hop rules fire when user via-edges are inserted.
2687 // Resolve etype back to string so on_edge_changed can match rules by name.
2688 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2689 let cursor = self.engine.pending_delta_count();
2690 let mut eng = std::mem::take(&mut self.engine);
2691 {
2692 let mut gm = make_graph_mut(
2693 &self.ids,
2694 &mut self.syms,
2695 &self.labels,
2696 build_props_view(&self.props, &self.base),
2697 &mut self.topo,
2698 &mut self.edge_props,
2699 );
2700 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2701 }
2702 self.engine = eng;
2703 if !self.view_store.is_empty() {
2704 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2705 for d in &new_deltas {
2706 self.view_store.on_edge_changed(
2707 d.etype_sym,
2708 d.src_id,
2709 d.dst_id,
2710 d.fired,
2711 &mut self.props,
2712 &build_topo_view(&self.topo, &self.base),
2713 &self.ids,
2714 &self.syms,
2715 &self.labels,
2716 self.base.as_ref().map(|b| {
2717 b.columns()
2718 .expect("base columns section bounds validated at open")
2719 }),
2720 );
2721 }
2722 }
2723 }
2724 }
2725 WalRecord::SetPropId { id, field, value } => {
2726 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2727 return Ok(());
2728 }
2729 let field_str = self
2730 .syms
2731 .resolve(*field)
2732 .ok_or_else(|| GraphError::Corrupt {
2733 detail: format!("wal SetPropId unknown field intern {field}"),
2734 })?
2735 .to_string();
2736 let old_value = build_props_view(&self.props, &self.base)
2737 .get(*id, &field_str)
2738 .map(|vr| vr.into_value());
2739 self.props.set(*id, &field_str, value.clone());
2740 let cursor = self.engine.pending_delta_count();
2741 let mut eng = std::mem::take(&mut self.engine);
2742 {
2743 let mut gm = make_graph_mut(
2744 &self.ids,
2745 &mut self.syms,
2746 &self.labels,
2747 build_props_view(&self.props, &self.base),
2748 &mut self.topo,
2749 &mut self.edge_props,
2750 );
2751 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2752 }
2753 self.engine = eng;
2754 if !self.view_store.is_empty() {
2755 #[cfg(test)]
2756 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2757 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2758 for d in &new_deltas {
2759 self.view_store.on_edge_changed(
2760 d.etype_sym,
2761 d.src_id,
2762 d.dst_id,
2763 d.fired,
2764 &mut self.props,
2765 &build_topo_view(&self.topo, &self.base),
2766 &self.ids,
2767 &self.syms,
2768 &self.labels,
2769 self.base.as_ref().map(|b| {
2770 b.columns()
2771 .expect("base columns section bounds validated at open")
2772 }),
2773 );
2774 }
2775 }
2776 self.view_store.on_prop_changed(
2777 *id,
2778 &field_str,
2779 &mut self.props,
2780 &build_topo_view(&self.topo, &self.base),
2781 &self.ids,
2782 &self.syms,
2783 &self.labels,
2784 self.base.as_ref().map(|b| {
2785 b.columns()
2786 .expect("base columns section bounds validated at open")
2787 }),
2788 );
2789 if self.fulltext.field_indexed(&field_str) {
2790 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2791 if sym == u32::MAX {
2792 None
2793 } else {
2794 self.syms.resolve(sym)
2795 }
2796 });
2797 if let Some(label) = label_opt {
2798 if self.fulltext.is_enabled(label, &field_str) {
2799 self.fulltext.remove_node_field(*id, &field_str);
2800 self.fulltext.add_tokens(*id, &field_str, value);
2801 }
2802 }
2803 }
2804 if self.prop_index.field_indexed(&field_str) {
2805 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2806 if sym == u32::MAX {
2807 None
2808 } else {
2809 self.syms.resolve(sym)
2810 }
2811 });
2812 if let Some(label) = label_opt {
2813 self.prop_index.set(label, &field_str, *id, value);
2814 }
2815 }
2816 }
2817 WalRecord::CreateRule { def_bytes } => {
2818 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2819 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2820 })?;
2821 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2822 // so the engine already has it; silently skip to avoid a spurious
2823 // RuleInvalid error in the crash window between snapshot write and WAL
2824 // truncation.
2825 if self.engine.rules().any(|r| r.name == def.name) {
2826 return Ok(());
2827 }
2828 let cursor = self.engine.pending_delta_count();
2829 let mut eng = std::mem::take(&mut self.engine);
2830 let result = {
2831 let mut gm = make_graph_mut(
2832 &self.ids,
2833 &mut self.syms,
2834 &self.labels,
2835 build_props_view(&self.props, &self.base),
2836 &mut self.topo,
2837 &mut self.edge_props,
2838 );
2839 eng.create_rule(def, &mut gm)
2840 };
2841 self.engine = eng;
2842 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2843 // Derived-edge fires from backfill → view updates.
2844 // Fast path: skip O(edge_count) allocation when no views exist.
2845 if !self.view_store.is_empty() {
2846 #[cfg(test)]
2847 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2848 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2849 for d in &new_deltas {
2850 self.view_store.on_edge_changed(
2851 d.etype_sym,
2852 d.src_id,
2853 d.dst_id,
2854 d.fired,
2855 &mut self.props,
2856 &build_topo_view(&self.topo, &self.base),
2857 &self.ids,
2858 &self.syms,
2859 &self.labels,
2860 self.base.as_ref().map(|b| {
2861 b.columns()
2862 .expect("base columns section bounds validated at open")
2863 }),
2864 );
2865 }
2866 }
2867 }
2868 WalRecord::DeleteRule { name } => {
2869 // Replay-over-snapshot idempotency: the snapshot already captured the
2870 // post-delete state so the rule is absent; silently skip to avoid a
2871 // spurious RuleNotFound error in the crash window between snapshot write
2872 // and WAL truncation.
2873 if !self.engine.rules().any(|r| r.name == *name) {
2874 return Ok(());
2875 }
2876 let cursor = self.engine.pending_delta_count();
2877 let mut eng = std::mem::take(&mut self.engine);
2878 let result = {
2879 let mut gm = make_graph_mut(
2880 &self.ids,
2881 &mut self.syms,
2882 &self.labels,
2883 build_props_view(&self.props, &self.base),
2884 &mut self.topo,
2885 &mut self.edge_props,
2886 );
2887 eng.delete_rule(name, &mut gm)
2888 };
2889 self.engine = eng;
2890 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2891 // Derived-edge retractions → view updates.
2892 if !self.view_store.is_empty() {
2893 #[cfg(test)]
2894 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2895 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2896 for d in &new_deltas {
2897 self.view_store.on_edge_changed(
2898 d.etype_sym,
2899 d.src_id,
2900 d.dst_id,
2901 d.fired,
2902 &mut self.props,
2903 &build_topo_view(&self.topo, &self.base),
2904 &self.ids,
2905 &self.syms,
2906 &self.labels,
2907 self.base.as_ref().map(|b| {
2908 b.columns()
2909 .expect("base columns section bounds validated at open")
2910 }),
2911 );
2912 }
2913 }
2914 }
2915 WalRecord::RemoveProp { key, field } => {
2916 // Recovery-safe: unknown key or already-absent field is a
2917 // clean no-op. Crash-window replay over a snapshot that
2918 // already applied this record must not Err.
2919 let Some(id) = self.ids.get(key) else {
2920 return Ok(());
2921 };
2922 // Read old value through the seam for rule retraction.
2923 let old = build_props_view(&self.props, &self.base)
2924 .get(id, field)
2925 .map(|vr| vr.into_value());
2926 self.props.remove(id, field);
2927 // If the base still supplies the value after the overlay removal,
2928 // record a tombstone so ColumnsView::get does not resurrect it.
2929 // This covers both the base-only case AND the both-resident case:
2930 // base-only (in_overlay=false): old prop was only in base, remove
2931 // is a no-op on overlay, base still visible → tombstone needed.
2932 // both-resident (in_overlay=true): overlay had v2, base has v1;
2933 // removing overlay uncovers v1 → tombstone needed.
2934 // Idempotent on double-replay: second pass sees the tombstone →
2935 // get() returns None → condition is false → no duplicate tombstone.
2936 if build_props_view(&self.props, &self.base)
2937 .get(id, field)
2938 .is_some()
2939 {
2940 self.props.record_prop_tombstone(id, field);
2941 }
2942 let cursor = self.engine.pending_delta_count();
2943 let mut eng = std::mem::take(&mut self.engine);
2944 {
2945 let mut gm = make_graph_mut(
2946 &self.ids,
2947 &mut self.syms,
2948 &self.labels,
2949 build_props_view(&self.props, &self.base),
2950 &mut self.topo,
2951 &mut self.edge_props,
2952 );
2953 eng.on_node_changed(id, Some((field, old)), &mut gm);
2954 }
2955 self.engine = eng;
2956 // Derived-edge deltas → view updates.
2957 if !self.view_store.is_empty() {
2958 #[cfg(test)]
2959 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2960 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2961 for d in &new_deltas {
2962 self.view_store.on_edge_changed(
2963 d.etype_sym,
2964 d.src_id,
2965 d.dst_id,
2966 d.fired,
2967 &mut self.props,
2968 &build_topo_view(&self.topo, &self.base),
2969 &self.ids,
2970 &self.syms,
2971 &self.labels,
2972 self.base.as_ref().map(|b| {
2973 b.columns()
2974 .expect("base columns section bounds validated at open")
2975 }),
2976 );
2977 }
2978 }
2979 // Neighbor-aggregate views that read `field` must also update.
2980 self.view_store.on_prop_changed(
2981 id,
2982 field,
2983 &mut self.props,
2984 &build_topo_view(&self.topo, &self.base),
2985 &self.ids,
2986 &self.syms,
2987 &self.labels,
2988 self.base.as_ref().map(|b| {
2989 b.columns()
2990 .expect("base columns section bounds validated at open")
2991 }),
2992 );
2993 // Full-text index maintenance: remove tokens for this field.
2994 if self.fulltext.field_indexed(field) {
2995 self.fulltext.remove_node_field(id, field);
2996 }
2997 // Property (equality) index maintenance: drop this node's entry.
2998 if self.prop_index.field_indexed(field) {
2999 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3000 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3001 }) {
3002 self.prop_index.remove_node(label, field, id);
3003 }
3004 }
3005 }
3006 WalRecord::DeleteEdge {
3007 edge_type,
3008 src_key,
3009 dst_key,
3010 } => {
3011 // Recovery-safe: unknown keys, unknown etype, or already-
3012 // absent edge is a clean no-op (remove_edge returns false).
3013 let Some(src) = self.ids.get(src_key) else {
3014 return Ok(());
3015 };
3016 let Some(dst) = self.ids.get(dst_key) else {
3017 return Ok(());
3018 };
3019 let Some(etype) = self.syms.get(edge_type) else {
3020 return Ok(());
3021 };
3022 // I3: phantom-tombstone guard. When a V8 base is present, a
3023 // DeleteEdge WAL record for an edge that was already absorbed into
3024 // the new base (i.e. neither in overlay nor in base) must be skipped.
3025 // Without this guard, remove_edge records a tombstone for an edge
3026 // that no longer exists, incorrectly understating edge_count.
3027 if self.base.is_some()
3028 && !self
3029 .topo_view()
3030 .neighbors(etype, core_storage::topology::Direction::Out, src)
3031 .contains(&dst)
3032 {
3033 return Ok(());
3034 }
3035 self.topo.remove_edge(etype, src, dst);
3036 self.edge_props.remove_edge(etype, src, dst);
3037 // View maintenance for manual edge delete (topo already updated above).
3038 self.view_store.on_edge_changed(
3039 etype,
3040 src,
3041 dst,
3042 false,
3043 &mut self.props,
3044 &build_topo_view(&self.topo, &self.base),
3045 &self.ids,
3046 &self.syms,
3047 &self.labels,
3048 self.base.as_ref().map(|b| {
3049 b.columns()
3050 .expect("base columns section bounds validated at open")
3051 }),
3052 );
3053 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3054 let cursor = self.engine.pending_delta_count();
3055 let mut eng = std::mem::take(&mut self.engine);
3056 {
3057 let mut gm = make_graph_mut(
3058 &self.ids,
3059 &mut self.syms,
3060 &self.labels,
3061 build_props_view(&self.props, &self.base),
3062 &mut self.topo,
3063 &mut self.edge_props,
3064 );
3065 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3066 }
3067 self.engine = eng;
3068 if !self.view_store.is_empty() {
3069 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3070 for d in &new_deltas {
3071 self.view_store.on_edge_changed(
3072 d.etype_sym,
3073 d.src_id,
3074 d.dst_id,
3075 d.fired,
3076 &mut self.props,
3077 &build_topo_view(&self.topo, &self.base),
3078 &self.ids,
3079 &self.syms,
3080 &self.labels,
3081 self.base.as_ref().map(|b| {
3082 b.columns()
3083 .expect("base columns section bounds validated at open")
3084 }),
3085 );
3086 }
3087 }
3088 }
3089 WalRecord::DeleteNode { key } => {
3090 // Recovery-safe: already-tombstoned / unknown key is a clean
3091 // no-op. Crash-window replay over a snapshot that already
3092 // applied this record cannot recover the retired id from the
3093 // key (`IdMap::get` is None), so every subsequent step is
3094 // skipped. Each step is independently idempotent if invoked
3095 // twice on a still-live id: retraction is a no-op on empty
3096 // provenance, `remove_edge` returns false, `remove_all` is a
3097 // no-op, `ids.delete` returns None, label sentinel is sticky.
3098 let Some(n) = self.ids.get(key) else {
3099 return Ok(());
3100 };
3101
3102 // (1) Retract derived edges + de-index while props/labels live.
3103 let cursor = self.engine.pending_delta_count();
3104 let mut eng = std::mem::take(&mut self.engine);
3105 {
3106 let mut gm = make_graph_mut(
3107 &self.ids,
3108 &mut self.syms,
3109 &self.labels,
3110 build_props_view(&self.props, &self.base),
3111 &mut self.topo,
3112 &mut self.edge_props,
3113 );
3114 eng.on_node_removed(n, &mut gm);
3115 }
3116 self.engine = eng;
3117 // Derived-edge retractions → view updates for neighbors.
3118 if !self.view_store.is_empty() {
3119 #[cfg(test)]
3120 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3121 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3122 for d in &new_deltas {
3123 self.view_store.on_edge_changed(
3124 d.etype_sym,
3125 d.src_id,
3126 d.dst_id,
3127 d.fired,
3128 &mut self.props,
3129 &build_topo_view(&self.topo, &self.base),
3130 &self.ids,
3131 &self.syms,
3132 &self.labels,
3133 self.base.as_ref().map(|b| {
3134 b.columns()
3135 .expect("base columns section bounds validated at open")
3136 }),
3137 );
3138 }
3139 }
3140
3141 // (2) Sweep ALL remaining edges incident to n, both directions,
3142 // every etype. This cascade is intentionally mask-independent:
3143 // topology integrity requires removing every edge touching the
3144 // deleted node regardless of the caller's visibility scope.
3145 // (The mask limits which nodes a role's read phase can return;
3146 // the WAL delete always executes with full storage authority.)
3147 // Collect then remove so neighbor slices stay valid during
3148 // iteration. Remove from topo first, then call view maintenance
3149 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3150 let etypes: Vec<u32> = self.topo.etypes().collect();
3151 let mut doomed = Vec::new();
3152 for et in &etypes {
3153 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3154 doomed.push((*et, n, dst));
3155 }
3156 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3157 doomed.push((*et, src, n));
3158 }
3159 }
3160 for (et, s, d) in doomed {
3161 self.topo.remove_edge(et, s, d);
3162 self.edge_props.remove_edge(et, s, d);
3163 // View maintenance: n's own view values will be cleared by
3164 // remove_all below; only update surviving neighbors.
3165 self.view_store.on_edge_changed(
3166 et,
3167 s,
3168 d,
3169 false,
3170 &mut self.props,
3171 &build_topo_view(&self.topo, &self.base),
3172 &self.ids,
3173 &self.syms,
3174 &self.labels,
3175 self.base.as_ref().map(|b| {
3176 b.columns()
3177 .expect("base columns section bounds validated at open")
3178 }),
3179 );
3180 }
3181
3182 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3183 self.props.remove_all(n);
3184 // Full-text index maintenance: remove all tokens for this node.
3185 self.fulltext.remove_node(n);
3186 // Property (equality) index maintenance: drop all entries for n.
3187 self.prop_index.remove_node_all(n);
3188
3189 // (4) Retire the dense id and stamp the label sentinel.
3190 self.ids.delete(key);
3191 if let Some(slot) = self.labels.get_mut(n as usize) {
3192 *slot = u32::MAX;
3193 }
3194 }
3195 WalRecord::Batch(inner) => {
3196 // Apply each inner record in order through the same apply path.
3197 // Inner records are validated free of nested Batch by encode_record.
3198 for rec in inner {
3199 self.apply(rec)?;
3200 }
3201 }
3202 WalRecord::RebuildRule { name } => {
3203 // Replay-over-snapshot idempotency: the snapshot may already
3204 // reflect a later delete_rule, so the rule is absent; skip.
3205 if !self.engine.rules().any(|r| r.name == *name) {
3206 return Ok(());
3207 }
3208 let cursor = self.engine.pending_delta_count();
3209 let mut eng = std::mem::take(&mut self.engine);
3210 let result = {
3211 let mut gm = make_graph_mut(
3212 &self.ids,
3213 &mut self.syms,
3214 &self.labels,
3215 build_props_view(&self.props, &self.base),
3216 &mut self.topo,
3217 &mut self.edge_props,
3218 );
3219 eng.rebuild(name, &mut gm)
3220 };
3221 self.engine = eng;
3222 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3223 // Derived-edge delta changes → view updates.
3224 if !self.view_store.is_empty() {
3225 #[cfg(test)]
3226 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3227 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3228 for d in &new_deltas {
3229 self.view_store.on_edge_changed(
3230 d.etype_sym,
3231 d.src_id,
3232 d.dst_id,
3233 d.fired,
3234 &mut self.props,
3235 &build_topo_view(&self.topo, &self.base),
3236 &self.ids,
3237 &self.syms,
3238 &self.labels,
3239 self.base.as_ref().map(|b| {
3240 b.columns()
3241 .expect("base columns section bounds validated at open")
3242 }),
3243 );
3244 }
3245 }
3246 }
3247 WalRecord::CreateView { def_bytes } => {
3248 let def: ViewDef =
3249 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3250 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3251 })?;
3252 // Replay-over-snapshot idempotency: view already present → skip.
3253 if self.view_store.has_view(&def.name) {
3254 return Ok(());
3255 }
3256 self.view_store
3257 .create_view(
3258 def,
3259 &mut self.props,
3260 &build_topo_view(&self.topo, &self.base),
3261 &self.ids,
3262 &self.syms,
3263 &self.labels,
3264 )
3265 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3266 }
3267 WalRecord::DeleteView { name } => {
3268 // Replay-over-snapshot idempotency: view already absent → skip.
3269 if !self.view_store.has_view(name) {
3270 return Ok(());
3271 }
3272 self.view_store
3273 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3274 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3275 }
3276 WalRecord::EnableFulltext { label, field } => {
3277 // Replay-over-snapshot idempotency: already enabled → skip.
3278 if self.fulltext.is_enabled(label, field) {
3279 return Ok(());
3280 }
3281 self.fulltext.enable(label, field);
3282 // Backfill: index all live nodes of this label that have the field.
3283 let n = self.ids.len() as u32;
3284 for id in 0..n {
3285 let Some(&sym) = self.labels.get(id as usize) else {
3286 continue;
3287 };
3288 if sym == u32::MAX {
3289 continue; // tombstoned
3290 }
3291 let Some(lbl) = self.syms.resolve(sym) else {
3292 continue;
3293 };
3294 if lbl != label {
3295 continue;
3296 }
3297 if let Some(value) = build_props_view(&self.props, &self.base)
3298 .get(id, field)
3299 .map(|vr| vr.into_value())
3300 {
3301 self.fulltext.add_tokens(id, field, &value);
3302 }
3303 }
3304 }
3305 WalRecord::DisableFulltext { label, field } => {
3306 // Replay-over-snapshot idempotency: already disabled → skip.
3307 if !self.fulltext.is_enabled(label, field) {
3308 return Ok(());
3309 }
3310 // If another label still indexes this field, the postings column
3311 // is kept — but it must not contain node_ids from the now-disabled
3312 // label. Remove them before calling disable() so the field_indexed
3313 // guard inside disable() sees the correct post-removal state.
3314 if self.fulltext.field_indexed_by_other(label, field) {
3315 if let Some(label_sym) = self.syms.get(label) {
3316 for (node_id, &lsym) in self.labels.iter().enumerate() {
3317 if lsym == label_sym {
3318 self.fulltext.remove_node_field(node_id as u32, field);
3319 }
3320 }
3321 }
3322 }
3323 self.fulltext.disable(label, field);
3324 }
3325 WalRecord::EnableIndex { label, field } => {
3326 // Replay-over-snapshot idempotency: already enabled → skip.
3327 if self.prop_index.is_enabled(label, field) {
3328 return Ok(());
3329 }
3330 self.prop_index.enable(label, field);
3331 // Backfill: index all live nodes of this label that have the field.
3332 let n = self.ids.len() as u32;
3333 for id in 0..n {
3334 let Some(&sym) = self.labels.get(id as usize) else {
3335 continue;
3336 };
3337 if sym == u32::MAX {
3338 continue; // tombstoned
3339 }
3340 let Some(lbl) = self.syms.resolve(sym) else {
3341 continue;
3342 };
3343 if lbl != label {
3344 continue;
3345 }
3346 if let Some(value) = build_props_view(&self.props, &self.base)
3347 .get(id, field)
3348 .map(|vr| vr.into_value())
3349 {
3350 self.prop_index.set(label, field, id, &value);
3351 }
3352 }
3353 }
3354 WalRecord::DisableIndex { label, field } => {
3355 self.prop_index.disable(label, field);
3356 }
3357 // History markers carry no replay state — rules re-derive edges
3358 // deterministically on open/replay. Skip unconditionally.
3359 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3360 // ── rename_node ──────────────────────────────────────────────────
3361 WalRecord::RenameNode { old_key, new_key } => {
3362 // Recovery-safe: if old_key is already gone (key was renamed
3363 // by a snapshot or a prior replay frame), skip cleanly.
3364 if self.ids.get(old_key).is_none() {
3365 return Ok(());
3366 }
3367 // The rename only updates the key-table; the dense id, all
3368 // topo edges, props, labels, and rule state are id-indexed and
3369 // require no change.
3370 self.ids
3371 .rename(old_key, new_key)
3372 .map_err(|e| GraphError::Corrupt {
3373 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3374 })?;
3375 }
3376 }
3377 Ok(())
3378 }
3379
3380 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3381 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3382 /// idempotent when the string is already bound. Always emit: after
3383 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3384 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3385 let id = if let Some(id) = self.syms.get(s) {
3386 id
3387 } else {
3388 self.syms.intern(s)
3389 };
3390 (
3391 id,
3392 WalRecord::Intern {
3393 id,
3394 text: s.to_string(),
3395 },
3396 )
3397 }
3398
3399 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3400 /// state is left mutated: speculative interns made while building the
3401 /// output are rolled back, so a later successful mutation cannot log an
3402 /// `Intern` record whose id replay would never reproduce.
3403 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3404 let syms_checkpoint = self.syms.len();
3405 let result = self.rewrite_wal_dense_inner(recs);
3406 if result.is_err() {
3407 self.syms.truncate(syms_checkpoint);
3408 }
3409 result
3410 }
3411
3412 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3413 let mut out = Vec::with_capacity(recs.len());
3414 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3415 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3416 let mut interned = std::collections::HashSet::<u32>::new();
3417 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3418 detail: "id space exhausted".into(),
3419 })?;
3420 let lookup = |ids: &IdMap,
3421 pending: &std::collections::HashMap<String, u32>,
3422 key: &str|
3423 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3424 for rec in recs {
3425 match rec {
3426 WalRecord::InsertNode { label, key, props } => {
3427 let (label_id, intern) = self.intern_wal(&label);
3428 if interned.insert(label_id) {
3429 out.push(intern);
3430 }
3431 let mut props_id = Vec::with_capacity(props.len());
3432 for (field, value) in props {
3433 let (field_id, intern) = self.intern_wal(&field);
3434 if interned.insert(field_id) {
3435 out.push(intern);
3436 }
3437 props_id.push((field_id, value));
3438 }
3439 if lookup(&self.ids, &pending, &key).is_none() {
3440 pending.insert(key.clone(), next);
3441 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3442 detail: "id space exhausted".into(),
3443 })?;
3444 }
3445 out.push(WalRecord::InsertNodeId {
3446 label: label_id,
3447 key,
3448 props: props_id,
3449 });
3450 }
3451 WalRecord::SetProp { key, field, value } => {
3452 let id =
3453 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3454 detail: format!("dense WAL rewrite missing key {key}"),
3455 })?;
3456 let (field_id, intern) = self.intern_wal(&field);
3457 if interned.insert(field_id) {
3458 out.push(intern);
3459 }
3460 out.push(WalRecord::SetPropId {
3461 id,
3462 field: field_id,
3463 value,
3464 });
3465 }
3466 WalRecord::InsertEdge {
3467 edge_type,
3468 src_key,
3469 dst_key,
3470 } => {
3471 let (etype, intern) = self.intern_wal(&edge_type);
3472 if interned.insert(etype) {
3473 out.push(intern);
3474 }
3475 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3476 GraphError::Corrupt {
3477 detail: format!("dense WAL rewrite missing src {src_key}"),
3478 }
3479 })?;
3480 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3481 GraphError::Corrupt {
3482 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3483 }
3484 })?;
3485 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3486 }
3487 WalRecord::RenameNode {
3488 ref old_key,
3489 ref new_key,
3490 } => {
3491 // Track the rename in `pending` so subsequent InsertEdge /
3492 // SetProp records in this batch can resolve the new key.
3493 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3494 GraphError::Corrupt {
3495 detail: format!(
3496 "dense WAL rewrite: RenameNode old key {old_key} not found"
3497 ),
3498 }
3499 })?;
3500 pending.remove(old_key.as_str());
3501 pending.insert(new_key.clone(), id);
3502 out.push(rec);
3503 }
3504 // # Symbol-order invariant (load-bearing)
3505 //
3506 // Write-time and replay-time symbol assignment must agree: every
3507 // symbol in a `Batch` frame has to receive the same dense id when
3508 // the frame's records are replayed in order as it received when
3509 // the frame was written.
3510 //
3511 // A rule's backfill interns its `edge_type` lazily
3512 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3513 // site), and that backfill runs from `apply` — during the
3514 // `CreateRule` record itself, and again from any later
3515 // `InsertNodeId` in the same frame that makes the rule fire. At
3516 // write time the whole batch is rewritten before any of it is
3517 // applied, so a later `InsertEdge` in the same batch would win the
3518 // lower id for its edge type; on replay the rule's lazy intern
3519 // gets there first and steals it, and the `Intern` record fails at
3520 // the `wal intern assigned …` check in `apply`.
3521 //
3522 // Pre-interning the rule's `edge_type` here, and emitting its
3523 // `Intern` record ahead of the `CreateRule` record, makes both
3524 // orders identical. `weight_prop` needs no pre-intern:
3525 // `EdgeProps::set` keys props by `String`, never through the
3526 // interner. `via_edge` needs none either: via-hop rules resolve it
3527 // with `syms.get` and skip when it is absent.
3528 //
3529 // `RebuildRule` and `DeleteRule` need no such handling here:
3530 // `RebuildRule` has no `BatchOp` variant, so it never appears
3531 // inside a `Batch` today — it is only ever issued as its own
3532 // standalone commit (`rebuild_rule`, or the auto-rebuild path
3533 // that logs it as a second commit after the triggering op).
3534 // `DeleteRule` does have a `BatchOp` variant and can appear
3535 // inside a `Batch`, but it carries only a rule `name` — no
3536 // `edge_type` or other symbol that needs pre-interning — so
3537 // only `CreateRule` needs this arm.
3538 WalRecord::CreateRule { ref def_bytes } => {
3539 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3540 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3541 })?;
3542 let (etype, intern) = self.intern_wal(&def.edge_type);
3543 if interned.insert(etype) {
3544 out.push(intern);
3545 }
3546 out.push(rec);
3547 }
3548 other => out.push(other),
3549 }
3550 }
3551 Ok(out)
3552 }
3553
3554 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3555 let recs = self.rewrite_wal_dense(recs)?;
3556 match recs.len() {
3557 0 => Ok(()),
3558 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3559 _ => self.log_then_apply(WalRecord::Batch(recs)),
3560 }
3561 }
3562
3563 /// Durable write, then notify the event sink. Replay (`apply` during
3564 /// `open`) never enters this function, so it is the replay-silent seam.
3565 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3566 self.log_then_apply_with(rec, None, self.fsync)
3567 }
3568
3569 /// Whether this frame must fsync under `policy`.
3570 ///
3571 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3572 /// mutations do not. The dense rewrite wraps a single mutation in a
3573 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3574 /// from the count — removing that filter would make every single-op write
3575 /// fsync under Batched (or, if the threshold were raised instead, skip a
3576 /// needed fsync for real two-op batches).
3577 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3578 match policy {
3579 FsyncPolicy::Relaxed => false,
3580 FsyncPolicy::Strict => true,
3581 FsyncPolicy::Batched => match rec {
3582 // Intern + one mutation is the single-op rewrite, not a user batch.
3583 WalRecord::Batch(inner) => {
3584 inner
3585 .iter()
3586 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3587 .count()
3588 > 1
3589 }
3590 _ => false,
3591 },
3592 }
3593 }
3594
3595 /// # Apply-infallibility invariant (load-bearing)
3596 ///
3597 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3598 /// for a `Batch` frame after a successful WAL write, the WAL would contain
3599 /// the full frame while in-memory state would reflect only the ops before
3600 /// the failure. On reopen, WAL replay would then apply the entire batch —
3601 /// diverging permanently from what the pre-crash process had in memory.
3602 ///
3603 /// For `Batch` frames this situation cannot arise because:
3604 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3605 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
3606 /// use, with no concurrent mutation between validation exit and apply entry.
3607 /// - Every `apply` arm for a validated op is either infallible by construction
3608 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3609 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3610 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3611 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3612 ///
3613 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3614 /// `Err` for a `Batch` frame, making any future regression immediately visible
3615 /// in tests rather than silently diverging crash-recovery behaviour.
3616 fn log_then_apply_with(
3617 &mut self,
3618 rec: WalRecord,
3619 ingest: Option<(String, usize)>,
3620 policy: FsyncPolicy,
3621 ) -> Result<()> {
3622 // Read-only guard: as-of instances must never write the WAL.
3623 if self.read_only {
3624 return Err(GraphError::ReadOnly);
3625 }
3626 // Degraded guard: fsync failure left WAL truncated; in-memory state
3627 // is ahead of the on-disk WAL, so further mutations would deepen the
3628 // divergence. Reopen the database to recover.
3629 if self.degraded {
3630 return Err(GraphError::Io(std::io::Error::other(
3631 "database degraded after group-commit fsync failure; reopen required",
3632 )));
3633 }
3634 // Ensure retained provenance bytes are decoded into the live mutable
3635 // fields before any mutation touches self.engine.provenance. This is a
3636 // no-op if provenance was never stored (fresh store) or has already been
3637 // consumed (subsequent mutations). WAL replay calls apply() directly
3638 // and is covered by consume_retained_state_eager before replay.
3639 self.ensure_v8_base_sections_loaded();
3640 self.engine.ensure_provenance_loaded_mut();
3641 // Invariant (I-1): no stale deltas may enter from a previous apply.
3642 // If any engine method ever accumulates deltas before erroring, they would
3643 // contaminate the *next* commit's event stream. This assert fires in debug
3644 // builds, making any future regression visible at the earliest point.
3645 debug_assert_eq!(
3646 self.engine.pending_delta_count(),
3647 0,
3648 "stale engine deltas at log_then_apply_with entry — \
3649 a previous apply arm may have accumulated deltas before erroring; \
3650 the caller must drain_deltas() on any error path before returning"
3651 );
3652 self.fs.append(FileId::Wal, &encode_record(&rec))?;
3653 if Self::wal_needs_sync(policy, &rec) {
3654 self.fs.sync(FileId::Wal)?;
3655 }
3656 // Marker writing always needs the engine deltas, but the engine only
3657 // accumulates them when emit_deltas is true (normally gated on subscribers
3658 // or views being present). Enable emission for this apply if it is
3659 // currently off, then restore the original state unconditionally via an
3660 // RAII guard — this prevents a panic in apply() from leaking the flag.
3661 // The same guard resets the engine's transient chaining state. A panic
3662 // unwinding out of a rule hook would otherwise leave `chain_depth`
3663 // non-zero, which makes every later `begin_chain` decide chaining is
3664 // already running and silently switch it off for good.
3665 struct RestoreEmitDeltas(*mut RuleEngine, bool);
3666 impl Drop for RestoreEmitDeltas {
3667 fn drop(&mut self) {
3668 // SAFETY: pointer into self (GraphDb); guard is dropped within
3669 // this frame before log_then_apply_with returns.
3670 unsafe {
3671 (*self.0).set_emit_deltas(self.1);
3672 (*self.0).reset_chain_state();
3673 }
3674 }
3675 }
3676 let original_emit = self.engine.emit_deltas();
3677 if !original_emit {
3678 self.engine.set_emit_deltas(true);
3679 }
3680 // SAFETY: raw pointer into self; guard dropped within this frame.
3681 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3682
3683 let apply_result = self.apply(&rec);
3684 // For Batch frames, post-validation apply must be infallible (see above).
3685 // A debug_assert here catches any future change that makes apply fallible
3686 // before the caller notices via silent WAL/memory divergence.
3687 if matches!(&rec, WalRecord::Batch(_)) {
3688 debug_assert!(
3689 apply_result.is_ok(),
3690 "Batch apply returned Err after successful WAL write — \
3691 the validate-then-apply invariant has been violated; \
3692 see log_then_apply_with invariant doc"
3693 );
3694 }
3695 if apply_result.is_err() {
3696 // Discard any partial deltas accumulated by the failed apply.
3697 // They must not ride the next commit's event stream (I-1).
3698 // _emit_guard restores emit_deltas on drop automatically.
3699 let _ = self.engine.drain_deltas();
3700 let _ = self.engine.take_rebuild_needed();
3701 apply_result?;
3702 }
3703 self.commit_seq += 1;
3704 let seq = self.commit_seq;
3705 // Update per-node last-change map for the committed record.
3706 // Must happen after commit_seq is incremented so the seq is correct.
3707 self.update_last_change_from_rec(&rec, seq);
3708 // Drain engine deltas and distribute to subscribers before the existing
3709 // MutationEvent sink fires — both happen post-fsync, post-apply.
3710 // _emit_guard restores emit_deltas after this line when it drops.
3711 let engine_deltas = self.engine.drain_deltas();
3712
3713 // Append history-marker WAL records for any derived-edge changes so
3714 // that `edge_history` and `was_linked` can surface rule-attributed
3715 // events. Markers are STATE NO-OPS during replay; they are written
3716 // without an additional fsync (the triggering commit's sync already
3717 // happened; the next commit's sync covers these lazily).
3718 if !engine_deltas.is_empty() {
3719 let markers: Vec<WalRecord> = engine_deltas
3720 .iter()
3721 .map(|d| {
3722 if d.fired {
3723 WalRecord::DerivedEdgeAdded {
3724 rule: d.rule.clone(),
3725 edge_type: d.edge_type.clone(),
3726 src_key: d.src_key.clone(),
3727 dst_key: d.dst_key.clone(),
3728 }
3729 } else {
3730 WalRecord::DerivedEdgeRetracted {
3731 rule: d.rule.clone(),
3732 edge_type: d.edge_type.clone(),
3733 src_key: d.src_key.clone(),
3734 dst_key: d.dst_key.clone(),
3735 }
3736 }
3737 })
3738 .collect();
3739 let marker_frame = if markers.len() == 1 {
3740 markers.into_iter().next().unwrap()
3741 } else {
3742 WalRecord::Batch(markers)
3743 };
3744 // Ignore append errors: markers are best-effort history
3745 // annotations. Losing them does not affect state correctness.
3746 let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3747 }
3748
3749 // Record MVCC CommitDelta for the epoch reader. The WAL record is
3750 // stored as-is (including any nested Batch / Intern records); the
3751 // ReaderSnapshot's apply_one function handles all variants.
3752 {
3753 let derived_inserts = engine_deltas
3754 .iter()
3755 .filter(|d| d.fired)
3756 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3757 .collect();
3758 let derived_deletes = engine_deltas
3759 .iter()
3760 .filter(|d| !d.fired)
3761 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3762 .collect();
3763 let delta = Arc::new(crate::reader::CommitDelta {
3764 records: vec![rec.clone()],
3765 derived_inserts,
3766 derived_deletes,
3767 });
3768 self.delta_tail.push(delta);
3769 self.commits_since_fold += 1;
3770 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3771 self.fold_now();
3772 }
3773 }
3774
3775 if self.defer_events {
3776 // Group-commit drain thread: hold events until after the group
3777 // fsync so subscribers only observe durable data (R2).
3778 self.deferred_events.push(DeferredEvent {
3779 rec: rec.clone(),
3780 engine_deltas,
3781 seq,
3782 ingest,
3783 });
3784 } else {
3785 self.distribute_events(&rec, &engine_deltas, seq);
3786 self.emit_committed(&rec, ingest);
3787 }
3788 // Drift is only known after apply, so auto-rebuild cannot join the
3789 // triggering op's WAL frame. Issue RebuildRule as a second commit.
3790 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3791 // retrigger loop is impossible if the fit succeeded, but we still
3792 // drain the flag so a leftover cannot re-enter.
3793 let rebuilds = self.engine.take_rebuild_needed();
3794 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3795 let mut failed = Vec::new();
3796 for name in rebuilds {
3797 if self.engine.rules().any(|r| r.name == name) {
3798 // User op is already durable. A failed second commit must
3799 // not surface as the caller's error.
3800 if let Err(e) =
3801 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3802 {
3803 eprintln!(
3804 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3805 );
3806 failed.push(name);
3807 }
3808 }
3809 }
3810 for name in failed {
3811 self.engine.queue_rebuild_needed(name);
3812 }
3813 }
3814 Ok(())
3815 }
3816
3817 /// Install a post-commit hook. Replaces any previous sink.
3818 ///
3819 /// The sink runs inside `log_then_apply` after a successful
3820 /// durable commit, while the caller still holds `&mut self`. When this
3821 /// database is behind a [`crate::SharedDb`], that means the **write
3822 /// guard is held**. The sink must never call `read` / `write` (or any
3823 /// other method) on the same `SharedDb` — the `RwLock` is not
3824 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3825 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3826 /// Intended examples: `std::sync::mpsc::SyncSender`,
3827 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3828 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3829 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3830 self.event_sink = Some(sink);
3831 }
3832
3833 /// Whether a post-commit event sink is currently installed.
3834 pub fn has_event_sink(&self) -> bool {
3835 self.event_sink.is_some()
3836 }
3837
3838 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3839 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3840 self.fsync = p;
3841 }
3842
3843 /// Return the current WAL fsync cadence.
3844 pub fn fsync_policy(&self) -> FsyncPolicy {
3845 self.fsync
3846 }
3847
3848 // ── Group-commit event deferral ───────────────────────────────────────────
3849
3850 /// Enable or disable deferred event mode.
3851 ///
3852 /// When `true`, event notifications (subscription `DbEvent`s and legacy
3853 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3854 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3855 /// or [`discard_deferred_events`] if the fsync failed and the group must
3856 /// be treated as lost.
3857 pub fn set_deferred_events_mode(&mut self, defer: bool) {
3858 self.defer_events = defer;
3859 }
3860
3861 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3862 /// was set to true. Clears the buffer.
3863 ///
3864 /// Called by the drain thread AFTER a successful group fsync, so
3865 /// subscribers observe only data that is durably on disk.
3866 pub fn flush_deferred_events(&mut self) {
3867 let events = std::mem::take(&mut self.deferred_events);
3868 for de in events {
3869 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3870 self.emit_committed(&de.rec, de.ingest);
3871 }
3872 }
3873
3874 /// Discard all buffered events without firing them.
3875 ///
3876 /// Called by the drain thread when a group fsync fails: the WAL has been
3877 /// truncated back to the pre-group offset, so the committed-but-unsynced
3878 /// ops must not be observable to subscribers.
3879 pub fn discard_deferred_events(&mut self) {
3880 self.deferred_events.clear();
3881 }
3882
3883 // ── Degraded state ────────────────────────────────────────────────────────
3884
3885 /// Mark this database as degraded.
3886 ///
3887 /// Called by the group-commit drain thread after a group fsync failure and
3888 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3889 /// further mutations would deepen the divergence. All subsequent calls to
3890 /// [`log_then_apply_with`] return `Err` until the database is reopened.
3891 pub fn set_degraded(&mut self) {
3892 self.degraded = true;
3893 }
3894
3895 fn emit(&self, ev: MutationEvent) {
3896 if let Some(sink) = &self.event_sink {
3897 sink(ev);
3898 }
3899 }
3900
3901 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3902 match rec {
3903 WalRecord::Batch(inner) => {
3904 for r in inner {
3905 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3906 self.emit(ev);
3907 }
3908 }
3909 match ingest {
3910 Some((label, inserted)) => {
3911 self.emit(MutationEvent::Ingested { label, inserted })
3912 }
3913 None => {
3914 let ops = inner
3915 .iter()
3916 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3917 .count();
3918 if ops > 1 {
3919 self.emit(MutationEvent::BatchApplied { ops });
3920 }
3921 }
3922 }
3923 }
3924 other => {
3925 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3926 self.emit(ev);
3927 }
3928 }
3929 }
3930 }
3931
3932 // -----------------------------------------------------------------------
3933 // Subscription API
3934 // -----------------------------------------------------------------------
3935
3936 /// Distribute post-commit events to all live subscribers.
3937 ///
3938 /// Build a row-key → row-data map from a [`ResultSet`].
3939 ///
3940 /// Each row is serialized to JSON to form its key; a debug fallback is used
3941 /// if serialization fails. Used by both the initial-seed path in
3942 /// [`Self::subscribe_query`] and the per-commit diff path in
3943 /// [`Self::distribute_events`] to keep the two in sync.
3944 fn result_to_row_map(
3945 result: &core_query::ResultSet,
3946 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3947 (0..result.len())
3948 .map(|i| {
3949 let row = result.row(i).to_vec();
3950 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3951 (key, row)
3952 })
3953 .collect()
3954 }
3955
3956 /// Collect the set of label syms touched by a WAL record.
3957 ///
3958 /// Returns `Some(set)` when every record in this commit can be attributed to
3959 /// a known label sym. Returns `None` when the commit must not be skipped:
3960 /// edge records, unresolvable key→label lookups, or any record type not in
3961 /// the explicit handled set.
3962 ///
3963 /// Handled record types and their actions:
3964 /// - `InsertNode` → look up label in interner (fails → None)
3965 /// - `InsertNodeId` → label sym is carried directly
3966 /// - `SetProp` → resolve key→id→label (fails → None)
3967 /// - `DeleteNode` → resolve key→id→label (fails → None)
3968 /// - `Batch` → recurse into every inner record
3969 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3970 /// - everything else → None (conservative)
3971 fn commit_touched_labels(
3972 rec: &WalRecord,
3973 syms: &Interner,
3974 ids: &IdMap,
3975 labels: &[u32],
3976 ) -> Option<BTreeSet<u32>> {
3977 let mut out = BTreeSet::new();
3978 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3979 Some(out)
3980 } else {
3981 None
3982 }
3983 }
3984
3985 fn collect_touched_labels(
3986 rec: &WalRecord,
3987 syms: &Interner,
3988 ids: &IdMap,
3989 labels: &[u32],
3990 out: &mut BTreeSet<u32>,
3991 ) -> bool {
3992 match rec {
3993 // String-key insert: the dense rewrite converts this to
3994 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3995 // records written before the dense path was added.
3996 WalRecord::InsertNode { label, .. } => {
3997 if let Some(sym) = syms.get(label) {
3998 out.insert(sym);
3999 true
4000 } else {
4001 false
4002 }
4003 }
4004 // Dense-id insert (produced by rewrite_wal_dense for every
4005 // insert_node call in the current codebase).
4006 WalRecord::InsertNodeId { label, .. } => {
4007 out.insert(*label);
4008 true
4009 }
4010 // String-key prop set: dense path converts to [Intern, SetPropId].
4011 WalRecord::SetProp { key, .. } => {
4012 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4013 out.insert(sym);
4014 true
4015 } else {
4016 false
4017 }
4018 }
4019 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4020 WalRecord::SetPropId { id, .. } => {
4021 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4022 out.insert(sym);
4023 true
4024 } else {
4025 false
4026 }
4027 }
4028 WalRecord::DeleteNode { key } => {
4029 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4030 out.insert(sym);
4031 true
4032 } else {
4033 false
4034 }
4035 }
4036 WalRecord::Batch(inner) => inner
4037 .iter()
4038 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4039 // Intern is a pure metadata record — it does not touch any node's
4040 // label and is safe to skip for the label-skip predicate.
4041 WalRecord::Intern { .. } => true,
4042 // Edge records: always re-execute (edges can change join results).
4043 WalRecord::InsertEdge { .. }
4044 | WalRecord::DeleteEdge { .. }
4045 | WalRecord::InsertEdgeId { .. } => false,
4046 _ => false,
4047 }
4048 }
4049
4050 /// Resolve a node key to its label sym via the dense id table.
4051 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4052 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4053 let id = ids.get(key)?;
4054 let sym = labels.get(id as usize).copied()?;
4055 (sym != u32::MAX).then_some(sym)
4056 }
4057
4058 /// Distribute post-commit events to all live subscribers.
4059 ///
4060 /// Called from `log_then_apply_with` after apply + fsync, before the
4061 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4062 ///
4063 /// Query subscriptions (subscribe_query) re-execute their plan on every
4064 /// call and diff the result against the previous run. Zero overhead when
4065 /// no query subscriptions are active.
4066 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4067 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4068 return;
4069 }
4070
4071 if !self.subscriptions.is_empty() {
4072 // Build write events from the WAL record.
4073 let write_events: Vec<DbEvent> =
4074 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4075
4076 // Build edge events from engine deltas. Weight is looked up from
4077 // edge_props at distribution time (after apply), so it's always fresh.
4078 let edge_events: Vec<DbEvent> = engine_deltas
4079 .iter()
4080 .map(|d| {
4081 if d.fired {
4082 // The score lives under the rule's declared weight_prop,
4083 // which is not always the literal "weight".
4084 let prop = self
4085 .engine
4086 .rules()
4087 .find(|r| r.name == d.rule)
4088 .and_then(|r| r.weight_prop.as_deref());
4089 let weight = prop.and_then(|p| {
4090 self.edge_props
4091 .get(d.etype_sym, d.src_id, d.dst_id, p)
4092 .and_then(|v| {
4093 if let core_storage::Value::Float(f) = v {
4094 Some(*f)
4095 } else {
4096 None
4097 }
4098 })
4099 });
4100 DbEvent::EdgeFired {
4101 rule: d.rule.clone(),
4102 src_key: d.src_key.clone(),
4103 dst_key: d.dst_key.clone(),
4104 edge_type: d.edge_type.clone(),
4105 weight,
4106 commit_seq: seq,
4107 }
4108 } else {
4109 DbEvent::EdgeRetracted {
4110 rule: d.rule.clone(),
4111 src_key: d.src_key.clone(),
4112 dst_key: d.dst_key.clone(),
4113 edge_type: d.edge_type.clone(),
4114 commit_seq: seq,
4115 }
4116 }
4117 })
4118 .collect();
4119
4120 // Prune dead entries; push matching events to live ones.
4121 self.subscriptions.retain(|entry| {
4122 let Some(inner) = entry.inner.upgrade() else {
4123 return false;
4124 };
4125 for ev in &write_events {
4126 if event_matches(ev, &entry.filter) {
4127 inner.push(ev.clone());
4128 }
4129 }
4130 for ev in &edge_events {
4131 if event_matches(ev, &entry.filter) {
4132 inner.push(ev.clone());
4133 }
4134 }
4135 true
4136 });
4137
4138 // Turn off delta accumulation if all subscribers dropped and no views remain.
4139 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4140 self.engine.set_emit_deltas(false);
4141 }
4142 }
4143
4144 // Query subscriptions: full re-run per commit, then diff rows.
4145 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4146 // Differential evaluation is roadmap / Phase 5.
4147 if !self.query_subscriptions.is_empty() {
4148 // Take the list out so we can call self.view() without borrow conflict.
4149 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4150 let empty_params = BTreeMap::new();
4151 query_subs.retain_mut(|entry| {
4152 let Some(inner) = entry.inner.upgrade() else {
4153 return false; // subscriber dropped — prune
4154 };
4155 // Label-skip: if the plan has a known scan label and this commit
4156 // can be proven to touch only different labels (and no rule-derived
4157 // edge deltas fired), the result set cannot have changed — skip.
4158 if let Some(scan_sym) = entry.scan_label {
4159 if engine_deltas.is_empty() {
4160 let touched =
4161 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4162 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4163 return true; // safe to skip — result set unchanged
4164 }
4165 }
4166 }
4167 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4168 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4169 Ok(r) => r,
4170 Err(e) => {
4171 // Keep the subscription alive; skip the diff for this commit.
4172 // Re-run errors are transient (e.g., planner change) and
4173 // self-heal when the next commit succeeds.
4174 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4175 return true;
4176 }
4177 };
4178 // Build new row map: serialized-key → row data.
4179 let new_row_map = Self::result_to_row_map(&result);
4180 // Removed rows: in prev but not in new.
4181 for (key, row) in &entry.prev_row_map {
4182 if !new_row_map.contains_key(key) {
4183 inner.push(DbEvent::QueryRowRemoved {
4184 columns: entry.columns.clone(),
4185 row: row.clone(),
4186 });
4187 }
4188 }
4189 // Added rows: in new but not in prev.
4190 for (key, row) in &new_row_map {
4191 if !entry.prev_row_map.contains_key(key) {
4192 inner.push(DbEvent::QueryRowAdded {
4193 columns: entry.columns.clone(),
4194 row: row.clone(),
4195 });
4196 }
4197 }
4198 entry.prev_row_map = new_row_map;
4199 true
4200 });
4201 self.query_subscriptions = query_subs;
4202 }
4203 }
4204
4205 /// Returns `true` if any live subscriber or view definition requires delta
4206 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4207 fn needs_emit_deltas(&self) -> bool {
4208 !self.view_store.is_empty()
4209 || self
4210 .subscriptions
4211 .iter()
4212 .any(|e| e.inner.upgrade().is_some())
4213 }
4214
4215 /// Convert a WAL record into `DbEvent` write events with the given seq.
4216 fn write_events_from_record(
4217 rec: &WalRecord,
4218 seq: u64,
4219 intern: &Interner,
4220 ids: &IdMap,
4221 ) -> Vec<DbEvent> {
4222 match rec {
4223 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4224 label: label.clone(),
4225 key: key.clone(),
4226 commit_seq: seq,
4227 }],
4228 // *Id arms run after a successful apply, so resolution can only
4229 // fail on a programming error. Skip the event rather than emit a
4230 // fabricated "" that clients can't tell from a real empty value
4231 // (mirrors event_from_record returning None).
4232 WalRecord::InsertNodeId { label, key, .. } => intern
4233 .resolve(*label)
4234 .map(|label| DbEvent::NodeInserted {
4235 label: label.to_string(),
4236 key: key.clone(),
4237 commit_seq: seq,
4238 })
4239 .into_iter()
4240 .collect(),
4241 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4242 key: key.clone(),
4243 field: field.clone(),
4244 commit_seq: seq,
4245 }],
4246 WalRecord::SetPropId { id, field, .. } => ids
4247 .key_of(*id)
4248 .zip(intern.resolve(*field))
4249 .map(|(key, field)| DbEvent::PropSet {
4250 key: key.to_string(),
4251 field: field.to_string(),
4252 commit_seq: seq,
4253 })
4254 .into_iter()
4255 .collect(),
4256 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4257 key: key.clone(),
4258 field: field.clone(),
4259 commit_seq: seq,
4260 }],
4261 WalRecord::InsertEdge {
4262 edge_type,
4263 src_key,
4264 dst_key,
4265 } => vec![DbEvent::EdgeInserted {
4266 edge_type: edge_type.clone(),
4267 src: src_key.clone(),
4268 dst: dst_key.clone(),
4269 commit_seq: seq,
4270 }],
4271 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4272 Some(DbEvent::EdgeInserted {
4273 edge_type: intern.resolve(*etype)?.to_string(),
4274 src: ids.key_of(*src)?.to_string(),
4275 dst: ids.key_of(*dst)?.to_string(),
4276 commit_seq: seq,
4277 })
4278 })()
4279 .into_iter()
4280 .collect(),
4281 WalRecord::DeleteEdge {
4282 edge_type,
4283 src_key,
4284 dst_key,
4285 } => vec![DbEvent::EdgeDeleted {
4286 edge_type: edge_type.clone(),
4287 src: src_key.clone(),
4288 dst: dst_key.clone(),
4289 commit_seq: seq,
4290 }],
4291 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4292 key: key.clone(),
4293 commit_seq: seq,
4294 }],
4295 WalRecord::Batch(inner) => inner
4296 .iter()
4297 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4298 .collect(),
4299 WalRecord::CreateRule { .. }
4300 | WalRecord::DeleteRule { .. }
4301 | WalRecord::RebuildRule { .. }
4302 | WalRecord::CreateView { .. }
4303 | WalRecord::DeleteView { .. }
4304 | WalRecord::EnableFulltext { .. }
4305 | WalRecord::DisableFulltext { .. }
4306 | WalRecord::EnableIndex { .. }
4307 | WalRecord::DisableIndex { .. }
4308 | WalRecord::Intern { .. }
4309 // History markers produce no DbEvent — the engine delta already
4310 // fired the EdgeFired/EdgeRetracted subscription events.
4311 | WalRecord::DerivedEdgeAdded { .. }
4312 | WalRecord::DerivedEdgeRetracted { .. }
4313 | WalRecord::RenameNode { .. } => vec![],
4314 }
4315 }
4316
4317 /// Subscribe to edge-fire and edge-retract events for one named rule.
4318 ///
4319 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4320 /// currently registered. Dropping the returned [`Subscription`] handle
4321 /// unregisters the subscriber — no further events are queued, no
4322 /// resources leak.
4323 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4324 if self.read_only {
4325 return Err(core_storage::GraphError::ReadOnly);
4326 }
4327 if !self.engine.rules().any(|r| r.name == rule_name) {
4328 return Err(core_storage::GraphError::RuleNotFound {
4329 name: rule_name.to_string(),
4330 });
4331 }
4332 let inner = SubInner::new(self.sub_capacity());
4333 self.subscriptions.push(SubEntry {
4334 filter: SubFilter::Rule(rule_name.to_string()),
4335 inner: std::sync::Arc::downgrade(&inner),
4336 });
4337 self.engine.set_emit_deltas(true);
4338 Ok(Subscription(inner))
4339 }
4340
4341 /// Subscribe to edge-fire and edge-retract events for **all** rules.
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_all_rules(&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::AllRules,
4353 inner: std::sync::Arc::downgrade(&inner),
4354 });
4355 self.engine.set_emit_deltas(true);
4356 Ok(Subscription(inner))
4357 }
4358
4359 /// Subscribe to write events: node insert/delete, prop set/remove.
4360 ///
4361 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4362 ///
4363 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4364 /// as-of instances never commit, so `distribute_events` never runs and the
4365 /// subscription would never deliver events.
4366 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4367 if self.read_only {
4368 return Err(core_storage::GraphError::ReadOnly);
4369 }
4370 let inner = SubInner::new(self.sub_capacity());
4371 self.subscriptions.push(SubEntry {
4372 filter: SubFilter::Writes,
4373 inner: std::sync::Arc::downgrade(&inner),
4374 });
4375 self.engine.set_emit_deltas(true);
4376 Ok(Subscription(inner))
4377 }
4378
4379 /// Subscribe to incremental Cypher query results.
4380 ///
4381 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4382 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4383 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4384 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4385 ///
4386 /// SKIP is not supported — it shifts the result window on every commit,
4387 /// causing spurious Added/Removed churn for rows whose data never changed.
4388 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4389 /// widens scope beyond the documented single-scan / single-hop subset.
4390 ///
4391 /// After each successful commit, the plan is **fully re-executed** and the
4392 /// result is diffed against the previous run. Added rows produce
4393 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4394 /// [`DbEvent::QueryRowRemoved`].
4395 ///
4396 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4397 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4398 /// is roadmap / Phase 5.
4399 ///
4400 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4401 /// as-of instances never commit, so `distribute_events` never runs and the
4402 /// subscription would never deliver events.
4403 ///
4404 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4405 /// or if the plan shape is not in the allowlist.
4406 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4407 if self.read_only {
4408 return Err(GraphError::ReadOnly);
4409 }
4410 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4411 detail: format!("lex: {e}"),
4412 })?;
4413 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4414 detail: format!("parse: {e}"),
4415 })?;
4416 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4417 detail: format!("plan: {e}"),
4418 })?;
4419 if !is_subscribable(&ops) {
4420 return Err(GraphError::QueryError {
4421 detail: "subscribe_query only supports allowlisted plan shapes: \
4422 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4423 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4424 Not supported: multi-hop Expand chains, SKIP (creates \
4425 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4426 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4427 Use LIMIT to bound re-execution cost."
4428 .to_string(),
4429 });
4430 }
4431 // Execute once to capture initial state (initial rows are not emitted as
4432 // events — the subscriber learns the baseline via the first query call).
4433 let empty_params = BTreeMap::new();
4434 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4435 GraphError::QueryError {
4436 detail: format!("execute: {e}"),
4437 }
4438 })?;
4439 let columns = initial.columns().to_vec();
4440 let prev_row_map = Self::result_to_row_map(&initial);
4441 let inner = SubInner::new(self.sub_capacity());
4442 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4443 // or unrecognized leading scan → None (always re-execute).
4444 let scan_label = extract_scan_label(&ops, &mut self.syms);
4445 self.query_subscriptions.push(QuerySubEntry {
4446 ops,
4447 columns,
4448 prev_row_map,
4449 inner: std::sync::Arc::downgrade(&inner),
4450 scan_label,
4451 });
4452 Ok(Subscription(inner))
4453 }
4454
4455 /// Queue capacity used for new subscriptions.
4456 fn sub_capacity(&self) -> usize {
4457 self.sub_capacity
4458 }
4459
4460 /// Override per-subscriber queue capacity for subsequently created
4461 /// subscriptions on this db instance.
4462 ///
4463 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4464 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4465 /// generating tens of thousands of events.
4466 ///
4467 /// This is a test-support escape hatch. Calling it in production reduces
4468 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4469 /// to discourage accidental production use.
4470 #[doc(hidden)]
4471 pub fn set_sub_capacity(&mut self, capacity: usize) {
4472 self.sub_capacity = capacity;
4473 }
4474
4475 // -----------------------------------------------------------------------
4476
4477 /// Start an atomic batch.
4478 ///
4479 /// The returned [`BatchBuilder`] borrows `self` mutably until
4480 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4481 /// validation, no WAL I/O. `commit` validates every queued op against
4482 /// live state plus preceding ops in this batch (duplicate key inside
4483 /// the batch is `Err`; an edge between two nodes created earlier in
4484 /// the batch is valid; `delete_node` then insert of the same key is a
4485 /// fresh identity). Validation never mutates the database. Any failure
4486 /// leaves WAL bytes and in-memory state identical to before `commit`.
4487 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4488 /// and each inner record is applied in order so rules fire per record.
4489 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4490 ///
4491 /// **Rule-window limitation:** batch validation cannot see edges that a
4492 /// rule created earlier in the *same* batch will derive at apply time, so
4493 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4494 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4495 /// is unaffected (idempotent apply, provenance intact). Create rules in
4496 /// their own batch, or sequentially, when later ops may touch derived
4497 /// edges.
4498 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4499 BatchBuilder {
4500 db: self,
4501 ops: Vec::new(),
4502 }
4503 }
4504
4505 /// Closure-style atomic write batch.
4506 ///
4507 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4508 /// then committing. All ops queued inside `build` are validated in order and
4509 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4510 /// once per inner record, in order, after commit — semantically identical to
4511 /// sequential single-op writes.
4512 ///
4513 /// **Error semantics — validate-then-apply.** `build` queues ops without
4514 /// touching the database. [`BatchBuilder::commit`] validates every op against
4515 /// live state plus earlier ops in this batch before writing anything. If op N
4516 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4517 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4518 /// changes. The database is identical to its state before `write_batch` was
4519 /// called.
4520 ///
4521 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4522 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4523 /// either fully applied or not at all. However, while applying a committed
4524 /// batch, concurrent readers may observe intermediate states as ops are applied
4525 /// sequentially in memory. There is no interactive transaction isolation in v1.
4526 /// This is documented as "crash-atomic write batches; no interactive
4527 /// transactions or read isolation."
4528 ///
4529 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4530 /// writes zero WAL bytes and returns `(0, 0)`.
4531 ///
4532 /// # Example
4533 ///
4534 /// ```rust,ignore
4535 /// let (nodes, edges) = db.write_batch(|b| {
4536 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4537 /// b.insert_node("Person", "bob", vec![]);
4538 /// b.insert_edge("KNOWS", "alice", "bob");
4539 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4540 /// b.delete_node("old_key");
4541 /// })?;
4542 /// // One fsync; on crash replay: all five ops land or none do.
4543 /// ```
4544 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4545 where
4546 C: FnOnce(&mut BatchBuilder<'_, F>),
4547 {
4548 let mut b = self.batch();
4549 build(&mut b);
4550 b.commit()
4551 }
4552
4553 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4554 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4555 /// inserts, so incremental fire sees the new rules. Per-row key problems
4556 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4557 /// `Err` means nothing was applied.
4558 ///
4559 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4560 /// distinct source labels sharing an FK field each get their own rule.
4561 pub fn ingest(
4562 &mut self,
4563 label: &str,
4564 rows: Vec<BTreeMap<String, Value>>,
4565 opts: &IngestOptions,
4566 ) -> Result<IngestReport> {
4567 self.ingest_with_edges(label, rows, opts, &[])
4568 }
4569
4570 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4571 /// A failing edge rejects the whole request; nothing is applied.
4572 pub fn ingest_with_edges(
4573 &mut self,
4574 label: &str,
4575 rows: Vec<BTreeMap<String, Value>>,
4576 opts: &IngestOptions,
4577 edges: &[(String, String, String)],
4578 ) -> Result<IngestReport> {
4579 crate::ingest::run(self, label, rows, opts, edges)
4580 }
4581
4582 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4583 ///
4584 /// JSON `null` fields are silently omitted (not stored, not a row error).
4585 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4586 /// Parse failures and a top-level value that is not an array of objects
4587 /// return [`GraphError::IngestError`].
4588 pub fn ingest_json(
4589 &mut self,
4590 label: &str,
4591 json: &str,
4592 opts: &IngestOptions,
4593 ) -> Result<IngestReport> {
4594 crate::ingest::run_json(self, label, json, opts)
4595 }
4596
4597 fn commit_logged_batch(
4598 &mut self,
4599 ops: Vec<BatchOp>,
4600 ingest: Option<(String, usize)>,
4601 // Two-source rule: write_batch_authz threads authz here directly (never
4602 // touches pending_write_authz); query_write_authz sets the field instead
4603 // and passes None. Only one source is non-None per call.
4604 param_authz: Option<WriteAuthz>,
4605 ) -> Result<(usize, usize)> {
4606 // Read-only guard: catches empty-batch calls before the early-return
4607 // that skips log_then_apply_with, ensuring all mutation entry points fail.
4608 if self.read_only {
4609 return Err(GraphError::ReadOnly);
4610 }
4611 // Ensure provenance is decoded before MutPreview accesses it
4612 // (note_delete_rule / is_rule_owned may call engine.provenance()).
4613 self.engine.ensure_provenance_loaded_mut();
4614
4615 // ── Authz pre-check ──────────────────────────────────────────────────
4616 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4617 // produces no WAL frame (all-or-nothing at the authz boundary extends
4618 // the existing validate-then-apply contract to role-scope checks).
4619 //
4620 // `batch_created` tracks key→label for nodes created by earlier ops in
4621 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4622 // as visible without needing to call `self.ids.get` on not-yet-committed
4623 // keys (they won't be there yet).
4624 //
4625 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4626 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4627 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4628 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4629 if let Some(ref authz) = authz_opt {
4630 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4631 for op in &ops {
4632 self.check_single_op_authz(authz, op, &batch_created)?;
4633 // Update batch_created after a passing authz check so that
4634 // subsequent ops in this batch see the nodes as "about to exist".
4635 match op {
4636 BatchOp::InsertNode { label, key, .. } => {
4637 // Only track genuinely new nodes (absent from the
4638 // snapshot at authz-check time). A pre-existing visible
4639 // key would be a DuplicateKey — not a real creation —
4640 // so MutPreview handles it. Letting it into batch_created
4641 // would allow a later SetProp to bypass update_labels
4642 // via the "batch-created → always updatable" ruling
4643 // (delete+recreate exploit, fix for I1 review round 2).
4644 //
4645 // Accepted edge: for a delete+recreate-with-different-
4646 // label batch, node_status resolves the pre-delete
4647 // (store) label for any subsequent update checks. This
4648 // grants no net-new capability — a role that can delete+
4649 // create can already place arbitrary props via
4650 // InsertNode's own props field.
4651 if self.ids.get(key.as_str()).is_none() {
4652 batch_created.insert(key.clone(), label.clone());
4653 }
4654 }
4655 BatchOp::InsertEdgeUpsert {
4656 placeholder_label,
4657 src_key,
4658 dst_key,
4659 ..
4660 } => {
4661 // Both endpoints will be created if not already in store.
4662 for ep_key in [src_key, dst_key] {
4663 if self.ids.get(ep_key.as_str()).is_none()
4664 && !batch_created.contains_key(ep_key.as_str())
4665 {
4666 batch_created.insert(ep_key.clone(), placeholder_label.clone());
4667 }
4668 }
4669 }
4670 _ => {}
4671 }
4672 }
4673 }
4674
4675 let recs = {
4676 let mut preview = MutPreview::new(self);
4677 let mut recs = Vec::with_capacity(ops.len());
4678 for op in ops {
4679 match op {
4680 BatchOp::InsertNode { label, key, props } => {
4681 preview.check_insert_node(&key)?;
4682 preview.note_insert_node(&key, &props);
4683 recs.push(WalRecord::InsertNode { label, key, props });
4684 }
4685 BatchOp::InsertEdge {
4686 edge_type,
4687 src_key,
4688 dst_key,
4689 } => {
4690 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4691 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4692 recs.push(WalRecord::InsertEdge {
4693 edge_type,
4694 src_key,
4695 dst_key,
4696 });
4697 }
4698 }
4699 BatchOp::SetProp { key, field, value } => {
4700 preview.check_live_key(&key)?;
4701 preview.note_set_prop(&key, &field, &value);
4702 recs.push(WalRecord::SetProp { key, field, value });
4703 }
4704 BatchOp::RemoveProp { key, field } => {
4705 if preview.prepare_remove_prop(&key, &field)? {
4706 preview.note_remove_prop(&key, &field);
4707 recs.push(WalRecord::RemoveProp { key, field });
4708 }
4709 }
4710 BatchOp::DeleteEdge {
4711 edge_type,
4712 src_key,
4713 dst_key,
4714 } => {
4715 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4716 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4717 recs.push(WalRecord::DeleteEdge {
4718 edge_type,
4719 src_key,
4720 dst_key,
4721 });
4722 }
4723 }
4724 BatchOp::DeleteNode { key } => {
4725 preview.check_live_key(&key)?;
4726 preview.note_delete_node(&key);
4727 recs.push(WalRecord::DeleteNode { key });
4728 }
4729 BatchOp::CreateRule(def) => {
4730 preview.check_create_rule(&def)?;
4731 let def_bytes =
4732 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4733 detail: format!("serialize rule: {e}"),
4734 })?;
4735 preview.note_create_rule(&def);
4736 recs.push(WalRecord::CreateRule { def_bytes });
4737 }
4738 BatchOp::DeleteRule { name } => {
4739 preview.check_delete_rule(&name)?;
4740 preview.note_delete_rule(&name);
4741 recs.push(WalRecord::DeleteRule { name });
4742 }
4743 BatchOp::RenameNode { old_key, new_key } => {
4744 preview.check_rename_node(&old_key, &new_key)?;
4745 preview.note_rename_node(&old_key, &new_key);
4746 recs.push(WalRecord::RenameNode { old_key, new_key });
4747 }
4748 BatchOp::InsertEdgeUpsert {
4749 edge_type,
4750 src_key,
4751 dst_key,
4752 placeholder_label,
4753 } => {
4754 // Auto-create any missing endpoints as plain InsertNode ops.
4755 // Rules fire and last-change is updated for each created node.
4756 for key in [&src_key, &dst_key] {
4757 if !preview.has_key(key) {
4758 preview.check_insert_node(key)?;
4759 preview.note_insert_node(key, &[]);
4760 recs.push(WalRecord::InsertNode {
4761 label: placeholder_label.clone(),
4762 key: key.clone(),
4763 props: vec![],
4764 });
4765 }
4766 }
4767 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4768 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4769 recs.push(WalRecord::InsertEdge {
4770 edge_type,
4771 src_key,
4772 dst_key,
4773 });
4774 }
4775 }
4776 }
4777 }
4778 recs
4779 };
4780 if recs.is_empty() {
4781 return Ok((0, 0));
4782 }
4783 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4784 // *Id form, so only the dense variants can appear in `recs` here.
4785 let recs = self.rewrite_wal_dense(recs)?;
4786 let nodes_inserted = recs
4787 .iter()
4788 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4789 .count();
4790 let edges_inserted = recs
4791 .iter()
4792 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4793 .count();
4794 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4795 // under Strict. Pass self.fsync directly so Strict stays Strict —
4796 // wal_needs_sync(Strict, _) always returns true regardless of op count.
4797 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4798 // short-circuit on single-op batches and silently skip the fsync.
4799 // Batched fsyncs only for multi-op batches; Relaxed always skips.
4800 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4801 Ok((nodes_inserted, edges_inserted))
4802 }
4803
4804 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4805 self.commit_logged_batch(ops, None, None)
4806 }
4807
4808 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4809 /// and the group-commit drain thread, which do a single group fsync later.
4810 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4811 // Restore fsync policy even on panic via a raw-pointer drop guard.
4812 // A panic here would poison the RwLock anyway, but the correct policy
4813 // must be in place if the guard is ever unwrapped.
4814 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4815 impl Drop for RestoreFsync {
4816 fn drop(&mut self) {
4817 // SAFETY: the pointer is valid for the full duration of
4818 // commit_batch_nosync; the guard is dropped before the frame
4819 // returns, and GraphDb outlives this frame.
4820 unsafe {
4821 *self.0 = self.1;
4822 }
4823 }
4824 }
4825 let saved = self.fsync;
4826 // SAFETY: raw pointer into self; guard dropped within this frame.
4827 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4828 self.fsync = FsyncPolicy::Relaxed;
4829 self.commit_logged_batch(ops, None, None)
4830 }
4831
4832 /// Commit multiple op-batches as a **group**: each submission gets its own
4833 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4834 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4835 ///
4836 /// # Durability semantics
4837 ///
4838 /// A crash before the group fsync may lose **all** submissions in the group.
4839 /// A crash after the group fsync preserves all of them. No submission is
4840 /// ever torn: each WAL frame is either fully applied on replay or dropped
4841 /// in its entirety (CRC-protected frame boundaries).
4842 ///
4843 /// Events and subscription notifications fire per-submission immediately
4844 /// after apply, which may be before the group fsync. From a subscriber's
4845 /// perspective this is equivalent to the `Relaxed` durability window.
4846 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4847 /// fsync, so from their perspective durability is fully guaranteed.
4848 ///
4849 /// # MVCC interplay
4850 ///
4851 /// Each submission records its own `CommitDelta`; the fold-every-K counter
4852 /// increments per submission (not per group), preserving existing reader
4853 /// snapshot semantics.
4854 ///
4855 /// # Returns
4856 ///
4857 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4858 /// in order. Failures are per-submission (validation errors); the group
4859 /// fsync error (if any) is returned as the second tuple element.
4860 pub fn commit_group(
4861 &mut self,
4862 groups: Vec<Vec<BatchOp>>,
4863 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4864 let mut results = Vec::with_capacity(groups.len());
4865 for ops in groups {
4866 results.push(self.commit_batch_nosync(ops));
4867 }
4868 let any_ok = results.iter().any(|r| r.is_ok());
4869 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4870 self.fs
4871 .sync(core_storage::fs::FileId::Wal)
4872 .map_err(GraphError::Io)
4873 .err()
4874 } else {
4875 None
4876 };
4877 (results, sync_err)
4878 }
4879
4880 /// Like [`commit_group`] but skips the group fsync entirely.
4881 ///
4882 /// Used by the drain thread to apply submissions under the write lock and
4883 /// then perform the single fsync OUTSIDE the lock (via
4884 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4885 /// to concurrent readers.
4886 pub fn commit_group_nosync(
4887 &mut self,
4888 groups: Vec<Vec<BatchOp>>,
4889 ) -> Vec<Result<(usize, usize)>> {
4890 let mut results = Vec::with_capacity(groups.len());
4891 for ops in groups {
4892 results.push(self.commit_batch_nosync(ops));
4893 }
4894 results
4895 }
4896
4897 pub fn insert_node(
4898 &mut self,
4899 label: &str,
4900 key: &str,
4901 props: Vec<(String, Value)>,
4902 ) -> Result<()> {
4903 if self.read_only {
4904 return Err(GraphError::ReadOnly);
4905 }
4906 MutPreview::new(self).check_insert_node(key)?;
4907 self.log_dense(vec![WalRecord::InsertNode {
4908 label: label.into(),
4909 key: key.into(),
4910 props,
4911 }])
4912 }
4913
4914 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4915 if self.read_only {
4916 return Err(GraphError::ReadOnly);
4917 }
4918 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4919 return Ok(false);
4920 }
4921 self.log_dense(vec![WalRecord::InsertEdge {
4922 edge_type: edge_type.into(),
4923 src_key: src_key.into(),
4924 dst_key: dst_key.into(),
4925 }])?;
4926 Ok(true)
4927 }
4928
4929 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4930 if self.read_only {
4931 return Err(GraphError::ReadOnly);
4932 }
4933 if let Some(view_name) = self.view_store.view_for_prop(field) {
4934 return Err(GraphError::ViewPropReadOnly {
4935 view_name: view_name.to_string(),
4936 });
4937 }
4938 MutPreview::new(self).check_live_key(key)?;
4939 self.log_dense(vec![WalRecord::SetProp {
4940 key: key.into(),
4941 field: field.into(),
4942 value,
4943 }])
4944 }
4945
4946 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4947 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4948 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4949 if self.read_only {
4950 return Err(GraphError::ReadOnly);
4951 }
4952 if let Some(view_name) = self.view_store.view_for_prop(field) {
4953 return Err(GraphError::ViewPropReadOnly {
4954 view_name: view_name.to_string(),
4955 });
4956 }
4957 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4958 return Ok(false);
4959 }
4960 self.log_then_apply(WalRecord::RemoveProp {
4961 key: key.into(),
4962 field: field.into(),
4963 })?;
4964 Ok(true)
4965 }
4966
4967 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4968 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4969 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4970 /// (the rule would just put the edge back; delete or change the rule).
4971 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4972 if self.read_only {
4973 return Err(GraphError::ReadOnly);
4974 }
4975 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4976 return Ok(false);
4977 }
4978 self.log_then_apply(WalRecord::DeleteEdge {
4979 edge_type: edge_type.into(),
4980 src_key: src_key.into(),
4981 dst_key: dst_key.into(),
4982 })?;
4983 Ok(true)
4984 }
4985
4986 /// Delete a live node. Unknown or already-tombstoned keys are
4987 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4988 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4989 /// (crash window) is a clean no-op.
4990 ///
4991 /// Returns a [`DeleteReport`] with counts of manual and derived edges
4992 /// removed (computed from live state before the deletion is applied).
4993 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4994 if self.read_only {
4995 return Err(GraphError::ReadOnly);
4996 }
4997 // Provenance must be loaded before we query provenance_touching.
4998 self.engine.ensure_provenance_loaded_mut();
4999 let id = self
5000 .ids
5001 .get(key)
5002 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5003
5004 // Count edges before the delete is applied so we can report counts.
5005 let derived_set: BTreeSet<(u32, u32, u32)> = self
5006 .engine
5007 .provenance_touching(id)
5008 .map(|(_, etype, src, dst)| (etype, src, dst))
5009 .collect();
5010 let derived_edges = derived_set.len() as u64;
5011
5012 let mut total_topo = 0u64;
5013 let tv = self.topo_view();
5014 for et in tv.etypes() {
5015 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5016 + tv.neighbors(et, Direction::In, id).len() as u64;
5017 }
5018 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5019 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5020 // The subtraction remains correct because both counts include both directions.
5021 let manual_edges = total_topo.saturating_sub(derived_edges);
5022
5023 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5024 Ok(DeleteReport {
5025 manual_edges,
5026 derived_edges,
5027 })
5028 }
5029
5030 /// Rename a live node's key. The dense id (and therefore all edges,
5031 /// props, history, and last-change tracking) is unaffected.
5032 ///
5033 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5034 /// Returns `Err(DuplicateKey)` if `new` is already live.
5035 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5036 if self.read_only {
5037 return Err(GraphError::ReadOnly);
5038 }
5039 MutPreview::new(self).check_rename_node(old, new)?;
5040 self.log_then_apply(WalRecord::RenameNode {
5041 old_key: old.into(),
5042 new_key: new.into(),
5043 })
5044 }
5045
5046 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5047 /// `None` if the rule does not exist or is not approximate.
5048 ///
5049 /// The drift counter increments on IVF insert/remove after the last fit.
5050 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5051 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5052 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5053 // SideIvfExport = (centroids, node→cluster, drift)
5054 self.engine
5055 .export_ivf_state()
5056 .remove(rule)
5057 .map(|(_src, dst)| dst.2)
5058 }
5059
5060 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5061 /// Validation and duplicate-name check run before logging so invalid rules
5062 /// never enter the WAL.
5063 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5064 if self.read_only {
5065 return Err(GraphError::ReadOnly);
5066 }
5067 MutPreview::new(self).check_create_rule(&def)?;
5068 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5069 detail: format!("serialize rule: {e}"),
5070 })?;
5071 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5072 }
5073
5074 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5075 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5076 if self.read_only {
5077 return Err(GraphError::ReadOnly);
5078 }
5079 MutPreview::new(self).check_delete_rule(name)?;
5080 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5081 }
5082
5083 /// Return a snapshot of all registered rules.
5084 pub fn rules(&self) -> Vec<RuleDef> {
5085 self.engine.rules().cloned().collect()
5086 }
5087
5088 // -----------------------------------------------------------------------
5089 // Rule suggestion API
5090 // -----------------------------------------------------------------------
5091
5092 /// Profile the database and suggest linking rules with previewed edge counts.
5093 ///
5094 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5095 /// sampling. Suggestions are sorted by estimated edge count (descending).
5096 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5097 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5098 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5099 }
5100
5101 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5102 /// reproducibility. Same seed + same data = identical output.
5103 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5104 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5105 .suggestions
5106 }
5107
5108 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5109 ///
5110 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5111 /// and a `truncated` flag indicating whether the global budget fired before all
5112 /// candidates were evaluated.
5113 pub fn suggest_rules_with_config(
5114 &self,
5115 config: &core_rules::suggest::SuggestConfig,
5116 seed: u64,
5117 ) -> core_rules::SuggestReport {
5118 use std::collections::BTreeMap;
5119
5120 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5121 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5122 for id in 0..self.ids.len() as u32 {
5123 let Some(key) = self.ids.key_of(id) else {
5124 continue;
5125 };
5126 let Some(&sym) = self.labels.get(id as usize) else {
5127 continue;
5128 };
5129 if sym == u32::MAX {
5130 continue; // tombstoned
5131 }
5132 let Some(label) = self.syms.resolve(sym) else {
5133 continue;
5134 };
5135 label_nodes
5136 .entry(label.to_string())
5137 .or_default()
5138 .push((id, key.to_string()));
5139 }
5140
5141 let existing = self.rules();
5142 let pv = build_props_view(&self.props, &self.base);
5143 let all_fields: Vec<String> = pv.field_names();
5144
5145 core_rules::suggest::suggest_rules(
5146 &label_nodes,
5147 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5148 &all_fields,
5149 &existing,
5150 config,
5151 seed,
5152 )
5153 }
5154
5155 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5156 /// plus later mutations replay identically (rebuild is a pure function
5157 /// of state).
5158 ///
5159 /// Only exit from the tripped latch: if the full desired set fits the
5160 /// budget, it is applied completely and `tripped` clears; if it still
5161 /// exceeds the budget, provenance is left untouched and `tripped` stays
5162 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5163 /// Unknown rule → `RuleNotFound`, nothing logged.
5164 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5165 if self.read_only {
5166 return Err(GraphError::ReadOnly);
5167 }
5168 if !self.engine.rules().any(|r| r.name == name) {
5169 return Err(GraphError::RuleNotFound { name: name.into() });
5170 }
5171 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5172 }
5173
5174 // -----------------------------------------------------------------------
5175 // Materialized view API
5176 // -----------------------------------------------------------------------
5177
5178 /// Register a new materialized property view, backfill its values for all
5179 /// existing nodes, and WAL-log the definition.
5180 ///
5181 /// # Errors
5182 /// - `ReadOnly`: called on an as-of instance.
5183 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5184 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5185 if self.read_only {
5186 return Err(GraphError::ReadOnly);
5187 }
5188 // Pre-validate before WAL write.
5189 def.validate()
5190 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5191 if self.view_store.has_view(&def.name) {
5192 return Err(GraphError::RuleInvalid {
5193 detail: format!("view {:?} already exists", def.name),
5194 });
5195 }
5196 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5197 return Err(GraphError::RuleInvalid {
5198 detail: format!(
5199 "view_prop {:?} is already used by view {:?}",
5200 def.view_prop, existing
5201 ),
5202 });
5203 }
5204 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5205 detail: format!("serialize view: {e}"),
5206 })?;
5207 // Enable delta accumulation before the view is registered so subsequent
5208 // incremental edge events reach view maintenance from this point onward.
5209 // (The backfill inside create_view reads topo directly; it does not rely
5210 // on pending deltas.)
5211 self.engine.set_emit_deltas(true);
5212 self.log_then_apply(WalRecord::CreateView { def_bytes })
5213 }
5214
5215 /// Remove a named view and delete its values from every node.
5216 ///
5217 /// # Errors
5218 /// - `ReadOnly`: called on an as-of instance.
5219 /// - `RuleNotFound`: view does not exist.
5220 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5221 if self.read_only {
5222 return Err(GraphError::ReadOnly);
5223 }
5224 if !self.view_store.has_view(name) {
5225 return Err(GraphError::RuleNotFound { name: name.into() });
5226 }
5227 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5228 // After deletion, disable accumulation if no listeners remain.
5229 if !self.needs_emit_deltas() {
5230 self.engine.set_emit_deltas(false);
5231 }
5232 result
5233 }
5234
5235 /// Snapshot of all registered view definitions.
5236 pub fn views(&self) -> Vec<ViewDef> {
5237 self.view_store.views().cloned().collect()
5238 }
5239
5240 // -----------------------------------------------------------------------
5241 // Full-text-lite API
5242 // -----------------------------------------------------------------------
5243
5244 /// Enable full-text indexing for all nodes of `label` on property `field`.
5245 ///
5246 /// After this call, every subsequent write to `(label, field)` is reflected
5247 /// in the index incrementally. Existing nodes are backfilled immediately.
5248 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5249 /// from scratch on re-open (no snapshot format changes).
5250 ///
5251 /// # Errors
5252 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5253 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5254 pub fn enable_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::RuleInvalid {
5260 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5261 });
5262 }
5263 self.log_then_apply(WalRecord::EnableFulltext {
5264 label: label.into(),
5265 field: field.into(),
5266 })
5267 }
5268
5269 /// Disable full-text indexing for `(label, field)` and drop its postings.
5270 ///
5271 /// # Errors
5272 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5273 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5274 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5275 if self.read_only {
5276 return Err(GraphError::ReadOnly);
5277 }
5278 if !self.fulltext.is_enabled(label, field) {
5279 return Err(GraphError::RuleNotFound {
5280 name: format!("fulltext({label},{field})"),
5281 });
5282 }
5283 self.log_then_apply(WalRecord::DisableFulltext {
5284 label: label.into(),
5285 field: field.into(),
5286 })
5287 }
5288
5289 /// Whether `(label, field)` is currently indexed for full-text search.
5290 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5291 self.fulltext.is_enabled(label, field)
5292 }
5293
5294 /// Every `(label, field)` pair with a live full-text index, sorted.
5295 ///
5296 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5297 /// declares which nodes are *indexed*, so callers that want to search
5298 /// everything indexed should query each distinct field once.
5299 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5300 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5301 v.sort();
5302 v
5303 }
5304
5305 /// Enable an equality index for all nodes of `label` on scalar property
5306 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5307 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5308 /// declaration persists via WAL and the postings rebuild on re-open.
5309 ///
5310 /// # Errors
5311 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5312 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5313 pub fn enable_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::RuleInvalid {
5319 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5320 });
5321 }
5322 self.log_then_apply(WalRecord::EnableIndex {
5323 label: label.into(),
5324 field: field.into(),
5325 })
5326 }
5327
5328 /// Disable the equality index for `(label, field)` and drop its postings.
5329 ///
5330 /// # Errors
5331 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5332 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5333 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5334 if self.read_only {
5335 return Err(GraphError::ReadOnly);
5336 }
5337 if !self.prop_index.is_enabled(label, field) {
5338 return Err(GraphError::RuleNotFound {
5339 name: format!("index({label},{field})"),
5340 });
5341 }
5342 self.log_then_apply(WalRecord::DisableIndex {
5343 label: label.into(),
5344 field: field.into(),
5345 })
5346 }
5347
5348 /// Whether `(label, field)` currently has an equality index.
5349 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5350 self.prop_index.is_enabled(label, field)
5351 }
5352
5353 /// Search a full-text-indexed field.
5354 ///
5355 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5356 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5357 ///
5358 /// **Query syntax:**
5359 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5360 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5361 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5362 /// - `AND` keyword is accepted explicitly and is the default.
5363 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5364 ///
5365 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5366 /// Pin: this is the documented, tested, stable behavior for v1.
5367 ///
5368 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5369 /// in-memory and proportional to total indexed text across all enabled fields.
5370 ///
5371 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5372 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5373 /// key ascending for deterministic tiebreaking.
5374 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5375 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5376 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5377 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5378 // which diverges from key order when nodes were not inserted in key-lex order.
5379 let mut results: Vec<(String, f64)> = self
5380 .fulltext
5381 .search(field, query, 0)
5382 .into_iter()
5383 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5384 .collect();
5385 results.sort_by(|a, b| {
5386 b.1.partial_cmp(&a.1)
5387 .unwrap_or(std::cmp::Ordering::Equal)
5388 .then(a.0.cmp(&b.0))
5389 });
5390 results
5391 }
5392
5393 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5394 ///
5395 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5396 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5397 /// them with RRF using a fixed constant of 60.
5398 ///
5399 /// ```text
5400 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5401 /// ```
5402 ///
5403 /// Returns the top `k` nodes by fused score, ties broken by node key
5404 /// ascending (deterministic).
5405 ///
5406 /// # Vector leg fallback
5407 ///
5408 /// When `query_vec` is empty the vector leg is skipped entirely and
5409 /// results are ranked by the text list alone through the same RRF path
5410 /// (each text result scores `1/(60 + rank)` from that single list).
5411 ///
5412 /// When `label` is `None`, the vector leg **always** returns empty results.
5413 /// Internally `label` is mapped to `""`, which does not match any rule-created
5414 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5415 /// the brute-force fallback finds no nodes with an empty label. The fused
5416 /// ranking is therefore text-only in this case.
5417 pub fn search_hybrid(
5418 &self,
5419 text_field: &str,
5420 query_text: &str,
5421 vector_field: &str,
5422 query_vec: &[f64],
5423 label: Option<&str>,
5424 k: usize,
5425 ) -> Vec<(String, f64)> {
5426 use std::collections::HashMap;
5427
5428 const RRF_K: f64 = 60.0;
5429 let pool = 4 * k;
5430
5431 // Accumulate per-node RRF scores.
5432 let mut scores: HashMap<String, f64> = HashMap::new();
5433
5434 // Text leg.
5435 let text_hits = self.search(text_field, query_text);
5436 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5437 let rank = (rank0 + 1) as f64;
5438 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5439 }
5440
5441 // Vector leg (skipped when query_vec is empty).
5442 if !query_vec.is_empty() {
5443 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5444 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5445 let rank = (rank0 + 1) as f64;
5446 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5447 }
5448 }
5449
5450 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5451 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5452 ranked.sort_by(|a, b| {
5453 b.1.partial_cmp(&a.1)
5454 .unwrap_or(std::cmp::Ordering::Equal)
5455 .then(a.0.cmp(&b.0))
5456 });
5457 ranked.truncate(k);
5458 ranked
5459 }
5460
5461 /// For DST/testing: scratch BM25 search over live nodes without the index.
5462 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5463 /// returns BM25-ranked results.
5464 ///
5465 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5466 /// `scratch_search(field, q)` at every quiescent state.
5467 #[doc(hidden)]
5468 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5469 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5470 use std::collections::BTreeMap;
5471
5472 let groups = parse_query(query);
5473 if groups.is_empty() {
5474 return vec![];
5475 }
5476
5477 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5478 struct NodeData {
5479 key: String,
5480 /// stemmed_token → positions (sorted)
5481 tokens: BTreeMap<String, Vec<u32>>,
5482 dl: u32,
5483 }
5484
5485 let mut nodes: Vec<NodeData> = Vec::new();
5486 for id in 0..self.ids.len() as u32 {
5487 let Some(key) = self.ids.key_of(id) else {
5488 continue;
5489 };
5490 let Some(&sym) = self.labels.get(id as usize) else {
5491 continue;
5492 };
5493 if sym == u32::MAX {
5494 continue;
5495 }
5496 let label = match self.syms.resolve(sym) {
5497 Some(l) => l,
5498 None => continue,
5499 };
5500 if !self.fulltext.is_enabled(label, field) {
5501 continue;
5502 }
5503 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5504 continue;
5505 };
5506 // Use value_tokens_stemmed_with_positions so list elements are
5507 // separated by POSITION_GAP — identical to the index path, which
5508 // prevents phrase queries from matching across element boundaries.
5509 let stemmed_with_pos = match &value {
5510 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5511 _ => continue,
5512 };
5513 let dl = stemmed_with_pos.len() as u32;
5514 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5515 for (tok, pos) in stemmed_with_pos {
5516 tok_map.entry(tok).or_default().push(pos);
5517 }
5518 nodes.push(NodeData {
5519 key: key.to_string(),
5520 tokens: tok_map,
5521 dl,
5522 });
5523 }
5524
5525 if nodes.is_empty() {
5526 return vec![];
5527 }
5528
5529 // --- BM25 corpus stats ---
5530 let n = nodes.len() as f64;
5531 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5532 // df per stemmed token across all live indexed nodes.
5533 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5534 for nd in &nodes {
5535 for tok in nd.tokens.keys() {
5536 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5537 }
5538 }
5539
5540 const K1: f64 = 1.2;
5541 const B: f64 = 0.75;
5542
5543 // --- Pass 2: score each node against each OR-group ---
5544 let mut results: Vec<(String, f64)> = Vec::new();
5545 for nd in &nodes {
5546 let dl = nd.dl as f64;
5547 let mut total_score = 0.0f64;
5548
5549 'group: for group in &groups {
5550 let mut group_score = 0.0f64;
5551
5552 for term in group {
5553 if term.negated {
5554 // Negated: if doc has this stemmed token → group fails.
5555 let present = if term.prefix {
5556 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5557 } else {
5558 nd.tokens.contains_key(term.token.as_str())
5559 };
5560 if present {
5561 continue 'group;
5562 }
5563 continue;
5564 }
5565 if term.prefix {
5566 // Prefix: sum BM25 for all matching stemmed tokens.
5567 let mut prefix_matched = false;
5568 for (tok, positions) in &nd.tokens {
5569 if tok.starts_with(term.token.as_str()) {
5570 let tf = positions.len() as f64;
5571 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5572 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5573 let tf_norm =
5574 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5575 group_score += idf * tf_norm;
5576 prefix_matched = true;
5577 }
5578 }
5579 if !prefix_matched {
5580 continue 'group;
5581 }
5582 } else {
5583 // term.token is already stemmed by parse_query; use directly.
5584 match nd.tokens.get(term.token.as_str()) {
5585 None => continue 'group,
5586 Some(positions) => {
5587 let tf = positions.len() as f64;
5588 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5589 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5590 let tf_norm =
5591 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5592 group_score += idf * tf_norm;
5593 }
5594 }
5595 }
5596 }
5597
5598 if group_score > 0.0 {
5599 total_score += group_score;
5600 }
5601 }
5602
5603 if total_score > 0.0 {
5604 results.push((nd.key.clone(), total_score));
5605 }
5606 }
5607
5608 results.sort_by(|a, b| {
5609 b.1.partial_cmp(&a.1)
5610 .unwrap_or(std::cmp::Ordering::Equal)
5611 .then(a.0.cmp(&b.0))
5612 });
5613 results
5614 }
5615
5616 /// Return the current view-maintained value of `view_prop` for node `key`.
5617 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5618 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5619 let id = self.ids.get(key)?;
5620 self.props_view()
5621 .get(id, view_prop)
5622 .map(|vr| vr.into_value())
5623 }
5624
5625 /// For testing / DST oracle: scratch recompute of a view value for one node.
5626 ///
5627 /// Returns `None` if the node does not exist, the view does not exist, or
5628 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5629 #[doc(hidden)]
5630 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5631 let node = self.ids.get(key)?;
5632 let def = self.view_store.views().find(|v| v.name == view_name)?;
5633 // Use TopologyView so that NeighborAgg sees base + overlay edges
5634 // without materialising a temporary Topology (I1).
5635 let topo_view = self.topo_view();
5636 core_rules::views::compute_view_value(
5637 def,
5638 node,
5639 self.props_view(),
5640 &topo_view,
5641 &self.ids,
5642 &self.syms,
5643 &self.labels,
5644 )
5645 }
5646
5647 // -----------------------------------------------------------------------
5648 // Graph algorithm API
5649 // -----------------------------------------------------------------------
5650
5651 /// Run PageRank over the unified topology (manual + derived edges).
5652 ///
5653 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5654 /// ascending). Set `config.edge_type` to restrict to one edge type.
5655 /// `config.converged` is `true` only when the power iteration converged
5656 /// within `config.max_iters` and within any time budget.
5657 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5658 let topo = build_topo_view(&self.topo, &self.base);
5659 crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5660 }
5661
5662 /// Weakly-connected components over the unified topology (treated as
5663 /// undirected regardless of how edges were inserted).
5664 ///
5665 /// Component IDs are the key of the smallest member in the component
5666 /// (deterministic). Result sorted by (component_id, key).
5667 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5668 let topo = build_topo_view(&self.topo, &self.base);
5669 crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5670 }
5671
5672 /// Degree centrality for every live node.
5673 ///
5674 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5675 /// `AlgoDir::Both` = out + in (total directed degree).
5676 ///
5677 /// For one-shot ranking use this; for a live property updated on every
5678 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5679 pub fn degree_centrality(
5680 &self,
5681 config: &crate::algo::DegreeConfig,
5682 ) -> crate::algo::DegreeReport {
5683 let topo = build_topo_view(&self.topo, &self.base);
5684 crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5685 }
5686
5687 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5688 /// atomically via a single write-batch (one WAL frame, one fsync).
5689 ///
5690 /// # Errors
5691 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5692 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5693 /// (collision check mirrors `create_view`).
5694 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5695 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5696 if self.read_only {
5697 return Err(GraphError::ReadOnly);
5698 }
5699 // Collision check: refuse if prop_name is view-managed.
5700 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5701 return Err(GraphError::RuleInvalid {
5702 detail: format!(
5703 "prop {:?} is managed by view {:?} and cannot be written as scores",
5704 prop_name, view_name
5705 ),
5706 });
5707 }
5708 // Refuse if prop_name is a view name itself (confusing namespace collision).
5709 if self.view_store.has_view(prop_name) {
5710 return Err(GraphError::RuleInvalid {
5711 detail: format!(
5712 "prop_name {:?} collides with an existing view name",
5713 prop_name
5714 ),
5715 });
5716 }
5717 // Write all scores in a single crash-atomic batch.
5718 self.write_batch(|b| {
5719 for (key, score) in scores {
5720 b.set_prop(key, prop_name, Value::Float(*score));
5721 }
5722 })?;
5723 Ok(())
5724 }
5725
5726 /// Return the value of `field` for the node with key `key`, or `None` if
5727 /// the node or field is absent. Reads through the overlay-over-base
5728 /// `ColumnsView`, materialising base values on demand (zero heap cost for
5729 /// overlay hits; one clone per base hit).
5730 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5731 let id = self.ids.get(key)?;
5732 self.props_view().get(id, field).map(|vr| vr.into_value())
5733 }
5734
5735 pub fn has_node(&self, key: &str) -> bool {
5736 self.ids.get(key).is_some()
5737 }
5738
5739 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5740 pub(crate) fn ids(&self) -> &IdMap {
5741 &self.ids
5742 }
5743
5744 // -----------------------------------------------------------------------
5745 // RBAC role resolution
5746 // -----------------------------------------------------------------------
5747
5748 /// Parse `roles.json` bytes from `fs`.
5749 ///
5750 /// Return values:
5751 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5752 /// and valid; in both cases `mask_for_role` uses the
5753 /// list normally (an absent file means no roles defined).
5754 /// `Ok(None)` — file present but corrupt or unrecognised version
5755 /// → poisoned state; `mask_for_role` returns `Err` for
5756 /// any role name until the file is fixed and the DB
5757 /// re-opened (or `apply_schema` is called to repair it).
5758 ///
5759 /// Note: `None` signals corruption, not absence — the opposite of what an
5760 /// optional "file missing" convention would suggest. The open path stores
5761 /// this result on `db.roles` directly.
5762 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5763 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5764 if bytes.is_empty() {
5765 // Empty bytes means either the file is absent or zero-byte — both
5766 // are treated identically as "no roles defined". A zero-byte
5767 // roles.json does NOT widen access: an absent file and a zero-byte
5768 // file both resolve to an empty role list (sees nothing by default).
5769 return Ok(Some(vec![]));
5770 }
5771 match serde_json::from_slice::<RolesFile>(&bytes) {
5772 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5773 // Corrupt or unrecognised version (>2): poison the roles state.
5774 _ => Ok(None),
5775 }
5776 }
5777
5778 /// Resolve a role to a node-visibility mask against the current graph state.
5779 ///
5780 /// Returns `Err` when:
5781 /// - `roles.json` was present but corrupt at open (poisoned state), or
5782 /// - `role` does not match any defined role name.
5783 ///
5784 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5785 /// all live nodes carrying any label in `labels`. Label resolution is live
5786 /// — new nodes of an allowed label are visible without re-applying the
5787 /// schema. An empty union = empty mask = sees nothing.
5788 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5789 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5790 detail:
5791 "roles.json was corrupt at open; fix the file and re-open to restore role access"
5792 .into(),
5793 })?;
5794 let def = roles
5795 .iter()
5796 .find(|r| r.name == role)
5797 .ok_or_else(|| GraphError::KeyNotFound {
5798 key: format!("role:{role}"),
5799 })?;
5800
5801 let mut visible = std::collections::HashSet::new();
5802
5803 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5804 for key in &def.keys {
5805 if let Some(id) = self.ids.get(key) {
5806 visible.insert(id);
5807 }
5808 }
5809
5810 // Label leg: live scan — iterate labels vec for matching symbol.
5811 for label_name in &def.labels {
5812 if let Some(sym) = self.syms.get(label_name) {
5813 for (i, &s) in self.labels.iter().enumerate() {
5814 if s == sym {
5815 visible.insert(i as u32);
5816 }
5817 }
5818 }
5819 }
5820
5821 Ok(crate::mask::NodeMask::from_ids(visible))
5822 }
5823
5824 /// Return the current list of role definitions.
5825 ///
5826 /// Returns an empty list when no roles are defined or when `roles.json`
5827 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5828 /// the fail-loud error in that case).
5829 pub fn roles(&self) -> Vec<RoleDef> {
5830 self.roles.as_deref().unwrap_or(&[]).to_vec()
5831 }
5832
5833 // ── Role-scoped write authz ───────────────────────────────────────────────
5834
5835 /// Execute `ops` with optional role-scoped write authorization.
5836 ///
5837 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5838 /// (zero-cost bypass of all authz checks).
5839 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5840 /// record is built. A denial returns an error with no WAL frame written
5841 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5842 ///
5843 /// See the plan's "authz decision table" section for the full semantics.
5844 pub fn write_batch_authz(
5845 &mut self,
5846 authz: Option<&WriteAuthz>,
5847 ops: Vec<BatchOp>,
5848 ) -> Result<(usize, usize)> {
5849 // Thread authz as a direct parameter — never touches pending_write_authz.
5850 self.commit_logged_batch(ops, None, authz.cloned())
5851 }
5852
5853 /// Execute a Cypher write statement with role-scoped write authorization.
5854 ///
5855 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5856 /// lifetime as execution, satisfying §5 lock discipline). The resolved
5857 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5858 /// call so that all inner `batch.commit()` calls are authz-checked.
5859 ///
5860 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5861 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5862 /// timing-oracle item (hidden ≡ absent for unscoped roles).
5863 ///
5864 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5865 /// "this endpoint is not permitted".
5866 pub fn query_write_authz(
5867 &mut self,
5868 role: &str,
5869 cypher: &str,
5870 params: &BTreeMap<String, Value>,
5871 ) -> Result<ResultSet> {
5872 // Resolve scope (fails fast if role has no write scope).
5873 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5874 let scope =
5875 {
5876 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5877 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5878 })?;
5879 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5880 GraphError::KeyNotFound {
5881 key: format!("role:{role}"),
5882 }
5883 })?;
5884 def.write
5885 .clone()
5886 .ok_or_else(|| GraphError::RoleWriteDenied {
5887 reason: "role-bound token: writes are not permitted".into(),
5888 })?
5889 };
5890 // Resolve mask inside the call (same guard, §5 coherence).
5891 let mask = self.mask_for_role(role)?;
5892 self.pending_write_authz = Some(WriteAuthz {
5893 role: role.into(),
5894 scope,
5895 mask,
5896 });
5897 // RAII guard: always clears pending_write_authz on scope exit, including
5898 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5899 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5900 impl Drop for ClearPendingAuthzOnDrop {
5901 fn drop(&mut self) {
5902 // SAFETY: pointer into the owning GraphDb; guard is dropped
5903 // within this function's frame before it returns.
5904 unsafe { *self.0 = None };
5905 }
5906 }
5907 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5908 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5909 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5910 detail: format!("lex: {e}"),
5911 })?;
5912 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5913 detail: format!("parse: {e}"),
5914 })?;
5915 self.exec_write_stmt(stmt, params)
5916 }
5917
5918 /// Execute `ops` with optional role-scoped write authorization, suppressing
5919 /// fsync (for use inside the group-commit drain thread, which performs one
5920 /// group fsync after releasing the write lock).
5921 ///
5922 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5923 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5924 /// contract established by [`commit_batch_nosync`].
5925 pub(crate) fn write_batch_authz_nosync(
5926 &mut self,
5927 authz: Option<&WriteAuthz>,
5928 ops: Vec<BatchOp>,
5929 ) -> Result<(usize, usize)> {
5930 let saved = self.fsync;
5931 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5932 impl Drop for RestoreFsync {
5933 fn drop(&mut self) {
5934 // SAFETY: pointer into the owning GraphDb; guard is dropped
5935 // within the enclosing function's frame before it returns.
5936 unsafe { *self.0 = self.1 };
5937 }
5938 }
5939 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5940 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5941 self.fsync = FsyncPolicy::Relaxed;
5942 self.commit_logged_batch(ops, None, authz.cloned())
5943 }
5944
5945 /// Execute a `/ingest` request with role-scoped write authorization.
5946 ///
5947 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5948 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5949 /// Sets `pending_write_authz` for the duration of the call so that the
5950 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5951 /// and evaluates the decision table per-op before any WAL write.
5952 ///
5953 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5954 /// denied by the decision table with the appropriate §4.3 scope reason;
5955 /// no special HTTP-layer check is needed.
5956 ///
5957 /// Roles with `write: None` return `RoleWriteDenied` with
5958 /// "writes are not permitted" (byte-identical to v1 blanket 403).
5959 pub fn ingest_with_edges_authz(
5960 &mut self,
5961 role: &str,
5962 label: &str,
5963 rows: Vec<std::collections::BTreeMap<String, Value>>,
5964 opts: &crate::ingest::IngestOptions,
5965 edges: &[(String, String, String)],
5966 ) -> Result<crate::ingest::IngestReport> {
5967 // Resolve scope (fails fast if role has no write scope).
5968 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5969 let scope =
5970 {
5971 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5972 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5973 })?;
5974 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5975 GraphError::KeyNotFound {
5976 key: format!("role:{role}"),
5977 }
5978 })?;
5979 def.write
5980 .clone()
5981 .ok_or_else(|| GraphError::RoleWriteDenied {
5982 reason: "role-bound token: writes are not permitted".into(),
5983 })?
5984 };
5985 let mask = self.mask_for_role(role)?;
5986 self.pending_write_authz = Some(WriteAuthz {
5987 role: role.into(),
5988 scope,
5989 mask,
5990 });
5991 // RAII guard: always clears pending_write_authz on scope exit, including
5992 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5993 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5994 impl Drop for ClearPendingAuthzOnDrop {
5995 fn drop(&mut self) {
5996 // SAFETY: pointer into the owning GraphDb; guard is dropped
5997 // within this function's frame before it returns.
5998 unsafe { *self.0 = None };
5999 }
6000 }
6001 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6002 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6003 self.ingest_with_edges(label, rows, opts, edges)
6004 }
6005
6006 /// Evaluate the write-authz decision table for one `BatchOp`.
6007 ///
6008 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6009 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
6010 /// the remaining ops are not evaluated and no WAL frame is written.
6011 ///
6012 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6013 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
6014 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6015 /// batch creates counts as visible if its label passed the create-class gate").
6016 fn check_single_op_authz(
6017 &self,
6018 authz: &WriteAuthz,
6019 op: &BatchOp,
6020 batch_created: &BTreeMap<String, String>,
6021 ) -> Result<()> {
6022 // Helper: 3-way node status under the authz mask.
6023 //
6024 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6025 // as Visible with their recorded label — their create gate already passed
6026 // and they are not yet in self.ids (not committed). This fixes the
6027 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6028 // the SetProp must not see the node as Absent.
6029 let node_status = |key: &str| -> NodeAuthzStatus {
6030 if let Some(label) = batch_created.get(key) {
6031 return NodeAuthzStatus::Visible(label.clone());
6032 }
6033 match self.ids.get(key) {
6034 None => NodeAuthzStatus::Absent,
6035 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6036 Some(id) => {
6037 let label = self
6038 .labels
6039 .get(id as usize)
6040 .and_then(|&sym| {
6041 if sym == u32::MAX {
6042 None
6043 } else {
6044 self.syms.resolve(sym).map(str::to_string)
6045 }
6046 })
6047 .unwrap_or_default();
6048 NodeAuthzStatus::Visible(label)
6049 }
6050 }
6051 };
6052
6053 // Helper: is an InsertEdgeUpsert endpoint visible?
6054 // A same-batch placeholder counts as visible if its label passed
6055 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6056 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6057 // In store and visible?
6058 if let Some(id) = self.ids.get(ep_key) {
6059 return authz.mask.contains_id(id);
6060 }
6061 // Created by an earlier op in this batch?
6062 if let Some(created_label) = batch_created.get(ep_key) {
6063 return authz.scope.create_labels.contains(created_label);
6064 }
6065 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6066 // must pass the create-class gate.
6067 authz
6068 .scope
6069 .create_labels
6070 .contains(&placeholder_label.to_string())
6071 };
6072
6073 match op {
6074 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6075 // These ops are never routed to role-scoped paths by the HTTP layer,
6076 // but we 403 them here to close any future bypass route.
6077 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6078 return Err(GraphError::RoleWriteDenied {
6079 reason: "role-bound token: this endpoint is not permitted".into(),
6080 });
6081 }
6082
6083 // ── CREATE-class: InsertNode ─────────────────────────────────────
6084 //
6085 // Decision table row 1 (scope-before-lookup): check label in
6086 // create_labels BEFORE any key lookup. This is the structural
6087 // closure of the §6.2 timing-oracle item — the denial fires even
6088 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6089 BatchOp::InsertNode { label, key, .. } => {
6090 if !authz.scope.create_labels.contains(label) {
6091 return Err(GraphError::RoleWriteDenied {
6092 reason: format!(
6093 "role-bound token: label '{}' not in write scope (create_labels)",
6094 label
6095 ),
6096 });
6097 }
6098 // Row 2/3: key lookup.
6099 match self.ids.get(key.as_str()) {
6100 Some(id) if authz.mask.contains_id(id) => {
6101 // Visible: DuplicateKey — let MutPreview handle this.
6102 }
6103 Some(_) => {
6104 // Hidden: indistinguishable from absent to the role.
6105 return Err(GraphError::RoleWriteDenied {
6106 reason: "role-bound token: target node not visible".into(),
6107 });
6108 }
6109 None => {
6110 // Absent: proceed (create).
6111 }
6112 }
6113 }
6114
6115 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6116 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6117 if batch_created.contains_key(key.as_str()) {
6118 // Batch-created node: create gate already passed this batch.
6119 // Updating it in the same batch is always allowed, regardless
6120 // of update_labels (ruling §3.5: "writer just created it").
6121 } else {
6122 let label = match node_status(key) {
6123 NodeAuthzStatus::Visible(lbl) => lbl,
6124 _ => {
6125 return Err(GraphError::RoleWriteDenied {
6126 reason: "role-bound token: target node not visible".into(),
6127 });
6128 }
6129 };
6130 if !authz.scope.update_labels.contains(&label) {
6131 return Err(GraphError::RoleWriteDenied {
6132 reason: format!(
6133 "role-bound token: label '{}' not in write scope (update_labels)",
6134 label
6135 ),
6136 });
6137 }
6138 }
6139 }
6140
6141 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6142 BatchOp::DeleteNode { key } => {
6143 let label = match node_status(key) {
6144 NodeAuthzStatus::Visible(lbl) => lbl,
6145 _ => {
6146 return Err(GraphError::RoleWriteDenied {
6147 reason: "role-bound token: target node not visible".into(),
6148 });
6149 }
6150 };
6151 if !authz.scope.delete_labels.contains(&label) {
6152 return Err(GraphError::RoleWriteDenied {
6153 reason: format!(
6154 "role-bound token: label '{}' not in write scope (delete_labels)",
6155 label
6156 ),
6157 });
6158 }
6159 }
6160
6161 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6162 //
6163 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6164 // check (spec §3.5: "existing derived-edge rejection precedes
6165 // delete_edge_types check").
6166 BatchOp::DeleteEdge {
6167 edge_type,
6168 src_key,
6169 dst_key,
6170 } => {
6171 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6172 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6173 self.ids.get(src_key.as_str()),
6174 self.ids.get(dst_key.as_str()),
6175 self.syms.get(edge_type.as_str()),
6176 ) {
6177 if self.engine.is_owned(et_sym, src_id, dst_id) {
6178 return Err(GraphError::RuleOwned {
6179 detail: format!(
6180 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6181 delete or change the owning rule"
6182 ),
6183 });
6184 }
6185 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6186 let preview = MutPreview::new(self);
6187 if preview.would_derive(edge_type, src_key, dst_key) {
6188 return Err(GraphError::RuleOwned {
6189 detail: format!(
6190 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6191 delete or change the owning rule, or a live rule would \
6192 re-derive it"
6193 ),
6194 });
6195 }
6196 }
6197 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6198 if !authz.scope.delete_edge_types.contains(edge_type) {
6199 return Err(GraphError::RoleWriteDenied {
6200 reason: format!(
6201 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6202 edge_type
6203 ),
6204 });
6205 }
6206 // Both endpoints must be visible.
6207 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6208 match self.ids.get(ep_key) {
6209 None => {
6210 return Err(GraphError::RoleWriteDenied {
6211 reason: "role-bound token: edge endpoint not visible".into(),
6212 });
6213 }
6214 Some(id) if !authz.mask.contains_id(id) => {
6215 return Err(GraphError::RoleWriteDenied {
6216 reason: "role-bound token: edge endpoint not visible".into(),
6217 });
6218 }
6219 _ => {}
6220 }
6221 }
6222 }
6223
6224 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6225 //
6226 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6227 BatchOp::InsertEdge {
6228 edge_type,
6229 src_key,
6230 dst_key,
6231 } => {
6232 if !authz.scope.create_edge_types.contains(edge_type) {
6233 return Err(GraphError::RoleWriteDenied {
6234 reason: format!(
6235 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6236 edge_type
6237 ),
6238 });
6239 }
6240 // Both endpoints must be visible. A node created by an earlier
6241 // InsertNode in the same batch (tracked in batch_created) counts
6242 // as visible if its label passed the create-class gate.
6243 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6244 if batch_created.contains_key(ep_key) {
6245 // Created earlier this batch — already scope-checked.
6246 continue;
6247 }
6248 match self.ids.get(ep_key) {
6249 None => {
6250 return Err(GraphError::RoleWriteDenied {
6251 reason: "role-bound token: edge endpoint not visible".into(),
6252 });
6253 }
6254 Some(id) if !authz.mask.contains_id(id) => {
6255 return Err(GraphError::RoleWriteDenied {
6256 reason: "role-bound token: edge endpoint not visible".into(),
6257 });
6258 }
6259 _ => {}
6260 }
6261 }
6262 }
6263
6264 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6265 //
6266 // Scope check first; then endpoint visibility using same-batch
6267 // placeholder awareness (spec: "a placeholder endpoint the SAME
6268 // batch creates counts as visible if its label passed the
6269 // create-class gate").
6270 BatchOp::InsertEdgeUpsert {
6271 edge_type,
6272 src_key,
6273 dst_key,
6274 placeholder_label,
6275 } => {
6276 if !authz.scope.create_edge_types.contains(edge_type) {
6277 return Err(GraphError::RoleWriteDenied {
6278 reason: format!(
6279 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6280 edge_type
6281 ),
6282 });
6283 }
6284 // Check placeholder label against create_labels (create-class gate).
6285 // This ensures the auto-created endpoints are scope-allowed.
6286 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6287 if !upsert_ep_visible(ep_key, placeholder_label) {
6288 return Err(GraphError::RoleWriteDenied {
6289 reason: "role-bound token: edge endpoint not visible".into(),
6290 });
6291 }
6292 }
6293 }
6294 }
6295 Ok(())
6296 }
6297
6298 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6299 ///
6300 /// Called by `apply_schema` when roles change. Never called on unchanged
6301 /// re-apply — this preserves byte-identical idempotency.
6302 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6303 let file = RolesFile::new_versioned(roles.clone());
6304 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6305 detail: format!("roles serialization: {e}"),
6306 })?;
6307 self.fs
6308 .write_atomic(FileId::Roles, &bytes)
6309 .map_err(GraphError::Io)?;
6310 self.roles = Some(roles);
6311 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6312 // updated role definitions without waiting for the next K-commit fold.
6313 self.fold_now();
6314 Ok(())
6315 }
6316
6317 fn view(&self) -> GraphView<'_> {
6318 GraphView {
6319 ids: &self.ids,
6320 syms: &self.syms,
6321 labels: &self.labels,
6322 props: self.props_view(),
6323 topo: self.topo_view(),
6324 edge_props: self.edge_props_view(),
6325 mask: None,
6326 prop_index: Some(&self.prop_index),
6327 }
6328 }
6329
6330 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6331 GraphView {
6332 ids: &self.ids,
6333 syms: &self.syms,
6334 labels: &self.labels,
6335 props: self.props_view(),
6336 topo: self.topo_view(),
6337 edge_props: self.edge_props_view(),
6338 mask: Some(&mask.visible),
6339 prop_index: Some(&self.prop_index),
6340 }
6341 }
6342
6343 /// Execute a read-only Cypher query with a node visibility mask.
6344 ///
6345 /// Only nodes whose key is in `mask` are accessible: label scans, key
6346 /// lookups, and neighbor expansions all respect the mask. Edges where
6347 /// either endpoint is hidden are silently dropped.
6348 ///
6349 /// Returns `Err` with a "masked queries are read-only" message when
6350 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6351 pub fn query_masked(
6352 &self,
6353 cypher: &str,
6354 params: &std::collections::BTreeMap<String, Value>,
6355 mask: &crate::mask::NodeMask,
6356 ) -> Result<ResultSet> {
6357 // Reject write statements up front.
6358 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6359 detail: format!("lex: {e}"),
6360 })?;
6361 if is_write_tokens(&tokens) {
6362 return Err(GraphError::MaskedReadOnly);
6363 }
6364 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6365 detail: format!("parse: {e}"),
6366 })?;
6367 // Each UNION part executes against the same masked view, so the mask
6368 // applies uniformly across the chain.
6369 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6370 GraphError::QueryError {
6371 detail: format!("execute: {e}"),
6372 }
6373 })
6374 }
6375
6376 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6377 let id = self.ids.get(key)?;
6378 Some(NodeRef { db: self, id })
6379 }
6380
6381 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6382 ///
6383 /// Hidden nodes are never used as traversal intermediaries in either
6384 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6385 /// only through a hidden node will not appear in results.
6386 ///
6387 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6388 /// a visited visible node are appended to the result as stub rows
6389 /// (`label` column is `null`, same key+depth columns as visible rows).
6390 /// They are NOT added to the BFS frontier.
6391 ///
6392 /// Returns `None` when `key` does not exist (caller should 404).
6393 ///
6394 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6395 /// stub rows are never produced on the role path.
6396 pub fn neighborhood_masked(
6397 &self,
6398 key: &str,
6399 depth: u32,
6400 edge_types: Option<&[&str]>,
6401 dir: Dir,
6402 mask: &crate::mask::NodeMask,
6403 ) -> Option<ResultSet> {
6404 let start_id = self.ids.get(key)?;
6405 let view = self.view_masked(mask);
6406 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6407 names
6408 .iter()
6409 .filter_map(|name| view.syms.get(name))
6410 .collect()
6411 });
6412 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6413 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6414 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6415 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6416 visited.push((start_id, 0));
6417 for (nid, d) in &nb.nodes {
6418 let k = view.key_of(*nid);
6419 let label = view
6420 .label_of(*nid)
6421 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6422 rs.push_row(vec![
6423 Some(Value::Str(k.to_string())),
6424 Some(Value::Str(label.to_string())),
6425 Some(Value::Int(*d as i64)),
6426 ]);
6427 visited.push((*nid, *d));
6428 }
6429 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6430 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6431 // frontier, so the BFS never expands through them.
6432 if mask.mode() == crate::mask::MaskMode::Stub {
6433 let raw_view = self.view();
6434 let mut seen: std::collections::HashSet<u32> =
6435 visited.iter().map(|(id, _)| *id).collect();
6436 for (node_id, node_depth) in &visited {
6437 if *node_depth >= depth {
6438 continue;
6439 }
6440 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6441 let nbr = if e.src == *node_id { e.dst } else { e.src };
6442 if !mask.contains_id(nbr) && seen.insert(nbr) {
6443 if let Some(k) = self.ids.key_of(nbr) {
6444 rs.push_row(vec![
6445 Some(Value::Str(k.to_string())),
6446 None,
6447 Some(Value::Int((*node_depth + 1) as i64)),
6448 ]);
6449 }
6450 }
6451 }
6452 }
6453 }
6454 Some(rs)
6455 }
6456
6457 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6458 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6459 let n = self.node_ref(key)?;
6460 Some(NodeInfo {
6461 key: n.key().to_string(),
6462 label: n.label().to_string(),
6463 props: n.props(),
6464 })
6465 }
6466
6467 /// Look up a node with mask awareness.
6468 ///
6469 /// | Key state | Omit mode | Stub mode |
6470 /// |-------------------|-----------------|------------------------|
6471 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6472 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6473 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6474 ///
6475 /// **SECURITY**: only call from client-mask (full-token) paths.
6476 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6477 pub fn node_info_masked(
6478 &self,
6479 key: &str,
6480 mask: &crate::mask::NodeMask,
6481 ) -> Option<MaskedNodeResult> {
6482 let id = self.ids.get(key)?;
6483 if mask.contains_id(id) {
6484 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6485 } else {
6486 match mask.mode() {
6487 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6488 crate::mask::MaskMode::Omit => None,
6489 }
6490 }
6491 }
6492
6493 /// Get edges for `key` with mask-aware hidden-endpoint handling.
6494 ///
6495 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6496 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6497 /// is `true` for each hidden endpoint.
6498 ///
6499 /// Unknown key → [`GraphError::KeyNotFound`].
6500 ///
6501 /// **SECURITY**: only call from client-mask (full-token) paths.
6502 pub fn node_edges_masked(
6503 &self,
6504 key: &str,
6505 mask: &crate::mask::NodeMask,
6506 ) -> Result<Vec<MaskedEdge>> {
6507 self.ensure_v8_base_sections_loaded();
6508 let id = self
6509 .ids
6510 .get(key)
6511 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6512 let derived: BTreeSet<(u32, u32, u32)> = self
6513 .engine
6514 .provenance_touching(id)
6515 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6516 .collect();
6517 let mut edges = Vec::new();
6518 let tv = self.topo_view();
6519 for etype in tv.etypes() {
6520 // etype comes from the archived CSR (access_unchecked, no eager CRC).
6521 // A bit-flip in the large TOPOLOGY section can produce an etype id
6522 // that is not in the interner. Return Corrupt rather than panic.
6523 let edge_type = self
6524 .syms
6525 .resolve(etype)
6526 .ok_or_else(|| GraphError::Corrupt {
6527 detail: format!("v8: topology etype {etype} not in interner"),
6528 })?
6529 .to_string();
6530 for dir in [Direction::Out, Direction::In] {
6531 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6532 let nbr_restricted = !mask.contains_id(nbr);
6533 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6534 continue;
6535 }
6536 let nbr_key = self
6537 .ids
6538 .key_of(nbr)
6539 .ok_or_else(|| GraphError::Corrupt {
6540 detail: format!("topology id {nbr} has no key"),
6541 })?
6542 .to_string();
6543 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6544 match dir {
6545 Direction::Out => {
6546 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6547 }
6548 Direction::In => {
6549 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6550 }
6551 };
6552 edges.push(MaskedEdge {
6553 edge_type: edge_type.clone(),
6554 src_key,
6555 src_restricted,
6556 dst_key,
6557 dst_restricted,
6558 derived: derived.contains(&(etype, src_id, dst_id)),
6559 });
6560 }
6561 }
6562 }
6563 edges.sort_by(|a, b| {
6564 a.edge_type
6565 .cmp(&b.edge_type)
6566 .then(a.src_key.cmp(&b.src_key))
6567 .then(a.dst_key.cmp(&b.dst_key))
6568 });
6569 edges.dedup_by(|a, b| {
6570 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6571 });
6572 Ok(edges)
6573 }
6574
6575 /// Every directed edge incident on `key`, both directions, every etype.
6576 ///
6577 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6578 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6579 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6580 /// Unknown key → [`GraphError::KeyNotFound`].
6581 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6582 self.ensure_v8_base_sections_loaded();
6583 let id = self
6584 .ids
6585 .get(key)
6586 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6587 let derived: BTreeSet<(u32, u32, u32)> = self
6588 .engine
6589 .provenance_touching(id)
6590 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6591 .collect();
6592 let mut edges = Vec::new();
6593 let tv = self.topo_view();
6594 for etype in tv.etypes() {
6595 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6596 let edge_type = self
6597 .syms
6598 .resolve(etype)
6599 .ok_or_else(|| GraphError::Corrupt {
6600 detail: format!("v8: topology etype {etype} not in interner"),
6601 })?
6602 .to_string();
6603 for dir in [Direction::Out, Direction::In] {
6604 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6605 let (src, dst, src_key, dst_key) = match dir {
6606 Direction::Out => (
6607 id,
6608 nbr,
6609 key.to_string(),
6610 self.ids
6611 .key_of(nbr)
6612 .ok_or_else(|| GraphError::Corrupt {
6613 detail: format!("topology id {nbr} has no key"),
6614 })?
6615 .to_string(),
6616 ),
6617 Direction::In => (
6618 nbr,
6619 id,
6620 self.ids
6621 .key_of(nbr)
6622 .ok_or_else(|| GraphError::Corrupt {
6623 detail: format!("topology id {nbr} has no key"),
6624 })?
6625 .to_string(),
6626 key.to_string(),
6627 ),
6628 };
6629 edges.push(EdgeInfo {
6630 edge_type: edge_type.clone(),
6631 src_key,
6632 dst_key,
6633 derived: derived.contains(&(etype, src, dst)),
6634 });
6635 }
6636 }
6637 }
6638 edges.sort_by(|a, b| {
6639 a.edge_type
6640 .cmp(&b.edge_type)
6641 .then(a.src_key.cmp(&b.src_key))
6642 .then(a.dst_key.cmp(&b.dst_key))
6643 });
6644 // Self-loops appear in both Out and In; sort makes the pair adjacent
6645 // (sort key matches PartialEq for this case) so one pass drops the dup.
6646 edges.dedup();
6647 Ok(edges)
6648 }
6649
6650 // ── Backup ────────────────────────────────────────────────────────────────
6651
6652 /// Copy this store to `dest` as a consistent, verified snapshot.
6653 ///
6654 /// Copies every durable file in the database directory — `snapshot.bin`,
6655 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6656 /// `roles.json` — into a freshly created `dest` directory using OS-level
6657 /// `copy` calls (no large in-process buffers).
6658 ///
6659 /// # Consistency guarantee
6660 ///
6661 /// The guarantee is **process-local**: the caller holds `&self`, which
6662 /// prevents any concurrent writer in the **same process** from modifying
6663 /// the files during the copy. Running `mushroomdb backup` against a
6664 /// directory that is **concurrently being written by another process** (e.g.
6665 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
6666 /// `verified: true` result reduces but does not eliminate the risk of a
6667 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6668 /// consistent mid-write snapshot).
6669 ///
6670 /// **The safe path for a live-served store is `POST /backup` on the HTTP
6671 /// server.** That handler acquires the read lock on the shared database
6672 /// before calling this method, which is the correct cross-process
6673 /// synchronisation point because the server is the single process writing
6674 /// the files.
6675 ///
6676 /// After copying, opens the destination read-only and runs the CRC section
6677 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6678 /// `BackupReport::verified` reflects whether both checks passed.
6679 ///
6680 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6681 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6682 // Derive source directory from snapshot_path (RealFs only).
6683 let src_dir = match self.fs.snapshot_path() {
6684 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6685 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6686 })?,
6687 None => {
6688 return Err(GraphError::Io(std::io::Error::other(
6689 "backup_to requires a real filesystem (RealFs)",
6690 )))
6691 }
6692 };
6693
6694 std::fs::create_dir_all(dest)?;
6695
6696 let mut files: Vec<String> = Vec::new();
6697 let mut bytes: u64 = 0;
6698
6699 // Helper: copy src_dir/name → dest/name if the file exists.
6700 let mut try_copy = |name: &str| -> std::io::Result<()> {
6701 let src_path = src_dir.join(name);
6702 if src_path.exists() {
6703 let n = std::fs::copy(&src_path, dest.join(name))?;
6704 bytes += n;
6705 files.push(name.to_string());
6706 }
6707 Ok(())
6708 };
6709
6710 try_copy("snapshot.bin")?;
6711 try_copy("snapshot.bin.bak")?;
6712 try_copy("wal.bin")?;
6713 try_copy("wal.floor")?;
6714 try_copy("wal.genesis")?;
6715 try_copy("roles.json")?;
6716
6717 // Copy WAL archives.
6718 let archives = self.fs.list_archives()?;
6719 for n in &archives {
6720 let name = format!("wal.{n}.archive");
6721 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6722 bytes += n_bytes;
6723 files.push(name);
6724 }
6725
6726 files.sort();
6727
6728 // Post-copy verification: open dest and run CRC checks.
6729 let snap_in_dest = dest.join("snapshot.bin").exists();
6730 let crc_ok = if snap_in_dest {
6731 crate::verify_snapshot(dest)
6732 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6733 .unwrap_or(false)
6734 } else {
6735 true // WAL-only store: nothing to CRC-check in snapshot
6736 };
6737 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6738 let verified = crc_ok && opens_ok;
6739
6740 Ok(BackupReport {
6741 files,
6742 bytes,
6743 verified,
6744 })
6745 }
6746
6747 // ── Export helpers ────────────────────────────────────────────────────────
6748
6749 /// All live nodes, sorted by key (deterministic).
6750 ///
6751 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6752 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6753 self.ensure_v8_base_sections_loaded();
6754 let pv = self.props_view();
6755 let mut nodes = Vec::new();
6756 for id in 0..self.ids.len() as u32 {
6757 let Some(key) = self.ids.key_of(id) else {
6758 continue;
6759 };
6760 let Some(&sym) = self.labels.get(id as usize) else {
6761 continue;
6762 };
6763 if sym == u32::MAX {
6764 continue; // tombstoned
6765 }
6766 let Some(label) = self.syms.resolve(sym) else {
6767 continue;
6768 };
6769 let mut props = BTreeMap::new();
6770 for field in pv.field_names() {
6771 if let Some(vr) = pv.get(id, &field) {
6772 props.insert(field, vr.into_value());
6773 }
6774 }
6775 nodes.push(NodeInfo {
6776 key: key.to_string(),
6777 label: label.to_string(),
6778 props,
6779 });
6780 }
6781 nodes.sort_by(|a, b| a.key.cmp(&b.key));
6782 nodes
6783 }
6784
6785 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6786 ///
6787 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6788 /// Manual edges carry `derived: false` and `rule: None`.
6789 /// Deterministic across runs on the same store state.
6790 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6791 self.ensure_v8_base_sections_loaded();
6792
6793 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6794 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6795 for (rule_name, triples) in self.engine.provenance() {
6796 for &(etype, src, dst) in triples {
6797 prov.insert((etype, src, dst), rule_name.clone());
6798 }
6799 }
6800
6801 let tv = self.topo_view();
6802 let mut edges = Vec::new();
6803
6804 for id in 0..self.ids.len() as u32 {
6805 let Some(key) = self.ids.key_of(id) else {
6806 continue;
6807 };
6808 let Some(&lsym) = self.labels.get(id as usize) else {
6809 continue;
6810 };
6811 if lsym == u32::MAX {
6812 continue; // tombstoned
6813 }
6814
6815 for etype_sym in tv.etypes() {
6816 // etype from archived CSR (access_unchecked, no eager CRC).
6817 // Skip edges whose etype is not in the interner; this can only
6818 // occur with a corrupt large TOPOLOGY section (bit-flip on an
6819 // etype field in the archived data). The function returns Vec,
6820 // not Result, so we continue rather than propagate.
6821 let Some(edge_type) = self.syms.resolve(etype_sym) else {
6822 continue;
6823 };
6824 let edge_type = edge_type.to_string();
6825 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6826 let Some(dst_key) = self.ids.key_of(nbr) else {
6827 continue; // skip corrupt entries
6828 };
6829 let prov_key = (etype_sym, id, nbr);
6830 let rule = prov.get(&prov_key).cloned();
6831 let derived = rule.is_some();
6832 edges.push(ExportEdge {
6833 edge_type: edge_type.clone(),
6834 src: key.to_string(),
6835 dst: dst_key.to_string(),
6836 derived,
6837 rule,
6838 });
6839 }
6840 }
6841 }
6842
6843 edges.sort_by(|a, b| {
6844 a.edge_type
6845 .cmp(&b.edge_type)
6846 .then(a.src.cmp(&b.src))
6847 .then(a.dst.cmp(&b.dst))
6848 });
6849 edges
6850 }
6851
6852 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6853 self.view()
6854 .nodes_with_label(label)
6855 .into_iter()
6856 .map(|id| NodeRef { db: self, id })
6857 .collect()
6858 }
6859
6860 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6861 let view = self.view();
6862 view.nodes_with_label(label)
6863 .into_iter()
6864 .filter(|&id| {
6865 eval_filter(filter, &|field| {
6866 view.prop(id, field).map(|vr| vr.into_value())
6867 })
6868 })
6869 .map(|id| NodeRef { db: self, id })
6870 .collect()
6871 }
6872
6873 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6874 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
6875 /// with `label = None` will use the native ANN path rather than the O(n)
6876 /// brute-force scan.
6877 pub fn has_vector_rule(&self, field: &str) -> bool {
6878 self.engine.hnsw_has_rule(field)
6879 }
6880
6881 /// Find nodes whose `field` vector is most similar to `q` (cosine
6882 /// similarity), returning up to `k` results with similarity ≥ `min`,
6883 /// sorted descending.
6884 ///
6885 /// When `label` is `None` the search spans all labels (via
6886 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6887 /// `Some(lbl)` it restricts to nodes with that label.
6888 ///
6889 /// Uses the HNSW index when one is available (fast path); otherwise falls
6890 /// back to an O(n) brute-force scan.
6891 pub fn find_similar_vector(
6892 &self,
6893 field: &str,
6894 label: Option<&str>,
6895 q: &[f64],
6896 k: usize,
6897 min: f64,
6898 ) -> Vec<(String, f64)> {
6899 // Ensure any HNSW blobs retained from the snapshot are deserialized
6900 // before the first ANN query on a clean-open (no-WAL) path.
6901 self.engine.ensure_hnsw_loaded();
6902 // L2-normalise query for cosine via dot product.
6903 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6904 if norm == 0.0 {
6905 return vec![];
6906 }
6907 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6908
6909 // Try HNSW fast path.
6910 // `None` label searches across all VectorSimilar rules covering `field`
6911 // (merging their results); `Some(lbl)` restricts to rules whose
6912 // dst_label matches. Returns `None` when no populated HNSW index
6913 // covers the request — the O(n) brute-force fallback handles that case.
6914 let hnsw_hits = match label {
6915 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6916 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6917 };
6918 if let Some(hits) = hnsw_hits {
6919 let mut out: Vec<(String, f64)> = hits
6920 .into_iter()
6921 .filter(|&(_, sim)| sim >= min)
6922 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6923 .collect();
6924 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6925 out.truncate(k);
6926 return out;
6927 }
6928
6929 // Brute-force fallback: O(n) scan (only reached when no HNSW index
6930 // covers the request).
6931 let view = self.view();
6932 let candidate_ids: Vec<u32> = match label {
6933 Some(lbl) => view.nodes_with_label(lbl),
6934 None => view.nodes_all(),
6935 };
6936 let mut scored: Vec<(String, f64)> = candidate_ids
6937 .into_iter()
6938 .filter_map(|id| {
6939 let v = view.prop(id, field)?;
6940 let v_owned = v.into_value();
6941 let xs = value_as_float_list(&v_owned)?;
6942 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6943 if v_norm == 0.0 {
6944 return None;
6945 }
6946 let dot: f64 = q_unit
6947 .iter()
6948 .zip(xs.iter())
6949 .map(|(a, b)| a * (b / v_norm))
6950 .sum();
6951 if dot < min {
6952 return None;
6953 }
6954 let key = self.ids.key_of(id)?.to_string();
6955 Some((key, dot))
6956 })
6957 .collect();
6958 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6959 scored.truncate(k);
6960 scored
6961 }
6962
6963 /// Like [`find_similar_vector`] but restricts results to nodes visible in
6964 /// `mask`. Hidden nodes never appear in results; the mask is applied
6965 /// **before** k-truncation so a caller still receives up to `k` visible
6966 /// hits.
6967 ///
6968 /// # HNSW path (over-fetch policy)
6969 ///
6970 /// When an HNSW index covers the request, this function fetches `4 * k`
6971 /// candidates from the index and discards hidden nodes in the post-filter
6972 /// step. If fewer than `k` visible nodes remain after filtering the caller
6973 /// receives whatever is available — we do not re-query the index. The 4×
6974 /// multiplier is a heuristic suited for sparsely masked graphs; callers
6975 /// operating under a very selective mask should register a VectorSimilar
6976 /// rule with a non-approximate index, or use the brute-force path (no HNSW
6977 /// rule) which exhaustively filters through the masked [`GraphView`].
6978 ///
6979 /// # Brute-force path
6980 ///
6981 /// When no HNSW index covers the request the function builds a masked
6982 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6983 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6984 /// fewer than `k` exist).
6985 pub fn find_similar_vector_masked(
6986 &self,
6987 field: &str,
6988 label: Option<&str>,
6989 q: &[f64],
6990 k: usize,
6991 min: f64,
6992 mask: &crate::mask::NodeMask,
6993 ) -> Vec<(String, f64)> {
6994 self.engine.ensure_hnsw_loaded();
6995 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6996 if norm == 0.0 {
6997 return vec![];
6998 }
6999 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7000
7001 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7002 // visible hits. See doc comment above for the policy rationale.
7003 let over_k = k.saturating_mul(4).max(k + 1);
7004 let hnsw_hits = match label {
7005 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7006 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7007 };
7008 if let Some(hits) = hnsw_hits {
7009 let mut out: Vec<(String, f64)> = hits
7010 .into_iter()
7011 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7012 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7013 .collect();
7014 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7015 out.truncate(k);
7016 return out;
7017 }
7018
7019 // Brute-force fallback — masked view ensures only visible nodes are
7020 // enumerated by nodes_all(); nodes_with_label() does not filter by
7021 // mask so we apply view.visible() explicitly for the labeled case.
7022 let view = self.view_masked(mask);
7023 let candidate_ids: Vec<u32> = match label {
7024 Some(lbl) => view
7025 .nodes_with_label(lbl)
7026 .into_iter()
7027 .filter(|&id| view.visible(id))
7028 .collect(),
7029 None => view.nodes_all(),
7030 };
7031 let mut scored: Vec<(String, f64)> = candidate_ids
7032 .into_iter()
7033 .filter_map(|id| {
7034 let v = view.prop(id, field)?;
7035 let v_owned = v.into_value();
7036 let xs = value_as_float_list(&v_owned)?;
7037 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7038 if v_norm == 0.0 {
7039 return None;
7040 }
7041 let dot: f64 = q_unit
7042 .iter()
7043 .zip(xs.iter())
7044 .map(|(a, b)| a * (b / v_norm))
7045 .sum();
7046 if dot < min {
7047 return None;
7048 }
7049 let key = self.ids.key_of(id)?.to_string();
7050 Some((key, dot))
7051 })
7052 .collect();
7053 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7054 scored.truncate(k);
7055 scored
7056 }
7057
7058 /// Read a single property from an edge.
7059 ///
7060 /// Returns `None` when the edge does not exist, the field is absent, or any
7061 /// of the string keys cannot be resolved to interned ids. Only edge props
7062 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7063 /// binding; topology-only edges (no props set) return `None` for every field.
7064 pub fn get_edge_prop(
7065 &self,
7066 edge_type: &str,
7067 src_key: &str,
7068 dst_key: &str,
7069 field: &str,
7070 ) -> Option<Value> {
7071 let etype = self.syms.get(edge_type)?;
7072 let src = self.ids.get(src_key)?;
7073 let dst = self.ids.get(dst_key)?;
7074 self.edge_props_view().get(etype, src, dst, field)
7075 }
7076
7077 /// Lex → parse → plan → execute `cypher` over a read-only view.
7078 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7079 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7080 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7081 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7082 detail: format!("lex: {e}"),
7083 })?;
7084 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7085 detail: format!("parse: {e}"),
7086 })?;
7087 let t0 = std::time::Instant::now();
7088 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7089 GraphError::QueryError {
7090 detail: format!("execute: {e}"),
7091 }
7092 });
7093 let elapsed_ms = t0.elapsed().as_millis() as u64;
7094 let threshold = self.slow_query_threshold_ms;
7095 if threshold > 0 && elapsed_ms >= threshold {
7096 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7097 let entry = SlowQueryEntry {
7098 ms: elapsed_ms,
7099 query: cypher.to_string(),
7100 at_commit: self.commit_seq,
7101 };
7102 if let Ok(mut log) = self.slow_queries.lock() {
7103 if log.entries.len() == SLOW_QUERY_RING_CAP {
7104 log.entries.pop_front();
7105 }
7106 log.entries.push_back(entry);
7107 log.total += 1;
7108 }
7109 }
7110 result
7111 }
7112
7113 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7114 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7115 /// calling [`GraphDb::query`].
7116 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7117 let map: BTreeMap<String, Value> = params
7118 .iter()
7119 .map(|(k, v)| (k.to_string(), v.clone()))
7120 .collect();
7121 self.query(cypher, &map)
7122 }
7123
7124 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7125 ///
7126 /// All mutations flow through the same `insert_node` / `set_prop` /
7127 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7128 /// fires and the WAL captures everything with one fsync per statement.
7129 ///
7130 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7131 /// and `deleted` matching the write-result contract.
7132 ///
7133 /// **Mutation routing**: mutations are collected into a single
7134 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7135 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7136 /// over `self.view()` — the borrow is dropped before the batch is opened.
7137 ///
7138 /// **Limitations (v1)**:
7139 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7140 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7141 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7142 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7143 /// - Deleting a derived edge → named error "cannot delete derived edge".
7144 pub fn query_write(
7145 &mut self,
7146 cypher: &str,
7147 params: &BTreeMap<String, Value>,
7148 ) -> Result<ResultSet> {
7149 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7150 detail: format!("lex: {e}"),
7151 })?;
7152 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7153 detail: format!("parse: {e}"),
7154 })?;
7155 self.exec_write_stmt(stmt, params)
7156 }
7157
7158 fn exec_write_stmt(
7159 &mut self,
7160 stmt: WriteStatement,
7161 params: &BTreeMap<String, Value>,
7162 ) -> Result<ResultSet> {
7163 match stmt {
7164 WriteStatement::Create(s) => self.exec_create(s, params),
7165 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7166 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7167 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7168 WriteStatement::Merge(s) => self.exec_merge(s, params),
7169 }
7170 }
7171
7172 fn exec_create(
7173 &mut self,
7174 stmt: core_query::cypher::CreateStmt,
7175 params: &BTreeMap<String, Value>,
7176 ) -> Result<ResultSet> {
7177 // Extract the node key from props: require a string-valued `id` field.
7178 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7179 for node in &stmt.nodes {
7180 let var = node.var.as_deref().unwrap_or("_cn0");
7181 let key = node
7182 .props
7183 .iter()
7184 .find(|(f, _)| f == "id")
7185 .and_then(|(_, v)| {
7186 if let Value::Str(s) = v {
7187 Some(s.clone())
7188 } else {
7189 None
7190 }
7191 })
7192 .ok_or_else(|| GraphError::QueryError {
7193 detail: format!(
7194 "CREATE node ({}:{}) requires a string 'id' property",
7195 var, node.label
7196 ),
7197 })?;
7198 var_to_key.insert(var.to_string(), key);
7199 }
7200
7201 let mut batch = self.batch();
7202 let mut created: usize = 0;
7203 for node in &stmt.nodes {
7204 let var = node.var.as_deref().unwrap_or("_cn0");
7205 let key = &var_to_key[var];
7206 batch.insert_node(&node.label, key, node.props.clone());
7207 created += 1;
7208 }
7209 for edge in &stmt.edges {
7210 let src_key = var_to_key
7211 .get(&edge.src_var)
7212 .ok_or_else(|| GraphError::QueryError {
7213 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7214 })?;
7215 let dst_key = var_to_key
7216 .get(&edge.dst_var)
7217 .ok_or_else(|| GraphError::QueryError {
7218 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7219 })?;
7220 batch.insert_edge(&edge.etype, src_key, dst_key);
7221 }
7222 batch.commit()?;
7223
7224 // Optional RETURN clause: project created bindings as a read result.
7225 if let Some(returns) = stmt.returns {
7226 // Each created node is looked up by its key via a separate MATCH pattern.
7227 // Multiple single-node patterns cross-join to produce 1 output row with
7228 // all variables bound (each pattern returns exactly 1 row).
7229 let patterns: Vec<Pattern> = stmt
7230 .nodes
7231 .iter()
7232 .map(|node| {
7233 let var = node.var.as_deref().unwrap_or("_cn0");
7234 let key = var_to_key[var].clone();
7235 Pattern {
7236 start: NodePat {
7237 var: Some(var.to_string()),
7238 label: Some(node.label.clone()),
7239 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7240 },
7241 chain: vec![],
7242 shortest: false,
7243 }
7244 })
7245 .collect();
7246 let q = Query {
7247 matches: patterns,
7248 optional_clauses: vec![],
7249 where_expr: None,
7250 unwinds: vec![],
7251 post_unwind_where: None,
7252 stages: vec![],
7253 returns,
7254 distinct: false,
7255 order_by: vec![],
7256 skip: None,
7257 limit: None,
7258 };
7259 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7260 detail: format!("plan: {e}"),
7261 })?;
7262 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7263 GraphError::QueryError {
7264 detail: format!("execute: {e}"),
7265 }
7266 });
7267 }
7268
7269 let mut rs = write_result_set();
7270 rs.push_row(vec![
7271 Some(Value::Int(created as i64)),
7272 Some(Value::Int(0)),
7273 Some(Value::Int(0)),
7274 ]);
7275 Ok(rs)
7276 }
7277
7278 fn exec_match_set(
7279 &mut self,
7280 stmt: core_query::cypher::MatchSetStmt,
7281 params: &BTreeMap<String, Value>,
7282 ) -> Result<ResultSet> {
7283 let project_returns = stmt.returns.clone();
7284 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7285 // so the post-write projection can look them up by key.
7286 let mut set_vars: Vec<String> = Vec::new();
7287 for s in &stmt.sets {
7288 if !set_vars.contains(&s.var) {
7289 set_vars.push(s.var.clone());
7290 }
7291 }
7292 let rel_vars = pattern_rel_vars(&stmt.matches);
7293 let mut lookup_vars = set_vars.clone();
7294 for v in pattern_node_vars(&stmt.matches) {
7295 add_var(&mut lookup_vars, &v);
7296 }
7297 if let Some(ref returns) = project_returns {
7298 for v in ret_node_vars(returns) {
7299 if !rel_vars.iter().any(|r| r == &v) {
7300 add_var(&mut lookup_vars, &v);
7301 }
7302 }
7303 }
7304
7305 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7306 // SET values are projected as ScalarExpr items so that arithmetic expressions
7307 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7308 let mut set_returns: Vec<RetItem> = lookup_vars
7309 .iter()
7310 .map(|v| RetItem {
7311 value: RetVal::Var(v.clone()),
7312 alias: None,
7313 })
7314 .collect();
7315 // One computed column per SET clause; alias is `__sv_<i>`.
7316 let set_val_cols: Vec<String> = stmt
7317 .sets
7318 .iter()
7319 .enumerate()
7320 .map(|(i, _)| format!("__sv_{i}"))
7321 .collect();
7322 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7323 set_returns.push(RetItem {
7324 value: RetVal::ScalarExpr(sc.value.clone()),
7325 alias: Some(col.clone()),
7326 });
7327 }
7328 // Capture relationship types while r is bound; SET does not change them.
7329 for r in &rel_vars {
7330 set_returns.push(RetItem {
7331 value: RetVal::FuncCall {
7332 name: "type".into(),
7333 args: vec![Operand::Var(r.clone())],
7334 },
7335 alias: Some(rel_type_alias(r)),
7336 });
7337 }
7338
7339 let read_q = Query {
7340 matches: stmt.matches.clone(),
7341 optional_clauses: vec![],
7342 where_expr: stmt.where_expr.clone(),
7343 unwinds: vec![],
7344 post_unwind_where: None,
7345 stages: vec![],
7346 returns: set_returns,
7347 distinct: false,
7348 order_by: vec![],
7349 skip: None,
7350 limit: None,
7351 };
7352 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7353 detail: format!("plan: {e}"),
7354 })?;
7355 // MATCH phase is read-only; borrow ends before batch opens.
7356 //
7357 // When a role-scoped write is in flight, run the MATCH read through
7358 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7359 // zero-rows (no SetProp ops generated, no existence-oracle 403).
7360 // Full-authority writes (pending_write_authz=None) keep view().
7361 let match_rs = {
7362 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7363 if let Some(ref mask) = mask_opt {
7364 execute(&self.view_masked(mask), &ops, &Params(params))
7365 } else {
7366 execute(&self.view(), &ops, &Params(params))
7367 }
7368 }
7369 .map_err(|e| GraphError::QueryError {
7370 detail: format!("execute: {e}"),
7371 })?;
7372
7373 // Collect (key, field, value) for each matched row × each SET clause.
7374 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7375 for row_i in 0..match_rs.len() {
7376 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7377 let key = match match_rs.get(row_i, &sc.var) {
7378 Some(Value::Str(k)) => k.clone(),
7379 _ => {
7380 return Err(GraphError::QueryError {
7381 detail: format!(
7382 "SET variable '{}' did not resolve to a node key",
7383 sc.var
7384 ),
7385 })
7386 }
7387 };
7388 // The SET value was already evaluated by the executor.
7389 let value = match match_rs.get(row_i, col) {
7390 Some(v) => v.clone(),
7391 None => {
7392 return Err(GraphError::QueryError {
7393 detail: format!(
7394 "SET value for {}.{} evaluated to null",
7395 sc.var, sc.field
7396 ),
7397 })
7398 }
7399 };
7400 set_ops.push((key, sc.field.clone(), value));
7401 }
7402 }
7403
7404 // Apply as one atomic batch.
7405 let props_set = set_ops.len();
7406 let mut batch = self.batch();
7407 for (key, field, value) in set_ops {
7408 batch.set_prop(&key, &field, value);
7409 }
7410 batch.commit()?;
7411
7412 if let Some(returns) = project_returns {
7413 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7414 }
7415
7416 let mut rs = write_result_set();
7417 rs.push_row(vec![
7418 Some(Value::Int(0)),
7419 Some(Value::Int(props_set as i64)),
7420 Some(Value::Int(0)),
7421 ]);
7422 Ok(rs)
7423 }
7424
7425 fn exec_match_delete(
7426 &mut self,
7427 stmt: core_query::cypher::MatchDeleteStmt,
7428 params: &BTreeMap<String, Value>,
7429 ) -> Result<ResultSet> {
7430 // Collect unique node vars needed to identify edge endpoints.
7431 let mut node_vars: Vec<String> = Vec::new();
7432 for ed in &stmt.deletes {
7433 if !node_vars.contains(&ed.src_var) {
7434 node_vars.push(ed.src_var.clone());
7435 }
7436 if !node_vars.contains(&ed.dst_var) {
7437 node_vars.push(ed.dst_var.clone());
7438 }
7439 }
7440
7441 // Synthesize read query.
7442 let returns: Vec<RetItem> = node_vars
7443 .iter()
7444 .map(|v| RetItem {
7445 value: RetVal::Var(v.clone()),
7446 alias: None,
7447 })
7448 .collect();
7449 let read_q = Query {
7450 matches: stmt.matches,
7451 optional_clauses: vec![],
7452 where_expr: stmt.where_expr,
7453 unwinds: vec![],
7454 post_unwind_where: None,
7455 stages: vec![],
7456 returns,
7457 distinct: false,
7458 order_by: vec![],
7459 skip: None,
7460 limit: None,
7461 };
7462 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7463 detail: format!("plan: {e}"),
7464 })?;
7465 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7466 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7467 let match_rs = {
7468 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7469 if let Some(ref mask) = mask_opt {
7470 execute(&self.view_masked(mask), &ops, &Params(params))
7471 } else {
7472 execute(&self.view(), &ops, &Params(params))
7473 }
7474 }
7475 .map_err(|e| GraphError::QueryError {
7476 detail: format!("execute: {e}"),
7477 })?;
7478
7479 // Collect (etype, src_key, dst_key) for each row × each delete target.
7480 let mut del_ops: Vec<(String, String, String)> = Vec::new();
7481 for row_i in 0..match_rs.len() {
7482 for ed in &stmt.deletes {
7483 let src_key = match match_rs.get(row_i, &ed.src_var) {
7484 Some(Value::Str(k)) => k.clone(),
7485 _ => {
7486 return Err(GraphError::QueryError {
7487 detail: format!(
7488 "DELETE src variable '{}' did not resolve to a node key",
7489 ed.src_var
7490 ),
7491 })
7492 }
7493 };
7494 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7495 Some(Value::Str(k)) => k.clone(),
7496 _ => {
7497 return Err(GraphError::QueryError {
7498 detail: format!(
7499 "DELETE dst variable '{}' did not resolve to a node key",
7500 ed.dst_var
7501 ),
7502 })
7503 }
7504 };
7505 del_ops.push((ed.etype.clone(), src_key, dst_key));
7506 }
7507 }
7508
7509 // Apply as one atomic batch.
7510 let deleted = del_ops.len();
7511 let mut batch = self.batch();
7512 for (etype, src_key, dst_key) in del_ops {
7513 batch.delete_edge(&etype, &src_key, &dst_key);
7514 }
7515 batch.commit().map_err(|e| match e {
7516 GraphError::RuleOwned { .. } => GraphError::QueryError {
7517 detail: "cannot delete derived edge; retract via the rule or change the property"
7518 .to_string(),
7519 },
7520 other => other,
7521 })?;
7522
7523 let mut rs = write_result_set();
7524 rs.push_row(vec![
7525 Some(Value::Int(0)),
7526 Some(Value::Int(0)),
7527 Some(Value::Int(deleted as i64)),
7528 ]);
7529 Ok(rs)
7530 }
7531
7532 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7533 ///
7534 /// Collects the matching node keys via an ephemeral read query, then calls
7535 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
7536 /// the executor first checks that the node has no incident edges; if any
7537 /// remain it returns a named error matching openCypher semantics.
7538 fn exec_match_delete_node(
7539 &mut self,
7540 stmt: MatchDeleteNodeStmt,
7541 params: &BTreeMap<String, Value>,
7542 ) -> Result<ResultSet> {
7543 // Build a read query returning only the node keys we need.
7544 let returns: Vec<RetItem> = stmt
7545 .node_vars
7546 .iter()
7547 .map(|v| RetItem {
7548 value: RetVal::Var(v.clone()),
7549 alias: None,
7550 })
7551 .collect();
7552 let read_q = Query {
7553 matches: stmt.matches,
7554 optional_clauses: vec![],
7555 where_expr: stmt.where_expr,
7556 unwinds: vec![],
7557 post_unwind_where: None,
7558 stages: vec![],
7559 returns,
7560 distinct: false,
7561 order_by: vec![],
7562 skip: None,
7563 limit: None,
7564 };
7565 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7566 detail: format!("plan: {e}"),
7567 })?;
7568 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7569 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7570 let match_rs = {
7571 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7572 if let Some(ref mask) = mask_opt {
7573 execute(&self.view_masked(mask), &ops, &Params(params))
7574 } else {
7575 execute(&self.view(), &ops, &Params(params))
7576 }
7577 }
7578 .map_err(|e| GraphError::QueryError {
7579 detail: format!("execute: {e}"),
7580 })?;
7581
7582 // Collect unique node keys to delete (deduplicate across rows × vars).
7583 let mut keys: Vec<String> = Vec::new();
7584 for row_i in 0..match_rs.len() {
7585 for var in &stmt.node_vars {
7586 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7587 if !keys.contains(k) {
7588 keys.push(k.clone());
7589 }
7590 }
7591 }
7592 }
7593
7594 if !stmt.detach {
7595 // openCypher bare DELETE: error if any matched node has incident edges.
7596 for key in &keys {
7597 if let Some(id) = self.ids.get(key) {
7598 let tv = self.topo_view();
7599 let has_edges = tv.etypes().any(|et| {
7600 !tv.neighbors(et, Direction::Out, id).is_empty()
7601 || !tv.neighbors(et, Direction::In, id).is_empty()
7602 });
7603 if has_edges {
7604 return Err(GraphError::QueryError {
7605 detail: format!(
7606 "Cannot delete node `{key}` because it still has incident edges. \
7607 Use DETACH DELETE to remove the node and all its edges."
7608 ),
7609 });
7610 }
7611 }
7612 }
7613 }
7614
7615 let mut nodes_deleted = 0i64;
7616 let mut edges_deleted = 0i64;
7617 for key in keys {
7618 match self.delete_node(&key) {
7619 Ok(report) => {
7620 nodes_deleted += 1;
7621 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7622 }
7623 Err(GraphError::KeyNotFound { .. }) => {
7624 // Node may have been deleted by an earlier iteration (e.g., via
7625 // multiple MATCH rows for the same node). Safe to skip.
7626 }
7627 Err(e) => return Err(e),
7628 }
7629 }
7630
7631 let mut rs = write_result_set();
7632 rs.push_row(vec![
7633 Some(Value::Int(0)),
7634 Some(Value::Int(0)),
7635 Some(Value::Int(nodes_deleted + edges_deleted)),
7636 ]);
7637 Ok(rs)
7638 }
7639
7640 fn exec_merge(
7641 &mut self,
7642 stmt: core_query::cypher::MergeStmt,
7643 params: &BTreeMap<String, Value>,
7644 ) -> Result<ResultSet> {
7645 // MERGE: check if a node with the given key already exists.
7646 let key = match &stmt.key_value {
7647 Value::Str(s) => s.clone(),
7648 _ => {
7649 return Err(GraphError::QueryError {
7650 detail: format!(
7651 "MERGE key value must be a string (got {:?})",
7652 stmt.key_value
7653 ),
7654 })
7655 }
7656 };
7657
7658 if let Some(var) = stmt.var.as_deref() {
7659 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7660 if sc.var != var {
7661 return Err(GraphError::QueryError {
7662 detail: format!(
7663 "SET variable '{}' does not match MERGE variable '{var}'",
7664 sc.var
7665 ),
7666 });
7667 }
7668 }
7669 }
7670
7671 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7672 //
7673 // MERGE scope precondition: check create OR update scope for the
7674 // declared label BEFORE calling `has_node` (timing-oracle closure,
7675 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7676 // unscoped roles — the scope denial fires without touching the key store).
7677 //
7678 // Clone to avoid holding a borrow on `self.pending_write_authz` while
7679 // also calling `self.ids.get(key)`.
7680 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7681 let has_create = authz.scope.create_labels.contains(&stmt.label);
7682 let has_update = authz.scope.update_labels.contains(&stmt.label);
7683 if !has_create && !has_update {
7684 // Scope-before-lookup: 403 without has_node call (timing oracle
7685 // closure — see test_merge_unscoped_no_key_lookup).
7686 return Err(GraphError::RoleWriteDenied {
7687 reason: format!(
7688 "role-bound token: label '{}' not in write scope (create_labels)",
7689 stmt.label
7690 ),
7691 });
7692 }
7693 // Key lookup under mask.
7694 match self.ids.get(key.as_str()) {
7695 Some(id) if authz.mask.contains_id(id) => {
7696 // Visible: must have update scope to proceed to match arm.
7697 if !has_update {
7698 return Err(GraphError::RoleWriteDenied {
7699 reason: format!(
7700 "role-bound token: label '{}' not in write scope (update_labels)",
7701 stmt.label
7702 ),
7703 });
7704 }
7705 true // existed = true → match arm
7706 }
7707 Some(_) => {
7708 // Hidden: same error as absent to the role (spec §3.1/§3.3).
7709 return Err(GraphError::RoleWriteDenied {
7710 reason: "role-bound token: target node not visible".into(),
7711 });
7712 }
7713 None => {
7714 // Absent: must have create scope to proceed to the create arm.
7715 //
7716 // Update-only roles (create_labels empty, update_labels set):
7717 // return the SAME "not visible" error as the hidden-key branch
7718 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7719 // "confirm existence of hidden nodes: No").
7720 //
7721 // Create-scoped roles (has_create=true): absent → create arm
7722 // as before. The accepted structural key-existence disclosure
7723 // (§THREAT-MODEL) applies only when the role holds create scope.
7724 if !has_create {
7725 return Err(GraphError::RoleWriteDenied {
7726 reason: "role-bound token: target node not visible".into(),
7727 });
7728 }
7729 false // existed = false → create arm
7730 }
7731 }
7732 } else {
7733 // Full authority: use the existing non-masked has_node check.
7734 self.has_node(&key)
7735 };
7736
7737 let existed = merge_existed;
7738 let mut created = 0i64;
7739 if !existed || !stmt.on_match.is_empty() {
7740 let mut batch = self.batch();
7741 if !existed {
7742 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7743 batch.insert_node(&stmt.label, &key, props);
7744 for sc in &stmt.on_create {
7745 let value = resolve_merge_set_value(&sc.value, params)?;
7746 batch.set_prop(&key, &sc.field, value);
7747 }
7748 created = 1;
7749 } else {
7750 for sc in &stmt.on_match {
7751 let value = resolve_merge_set_value(&sc.value, params)?;
7752 batch.set_prop(&key, &sc.field, value);
7753 }
7754 }
7755 batch.commit()?;
7756 }
7757
7758 // Refresh the role mask so the just-created node is visible to this
7759 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7760 // (apply_schema subset rule), so the new node's label is already in the
7761 // role's read scope — this never widens beyond the role's declared labels.
7762 if !existed {
7763 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7764 let new_mask = self.mask_for_role(&role)?;
7765 if let Some(a) = self.pending_write_authz.as_mut() {
7766 a.mask = new_mask;
7767 }
7768 }
7769 }
7770
7771 // Optional RETURN clause: project the node (created or matched) as a read result.
7772 if let Some(returns) = stmt.returns {
7773 let var = stmt.var.as_deref().unwrap_or("_mn0");
7774 let q = Query {
7775 matches: vec![Pattern {
7776 start: NodePat {
7777 var: Some(var.to_string()),
7778 label: Some(stmt.label.clone()),
7779 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7780 },
7781 chain: vec![],
7782 shortest: false,
7783 }],
7784 optional_clauses: vec![],
7785 where_expr: None,
7786 unwinds: vec![],
7787 post_unwind_where: None,
7788 stages: vec![],
7789 returns,
7790 distinct: false,
7791 order_by: vec![],
7792 skip: None,
7793 limit: None,
7794 };
7795 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7796 detail: format!("plan: {e}"),
7797 })?;
7798 // Use view_masked when a role-scoped write is in flight so the
7799 // post-merge projection is consistent with the masked read phase.
7800 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7801 return (if let Some(ref mask) = mask_opt {
7802 execute(&self.view_masked(mask), &ops, &Params(params))
7803 } else {
7804 execute(&self.view(), &ops, &Params(params))
7805 })
7806 .map_err(|e| GraphError::QueryError {
7807 detail: format!("execute: {e}"),
7808 });
7809 }
7810
7811 let mut rs = write_result_set();
7812 rs.push_row(vec![
7813 Some(Value::Int(created)),
7814 Some(Value::Int(0)),
7815 Some(Value::Int(0)),
7816 ]);
7817 Ok(rs)
7818 }
7819
7820 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7821 /// annotated with rule name, edge type, direction, and weight.
7822 /// Results are sorted by (rule, edge_type).
7823 /// Returns `Err(KeyNotFound)` if either key is unknown.
7824 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7825 self.ensure_v8_base_sections_loaded();
7826 let id_a = self
7827 .ids
7828 .get(key_a)
7829 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7830 let id_b = self
7831 .ids
7832 .get(key_b)
7833 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7834
7835 let mut results = Vec::new();
7836
7837 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7838 // rather than O(total provenance).
7839 let scan = if self.engine.provenance_touching_len(id_a)
7840 <= self.engine.provenance_touching_len(id_b)
7841 {
7842 id_a
7843 } else {
7844 id_b
7845 };
7846 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7847 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7848 continue;
7849 }
7850 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7851 continue;
7852 };
7853 let edge_type = match self.syms.resolve(etype) {
7854 Some(s) => s.to_string(),
7855 None => continue,
7856 };
7857 // Provenance (src, dst) ids come from the archived PROVENANCE section
7858 // (large, no eager CRC). A corrupt section can produce ids that are
7859 // out of range; return Corrupt rather than panic.
7860 let src_key = self
7861 .ids
7862 .key_of(src)
7863 .ok_or_else(|| GraphError::Corrupt {
7864 detail: format!("v8: provenance src id {src} not in id table"),
7865 })?
7866 .to_string();
7867 let dst_key = self
7868 .ids
7869 .key_of(dst)
7870 .ok_or_else(|| GraphError::Corrupt {
7871 detail: format!("v8: provenance dst id {dst} not in id table"),
7872 })?
7873 .to_string();
7874 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
7875 self.edge_props_view()
7876 .get(etype, src, dst, prop)
7877 .and_then(|v| {
7878 if let Value::Float(f) = v {
7879 Some(f)
7880 } else {
7881 None
7882 }
7883 })
7884 });
7885 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
7886 // still have a score: recompute it from the predicate so explain
7887 // never reports "no score" for an edge the engine scored. Via-hop
7888 // rules score over their via set, not over (src, dst), so leave
7889 // those None rather than report a number the rule did not produce.
7890 let weight = stored.or_else(|| {
7891 if rule_def.via_edge.is_some() {
7892 return None;
7893 }
7894 let props_view = build_props_view(&self.props, &self.base);
7895 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
7896 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
7897 let src_view = NodeView {
7898 key: &src_key,
7899 props: &src_get,
7900 };
7901 let dst_view = NodeView {
7902 key: &dst_key,
7903 props: &dst_get,
7904 };
7905 evaluate(&rule_def.predicate, &src_view, &dst_view)
7906 });
7907 results.push(Explanation {
7908 rule: rule_name.to_string(),
7909 edge_type,
7910 src_key,
7911 dst_key,
7912 weight,
7913 predicate: PredicateSummary {
7914 approximate: rule_def.approximate,
7915 ..PredicateSummary::from(&rule_def.predicate)
7916 },
7917 via_edge: rule_def.via_edge.clone(),
7918 });
7919 }
7920
7921 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7922 Ok(results)
7923 }
7924
7925 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7926 let id = self
7927 .ids
7928 .get(key)
7929 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7930 let Some(sym) = self.syms.get(edge_type) else {
7931 return Ok(Vec::new());
7932 };
7933 self.topo_view()
7934 .neighbors(sym, dir, id)
7935 .iter()
7936 .map(|&n| {
7937 self.ids
7938 .key_of(n)
7939 .map(|k| k.to_string())
7940 .ok_or_else(|| GraphError::Corrupt {
7941 detail: format!("topology id {n} has no key"),
7942 })
7943 })
7944 .collect::<Result<Vec<_>>>()
7945 }
7946
7947 /// Return the last-change commit sequence for `key`, or `None` if the node
7948 /// does not exist or has never been mutated since the last V5-V7 snapshot
7949 /// (horizon-bounded for legacy stores).
7950 ///
7951 /// The returned sequence is a monotonically increasing counter that starts
7952 /// at 1 for the first commit after `open` and increments with every
7953 /// successful write. WAL replay at open also assigns sequences (1..N for N
7954 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7955 ///
7956 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7957 /// in the snapshot but not touched by any WAL frame will return `None`
7958 /// (horizon-bounded: CAS against such nodes is only safe after the first
7959 /// V8 snapshot or after the node is next mutated).
7960 pub fn last_changed(&self, key: &str) -> Option<u64> {
7961 let id = self.ids.get(key)?;
7962 self.last_change.get(&id).copied()
7963 }
7964
7965 /// The current commit sequence (number of successful commits since open,
7966 /// including WAL replay frames). Useful for recording a baseline before
7967 /// a read-modify-write cycle.
7968 pub fn commit_seq(&self) -> u64 {
7969 self.commit_seq
7970 }
7971
7972 /// Check that all `preconds` are satisfied against the current db state.
7973 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7974 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7975 for precond in preconds {
7976 match precond {
7977 Precondition::NodeUnchangedSince { key, expected } => {
7978 // Missing entry means the node predates the WAL window or
7979 // does not exist; treat as 0 (before any commit).
7980 let actual = self.last_changed(key).unwrap_or_default();
7981 if actual != *expected {
7982 return Err(GraphError::CasConflict {
7983 key: key.clone(),
7984 expected: *expected,
7985 actual,
7986 });
7987 }
7988 }
7989 Precondition::NodeAbsent { key } => {
7990 // Node must not exist (not live).
7991 if self.ids.get(key).is_some() {
7992 let actual = self.last_changed(key).unwrap_or(0);
7993 return Err(GraphError::CasConflict {
7994 key: key.clone(),
7995 expected: u64::MAX,
7996 actual,
7997 });
7998 }
7999 }
8000 }
8001 }
8002 Ok(())
8003 }
8004
8005 /// Apply a batch of mutations with compare-and-set preconditions.
8006 ///
8007 /// All preconditions are checked atomically before any operation is applied.
8008 /// If any precondition fails, the entire batch is rejected with
8009 /// [`GraphError::CasConflict`] and no WAL frame is written.
8010 ///
8011 /// # Returns
8012 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8013 ///
8014 /// # Errors
8015 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8016 /// - Any error that [`write_batch`] would return for the ops themselves.
8017 pub fn write_batch_cas(
8018 &mut self,
8019 preconds: Vec<Precondition>,
8020 ops: Vec<BatchOp>,
8021 ) -> Result<(usize, usize)> {
8022 self.check_preconditions(&preconds)?;
8023 self.commit_logged_batch(ops, None, None)
8024 }
8025
8026 /// Update the per-node last-change map for a WAL record at commit `seq`.
8027 ///
8028 /// Called after a successful apply to record which nodes were touched.
8029 /// For replay, called with the WAL-frame's replayed seq.
8030 ///
8031 /// Touch definition (see [`Precondition`] doc):
8032 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8033 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8034 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8035 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8036 /// - Batch → recurse into inner records.
8037 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8038 match rec {
8039 WalRecord::InsertNode { key, .. }
8040 | WalRecord::SetProp { key, .. }
8041 | WalRecord::RemoveProp { key, .. } => {
8042 if let Some(id) = self.ids.get(key) {
8043 self.last_change.insert(id, seq);
8044 }
8045 }
8046 WalRecord::InsertNodeId { key, .. } => {
8047 if let Some(id) = self.ids.get(key) {
8048 self.last_change.insert(id, seq);
8049 }
8050 }
8051 WalRecord::SetPropId { id, .. } => {
8052 self.last_change.insert(*id, seq);
8053 }
8054 WalRecord::InsertEdge {
8055 src_key, dst_key, ..
8056 }
8057 | WalRecord::DeleteEdge {
8058 src_key, dst_key, ..
8059 } => {
8060 if let Some(src_id) = self.ids.get(src_key) {
8061 self.last_change.insert(src_id, seq);
8062 }
8063 if let Some(dst_id) = self.ids.get(dst_key) {
8064 self.last_change.insert(dst_id, seq);
8065 }
8066 }
8067 WalRecord::InsertEdgeId { src, dst, .. } => {
8068 self.last_change.insert(*src, seq);
8069 self.last_change.insert(*dst, seq);
8070 }
8071 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8072 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8073 // History markers: state no-ops; the underlying mutation already
8074 // touched the relevant nodes' last_change entries.
8075 WalRecord::DeleteNode { .. }
8076 | WalRecord::DerivedEdgeAdded { .. }
8077 | WalRecord::DerivedEdgeRetracted { .. }
8078 | WalRecord::Intern { .. }
8079 | WalRecord::CreateRule { .. }
8080 | WalRecord::DeleteRule { .. }
8081 | WalRecord::RebuildRule { .. }
8082 | WalRecord::CreateView { .. }
8083 | WalRecord::DeleteView { .. }
8084 | WalRecord::EnableFulltext { .. }
8085 | WalRecord::DisableFulltext { .. }
8086 | WalRecord::EnableIndex { .. }
8087 | WalRecord::DisableIndex { .. } => {}
8088 // RenameNode: node id is stable; update last_change via the new key.
8089 // Called after apply(), so ids already reflects new_key.
8090 WalRecord::RenameNode { new_key, .. } => {
8091 if let Some(id) = self.ids.get(new_key) {
8092 self.last_change.insert(id, seq);
8093 }
8094 }
8095 WalRecord::Batch(inner) => {
8096 for inner_rec in inner {
8097 self.update_last_change_from_rec(inner_rec, seq);
8098 }
8099 }
8100 }
8101 }
8102
8103 pub fn node_count(&self) -> usize {
8104 self.ids.len()
8105 }
8106
8107 /// Configure archive retention: keep the `N` newest WAL archives at each
8108 /// [`snapshot_with`] call when `archive_wal: true`.
8109 ///
8110 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8111 /// `Some(0)` or `None` → unlimited (no pruning).
8112 ///
8113 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8114 /// stores the policy. Archives below the retention limit are deleted
8115 /// oldest-first. The horizon floor is updated so that
8116 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8117 /// in pruned archives rather than silently returning wrong data.
8118 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8119 self.wal_archive_retention = keep;
8120 }
8121
8122 /// Delete any WAL archives that are fully below the current horizon floor.
8123 ///
8124 /// Orphaned archives arise when the floor is written first during retention
8125 /// pruning and then a crash interrupts the archive-delete sequence. The
8126 /// opening cleanup ensures no subsequent read path sees stale data.
8127 ///
8128 /// Under the monotonic naming scheme, the archive name N equals the
8129 /// cumulative end-frame index of the archive in global commit space (i.e.
8130 /// the archive covers global frames `[prev_n, N)`). An archive is
8131 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8132 /// below the floor and have already been counted in it.
8133 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8134 if self.wal_horizon_floor == 0 {
8135 // Floor at 0 means no pruning has ever occurred; nothing to clean.
8136 return Ok(());
8137 }
8138 let archive_ns = self.fs.list_archives()?;
8139 for n in archive_ns {
8140 if n <= self.wal_horizon_floor {
8141 // Archive N ends at global frame N; all its frames are below
8142 // the floor (floor already accounts for them) → orphaned.
8143 self.fs.delete_archive(n).map_err(GraphError::Io)?;
8144 } else {
8145 // Archives are sorted ascending; first one above floor stops scan.
8146 break;
8147 }
8148 }
8149 Ok(())
8150 }
8151
8152 /// Collect all WAL frames from surviving archives (oldest-first) then the
8153 /// live WAL into one flat list, and return the total along with the number
8154 /// of archive frames at the front of the list.
8155 ///
8156 /// Commit indices into the returned list are LOCAL (0 = first frame of
8157 /// oldest surviving archive). To obtain the GLOBAL index add
8158 /// `self.wal_horizon_floor`.
8159 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8160 let archive_ns = self.fs.list_archives()?;
8161 let mut all: Vec<WalRecord> = Vec::new();
8162 for n in archive_ns {
8163 let bytes = self.fs.read_archive(n)?;
8164 let (frames, _) = decode_all(&bytes);
8165 all.extend(frames);
8166 }
8167 let archive_count = all.len() as u64;
8168 let live_bytes = self.fs.read(FileId::Wal)?;
8169 let (live_frames, _) = decode_all(&live_bytes);
8170 all.extend(live_frames);
8171 Ok((all, archive_count))
8172 }
8173
8174 /// Return the total number of committed WAL frames visible in the current
8175 /// horizon window, including frames in surviving WAL archives.
8176 ///
8177 /// This is the exclusive upper bound for valid `at_commit` indices in
8178 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8179 ///
8180 /// Returns the horizon floor when all surviving history is empty.
8181 pub fn wal_total_commits(&self) -> Result<u64> {
8182 let (frames, _) = self.all_frames()?;
8183 Ok(self.wal_horizon_floor + frames.len() as u64)
8184 }
8185
8186 /// The global frame index of the first commit reachable through surviving
8187 /// archives (0 when no archives have been pruned).
8188 pub fn wal_horizon_floor(&self) -> u64 {
8189 self.wal_horizon_floor
8190 }
8191
8192 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8193 ///
8194 /// ## Horizon
8195 ///
8196 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8197 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8198 /// zero-cost contract; a durable history log is out of scope.
8199 ///
8200 /// ## Derived edges
8201 ///
8202 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8203 /// history. Only edges written directly by the application are recorded.
8204 ///
8205 /// ## Deleted nodes
8206 ///
8207 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8208 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8209 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8210 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8211 ///
8212 /// ## Dense-id edge entries and tombstoned partners
8213 ///
8214 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8215 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8216 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8217 /// Build commit-bounded alias intervals for `queried_key`.
8218 ///
8219 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8220 /// A record written under `key` at commit `c` matches the queried identity iff
8221 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8222 ///
8223 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8224 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8225 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8226 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8227 /// only identity-2's events (commits 7–9 under "a") are in scope.
8228 ///
8229 /// Only **forward aliasing**: querying the *new* key surfaces events written
8230 /// under the *old* key. The reverse direction is not supported.
8231 fn build_key_alias_intervals(
8232 &self,
8233 frames: &[core_storage::wal::WalRecord],
8234 queried_key: &str,
8235 ) -> Vec<(String, u64, Option<u64>)> {
8236 use core_storage::wal::WalRecord;
8237
8238 // Pre-pass: build reverse_rename and key_starts maps.
8239 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8240 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8241
8242 for (local_i, frame) in frames.iter().enumerate() {
8243 let commit = self.wal_horizon_floor + local_i as u64;
8244 let records: &[WalRecord] = match frame {
8245 WalRecord::Batch(inner) => inner.as_slice(),
8246 single => std::slice::from_ref(single),
8247 };
8248 for rec in records {
8249 match rec {
8250 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8251 key_starts.entry(key.clone()).or_default().push(commit);
8252 }
8253 WalRecord::RenameNode { old_key, new_key } => {
8254 // new_key came into existence at this commit.
8255 key_starts.entry(new_key.clone()).or_default().push(commit);
8256 // Record the reverse rename: new_key was introduced by renaming old_key.
8257 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8258 }
8259 _ => {}
8260 }
8261 }
8262 }
8263
8264 // Build alias intervals by following the reverse rename chain.
8265 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8266 let mut current_key = queried_key.to_string();
8267 let mut current_valid_until: Option<u64> = None;
8268
8269 loop {
8270 // valid_from: the most recent commit where current_key was assigned to this
8271 // identity. For aliases (valid_until = Some(vu)), find the last start event
8272 // for the key strictly before vu — this is where the alias's occupancy by
8273 // this identity began, correctly excluding prior identities that reused the key.
8274 let valid_from = if let Some(vu) = current_valid_until {
8275 key_starts
8276 .get(¤t_key)
8277 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8278 .unwrap_or(self.wal_horizon_floor)
8279 } else {
8280 // Queried key — no upper bound; may have been introduced at any commit.
8281 self.wal_horizon_floor
8282 };
8283
8284 result.push((current_key.clone(), valid_from, current_valid_until));
8285
8286 match reverse_rename.get(¤t_key) {
8287 Some((old_key, rename_commit)) => {
8288 current_valid_until = Some(*rename_commit);
8289 current_key = old_key.clone();
8290 }
8291 None => break,
8292 }
8293 }
8294
8295 result
8296 }
8297
8298 /// Returns true if `record_key` matches any alias interval that covers `commit`.
8299 fn aliases_match(
8300 intervals: &[(String, u64, Option<u64>)],
8301 record_key: &str,
8302 commit: u64,
8303 ) -> bool {
8304 intervals
8305 .iter()
8306 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8307 }
8308
8309 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8310 use crate::history::{HistoryChange, HistoryEntry};
8311 use core_storage::wal::WalRecord;
8312
8313 let (frames, _) = self.all_frames()?;
8314
8315 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8316 let alias_intervals = self.build_key_alias_intervals(&frames, key);
8317
8318 let mut out: Vec<HistoryEntry> = Vec::new();
8319
8320 for (local_i, frame) in frames.iter().enumerate() {
8321 let commit = self.wal_horizon_floor + local_i as u64;
8322 // Collect the inner records to process — Batch is one commit, single records are one commit.
8323 let records: &[WalRecord] = match frame {
8324 WalRecord::Batch(inner) => inner.as_slice(),
8325 single => std::slice::from_ref(single),
8326 };
8327
8328 for rec in records {
8329 let change = match rec {
8330 WalRecord::InsertNode { label, key: k, .. }
8331 if Self::aliases_match(&alias_intervals, k, commit) =>
8332 {
8333 Some(HistoryChange::NodeInserted {
8334 label: label.clone(),
8335 })
8336 }
8337 WalRecord::InsertNodeId { label, key: k, .. }
8338 if Self::aliases_match(&alias_intervals, k, commit) =>
8339 {
8340 let label_str = match self.syms.resolve(*label) {
8341 Some(s) => s.to_string(),
8342 None => continue,
8343 };
8344 Some(HistoryChange::NodeInserted { label: label_str })
8345 }
8346 WalRecord::SetProp {
8347 key: k,
8348 field,
8349 value,
8350 } if Self::aliases_match(&alias_intervals, k, commit) => {
8351 Some(HistoryChange::PropSet {
8352 field: field.clone(),
8353 value: value.clone(),
8354 })
8355 }
8356 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8357 // key_of returns the current (post-rename) key; compare to queried key.
8358 Some(resolved) if resolved == key => {
8359 let field_str = match self.syms.resolve(*field) {
8360 Some(s) => s.to_string(),
8361 None => continue,
8362 };
8363 Some(HistoryChange::PropSet {
8364 field: field_str,
8365 value: value.clone(),
8366 })
8367 }
8368 _ => None,
8369 },
8370 WalRecord::RemoveProp { key: k, field }
8371 if Self::aliases_match(&alias_intervals, k, commit) =>
8372 {
8373 Some(HistoryChange::PropRemoved {
8374 field: field.clone(),
8375 })
8376 }
8377 WalRecord::InsertEdge {
8378 edge_type,
8379 src_key,
8380 dst_key,
8381 } => {
8382 if Self::aliases_match(&alias_intervals, src_key, commit) {
8383 Some(HistoryChange::EdgeAdded {
8384 edge_type: edge_type.clone(),
8385 other: dst_key.clone(),
8386 outgoing: true,
8387 })
8388 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8389 Some(HistoryChange::EdgeAdded {
8390 edge_type: edge_type.clone(),
8391 other: src_key.clone(),
8392 outgoing: false,
8393 })
8394 } else {
8395 None
8396 }
8397 }
8398 WalRecord::InsertEdgeId { etype, src, dst } => {
8399 let etype_str = match self.syms.resolve(*etype) {
8400 Some(s) => s.to_string(),
8401 None => continue,
8402 };
8403 let src_key = self.ids.key_of(*src);
8404 let dst_key = self.ids.key_of(*dst);
8405 if src_key == Some(key) {
8406 let other = match dst_key {
8407 Some(s) => s.to_string(),
8408 None => continue,
8409 };
8410 Some(HistoryChange::EdgeAdded {
8411 edge_type: etype_str,
8412 other,
8413 outgoing: true,
8414 })
8415 } else if dst_key == Some(key) {
8416 let other = match src_key {
8417 Some(s) => s.to_string(),
8418 None => continue,
8419 };
8420 Some(HistoryChange::EdgeAdded {
8421 edge_type: etype_str,
8422 other,
8423 outgoing: false,
8424 })
8425 } else {
8426 None
8427 }
8428 }
8429 WalRecord::DeleteEdge {
8430 edge_type,
8431 src_key,
8432 dst_key,
8433 } => {
8434 if Self::aliases_match(&alias_intervals, src_key, commit) {
8435 Some(HistoryChange::EdgeRemoved {
8436 edge_type: edge_type.clone(),
8437 other: dst_key.clone(),
8438 outgoing: true,
8439 })
8440 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8441 Some(HistoryChange::EdgeRemoved {
8442 edge_type: edge_type.clone(),
8443 other: src_key.clone(),
8444 outgoing: false,
8445 })
8446 } else {
8447 None
8448 }
8449 }
8450 WalRecord::DeleteNode { key: k }
8451 if Self::aliases_match(&alias_intervals, k, commit) =>
8452 {
8453 Some(HistoryChange::NodeDeleted)
8454 }
8455 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8456 _ => None,
8457 };
8458
8459 if let Some(change) = change {
8460 out.push(HistoryEntry { commit, change });
8461 }
8462 }
8463 }
8464
8465 Ok(out)
8466 }
8467
8468 /// Return the per-edge change history between nodes `a` and `b` by scanning
8469 /// the on-disk WAL.
8470 ///
8471 /// ## Horizon
8472 ///
8473 /// History reaches back only to the last WAL-truncating snapshot, exactly
8474 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8475 /// `total_commits` (= number of WAL frames), which is the exclusive upper
8476 /// bound for valid commit indices.
8477 ///
8478 /// ## Derived edges
8479 ///
8480 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8481 /// WAL markers written by `log_then_apply_with` after each rule-firing
8482 /// mutation. The `rule` field of those events carries the rule name.
8483 ///
8484 /// ## DeleteNode
8485 ///
8486 /// When a node is deleted, its manual incident edges are swept inline without
8487 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8488 /// events for either endpoint and synthesises `Retracted(rule:None)` events
8489 /// for each manual edge that was active at that point. Derived edges active at
8490 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8491 /// the engine appends immediately after the `DeleteNode` record; those events
8492 /// carry correct rule attribution and are emitted by the marker arm, not the
8493 /// synthetic sweep.
8494 ///
8495 /// ## Masks
8496 ///
8497 /// Like `node_history`, this method has no mask parameter and returns WAL
8498 /// history regardless of any role mask. For masked history semantics, apply
8499 /// the mask at the caller level.
8500 pub fn edge_history(
8501 &self,
8502 a: &str,
8503 b: &str,
8504 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8505 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8506 use core_storage::wal::WalRecord;
8507
8508 let (frames, _) = self.all_frames()?;
8509 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8510
8511 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8512 // Intervals are commit-bounded so recycled keys don't contaminate histories.
8513 let alias_a = self.build_key_alias_intervals(&frames, a);
8514 let alias_b = self.build_key_alias_intervals(&frames, b);
8515
8516 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8517 // The is_derived flag is used by the DeleteNode sweep: manual edges are
8518 // swept with a synthetic Retracted(rule:None); derived edges are skipped
8519 // because the engine writes a DerivedEdgeRetracted marker immediately after
8520 // the DeleteNode record, which carries the correct rule attribution.
8521 let mut active: Vec<(String, String, String, bool)> = Vec::new();
8522 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8523
8524 for (local_i, frame) in frames.iter().enumerate() {
8525 let commit = self.wal_horizon_floor + local_i as u64;
8526 let records: &[WalRecord] = match frame {
8527 WalRecord::Batch(inner) => inner.as_slice(),
8528 single => std::slice::from_ref(single),
8529 };
8530
8531 for rec in records {
8532 match rec {
8533 WalRecord::InsertEdge {
8534 edge_type,
8535 src_key,
8536 dst_key,
8537 } => {
8538 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8539 && Self::aliases_match(&alias_b, dst_key, commit);
8540 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8541 && Self::aliases_match(&alias_a, dst_key, commit);
8542 if is_ab || is_ba {
8543 active.push((
8544 edge_type.clone(),
8545 src_key.clone(),
8546 dst_key.clone(),
8547 false,
8548 ));
8549 out.push(EdgeHistoryEvent {
8550 edge_type: edge_type.clone(),
8551 commit,
8552 event: EdgeEvent::Added,
8553 rule: None,
8554 });
8555 }
8556 }
8557 WalRecord::InsertEdgeId { etype, src, dst } => {
8558 let etype_str = match self.syms.resolve(*etype) {
8559 Some(s) => s.to_string(),
8560 None => continue,
8561 };
8562 // Use key_of_historical so tombstoned nodes (deleted
8563 // later in the WAL) still resolve during the scan.
8564 let src_key = self.ids.key_of_historical(*src);
8565 let dst_key = self.ids.key_of_historical(*dst);
8566 let is_ab = src_key == Some(a) && dst_key == Some(b);
8567 let is_ba = src_key == Some(b) && dst_key == Some(a);
8568 if is_ab || is_ba {
8569 let src_str = src_key.unwrap().to_string();
8570 let dst_str = dst_key.unwrap().to_string();
8571 active.push((etype_str.clone(), src_str, dst_str, false));
8572 out.push(EdgeHistoryEvent {
8573 edge_type: etype_str,
8574 commit,
8575 event: EdgeEvent::Added,
8576 rule: None,
8577 });
8578 }
8579 }
8580 WalRecord::DeleteEdge {
8581 edge_type,
8582 src_key,
8583 dst_key,
8584 } => {
8585 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8586 && Self::aliases_match(&alias_b, dst_key, commit);
8587 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8588 && Self::aliases_match(&alias_a, dst_key, commit);
8589 if is_ab || is_ba {
8590 // Remove the first matching active entry (flag ignored).
8591 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8592 et == edge_type && s == src_key && d == dst_key
8593 }) {
8594 active.remove(pos);
8595 }
8596 out.push(EdgeHistoryEvent {
8597 edge_type: edge_type.clone(),
8598 commit,
8599 event: EdgeEvent::Retracted,
8600 rule: None,
8601 });
8602 }
8603 }
8604 WalRecord::DeleteNode { key: k }
8605 if Self::aliases_match(&alias_a, k, commit)
8606 || Self::aliases_match(&alias_b, k, commit) =>
8607 {
8608 // Sweep: implicitly retract only MANUAL active edges.
8609 // Derived active edges are skipped here because the rule
8610 // engine appends a DerivedEdgeRetracted marker immediately
8611 // after this DeleteNode record; that marker produces the
8612 // single correctly-attributed Retracted event. Derived
8613 // entries are dropped from `active` (the marker arm's
8614 // idempotent retain finds nothing to remove).
8615 for (et, _, _, is_derived) in active.drain(..) {
8616 if !is_derived {
8617 out.push(EdgeHistoryEvent {
8618 edge_type: et,
8619 commit,
8620 event: EdgeEvent::Retracted,
8621 rule: None,
8622 });
8623 }
8624 // Derived: drop silently; marker carries the Retracted event.
8625 }
8626 }
8627 WalRecord::DerivedEdgeAdded {
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 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8639 out.push(EdgeHistoryEvent {
8640 edge_type: et.clone(),
8641 commit,
8642 event: EdgeEvent::Added,
8643 rule: Some(rule.clone()),
8644 });
8645 }
8646 }
8647 WalRecord::DerivedEdgeRetracted {
8648 rule,
8649 edge_type: et,
8650 src_key,
8651 dst_key,
8652 } => {
8653 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8654 && Self::aliases_match(&alias_b, dst_key, commit);
8655 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8656 && Self::aliases_match(&alias_a, dst_key, commit);
8657 if is_ab || is_ba {
8658 // Push unconditionally: a derived edge whose Added marker
8659 // predates the history horizon has no `active` entry, but
8660 // the retraction is still a real in-window event.
8661 // Remove from active idempotently if present.
8662 active.retain(|(aet, s, d, _)| {
8663 !(aet == et && s == src_key && d == dst_key)
8664 });
8665 out.push(EdgeHistoryEvent {
8666 edge_type: et.clone(),
8667 commit,
8668 event: EdgeEvent::Retracted,
8669 rule: Some(rule.clone()),
8670 });
8671 }
8672 }
8673 // All other records (InsertNode, SetProp, CreateRule, etc.)
8674 // do not affect edges between a and b.
8675 _ => {}
8676 }
8677 }
8678 }
8679
8680 Ok(HistoryResult {
8681 items: out,
8682 total_commits,
8683 })
8684 }
8685
8686 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8687 /// (in either direction) at the WAL commit `at_commit`.
8688 ///
8689 /// ## Horizon
8690 ///
8691 /// Valid commit indices are `0..total_commits` where `total_commits` is the
8692 /// number of WAL frames. An `at_commit >= total_commits` is outside the
8693 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8694 ///
8695 /// ## Derived edges
8696 ///
8697 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8698 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8699 /// and therefore includes derived edges in its point-in-time evaluation,
8700 /// matching `edge_history`'s fidelity.
8701 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8702 use core_storage::wal::WalRecord;
8703
8704 let (frames, _) = self.all_frames()?;
8705 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8706
8707 // Horizon floor: commits in pruned archives are unreachable.
8708 if at_commit < self.wal_horizon_floor {
8709 return Err(GraphError::CommitOutOfRange {
8710 commit: at_commit,
8711 total: total_commits,
8712 });
8713 }
8714 if at_commit >= total_commits {
8715 return Err(GraphError::CommitOutOfRange {
8716 commit: at_commit,
8717 total: total_commits,
8718 });
8719 }
8720
8721 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8722 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8723 let alias_a = self.build_key_alias_intervals(&frames, a);
8724 let alias_b = self.build_key_alias_intervals(&frames, b);
8725
8726 // Local index into surviving frames (0 = first frame of oldest archive).
8727 let local_commit = at_commit - self.wal_horizon_floor;
8728
8729 // Replay local frames 0..=local_commit, tracking active edges.
8730 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8731
8732 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8733 let commit = self.wal_horizon_floor + local_i as u64;
8734 let records: &[WalRecord] = match frame {
8735 WalRecord::Batch(inner) => inner.as_slice(),
8736 single => std::slice::from_ref(single),
8737 };
8738
8739 for rec in records {
8740 match rec {
8741 WalRecord::InsertEdge {
8742 edge_type: et,
8743 src_key,
8744 dst_key,
8745 } => {
8746 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8747 && Self::aliases_match(&alias_b, dst_key, commit);
8748 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8749 && Self::aliases_match(&alias_a, dst_key, commit);
8750 if is_ab || is_ba {
8751 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8752 }
8753 }
8754 WalRecord::InsertEdgeId { etype, src, dst } => {
8755 let etype_str = match self.syms.resolve(*etype) {
8756 Some(s) => s.to_string(),
8757 None => continue,
8758 };
8759 // Use key_of_historical so tombstoned nodes resolve.
8760 let src_key = self.ids.key_of_historical(*src);
8761 let dst_key = self.ids.key_of_historical(*dst);
8762 let is_ab = src_key == Some(a) && dst_key == Some(b);
8763 let is_ba = src_key == Some(b) && dst_key == Some(a);
8764 if is_ab || is_ba {
8765 active.insert((
8766 etype_str,
8767 src_key.unwrap().to_string(),
8768 dst_key.unwrap().to_string(),
8769 ));
8770 }
8771 }
8772 WalRecord::DeleteEdge {
8773 edge_type: et,
8774 src_key,
8775 dst_key,
8776 } => {
8777 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8778 && Self::aliases_match(&alias_b, dst_key, commit);
8779 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8780 && Self::aliases_match(&alias_a, dst_key, commit);
8781 if is_ab || is_ba {
8782 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8783 }
8784 }
8785 WalRecord::DeleteNode { key: k }
8786 if Self::aliases_match(&alias_a, k, commit)
8787 || Self::aliases_match(&alias_b, k, commit) =>
8788 {
8789 // All edges touching the deleted node are gone.
8790 active.retain(|(_, s, d)| s != k && d != k);
8791 }
8792 WalRecord::DerivedEdgeAdded {
8793 edge_type: et,
8794 src_key,
8795 dst_key,
8796 ..
8797 } => {
8798 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8799 && Self::aliases_match(&alias_b, dst_key, commit);
8800 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8801 && Self::aliases_match(&alias_a, dst_key, commit);
8802 if is_ab || is_ba {
8803 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8804 }
8805 }
8806 WalRecord::DerivedEdgeRetracted {
8807 edge_type: et,
8808 src_key,
8809 dst_key,
8810 ..
8811 } => {
8812 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8813 && Self::aliases_match(&alias_b, dst_key, commit);
8814 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8815 && Self::aliases_match(&alias_a, dst_key, commit);
8816 if is_ab || is_ba {
8817 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8818 }
8819 }
8820 _ => {}
8821 }
8822 }
8823 }
8824
8825 Ok(active.iter().any(|(et, _, _)| et == edge_type))
8826 }
8827
8828 pub fn edge_count(&self) -> u64 {
8829 self.topo_view().edge_count()
8830 }
8831
8832 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8833 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8834 pub fn stats(&self) -> Stats {
8835 self.ensure_v8_base_sections_loaded();
8836 let rules: Vec<RuleStats> = self
8837 .engine
8838 .rules()
8839 .map(|r| RuleStats {
8840 name: r.name.clone(),
8841 edges: self
8842 .engine
8843 .provenance()
8844 .get(&r.name)
8845 .map(|s| s.len() as u64)
8846 .unwrap_or(0),
8847 tripped: self.engine.is_tripped(&r.name),
8848 fires: self.engine.fire_count(&r.name),
8849 approximate: r.approximate,
8850 })
8851 .collect();
8852 Stats {
8853 nodes_live: self.ids.live_len(),
8854 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8855 edges: self.topo_view().edge_count(),
8856 rules,
8857 chain_truncations: self.engine.chain_truncations(),
8858 }
8859 }
8860
8861 /// On-disk size of the WAL file in bytes.
8862 ///
8863 /// Reads file metadata without loading WAL contents. Returns `Err` for
8864 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8865 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8866 let path = self.fs.wal_path().ok_or_else(|| {
8867 std::io::Error::new(
8868 std::io::ErrorKind::Unsupported,
8869 "wal_path not available for this Fs implementation",
8870 )
8871 })?;
8872 Ok(std::fs::metadata(path)?.len())
8873 }
8874
8875 /// Set the slow-query threshold. Queries whose execution time equals or
8876 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
8877 ///
8878 /// Use this setter in tests — the environment variable
8879 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8880 /// threads.
8881 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8882 self.slow_query_threshold_ms = ms;
8883 }
8884
8885 /// Snapshot of the slow-query ring buffer and lifetime counter.
8886 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8887 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8888 SlowQuerySnapshot {
8889 threshold_ms: self.slow_query_threshold_ms,
8890 count: log.total,
8891 last: log.entries.iter().cloned().collect(),
8892 }
8893 }
8894
8895 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
8896 /// to compute uptime.
8897 pub fn started_at(&self) -> std::time::Instant {
8898 self.started_at
8899 }
8900
8901 /// On-disk snapshot format version this binary writes and reads.
8902 pub fn format_version() -> u16 {
8903 core_storage::snapshot::VERSION
8904 }
8905
8906 /// Test-support: total bytes appended (SimFs only usage).
8907 pub fn fs_total_appended(&self) -> usize
8908 where
8909 F: FsIntrospect,
8910 {
8911 self.fs.total_appended()
8912 }
8913
8914 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8915 pub fn fs_sync_count(&self) -> usize
8916 where
8917 F: FsIntrospect,
8918 {
8919 self.fs.sync_count()
8920 }
8921
8922 /// Consume the db, returning its fs (for crash simulation).
8923 pub fn into_fs(self) -> F {
8924 self.fs
8925 }
8926
8927 pub fn snapshot(&mut self) -> Result<()> {
8928 self.snapshot_with(SnapshotOptions::default())
8929 }
8930
8931 /// Snapshot with explicit options.
8932 ///
8933 /// # `keep_wal`
8934 ///
8935 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8936 /// - The WAL is replaced with a minimal baseline containing one
8937 /// `EnableFulltext` record per active declaration. All pre-snapshot
8938 /// history is discarded; `open_at` can only reach post-snapshot commits.
8939 ///
8940 /// When `keep_wal` is `true`:
8941 /// - The WAL is left intact. All pre-snapshot commits remain reachable
8942 /// via `open_at`. The existing WAL already contains the original
8943 /// `EnableFulltext` records, so no baseline re-write is needed; the
8944 /// recovery guards in `apply()` silently skip any duplicate records on
8945 /// replay.
8946 /// - Crash window: a crash after the snapshot write but before the next
8947 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
8948 /// snapshot is loaded and the WAL replayed idempotently over it — safe
8949 /// because every `apply()` arm is idempotent when replayed over an
8950 /// already-current snapshot.
8951 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8952 if self.read_only {
8953 return Err(GraphError::ReadOnly);
8954 }
8955 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8956 // Used by the archive path's conservative genesis-chain check: if a prior
8957 // snapshot exists but wal.truncated does not, we cannot distinguish a
8958 // legacy store (may have been truncated in an older code version) from a
8959 // new store that only used keep_wal=true. Conservative: refuse genesis in
8960 // both cases. Must be sampled here, before the snapshot write below.
8961 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8962 self.ensure_v8_base_sections_loaded();
8963 // Ensure provenance is decoded before to_persist() clones it.
8964 self.engine.ensure_provenance_loaded_mut();
8965 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8966 let rule_defs = rule_defs_typed
8967 .iter()
8968 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8969 .collect();
8970 // Collect HNSW state and IVF state. When indexes are not yet
8971 // populated (clean open, no mutation since open), pass the retained
8972 // raw bytes through directly so that migrate/snapshot does not
8973 // silently discard fitted approximate-rule indexes.
8974 let hnsw_state = self.engine.export_hnsw_state_passthrough();
8975 let ivf_bytes = if !self.engine.indexes_populated() {
8976 // Pass retained IVF bytes through unchanged (no re-encode).
8977 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8978 } else {
8979 // Indexes live: encode from current state.
8980 let raw_ivf = self.engine.export_ivf_state();
8981 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8982 .into_iter()
8983 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8984 (
8985 name,
8986 core_storage::snapshot::PerRuleIvfState {
8987 src: core_storage::snapshot::SideIvfState {
8988 centroids: sc,
8989 clusters: sa,
8990 drift: sd,
8991 },
8992 dst: core_storage::snapshot::SideIvfState {
8993 centroids: dc,
8994 clusters: da,
8995 drift: dd,
8996 },
8997 },
8998 )
8999 })
9000 .collect();
9001 if ivf_state_map.is_empty() {
9002 Vec::new()
9003 } else {
9004 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
9005 }
9006 };
9007 let view_defs: Vec<Vec<u8>> = self
9008 .view_store
9009 .views()
9010 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
9011 .collect();
9012 if self.base.is_some() {
9013 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
9014 // write it atomically, remap it as the new base, then clear the overlay.
9015 let meta = V8Meta {
9016 labels: self.labels.clone(),
9017 edge_props: self.edge_props.clone(),
9018 rule_defs,
9019 provenance,
9020 rule_tripped,
9021 rule_fires,
9022 ivf_bytes,
9023 view_defs,
9024 wal_truncated: !opts.keep_wal,
9025 hnsw: hnsw_state,
9026 last_change: self.last_change.clone(),
9027 };
9028 let mut buf: Vec<u8> = Vec::new();
9029 {
9030 // Clone the Arc so the old base stays alive while we encode.
9031 // The borrow of archived_csr (into old_base's mmap) is released
9032 // at the end of this block, before we replace self.base.
9033 let old_base = self.base.clone().expect("is_some checked above");
9034 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9035 detail: format!("v8 snapshot: topology section: {e:?}"),
9036 })?;
9037 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9038 detail: format!("v8 snapshot: columns section: {e:?}"),
9039 })?;
9040 let archived_edge_props =
9041 old_base
9042 .edge_props_section()
9043 .map_err(|e| GraphError::Corrupt {
9044 detail: format!("v8 snapshot: edge_props section: {e:?}"),
9045 })?;
9046 let edge_props_raw =
9047 old_base
9048 .edge_props_raw_bytes()
9049 .map_err(|e| GraphError::Corrupt {
9050 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9051 })?;
9052 let prov_raw =
9053 old_base
9054 .provenance_raw_bytes()
9055 .map_err(|e| GraphError::Corrupt {
9056 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9057 })?;
9058 encode_v8(
9059 Some(archived_csr),
9060 Some(archived_cols),
9061 Some((archived_edge_props, edge_props_raw)),
9062 Some(prov_raw),
9063 &self.topo,
9064 &self.props,
9065 &self.ids,
9066 &self.syms,
9067 &meta,
9068 &mut buf,
9069 )?;
9070 }
9071 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9072 // Remap the freshly-written snapshot as the new base.
9073 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9074 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9075 core_storage::v8::MappedBase::map(&snap_path)
9076 } else {
9077 core_storage::v8::MappedBase::from_bytes(buf)
9078 }
9079 .map_err(|e| GraphError::Corrupt {
9080 detail: format!("v8 snapshot: remap new base: {e:?}"),
9081 })?;
9082 self.base = Some(Arc::new(new_base));
9083 // Clear the overlay and prop tombstones — all data is now in the new base.
9084 self.topo = Topology::new();
9085 self.props = core_storage::columns::ColumnStore::new();
9086 } else {
9087 // Legacy path (V5–V7 stores without a V8 base).
9088 //
9089 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9090 // clone and no encode_v8_from_state intermediate clones. The big
9091 // structures (self.topo, self.props) are borrowed, not cloned.
9092 // self.edge_props is moved (not cloned) because we immediately clear it
9093 // when we remap the new V8 snapshot as self.base (see below).
9094 //
9095 // Eliminates from peak RSS vs. the old SnapshotState path:
9096 // • self.topo.clone() (~topology HashMap footprint)
9097 // • self.props.clone() (~column-store footprint)
9098 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9099 let meta = V8Meta {
9100 labels: self.labels.clone(),
9101 wal_truncated: !opts.keep_wal,
9102 // Move edge_props out so the large overlay is freed when meta
9103 // drops at end of this block (self.edge_props is now empty; reads
9104 // after base assignment go through the mmap'd base section).
9105 edge_props: std::mem::take(&mut self.edge_props),
9106 rule_defs,
9107 provenance,
9108 rule_tripped,
9109 rule_fires,
9110 ivf_bytes,
9111 view_defs,
9112 hnsw: hnsw_state,
9113 last_change: self.last_change.clone(),
9114 };
9115 let mut buf = Vec::new();
9116 encode_v8(
9117 None,
9118 None,
9119 None,
9120 None,
9121 &self.topo,
9122 &self.props,
9123 &self.ids,
9124 &self.syms,
9125 &meta,
9126 &mut buf,
9127 )?;
9128 // meta (and the moved edge_props inside it) is no longer needed;
9129 // drop it before the write to keep the peak window narrow.
9130 drop(meta);
9131 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9132 // Remap the freshly-written V8 snapshot as self.base.
9133 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9134 // On SimFs (tests): pass buf to from_bytes.
9135 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9136 drop(buf);
9137 core_storage::v8::MappedBase::map(&snap_path)
9138 } else {
9139 core_storage::v8::MappedBase::from_bytes(buf)
9140 }
9141 .map_err(|e| GraphError::Corrupt {
9142 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9143 })?;
9144 self.base = Some(Arc::new(new_base));
9145 // Free the large heap-allocated decoded state — all data is now in the
9146 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
9147 // self.edge_props was already moved into meta and is effectively empty.
9148 self.topo = Topology::new();
9149 self.props = core_storage::columns::ColumnStore::new();
9150 }
9151
9152 if opts.archive_wal {
9153 // History-preserving snapshot (Task 4):
9154 // 1. Snapshot already written above (write_atomic → fsynced).
9155 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
9156 // Crash window B: crash here leaves archive present, WAL
9157 // absent. Reopen: snapshot loaded (full state), no WAL
9158 // replay. Archive is NOT replayed into live state — it is
9159 // pre-snapshot by construction. Safe.
9160 // 3. Optionally write genesis marker (first archive only, no
9161 // prior WAL truncation).
9162 // 4. Prune old archives (retention), update horizon floor.
9163 // Pruning invalidates the genesis chain; delete marker.
9164 // 5. Write new minimal baseline WAL (write_atomic).
9165 // Crash window C: crash here leaves new archive plus no live
9166 // WAL. Same as window B — handled above.
9167 //
9168 // Sample existing archives BEFORE the rename so we can detect
9169 // whether this is the first archive.
9170 let existing_archives = self.fs.list_archives()?;
9171 let is_first_archive = existing_archives.is_empty();
9172
9173 // Compute a globally-monotonic archive name: the name equals the
9174 // cumulative end-frame index of the archive in global commit space.
9175 //
9176 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9177 // commit_seq is seeded from max(last_change), which underestimates
9178 // the WAL depth when trailing commits (e.g. insert_edge) do not
9179 // update last_change. A session-2 archive could then receive a name
9180 // ≤ the session-1 archive, causing incorrect sort order or collision.
9181 //
9182 // Instead: read and decode the live WAL here (before the rename) to
9183 // get its exact frame count, then add it to the last known global
9184 // end-frame index (the name of the most recent existing archive, or
9185 // wal_horizon_floor if no archives exist). This is O(WAL size) but
9186 // snapshot is already serialising the full graph state, so the cost
9187 // is dominated.
9188 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9189 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9190 let archive_n = existing_archives
9191 .last()
9192 .copied()
9193 .unwrap_or(self.wal_horizon_floor)
9194 + live_frames_for_name.len() as u64;
9195 self.fs.archive_wal(archive_n)?;
9196
9197 // Genesis marker: written once when the first archive is taken
9198 // from a store that has never undergone a WAL-truncating snapshot.
9199 // When present, `open_at` may replay archive-resident commits from
9200 // empty state (the archive chain covers from global index 0).
9201 //
9202 // Two conditions must ALL hold:
9203 // 1. This is the first archive (existing_archives was empty).
9204 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9205 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9206 // before truncating the WAL, so if any prior truncating snapshot was taken
9207 // — even in a previous session — snapshot.bin is present and this condition
9208 // is false. This subsumes the cross-session truncation case without
9209 // requiring a separate wal.truncated sidecar file.
9210 // For legacy stores (snapshot.bin written by an older code version that
9211 // may have truncated the WAL), the same conservative refusal applies:
9212 // we cannot prove the chain is complete, so we refuse genesis (cost =
9213 // no as-of-through-archives; never silent wrong data).
9214 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9215 // so SimFs always passes this check.
9216 if is_first_archive && !had_prior_snapshot {
9217 self.fs.write_genesis_marker()?;
9218 self.archive_genesis_chain = true;
9219 }
9220
9221 // Retention pruning: keep newest `keep` archives; delete oldest.
9222 // Pruning is the ONLY deletion site for archives.
9223 //
9224 // Crash-safety ordering (C1 fix):
9225 // 1. Count frames in surplus archives (reads only — no mutation).
9226 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
9227 // rename (atomic). A crash after this point leaves orphaned
9228 // archives on disk, but the floor is correct. The opening
9229 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
9230 // the next open, so the store is always safe to reopen.
9231 // 3. Delete the genesis marker (floor > 0 already blocks open_at
9232 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
9233 // 4. Delete surplus archives. A crash between any two deletes
9234 // leaves the floor committed and orphaned archives cleaned at
9235 // next open — never a stale floor with a missing archive prefix.
9236 if let Some(keep) = self.wal_archive_retention {
9237 if keep > 0 {
9238 let archives = self.fs.list_archives()?;
9239 // archives is sorted ascending (oldest first)
9240 if archives.len() as u32 > keep {
9241 let surplus = archives.len() - keep as usize;
9242 // Step 1: count pruned frames (reads, no mutation).
9243 let mut pruned_frames = 0u64;
9244 for &n in &archives[..surplus] {
9245 let bytes = self.fs.read_archive(n)?;
9246 let (frames, _) = decode_all(&bytes);
9247 pruned_frames += frames.len() as u64;
9248 }
9249 // Step 2: advance and persist floor FIRST.
9250 self.wal_horizon_floor += pruned_frames;
9251 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9252 // Step 3: delete genesis marker (floor > 0 already
9253 // blocks open_at; this is belt-and-suspenders cleanup).
9254 if pruned_frames > 0 && self.archive_genesis_chain {
9255 self.fs.delete_genesis_marker()?;
9256 self.archive_genesis_chain = false;
9257 }
9258 // Step 4: delete surplus archives. Crash here →
9259 // orphaned archives; cleaned at next open.
9260 for &n in &archives[..surplus] {
9261 self.fs.delete_archive(n)?;
9262 }
9263 }
9264 }
9265 }
9266
9267 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9268 let mut baseline_wal: Vec<u8> = Vec::new();
9269 for (label, field) in self.fulltext.enabled_pairs() {
9270 let rec = WalRecord::EnableFulltext {
9271 label: label.clone(),
9272 field: field.clone(),
9273 };
9274 baseline_wal.extend_from_slice(&encode_record(&rec));
9275 }
9276 for (label, field) in self.prop_index.enabled_pairs() {
9277 let rec = WalRecord::EnableIndex {
9278 label: label.clone(),
9279 field: field.clone(),
9280 };
9281 baseline_wal.extend_from_slice(&encode_record(&rec));
9282 }
9283 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9284 } else if opts.keep_wal {
9285 // keep_wal=true: WAL is left untouched. The existing WAL already
9286 // contains the EnableFulltext records from the original enable calls;
9287 // replay is idempotent (guards in apply() skip already-live entries).
9288 // No baseline re-write is needed or safe here — the full WAL history
9289 // must remain intact for open_at to reach pre-snapshot commits.
9290 } else {
9291 // keep_wal=false (default): truncate by replacing the WAL with a
9292 // minimal baseline of one EnableFulltext record per active pair.
9293 //
9294 // Crash-ordering: write_atomic is atomic.
9295 // • Crash before snapshot write → WAL unchanged. Safe.
9296 // • Crash after snapshot write but before this WAL write → full
9297 // pre-snapshot WAL still present; open_with replays idempotently.
9298 // • Crash after both writes → normal post-snapshot state.
9299 //
9300 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9301 // for any archives taken AFTER this point (their WAL slices would
9302 // not start at genesis). Delete any existing genesis marker so that
9303 // open_at refuses archive-resident commits. Future sessions are
9304 // covered by had_prior_snapshot: snapshot.bin written here persists
9305 // across sessions and prevents a later archiving session from
9306 // incorrectly claiming a complete genesis chain.
9307 if self.archive_genesis_chain {
9308 self.fs.delete_genesis_marker()?;
9309 self.archive_genesis_chain = false;
9310 }
9311 let mut baseline_wal: Vec<u8> = Vec::new();
9312 for (label, field) in self.fulltext.enabled_pairs() {
9313 let rec = WalRecord::EnableFulltext {
9314 label: label.clone(),
9315 field: field.clone(),
9316 };
9317 baseline_wal.extend_from_slice(&encode_record(&rec));
9318 }
9319 for (label, field) in self.prop_index.enabled_pairs() {
9320 let rec = WalRecord::EnableIndex {
9321 label: label.clone(),
9322 field: field.clone(),
9323 };
9324 baseline_wal.extend_from_slice(&encode_record(&rec));
9325 }
9326 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9327 }
9328 // After snapshot the overlay may have changed (V8 merge path clears
9329 // self.topo and self.props). Refresh the MVCC fold so future readers
9330 // see the post-snapshot state rather than stale overlay data.
9331 self.fold_now();
9332 Ok(())
9333 }
9334}
9335
9336/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9337///
9338/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9339/// callers can build a set of mutations without holding `&mut GraphDb` and
9340/// hand them off to the group-committing writer for durable, batched I/O.
9341pub enum BatchOp {
9342 InsertNode {
9343 label: String,
9344 key: String,
9345 props: Vec<(String, Value)>,
9346 },
9347 InsertEdge {
9348 edge_type: String,
9349 src_key: String,
9350 dst_key: String,
9351 },
9352 SetProp {
9353 key: String,
9354 field: String,
9355 value: Value,
9356 },
9357 RemoveProp {
9358 key: String,
9359 field: String,
9360 },
9361 DeleteEdge {
9362 edge_type: String,
9363 src_key: String,
9364 dst_key: String,
9365 },
9366 DeleteNode {
9367 key: String,
9368 },
9369 CreateRule(RuleDef),
9370 DeleteRule {
9371 name: String,
9372 },
9373 /// Rename a node's key. Validated: old must exist, new must not.
9374 RenameNode {
9375 old_key: String,
9376 new_key: String,
9377 },
9378 /// Insert an edge, auto-creating any missing endpoint as a plain node with
9379 /// `placeholder_label` and no props. Rules fire and last-change is updated
9380 /// for each created endpoint (normal InsertNode semantics in the batch frame).
9381 InsertEdgeUpsert {
9382 edge_type: String,
9383 src_key: String,
9384 dst_key: String,
9385 placeholder_label: String,
9386 },
9387}
9388
9389/// Three-way node visibility status used by `check_single_op_authz`.
9390enum NodeAuthzStatus {
9391 /// Node exists in the store and is in the role's read mask.
9392 Visible(String), // carries the node's label
9393 /// Node exists in the store but is NOT in the role's read mask.
9394 Hidden,
9395 /// Node does not exist in the store.
9396 Absent,
9397}
9398
9399/// Overlay of ops already accepted earlier in the same batch. Never written
9400/// back to the database — validation only.
9401#[derive(Default)]
9402struct Overlay {
9403 extra_keys: BTreeSet<String>,
9404 deleted_keys: BTreeSet<String>,
9405 extra_props: BTreeMap<(String, String), Value>,
9406 removed_props: BTreeSet<(String, String)>,
9407 extra_edges: BTreeSet<(String, String, String)>,
9408 deleted_edges: BTreeSet<(String, String, String)>,
9409 extra_rules: BTreeSet<String>,
9410 deleted_rules: BTreeSet<String>,
9411 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
9412 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
9413 /// sees only the rules already committed to the engine. Keyed by name so a
9414 /// later `DeleteRule` in the same batch drops the arc with the rule.
9415 extra_rule_arcs: BTreeMap<String, (String, String)>,
9416}
9417
9418/// Read-only view of live db state plus a batch overlay. Shared by single-op
9419/// public methods (empty overlay) and `commit_batch`.
9420struct MutPreview<'a, F: Fs> {
9421 db: &'a GraphDb<F>,
9422 overlay: Overlay,
9423}
9424
9425/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
9426/// `None` if `target` is unreachable.
9427///
9428/// Used for rule-chain cycle detection, where an arc is "a rule hops over
9429/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
9430/// reported path is stable for a given rule set, and iterative so a pathological
9431/// rule graph cannot overflow the stack.
9432fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
9433 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
9434 for (from, to) in arcs {
9435 adj.entry(from.as_str()).or_default().insert(to.as_str());
9436 }
9437 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
9438 let mut visited: BTreeSet<&str> = BTreeSet::new();
9439 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
9440 visited.insert(start);
9441 queue.push_back(start);
9442 while let Some(node) = queue.pop_front() {
9443 if node == target {
9444 let mut path = vec![node.to_string()];
9445 let mut cur = node;
9446 while let Some(&p) = parent.get(cur) {
9447 path.push(p.to_string());
9448 cur = p;
9449 }
9450 path.reverse();
9451 return Some(path);
9452 }
9453 for &next in adj.get(node).into_iter().flatten() {
9454 if visited.insert(next) {
9455 parent.insert(next, node);
9456 queue.push_back(next);
9457 }
9458 }
9459 }
9460 None
9461}
9462
9463impl<'a, F: Fs> MutPreview<'a, F> {
9464 fn new(db: &'a GraphDb<F>) -> Self {
9465 Self {
9466 db,
9467 overlay: Overlay::default(),
9468 }
9469 }
9470
9471 fn has_key(&self, key: &str) -> bool {
9472 if self.overlay.extra_keys.contains(key) {
9473 return true;
9474 }
9475 if self.overlay.deleted_keys.contains(key) {
9476 return false;
9477 }
9478 self.db.ids.get(key).is_some()
9479 }
9480
9481 fn has_prop(&self, key: &str, field: &str) -> bool {
9482 if !self.has_key(key) {
9483 return false;
9484 }
9485 let k = (key.to_string(), field.to_string());
9486 if self.overlay.removed_props.contains(&k) {
9487 return false;
9488 }
9489 if self.overlay.extra_props.contains_key(&k) {
9490 return true;
9491 }
9492 // Fresh identity (first insert in this batch, or delete+reinsert):
9493 // ignore props still sitting on the soon-to-be-tombstoned slot.
9494 if self.overlay.extra_keys.contains(key) {
9495 return false;
9496 }
9497 self.db.get_prop(key, field).is_some()
9498 }
9499
9500 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9501 let k = (
9502 edge_type.to_string(),
9503 src_key.to_string(),
9504 dst_key.to_string(),
9505 );
9506 if self.overlay.deleted_edges.contains(&k) {
9507 return false;
9508 }
9509 if self.overlay.extra_edges.contains(&k) {
9510 return true;
9511 }
9512 // A key created in this batch (including reinsert) has no db edges.
9513 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9514 return false;
9515 }
9516 if self.overlay.deleted_keys.contains(src_key)
9517 || self.overlay.deleted_keys.contains(dst_key)
9518 {
9519 return false;
9520 }
9521 let Some(src) = self.db.ids.get(src_key) else {
9522 return false;
9523 };
9524 let Some(dst) = self.db.ids.get(dst_key) else {
9525 return false;
9526 };
9527 let Some(sym) = self.db.syms.get(edge_type) else {
9528 return false;
9529 };
9530 self.db
9531 .topo_view()
9532 .neighbors(sym, Direction::Out, src)
9533 .binary_search(&dst)
9534 .is_ok()
9535 }
9536
9537 fn has_rule(&self, name: &str) -> bool {
9538 if self.overlay.extra_rules.contains(name) {
9539 return true;
9540 }
9541 if self.overlay.deleted_rules.contains(name) {
9542 return false;
9543 }
9544 self.db.engine.rules().any(|r| r.name == name)
9545 }
9546
9547 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9548 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9549 return false;
9550 }
9551 if self.overlay.deleted_keys.contains(src_key)
9552 || self.overlay.deleted_keys.contains(dst_key)
9553 {
9554 return false;
9555 }
9556 let Some(src) = self.db.ids.get(src_key) else {
9557 return false;
9558 };
9559 let Some(dst) = self.db.ids.get(dst_key) else {
9560 return false;
9561 };
9562 let Some(et) = self.db.syms.get(edge_type) else {
9563 return false;
9564 };
9565 // extra_rules is deliberately not consulted: a CreateRule earlier in
9566 // this batch has not fired, so it contributes no provenance. That is
9567 // the documented rule-window gap (see GraphDb::batch).
9568 if self.overlay.deleted_rules.is_empty() {
9569 return self.db.engine.is_owned(et, src, dst);
9570 }
9571 for (rule, triples) in self.db.engine.provenance() {
9572 if self.overlay.deleted_rules.contains(rule) {
9573 continue;
9574 }
9575 if triples.contains(&(et, src, dst)) {
9576 return true;
9577 }
9578 }
9579 false
9580 }
9581
9582 fn check_insert_node(&self, key: &str) -> Result<()> {
9583 if self.has_key(key) {
9584 Err(GraphError::DuplicateKey { key: key.into() })
9585 } else {
9586 Ok(())
9587 }
9588 }
9589
9590 fn check_live_key(&self, key: &str) -> Result<()> {
9591 if self.has_key(key) {
9592 Ok(())
9593 } else {
9594 Err(GraphError::KeyNotFound { key: key.into() })
9595 }
9596 }
9597
9598 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9599 for k in [src_key, dst_key] {
9600 if !self.has_key(k) {
9601 return Err(GraphError::KeyNotFound { key: k.into() });
9602 }
9603 }
9604 if self.is_rule_owned(edge_type, src_key, dst_key) {
9605 return Err(GraphError::RuleOwned {
9606 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9607 });
9608 }
9609 Ok(!self.has_edge(edge_type, src_key, dst_key))
9610 }
9611
9612 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9613 self.check_live_key(key)?;
9614 Ok(self.has_prop(key, field))
9615 }
9616
9617 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9618 for k in [src_key, dst_key] {
9619 if !self.has_key(k) {
9620 return Err(GraphError::KeyNotFound { key: k.into() });
9621 }
9622 }
9623 // Provenance-owned OR a live rule would derive this pair. User-first
9624 // edges that a later rule matches are not in `owned`, but deleting
9625 // them would leave a hole `rebuild_rule` immediately fills.
9626 if self.is_rule_owned(edge_type, src_key, dst_key) {
9627 return Err(GraphError::RuleOwned {
9628 detail: format!(
9629 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9630 delete or change the owning rule"
9631 ),
9632 });
9633 }
9634 if self.would_derive(edge_type, src_key, dst_key) {
9635 return Err(GraphError::RuleOwned {
9636 detail: format!(
9637 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9638 delete or change the owning rule, or a live rule would re-derive it"
9639 ),
9640 });
9641 }
9642 Ok(self.has_edge(edge_type, src_key, dst_key))
9643 }
9644
9645 /// True if any live rule (minus overlay-deleted names) would derive
9646 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9647 /// CreateRule names in `extra_rules` are ignored — same documented
9648 /// same-batch rule-window as [`Self::is_rule_owned`].
9649 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9650 if src_key == dst_key {
9651 return false;
9652 }
9653 let Some(src_label) = self.label_of(src_key) else {
9654 return false;
9655 };
9656 let Some(dst_label) = self.label_of(dst_key) else {
9657 return false;
9658 };
9659 for rule in self.db.engine.rules() {
9660 if self.overlay.deleted_rules.contains(&rule.name) {
9661 continue;
9662 }
9663 if rule.edge_type != edge_type {
9664 continue;
9665 }
9666 if rule.src_label != src_label || rule.dst_label != dst_label {
9667 continue;
9668 }
9669 let src_props = |f: &str| self.prop_value(src_key, f);
9670 let dst_props = |f: &str| self.prop_value(dst_key, f);
9671 let src_view = NodeView {
9672 key: src_key,
9673 props: &src_props,
9674 };
9675 let dst_view = NodeView {
9676 key: dst_key,
9677 props: &dst_props,
9678 };
9679 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9680 return true;
9681 }
9682 }
9683 false
9684 }
9685
9686 fn label_of(&self, key: &str) -> Option<String> {
9687 if self.overlay.deleted_keys.contains(key) {
9688 return None;
9689 }
9690 // Fresh identities created in this batch have no stored label in the
9691 // overlay; they cannot be provenance-owned yet either.
9692 let id = self.db.ids.get(key)?;
9693 let sym = self.db.labels.get(id as usize).copied()?;
9694 if sym == u32::MAX {
9695 return None;
9696 }
9697 self.db.syms.resolve(sym).map(str::to_string)
9698 }
9699
9700 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9701 if !self.has_key(key) {
9702 return None;
9703 }
9704 let k = (key.to_string(), field.to_string());
9705 if self.overlay.removed_props.contains(&k) {
9706 return None;
9707 }
9708 if let Some(v) = self.overlay.extra_props.get(&k) {
9709 return Some(v.clone());
9710 }
9711 if self.overlay.extra_keys.contains(key) {
9712 return None;
9713 }
9714 self.db.get_prop(key, field)
9715 }
9716
9717 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9718 def.validate()
9719 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9720 if self.has_rule(&def.name) {
9721 return Err(GraphError::RuleInvalid {
9722 detail: format!("rule {:?} already exists", def.name),
9723 });
9724 }
9725 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
9726 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
9727 // `edge_type`". A cycle in that graph is a rule set that would re-fire
9728 // itself forever; the engine's depth cap would silently truncate it
9729 // instead, leaving an arbitrary partial result. Reject it here, the one
9730 // place that sees the whole rule set.
9731 //
9732 // Rules accepted earlier in the same batch count too: the overlay
9733 // carries their arcs, so a cycle cannot be assembled one op at a time.
9734 if let Some(via) = def.via_edge.as_deref() {
9735 if via == def.edge_type {
9736 return Err(GraphError::RuleInvalid {
9737 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
9738 });
9739 }
9740 let mut arcs: Vec<(String, String)> = self
9741 .db
9742 .engine
9743 .rules()
9744 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
9745 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
9746 .collect();
9747 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
9748 arcs.push((via.to_string(), def.edge_type.clone()));
9749 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
9750 return Err(GraphError::RuleInvalid {
9751 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
9752 });
9753 }
9754 }
9755 Ok(())
9756 }
9757
9758 fn check_delete_rule(&self, name: &str) -> Result<()> {
9759 if self.has_rule(name) {
9760 Ok(())
9761 } else {
9762 Err(GraphError::RuleNotFound { name: name.into() })
9763 }
9764 }
9765
9766 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9767 self.overlay.deleted_keys.remove(key);
9768 self.overlay.extra_keys.insert(key.to_string());
9769 self.overlay.extra_props.retain(|(k, _), _| k != key);
9770 self.overlay.removed_props.retain(|(k, _)| k != key);
9771 for (field, value) in props {
9772 self.overlay
9773 .extra_props
9774 .insert((key.to_string(), field.clone()), value.clone());
9775 }
9776 }
9777
9778 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9779 let k = (
9780 edge_type.to_string(),
9781 src_key.to_string(),
9782 dst_key.to_string(),
9783 );
9784 self.overlay.deleted_edges.remove(&k);
9785 self.overlay.extra_edges.insert(k);
9786 }
9787
9788 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9789 let k = (key.to_string(), field.to_string());
9790 self.overlay.removed_props.remove(&k);
9791 self.overlay.extra_props.insert(k, value.clone());
9792 }
9793
9794 fn note_remove_prop(&mut self, key: &str, field: &str) {
9795 let k = (key.to_string(), field.to_string());
9796 self.overlay.extra_props.remove(&k);
9797 self.overlay.removed_props.insert(k);
9798 }
9799
9800 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9801 let k = (
9802 edge_type.to_string(),
9803 src_key.to_string(),
9804 dst_key.to_string(),
9805 );
9806 self.overlay.extra_edges.remove(&k);
9807 self.overlay.deleted_edges.insert(k);
9808 }
9809
9810 fn note_delete_node(&mut self, key: &str) {
9811 self.overlay.extra_keys.remove(key);
9812 self.overlay.deleted_keys.insert(key.to_string());
9813 self.overlay.extra_props.retain(|(k, _), _| k != key);
9814 self.overlay.removed_props.retain(|(k, _)| k != key);
9815 self.overlay
9816 .extra_edges
9817 .retain(|(_, s, d)| s != key && d != key);
9818 self.overlay
9819 .deleted_edges
9820 .retain(|(_, s, d)| s != key && d != key);
9821 }
9822
9823 fn note_create_rule(&mut self, def: &RuleDef) {
9824 self.overlay.deleted_rules.remove(&def.name);
9825 self.overlay.extra_rules.insert(def.name.clone());
9826 // Rules accepted earlier in this batch are not in the engine yet, so
9827 // the cycle check would not see their arcs. Keep the arc, not just the
9828 // name, so a batch cannot smuggle in a cycle one op at a time.
9829 if let Some(via) = def.via_edge.clone() {
9830 self.overlay
9831 .extra_rule_arcs
9832 .insert(def.name.clone(), (via, def.edge_type.clone()));
9833 }
9834 }
9835
9836 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9837 if !self.has_key(old) {
9838 return Err(GraphError::KeyNotFound { key: old.into() });
9839 }
9840 if self.has_key(new) {
9841 return Err(GraphError::DuplicateKey { key: new.into() });
9842 }
9843 Ok(())
9844 }
9845
9846 fn note_rename_node(&mut self, old: &str, new: &str) {
9847 // Mark old as deleted so subsequent batch ops cannot reference it.
9848 self.overlay.extra_keys.remove(old);
9849 self.overlay.deleted_keys.insert(old.to_string());
9850 // Mark new as extra so subsequent batch ops can reference it.
9851 self.overlay.deleted_keys.remove(new);
9852 self.overlay.extra_keys.insert(new.to_string());
9853 // Migrate any overlay props from old key to new key.
9854 let new_str = new.to_string();
9855 let transferred: Vec<((String, String), Value)> = self
9856 .overlay
9857 .extra_props
9858 .iter()
9859 .filter(|((k, _), _)| k.as_str() == old)
9860 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9861 .collect();
9862 self.overlay
9863 .extra_props
9864 .retain(|(k, _), _| k.as_str() != old);
9865 for (k, v) in transferred {
9866 self.overlay.extra_props.insert(k, v);
9867 }
9868 // Migrate removed_props.
9869 let transferred_removed: Vec<(String, String)> = self
9870 .overlay
9871 .removed_props
9872 .iter()
9873 .filter(|(k, _)| k.as_str() == old)
9874 .map(|(_, f)| (new_str.clone(), f.clone()))
9875 .collect();
9876 self.overlay
9877 .removed_props
9878 .retain(|(k, _)| k.as_str() != old);
9879 for k in transferred_removed {
9880 self.overlay.removed_props.insert(k);
9881 }
9882 }
9883
9884 fn note_delete_rule(&mut self, name: &str) {
9885 self.overlay.extra_rules.remove(name);
9886 // Drop its chain arc too: a rule created and then deleted in the same
9887 // batch must not make a later, legal rule look like a cycle.
9888 self.overlay.extra_rule_arcs.remove(name);
9889 self.overlay.deleted_rules.insert(name.to_string());
9890 // Treat the deleted rule's current provenance as gone so a later
9891 // delete_edge of those triples is a no-op (matches sequential).
9892 if let Some(triples) = self.db.engine.provenance().get(name) {
9893 for &(et, s, d) in triples {
9894 let Some(etype) = self.db.syms.resolve(et) else {
9895 continue;
9896 };
9897 let Some(src) = self.db.ids.key_of(s) else {
9898 continue;
9899 };
9900 let Some(dst) = self.db.ids.key_of(d) else {
9901 continue;
9902 };
9903 let k = (etype.to_string(), src.to_string(), dst.to_string());
9904 self.overlay.extra_edges.remove(&k);
9905 self.overlay.deleted_edges.insert(k);
9906 }
9907 }
9908 }
9909}
9910
9911/// Collects mutations and commits them as one WAL `Batch` frame.
9912///
9913/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9914/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9915/// See [`GraphDb::batch`] for validation and atomicity rules.
9916pub struct BatchBuilder<'a, F: Fs> {
9917 db: &'a mut GraphDb<F>,
9918 ops: Vec<BatchOp>,
9919}
9920
9921impl<'a, F: Fs> BatchBuilder<'a, F> {
9922 pub fn insert_node(
9923 &mut self,
9924 label: &str,
9925 key: &str,
9926 props: Vec<(String, Value)>,
9927 ) -> &mut Self {
9928 self.ops.push(BatchOp::InsertNode {
9929 label: label.into(),
9930 key: key.into(),
9931 props,
9932 });
9933 self
9934 }
9935
9936 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9937 self.ops.push(BatchOp::InsertEdge {
9938 edge_type: edge_type.into(),
9939 src_key: src_key.into(),
9940 dst_key: dst_key.into(),
9941 });
9942 self
9943 }
9944
9945 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9946 self.ops.push(BatchOp::SetProp {
9947 key: key.into(),
9948 field: field.into(),
9949 value,
9950 });
9951 self
9952 }
9953
9954 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9955 self.ops.push(BatchOp::RemoveProp {
9956 key: key.into(),
9957 field: field.into(),
9958 });
9959 self
9960 }
9961
9962 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9963 self.ops.push(BatchOp::DeleteEdge {
9964 edge_type: edge_type.into(),
9965 src_key: src_key.into(),
9966 dst_key: dst_key.into(),
9967 });
9968 self
9969 }
9970
9971 pub fn delete_node(&mut self, key: &str) -> &mut Self {
9972 self.ops.push(BatchOp::DeleteNode { key: key.into() });
9973 self
9974 }
9975
9976 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9977 self.ops.push(BatchOp::CreateRule(def));
9978 self
9979 }
9980
9981 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9982 self.ops.push(BatchOp::DeleteRule { name: name.into() });
9983 self
9984 }
9985
9986 /// Queue a node-rename in this batch.
9987 ///
9988 /// Validation (old exists, new not taken) runs at commit time.
9989 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9990 self.ops.push(BatchOp::RenameNode {
9991 old_key: old_key.into(),
9992 new_key: new_key.into(),
9993 });
9994 self
9995 }
9996
9997 /// Queue an edge insert with endpoint auto-creation.
9998 ///
9999 /// Any missing endpoint is created as a plain node `{key, label:
10000 /// placeholder_label, no props}` inside this batch frame. Rules fire and
10001 /// last-change is updated for each auto-created node.
10002 pub fn insert_edge_upsert(
10003 &mut self,
10004 edge_type: &str,
10005 src_key: &str,
10006 dst_key: &str,
10007 placeholder_label: &str,
10008 ) -> &mut Self {
10009 self.ops.push(BatchOp::InsertEdgeUpsert {
10010 edge_type: edge_type.into(),
10011 src_key: src_key.into(),
10012 dst_key: dst_key.into(),
10013 placeholder_label: placeholder_label.into(),
10014 });
10015 self
10016 }
10017
10018 /// Validate every queued op, then log one `Batch` frame and apply.
10019 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10020 /// A second `commit()` after a successful one is an empty-batch no-op
10021 /// (queued ops were taken).
10022 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10023 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10024 ///
10025 /// **Rule-window limitation:** batch validation cannot see edges that a
10026 /// rule created earlier in the *same* batch will derive at apply time, so
10027 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10028 /// where sequential calls would return `Err(RuleOwned)`. State integrity
10029 /// is unaffected (idempotent apply, provenance intact). Create rules in
10030 /// their own batch, or sequentially, when later ops may touch derived
10031 /// edges.
10032 /// Validate every queued op and commit atomically.
10033 ///
10034 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10035 /// WAL records actually written (duplicate edges are silent no-ops and are
10036 /// NOT counted). Both are 0 when the batch is empty or all-noop.
10037 pub fn commit(&mut self) -> Result<(usize, usize)> {
10038 let ops = std::mem::take(&mut self.ops);
10039 self.db.commit_batch(ops)
10040 }
10041
10042 /// Same as [`commit`](Self::commit) but tail the inner events with
10043 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10044 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10045 let ops = std::mem::take(&mut self.ops);
10046 self.db
10047 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10048 }
10049}
10050
10051pub struct NodeRef<'a, F: Fs> {
10052 db: &'a GraphDb<F>,
10053 id: u32,
10054}
10055
10056impl<'a, F: Fs> NodeRef<'a, F> {
10057 pub fn key(&self) -> &str {
10058 self.db.ids.key_of(self.id).expect("dense ids")
10059 }
10060
10061 pub fn label(&self) -> &str {
10062 let sym = self
10063 .db
10064 .labels
10065 .get(self.id as usize)
10066 .copied()
10067 .filter(|&s| s != u32::MAX)
10068 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10069 self.db.syms.resolve(sym).expect("interned label symbol")
10070 }
10071
10072 pub fn prop(&self, field: &str) -> Option<Value> {
10073 self.db
10074 .props_view()
10075 .get(self.id, field)
10076 .map(|vr| vr.into_value())
10077 }
10078
10079 /// All stored fields for this node, sorted by field name.
10080 ///
10081 /// Reads from the full base+overlay view so that props stored only in the
10082 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10083 pub fn props(&self) -> BTreeMap<String, Value> {
10084 let mut out = BTreeMap::new();
10085 let pv = self.db.props_view();
10086 for field in pv.field_names() {
10087 if let Some(vr) = pv.get(self.id, &field) {
10088 out.insert(field, vr.into_value());
10089 }
10090 }
10091 out
10092 }
10093
10094 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10095 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10096 let view = self.db.view();
10097 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10098 names
10099 .iter()
10100 .filter_map(|name| view.syms.get(name))
10101 .collect()
10102 });
10103 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10104 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10105 for (nid, d) in nb.nodes {
10106 let key = view.key_of(nid);
10107 let label = view
10108 .label_of(nid)
10109 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10110 rs.push_row(vec![
10111 Some(Value::Str(key.to_string())),
10112 Some(Value::Str(label.to_string())),
10113 Some(Value::Int(d as i64)),
10114 ]);
10115 }
10116 rs
10117 }
10118
10119 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10120 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10121 let view = self.db.view();
10122 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10123 for e in expand(&view, self.id, None, Dir::Both) {
10124 // Skip edges with unknown etypes (only possible from corrupt large
10125 // TOPOLOGY section; function returns BTreeMap not Result).
10126 let Some(etype) = view.syms.resolve(e.etype) else {
10127 continue;
10128 };
10129 let etype = etype.to_string();
10130 let nbr = if e.src == self.id { e.dst } else { e.src };
10131 groups
10132 .entry(etype)
10133 .or_default()
10134 .insert(view.key_of(nbr).to_string());
10135 }
10136 groups
10137 .into_iter()
10138 .map(|(k, v)| (k, v.into_iter().collect()))
10139 .collect()
10140 }
10141}
10142
10143#[cfg(test)]
10144mod tests {
10145 use super::*;
10146 use core_rules::Predicate;
10147
10148 fn tmp_dir(name: &str) -> std::path::PathBuf {
10149 let d =
10150 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10151 let _ = std::fs::remove_dir_all(&d);
10152 d
10153 }
10154
10155 fn fk_rule() -> RuleDef {
10156 RuleDef {
10157 name: "works_at".into(),
10158 src_label: "Person".into(),
10159 dst_label: "Org".into(),
10160 predicate: Predicate::KeyMatch {
10161 field: "org_id".into(),
10162 },
10163 edge_type: "WORKS_AT".into(),
10164 weight_prop: None,
10165 max_edges: None,
10166 approximate: false,
10167 via_label: None,
10168 via_edge: None,
10169 via_dir: None,
10170 }
10171 }
10172
10173 /// Regression guard for the no-views delta-copy fast path.
10174 ///
10175 /// When no views are defined, `pending_deltas_since().to_vec()` must never
10176 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10177 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10178 /// a count of 0 after the entire sequence proves the guard fires correctly.
10179 #[test]
10180 fn no_delta_copy_when_no_views() {
10181 DELTA_COPY_COUNT.with(|c| c.set(0));
10182 let dir = tmp_dir("no-delta-copy");
10183 {
10184 let mut db = GraphDb::open(&dir).unwrap();
10185 // Insert 50 Org + 50 Person nodes with FK links.
10186 for i in 0..50u32 {
10187 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10188 }
10189 for i in 0..50u32 {
10190 db.insert_node(
10191 "Person",
10192 &format!("p{i}"),
10193 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10194 )
10195 .unwrap();
10196 }
10197 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10198 db.create_rule(fk_rule()).unwrap();
10199
10200 // Counter must stay 0 — no views, no copies.
10201 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10202 assert_eq!(
10203 copies, 0,
10204 "pending_deltas_since().to_vec() called despite no views"
10205 );
10206
10207 // Derived edges must still be correct (the guard skips only the
10208 // empty delta propagation loop, not the rule application itself).
10209 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10210 assert_eq!(
10211 nbrs,
10212 vec!["o0"],
10213 "rule must derive edges even with no views"
10214 );
10215 }
10216 let _ = std::fs::remove_dir_all(&dir);
10217 }
10218
10219 /// Gating regression: subscribe AFTER a backfill must see no stale events.
10220 /// subscribe BEFORE a backfill must see every edge-fire event.
10221 #[test]
10222 fn subscribe_after_backfill_no_stale_events() {
10223 let dir = tmp_dir("sub-after-backfill");
10224 {
10225 let mut db = GraphDb::open(&dir).unwrap();
10226 for i in 0..10u32 {
10227 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10228 db.insert_node(
10229 "Person",
10230 &format!("p{i}"),
10231 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10232 )
10233 .unwrap();
10234 }
10235 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10236 db.create_rule(fk_rule()).unwrap();
10237
10238 // Subscribe AFTER the backfill — queue must be empty (no stale events).
10239 let sub = db.subscribe_all_rules().unwrap();
10240 // No events should have queued for the prior backfill.
10241 assert!(
10242 sub.try_recv().is_none(),
10243 "subscribe after backfill must see no stale events"
10244 );
10245
10246 // Inserting a new node now should fire an event (emit_deltas is now true).
10247 db.insert_node("Org", "o_new", vec![]).unwrap();
10248 db.insert_node(
10249 "Person",
10250 "p_new",
10251 vec![("org_id".into(), Value::Str("o_new".into()))],
10252 )
10253 .unwrap();
10254 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10255 assert!(
10256 ev.is_some(),
10257 "edge-fire event must arrive after subscribe (emit_deltas=true)"
10258 );
10259 }
10260 let _ = std::fs::remove_dir_all(&dir);
10261 }
10262
10263 /// Gating regression: subscribe BEFORE a backfill → events flow.
10264 #[test]
10265 fn subscribe_before_backfill_events_flow() {
10266 let dir = tmp_dir("sub-before-backfill");
10267 {
10268 let mut db = GraphDb::open(&dir).unwrap();
10269 // Subscribe FIRST — emit_deltas becomes true.
10270 let sub = db.subscribe_all_rules().unwrap();
10271
10272 for i in 0..5u32 {
10273 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10274 db.insert_node(
10275 "Person",
10276 &format!("p{i}"),
10277 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10278 )
10279 .unwrap();
10280 }
10281 // Backfill fires with emit_deltas=true → events queued.
10282 db.create_rule(fk_rule()).unwrap();
10283
10284 // Should receive at least one edge-fired event from the backfill.
10285 let mut received = 0usize;
10286 while sub.try_recv().is_some() {
10287 received += 1;
10288 }
10289 assert!(
10290 received > 0,
10291 "subscribe before backfill must receive edge-fire events (got 0)"
10292 );
10293 }
10294 let _ = std::fs::remove_dir_all(&dir);
10295 }
10296
10297 /// Companion: when a view IS defined, the delta path fires and view values update.
10298 #[test]
10299 fn delta_copy_fires_when_view_exists() {
10300 use core_rules::ViewSource;
10301 DELTA_COPY_COUNT.with(|c| c.set(0));
10302 let dir = tmp_dir("delta-copy-with-view");
10303 {
10304 let mut db = GraphDb::open(&dir).unwrap();
10305 db.insert_node("Org", "o1", vec![]).unwrap();
10306 db.insert_node(
10307 "Person",
10308 "p1",
10309 vec![("org_id".into(), Value::Str("o1".into()))],
10310 )
10311 .unwrap();
10312 // Declare a Degree view so is_empty() returns false.
10313 db.create_view(ViewDef {
10314 name: "degree_out".into(),
10315 label: "Person".into(),
10316 view_prop: "degree_out".into(),
10317 source: ViewSource::Degree {
10318 edge_type: "WORKS_AT".into(),
10319 direction: Direction::Out,
10320 },
10321 })
10322 .unwrap();
10323 db.create_rule(fk_rule()).unwrap();
10324
10325 // At least one delta copy should have happened (CreateRule backfill).
10326 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10327 assert!(
10328 copies > 0,
10329 "expected delta copy to fire when a view is defined"
10330 );
10331
10332 // View value should be computed: p1 has one WORKS_AT out-edge.
10333 let info = db.node_info("p1").unwrap();
10334 let degree = info.props.get("degree_out");
10335 assert!(
10336 degree.is_some(),
10337 "view prop should be written to node props"
10338 );
10339 }
10340 let _ = std::fs::remove_dir_all(&dir);
10341 }
10342
10343 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10344 /// derived-edge-driven view values reflect the as-of state rather than just
10345 /// the initial backfill written at `CreateView` time.
10346 ///
10347 /// Base WAL frames (indices 0..=5 before history markers):
10348 /// 0: insert Org "o1"
10349 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10350 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10351 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
10352 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10353 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
10354 ///
10355 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10356 /// no-op), so the total commit count is higher than the base frame count.
10357 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10358 ///
10359 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10360 /// initial backfill value (0) instead of reflecting the replayed derived edges.
10361 #[test]
10362 fn open_at_derived_edge_view_values_correct() {
10363 use core_rules::ViewSource;
10364 let dir = tmp_dir("open-at-view-rebuild");
10365 {
10366 let mut db = GraphDb::open(&dir).unwrap();
10367 // frame 0
10368 db.insert_node("Org", "o1", vec![]).unwrap();
10369 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10370 db.create_view(ViewDef {
10371 name: "employee_count".into(),
10372 label: "Org".into(),
10373 view_prop: "emp".into(),
10374 source: ViewSource::Degree {
10375 edge_type: "WORKS_AT".into(),
10376 direction: Direction::In,
10377 },
10378 })
10379 .unwrap();
10380 // frame 2: create rule — no Persons yet; backfill is a no-op
10381 db.create_rule(fk_rule()).unwrap();
10382 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10383 db.insert_node(
10384 "Person",
10385 "p1",
10386 vec![("org_id".into(), Value::Str("o1".into()))],
10387 )
10388 .unwrap();
10389 // frame 4: p2 — degree = 2
10390 db.insert_node(
10391 "Person",
10392 "p2",
10393 vec![("org_id".into(), Value::Str("o1".into()))],
10394 )
10395 .unwrap();
10396 // frame 5: p3 — degree = 3
10397 db.insert_node(
10398 "Person",
10399 "p3",
10400 vec![("org_id".into(), Value::Str("o1".into()))],
10401 )
10402 .unwrap();
10403 // Sanity: normal open sees degree = 3.
10404 assert_eq!(
10405 db.get_view_prop("o1", "emp"),
10406 Some(Value::Int(3)),
10407 "normal db must show degree 3 after 3 derived edges"
10408 );
10409 } // WAL flushed
10410
10411 // Re-open normally to get the authoritative reference value.
10412 let normal_db = GraphDb::open(&dir).unwrap();
10413 let normal_emp = normal_db.get_view_prop("o1", "emp");
10414 assert_eq!(
10415 normal_emp,
10416 Some(Value::Int(3)),
10417 "re-opened normal db must show degree 3"
10418 );
10419
10420 // Latest as-of (last WAL commit): must match the normal open.
10421 // History-marker frames are appended after each rule-fire, so the total
10422 // commit count is computed dynamically rather than hardcoded.
10423 let total = crate::wal_commit_count_at(&dir).unwrap();
10424 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10425 assert_eq!(
10426 aof_latest.get_view_prop("o1", "emp"),
10427 normal_emp,
10428 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10429 );
10430
10431 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10432 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10433 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10434 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10435 assert_eq!(
10436 aof_mid.get_view_prop("o1", "emp"),
10437 Some(Value::Int(1)),
10438 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10439 );
10440
10441 let _ = std::fs::remove_dir_all(&dir);
10442 }
10443
10444 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10445 /// as-of instances never commit, so distribute_events never runs and any
10446 /// subscription would wait forever.
10447 #[test]
10448 fn subscribe_on_as_of_returns_read_only_error() {
10449 let dir = tmp_dir("sub-as-of-read-only");
10450 {
10451 let mut db = GraphDb::open(&dir).unwrap();
10452 db.insert_node("Org", "o1", vec![]).unwrap();
10453 db.create_rule(fk_rule()).unwrap();
10454 }
10455 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10456
10457 assert!(
10458 matches!(
10459 aof.subscribe_all_rules(),
10460 Err(core_storage::GraphError::ReadOnly)
10461 ),
10462 "subscribe_all_rules on as-of must return ReadOnly"
10463 );
10464 assert!(
10465 matches!(
10466 aof.subscribe_writes(),
10467 Err(core_storage::GraphError::ReadOnly)
10468 ),
10469 "subscribe_writes on as-of must return ReadOnly"
10470 );
10471 assert!(
10472 matches!(
10473 aof.subscribe_rule("works_at"),
10474 Err(core_storage::GraphError::ReadOnly)
10475 ),
10476 "subscribe_rule on as-of must return ReadOnly"
10477 );
10478 let _ = std::fs::remove_dir_all(&dir);
10479 }
10480
10481 /// Regression: a failed dense WAL rewrite must not leave speculative
10482 /// interns in `syms`. If it does, the next successful mutation logs an
10483 /// `Intern` record with an inflated id; replay (which never saw the
10484 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10485 #[test]
10486 fn dense_rewrite_error_rolls_back_speculative_interns() {
10487 let dir = tmp_dir("dense-rewrite-rollback");
10488 {
10489 let mut db = GraphDb::open(&dir).unwrap();
10490 db.insert_node("Person", "a", vec![]).unwrap();
10491
10492 // Bypass MutPreview validation to hit the rewrite's own error path
10493 // (same shape as an id-exhaustion failure mid-rewrite). The
10494 // InsertEdge arm interns the edge type before it resolves keys.
10495 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10496 edge_type: "ORPHAN_TYPE".into(),
10497 src_key: "missing".into(),
10498 dst_key: "a".into(),
10499 }]);
10500 assert!(err.is_err(), "rewrite of a missing src key must fail");
10501 assert_eq!(
10502 db.syms.get("ORPHAN_TYPE"),
10503 None,
10504 "failed rewrite must roll back speculative interns"
10505 );
10506
10507 // A later successful mutation must produce a replayable WAL.
10508 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10509 }
10510 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10511 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10512 let _ = std::fs::remove_dir_all(&dir);
10513 }
10514}