core_api/db.rs
1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4 event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::ArithOp;
7use core_query::cypher::{
8 execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9 plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10 WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15 ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21 archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22 archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28 ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37 ($phase:literal, $t:expr) => {
38 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39 eprintln!(
40 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41 $phase,
42 $t.elapsed()
43 );
44 }
45 };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51 ($phase:literal, $t:expr) => {
52 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53 eprintln!(
54 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55 $phase,
56 $t.elapsed()
57 );
58 }
59 };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70// Per-thread count of query-subscription `execute` calls in `distribute_events`.
71//
72// Incremented each time a query subscription actually runs its plan (i.e.,
73// the label-skip fast-path did not fire). Because `distribute_events` is
74// called synchronously on the writer thread, this thread-local correctly
75// isolates each test thread's count even when integration tests run in
76// parallel. Read via [`query_sub_exec_count`].
77thread_local! {
78 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
79}
80
81/// Return the number of query-subscription re-executions logged on this
82/// thread since the process started (or since last reset via
83/// [`reset_query_sub_exec_count`]).
84///
85/// Primarily for integration tests that verify the label-skip fast-path.
86#[doc(hidden)]
87pub fn query_sub_exec_count() -> usize {
88 QUERY_SUB_EXECS_TL.with(|c| c.get())
89}
90
91/// Reset the per-thread query-subscription execution counter to zero.
92#[doc(hidden)]
93pub fn reset_query_sub_exec_count() {
94 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
95}
96
97/// Internal state for a single `subscribe_query` subscription.
98///
99/// On every commit, `distribute_events` re-executes `ops` against the current
100/// graph state, diffs the result against `prev_rows`, and pushes
101/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
102///
103/// **Full re-run per commit; use LIMIT to bound execution cost.**
104/// (Differential evaluation is roadmap / Phase 5.)
105pub(crate) struct QuerySubEntry {
106 /// Compiled plan for the subscribed Cypher query.
107 ops: Vec<PlanOp>,
108 /// Column names from the first execution (fixed for the subscription lifetime).
109 columns: Vec<String>,
110 /// Serialized (JSON) row key → row data, representing the result set at
111 /// the end of the last commit. Used to diff against the new result.
112 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
113 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
114 inner: std::sync::Weak<SubInner>,
115 /// Interned label sym captured at subscribe time from the plan's leading scan
116 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
117 ///
118 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
119 /// with a concrete label), and this subscription must re-execute on every
120 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
121 /// queries are never skipped because edges can alter join results regardless
122 /// of which node labels were written.
123 scan_label: Option<u32>,
124}
125
126/// A post-commit mutation notification.
127///
128/// Emitted from `log_then_apply` after the WAL append, fsync, and
129/// in-memory `apply` all succeed. Never emitted for rejected operations
130/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
131/// deletes/removes). Event payloads carry user keys and rule names, never
132/// internal ids.
133///
134/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
135/// `apply` only. Emission lives exclusively in `log_then_apply`, so
136/// recovery is silent even if a sink were installed (it cannot be: the
137/// sink is in-memory and set after open).
138///
139/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
140/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
141/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
142/// or all-noop batch writes no WAL and emits nothing (including no summary).
143///
144/// **Derived edges:** rule-created or retracted edges are not individually
145/// evented — they are recoverable from the triggering mutation plus the live
146/// rule set. Only the triggering record is emitted.
147///
148/// **Wire form:** externally tagged snake_case JSON
149/// (`{"node_inserted":{"label":"A","key":"k"}}`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum MutationEvent {
153 NodeInserted {
154 label: String,
155 key: String,
156 },
157 PropSet {
158 key: String,
159 field: String,
160 },
161 PropRemoved {
162 key: String,
163 field: String,
164 },
165 EdgeInserted {
166 edge_type: String,
167 src: String,
168 dst: String,
169 },
170 EdgeDeleted {
171 edge_type: String,
172 src: String,
173 dst: String,
174 },
175 NodeDeleted {
176 key: String,
177 },
178 RuleCreated {
179 name: String,
180 },
181 RuleDeleted {
182 name: String,
183 },
184 RuleRebuilt {
185 name: String,
186 },
187 BatchApplied {
188 ops: usize,
189 },
190 Ingested {
191 label: String,
192 inserted: usize,
193 },
194}
195
196fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
197 match rec {
198 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
199 label: label.clone(),
200 key: key.clone(),
201 }),
202 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
203 label: intern.resolve(*label)?.to_string(),
204 key: key.clone(),
205 }),
206 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
207 key: key.clone(),
208 field: field.clone(),
209 }),
210 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
211 key: ids.key_of(*id)?.to_string(),
212 field: intern.resolve(*field)?.to_string(),
213 }),
214 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
215 key: key.clone(),
216 field: field.clone(),
217 }),
218 WalRecord::InsertEdge {
219 edge_type,
220 src_key,
221 dst_key,
222 } => Some(MutationEvent::EdgeInserted {
223 edge_type: edge_type.clone(),
224 src: src_key.clone(),
225 dst: dst_key.clone(),
226 }),
227 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
228 edge_type: intern.resolve(*etype)?.to_string(),
229 src: ids.key_of(*src)?.to_string(),
230 dst: ids.key_of(*dst)?.to_string(),
231 }),
232 WalRecord::DeleteEdge {
233 edge_type,
234 src_key,
235 dst_key,
236 } => Some(MutationEvent::EdgeDeleted {
237 edge_type: edge_type.clone(),
238 src: src_key.clone(),
239 dst: dst_key.clone(),
240 }),
241 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
242 WalRecord::CreateRule { def_bytes } => {
243 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
244 Some(MutationEvent::RuleCreated { name: def.name })
245 }
246 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
247 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
248 WalRecord::Batch(_)
249 | WalRecord::CreateView { .. }
250 | WalRecord::DeleteView { .. }
251 | WalRecord::EnableFulltext { .. }
252 | WalRecord::DisableFulltext { .. }
253 | WalRecord::EnableIndex { .. }
254 | WalRecord::DisableIndex { .. }
255 | WalRecord::Intern { .. }
256 // History markers are no-ops for mutation events — they carry no new
257 // state and rules re-derive deterministically on replay.
258 | WalRecord::DerivedEdgeAdded { .. }
259 | WalRecord::DerivedEdgeRetracted { .. }
260 // RenameNode carries no node/edge count change; no special event.
261 | WalRecord::RenameNode { .. } => None,
262 }
263}
264
265/// Database-wide counters plus per-rule budget/fire stats.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct Stats {
268 pub nodes_live: usize,
269 pub nodes_tombstoned: usize,
270 pub edges: u64,
271 pub rules: Vec<RuleStats>,
272 /// How many writes hit the rule-chaining depth cap with work still pending,
273 /// since this handle was opened. Non-zero means some derived edges beyond
274 /// the cap are stale and no single later write will repair them: split the
275 /// rule chain or shorten it. Never persisted, so it resets on reopen.
276 #[serde(default)]
277 pub chain_truncations: u64,
278}
279
280/// One rule's provenance size, trip latch, and fire counter.
281///
282/// `tripped` is a one-way latch: once set, the engine adds no new edges for
283/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
284/// set then fits). `fires` counts `on_node_changed` evaluations plus
285/// backfill/rebuild participant ticks (rebuild counts even when it is a
286/// provenance no-op).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct RuleStats {
289 pub name: String,
290 pub edges: u64,
291 pub tripped: bool,
292 pub fires: u64,
293 /// Whether this rule uses the approximate IVF-Flat candidate path.
294 pub approximate: bool,
295}
296
297/// One entry in the slow-query ring buffer.
298#[derive(Debug, Clone, Serialize)]
299pub struct SlowQueryEntry {
300 /// Execution time in whole milliseconds.
301 pub ms: u64,
302 /// The Cypher query string that was slow.
303 pub query: String,
304 /// The commit sequence number at the time the query ran.
305 pub at_commit: u64,
306}
307
308/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
309#[derive(Debug, Clone, Serialize)]
310pub struct SlowQuerySnapshot {
311 /// Current threshold in milliseconds (0 = disabled).
312 pub threshold_ms: u64,
313 /// Total number of slow queries ever recorded (not capped by ring size).
314 pub count: u64,
315 /// Most-recent slow queries (up to 16), oldest first.
316 pub last: Vec<SlowQueryEntry>,
317}
318
319/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
320/// write to it without a mutable borrow.
321struct SlowQueryLog {
322 entries: std::collections::VecDeque<SlowQueryEntry>,
323 total: u64,
324}
325
326/// Maximum number of entries kept in the slow-query ring buffer.
327const SLOW_QUERY_RING_CAP: usize = 16;
328
329/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
330/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct PredicateSummary {
333 pub kind: String,
334 pub fields: Vec<String>,
335 pub min: Option<f64>,
336 pub tolerance: Option<f64>,
337 pub km: Option<f64>,
338 pub parts: Option<Vec<PredicateSummary>>,
339 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
340 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
341 #[serde(default)]
342 pub approximate: bool,
343}
344
345impl From<&Predicate> for PredicateSummary {
346 fn from(p: &Predicate) -> Self {
347 match p {
348 Predicate::KeyMatch { field } => PredicateSummary {
349 kind: "key_match".into(),
350 fields: vec![field.clone()],
351 min: None,
352 tolerance: None,
353 km: None,
354 parts: None,
355 approximate: false,
356 },
357 Predicate::FieldEqual { field } => PredicateSummary {
358 kind: "field_equal".into(),
359 fields: vec![field.clone()],
360 min: None,
361 tolerance: None,
362 km: None,
363 parts: None,
364 approximate: false,
365 },
366 Predicate::Overlap { field, min } => PredicateSummary {
367 kind: "overlap".into(),
368 fields: vec![field.clone()],
369 min: Some(*min),
370 tolerance: None,
371 km: None,
372 parts: None,
373 approximate: false,
374 },
375 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
376 kind: "numeric_within".into(),
377 fields: vec![field.clone()],
378 min: None,
379 tolerance: Some(*tolerance),
380 km: None,
381 parts: None,
382 approximate: false,
383 },
384 Predicate::GeoRadius { field, km } => PredicateSummary {
385 kind: "geo_radius".into(),
386 fields: vec![field.clone()],
387 min: None,
388 tolerance: None,
389 km: Some(*km),
390 parts: None,
391 approximate: false,
392 },
393 Predicate::VectorSimilar { field, min } => PredicateSummary {
394 kind: "vector_similar".into(),
395 fields: vec![field.clone()],
396 min: Some(*min),
397 tolerance: None,
398 km: None,
399 parts: None,
400 approximate: false,
401 },
402 Predicate::All(inner) => {
403 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
404 let mut fields = Vec::new();
405 for part in &parts {
406 for f in &part.fields {
407 if !fields.contains(f) {
408 fields.push(f.clone());
409 }
410 }
411 }
412 PredicateSummary {
413 kind: "all".into(),
414 fields,
415 min: None,
416 tolerance: None,
417 km: None,
418 parts: Some(parts),
419 approximate: false,
420 }
421 }
422 Predicate::Any(inner) => {
423 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
424 let mut fields = Vec::new();
425 for part in &parts {
426 for f in &part.fields {
427 if !fields.contains(f) {
428 fields.push(f.clone());
429 }
430 }
431 }
432 PredicateSummary {
433 kind: "any".into(),
434 fields,
435 min: None,
436 tolerance: None,
437 km: None,
438 parts: Some(parts),
439 approximate: false,
440 }
441 }
442 }
443 }
444}
445
446/// Snapshot of a live node's key, label, and columnar properties.
447///
448/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
449/// regardless of insert order or the columnar store's `HashMap` iteration.
450///
451/// Deliberately does not derive `Serialize`: `Value`'s serde form is
452/// internally tagged. Wire JSON is built by `value_to_json` in the server.
453#[derive(Debug, Clone, PartialEq)]
454pub struct NodeInfo {
455 pub key: String,
456 pub label: String,
457 pub props: BTreeMap<String, Value>,
458}
459
460/// Counts returned by [`GraphDb::delete_node`].
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct DeleteReport {
463 /// Number of manual (user-inserted) edges removed.
464 pub manual_edges: u64,
465 /// Number of derived (rule-owned) edges retracted.
466 pub derived_edges: u64,
467}
468
469/// One directed edge incident on a node, with provenance membership.
470///
471/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
472/// Plan-8 `by_node` provenance index.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct EdgeInfo {
475 pub edge_type: String,
476 pub src_key: String,
477 pub dst_key: String,
478 pub derived: bool,
479}
480
481/// An edge with mask-aware endpoint visibility.
482///
483/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
484/// mode — hidden endpoints carry `*_restricted: true`.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct MaskedEdge {
487 pub edge_type: String,
488 pub src_key: String,
489 /// `true` when `src_key` is in the DB but hidden from the mask.
490 pub src_restricted: bool,
491 pub dst_key: String,
492 /// `true` when `dst_key` is in the DB but hidden from the mask.
493 pub dst_restricted: bool,
494 pub derived: bool,
495}
496
497/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
498///
499/// `None` from that method means the key does not exist (→ 404).
500/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
501#[derive(Debug, PartialEq)]
502pub enum MaskedNodeResult {
503 Visible(NodeInfo),
504 /// Node exists in the DB but is hidden from this mask.
505 Restricted,
506}
507
508/// One rule-owned edge between two nodes, with the rule name, edge type,
509/// direction (src_key → dst_key), and weight if the rule stores one.
510#[derive(Debug, Clone, PartialEq, Serialize)]
511pub struct Explanation {
512 pub rule: String,
513 pub edge_type: String,
514 pub src_key: String,
515 pub dst_key: String,
516 pub weight: Option<f64>,
517 pub predicate: PredicateSummary,
518 /// For a via-hop rule, the edge type the rule hops over to reach its
519 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
520 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
521 /// was written by another rule in the same commit.
522 #[serde(default)]
523 pub via_edge: Option<String>,
524}
525
526/// Report returned by [`GraphDb::backup_to`].
527#[derive(Debug, Clone)]
528pub struct BackupReport {
529 /// Filenames copied into the destination directory (sorted ascending).
530 pub files: Vec<String>,
531 /// Total bytes written across all copied files.
532 pub bytes: u64,
533 /// `true` when the destination opened cleanly and passed post-copy checks.
534 ///
535 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
536 /// matched **and** the destination opened without error.
537 ///
538 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
539 /// CRC-check; `verified` is `true` when the destination opened and
540 /// replayed the WAL without error (record-level checksums in the WAL
541 /// provide the integrity signal, not section CRCs).
542 pub verified: bool,
543}
544
545/// One directed edge in export form, with optional rule attribution for derived edges.
546///
547/// Returned by [`GraphDb::all_edges_for_export`].
548///
549/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
550/// order. Callers that need a stable edge ordering already sort by
551/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
552#[derive(Debug, Clone, PartialEq, PartialOrd)]
553pub struct ExportEdge {
554 pub edge_type: String,
555 pub src: String,
556 pub dst: String,
557 pub derived: bool,
558 /// Rule name that created this edge, if derived. `None` for manual edges.
559 pub rule: Option<String>,
560 /// The creating rule's declared `weight_prop`, read off this edge, when
561 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
562 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
563 pub weight: Option<f64>,
564}
565
566/// Construct the standard write-query result set (columns: created, properties_set, deleted).
567fn write_result_set() -> ResultSet {
568 ResultSet::new(vec![
569 "created".into(),
570 "properties_set".into(),
571 "deleted".into(),
572 ])
573}
574
575fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
576 match op {
577 Operand::Lit(v) => Ok(v.clone()),
578 Operand::Param(name) => params
579 .get(name)
580 .cloned()
581 .ok_or_else(|| GraphError::QueryError {
582 detail: format!("missing parameter `{name}`"),
583 }),
584 _ => Err(GraphError::QueryError {
585 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
586 }),
587 }
588}
589
590fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
591 match op {
592 Operand::Prop { var, .. } | Operand::Var(var) => {
593 if !out.contains(var) {
594 out.push(var.clone());
595 }
596 }
597 Operand::FuncCall { args, .. } => {
598 for arg in args {
599 operand_node_vars(arg, out);
600 }
601 }
602 Operand::BinArith { left, right, .. } => {
603 operand_node_vars(left, out);
604 operand_node_vars(right, out);
605 }
606 Operand::Case { branches, default } => {
607 // Branch conditions reference vars already bound (and mask-filtered)
608 // by the MATCH phase, so collecting from the value operands + ELSE
609 // is sufficient for RETURN-projection var discovery.
610 for (_, value) in branches {
611 operand_node_vars(value, out);
612 }
613 if let Some(d) = default {
614 operand_node_vars(d, out);
615 }
616 }
617 Operand::Lit(_) | Operand::Param(_) => {}
618 }
619}
620
621fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
622 let mut out = Vec::new();
623 for item in items {
624 match &item.value {
625 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
626 if !out.contains(v) {
627 out.push(v.clone());
628 }
629 }
630 RetVal::FuncCall { args, .. } => {
631 for arg in args {
632 operand_node_vars(arg, &mut out);
633 }
634 }
635 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
636 RetVal::Agg { .. } => {}
637 }
638 }
639 out
640}
641
642fn add_var(out: &mut Vec<String>, v: &str) {
643 if !out.iter().any(|x| x == v) {
644 out.push(v.to_string());
645 }
646}
647
648fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
649 let mut out = Vec::new();
650 for p in pats {
651 if let Some(v) = &p.start.var {
652 add_var(&mut out, v);
653 }
654 for (_, dest) in &p.chain {
655 if let Some(v) = &dest.var {
656 add_var(&mut out, v);
657 }
658 }
659 }
660 out
661}
662
663fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
664 let mut out = Vec::new();
665 for p in pats {
666 for (rel, _) in &p.chain {
667 if rel.hops.is_none() {
668 if let Some(v) = &rel.var {
669 add_var(&mut out, v);
670 }
671 }
672 }
673 }
674 out
675}
676
677fn rel_type_alias(var: &str) -> String {
678 format!("__rt_{var}")
679}
680
681fn ret_column_name(item: &RetItem) -> String {
682 if let Some(alias) = &item.alias {
683 return alias.clone();
684 }
685 match &item.value {
686 RetVal::Var(v) => v.clone(),
687 RetVal::Prop { var, field } => format!("{var}.{field}"),
688 RetVal::FuncCall { name, args } => {
689 let arg_strs: Vec<String> = args
690 .iter()
691 .map(|a| match a {
692 Operand::Var(v) => v.clone(),
693 Operand::Prop { var, field } => format!("{var}.{field}"),
694 Operand::Lit(_) => "<lit>".to_string(),
695 Operand::Param(p) => format!("${p}"),
696 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
697 Operand::BinArith { .. } => "<arith>".to_string(),
698 Operand::Case { .. } => "<case>".to_string(),
699 })
700 .collect();
701 format!("{name}({})", arg_strs.join(", "))
702 }
703 RetVal::ScalarExpr(_) => "<expr>".to_string(),
704 RetVal::Agg { .. } => "<agg>".to_string(),
705 }
706}
707
708fn eval_set_return_operand<F: Fs>(
709 db: &GraphDb<F>,
710 match_rs: &ResultSet,
711 row: usize,
712 rel_vars: &[String],
713 op: &Operand,
714 params: &BTreeMap<String, Value>,
715) -> Result<Option<Value>> {
716 match op {
717 Operand::Lit(v) => Ok(Some(v.clone())),
718 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
719 detail: format!("missing parameter `{name}`"),
720 }).map(Some),
721 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
722 detail: format!(
723 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
724 ),
725 }),
726 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
727 Operand::Prop { var, field } => {
728 if rel_vars.iter().any(|r| r == var) {
729 return Ok(None);
730 }
731 let Some(Value::Str(key)) = match_rs.get(row, var) else {
732 return Ok(None);
733 };
734 Ok(db.get_prop(key, field))
735 }
736 Operand::FuncCall { name, args } => {
737 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
738 }
739 Operand::BinArith { op, left, right } => {
740 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
741 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
742 eval_set_return_arith(op, lv, rv)
743 }
744 // CASE is supported in read-query RETURN; in a write-statement RETURN
745 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
746 Operand::Case { .. } => Err(GraphError::QueryError {
747 detail: "CASE is not supported in a write-statement RETURN projection; \
748 use a read query"
749 .into(),
750 }),
751 }
752}
753
754fn eval_set_return_arith(
755 op: &ArithOp,
756 lv: Option<Value>,
757 rv: Option<Value>,
758) -> Result<Option<Value>> {
759 match (lv, rv) {
760 (None, _) | (_, None) => Ok(None),
761 (Some(Value::Int(a)), Some(Value::Int(b))) => {
762 let result = match op {
763 ArithOp::Sub => a.saturating_sub(b),
764 ArithOp::Mul => a.saturating_mul(b),
765 ArithOp::Add => a.saturating_add(b),
766 ArithOp::Div => {
767 if b == 0 {
768 return Err(GraphError::QueryError {
769 detail: "division by zero".into(),
770 });
771 }
772 a.checked_div(b).unwrap_or(i64::MAX)
773 }
774 };
775 Ok(Some(Value::Int(result)))
776 }
777 (Some(lv), Some(rv)) => {
778 let a = match &lv {
779 Value::Float(f) => *f,
780 Value::Int(i) => *i as f64,
781 _ => {
782 return Err(GraphError::QueryError {
783 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
784 })
785 }
786 };
787 let b = match &rv {
788 Value::Float(f) => *f,
789 Value::Int(i) => *i as f64,
790 _ => {
791 return Err(GraphError::QueryError {
792 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
793 })
794 }
795 };
796 let result = match op {
797 ArithOp::Sub => a - b,
798 ArithOp::Mul => a * b,
799 ArithOp::Add => a + b,
800 ArithOp::Div => {
801 if b == 0.0 {
802 return Err(GraphError::QueryError {
803 detail: "division by zero".into(),
804 });
805 }
806 a / b
807 }
808 };
809 Ok(Some(Value::Float(result)))
810 }
811 }
812}
813
814fn eval_set_return_func<F: Fs>(
815 db: &GraphDb<F>,
816 match_rs: &ResultSet,
817 row: usize,
818 rel_vars: &[String],
819 name: &str,
820 args: &[Operand],
821 params: &BTreeMap<String, Value>,
822) -> Result<Option<Value>> {
823 let norm = name.to_ascii_lowercase();
824 if norm == "type" {
825 if args.len() != 1 {
826 return Err(GraphError::QueryError {
827 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
828 });
829 }
830 let Operand::Var(rel) = &args[0] else {
831 return Err(GraphError::QueryError {
832 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
833 });
834 };
835 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
836 }
837 if norm == "key" {
838 if args.len() != 1 {
839 return Err(GraphError::QueryError {
840 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
841 });
842 }
843 let Operand::Var(var) = &args[0] else {
844 return Err(GraphError::QueryError {
845 detail: "key() argument must be a node variable (e.g. key(n))".into(),
846 });
847 };
848 if rel_vars.iter().any(|r| r == var) {
849 return Err(GraphError::QueryError {
850 detail: format!("key() argument `{var}` is a relationship, not a node"),
851 });
852 }
853 // MATCH rows bind node variables to their key string, so the column
854 // value *is* the key.
855 return Ok(match_rs.get(row, var).cloned());
856 }
857 let mut vals = Vec::with_capacity(args.len());
858 for arg in args {
859 vals.push(eval_set_return_operand(
860 db, match_rs, row, rel_vars, arg, params,
861 )?);
862 }
863 match norm.as_str() {
864 "tolower" => {
865 if vals.len() != 1 {
866 return Err(GraphError::QueryError {
867 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
868 });
869 }
870 Ok(vals[0].clone().map(|val| match val {
871 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
872 other => other,
873 }))
874 }
875 "toupper" => {
876 if vals.len() != 1 {
877 return Err(GraphError::QueryError {
878 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
879 });
880 }
881 Ok(vals[0].clone().map(|val| match val {
882 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
883 other => other,
884 }))
885 }
886 "size" => match vals.first().cloned().flatten() {
887 None => Ok(None),
888 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
889 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
890 Some(_) => Ok(None),
891 },
892 "coalesce" => Ok(vals.into_iter().flatten().next()),
893 "abs" => match vals.first().cloned().flatten() {
894 None => Ok(None),
895 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
896 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
897 Some(_) => Ok(None),
898 },
899 "round" => match vals.first().cloned().flatten() {
900 None => Ok(None),
901 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
902 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
903 Some(_) => Ok(None),
904 },
905 "decay" => {
906 if vals.len() != 3 {
907 return Err(GraphError::QueryError {
908 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
909 });
910 }
911 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
912 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
913 (Some(b), Some(a), Some(h)) => {
914 let numeric = |v: Value| -> Result<f64> {
915 match v {
916 Value::Int(n) => Ok(n as f64),
917 Value::Float(f) => Ok(f),
918 other => Err(GraphError::QueryError {
919 detail: format!(
920 "decay() requires numeric arguments, got {other:?}"
921 ),
922 }),
923 }
924 };
925 let b = numeric(b)?;
926 let a = numeric(a)?;
927 let h = numeric(h)?;
928 if h <= 0.0 {
929 return Err(GraphError::QueryError {
930 detail: "decay() requires halflife > 0".into(),
931 });
932 }
933 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
934 }
935 }
936 }
937 _ => Err(GraphError::QueryError {
938 detail: format!(
939 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
940 ),
941 }),
942 }
943}
944
945fn eval_set_return_item<F: Fs>(
946 db: &GraphDb<F>,
947 match_rs: &ResultSet,
948 row: usize,
949 rel_vars: &[String],
950 item: &RetItem,
951 params: &BTreeMap<String, Value>,
952) -> Result<Option<Value>> {
953 match &item.value {
954 RetVal::Var(v) => eval_set_return_operand(
955 db,
956 match_rs,
957 row,
958 rel_vars,
959 &Operand::Var(v.clone()),
960 params,
961 ),
962 RetVal::Prop { var, field } => eval_set_return_operand(
963 db,
964 match_rs,
965 row,
966 rel_vars,
967 &Operand::Prop {
968 var: var.clone(),
969 field: field.clone(),
970 },
971 params,
972 ),
973 RetVal::FuncCall { name, args } => {
974 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
975 }
976 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
977 RetVal::Agg { .. } => Err(GraphError::QueryError {
978 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
979 }),
980 }
981}
982
983/// Project user RETURN from original MATCH rows after SET. No rematch.
984fn project_set_return_rows<F: Fs>(
985 db: &GraphDb<F>,
986 rel_vars: &[String],
987 match_rs: &ResultSet,
988 returns: &[RetItem],
989 params: &BTreeMap<String, Value>,
990) -> Result<ResultSet> {
991 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
992 let mut out = ResultSet::new(columns);
993 for row in 0..match_rs.len() {
994 let mut cells = Vec::with_capacity(returns.len());
995 for item in returns {
996 cells.push(eval_set_return_item(
997 db, match_rs, row, rel_vars, item, params,
998 )?);
999 }
1000 out.push_row(cells);
1001 }
1002 Ok(out)
1003}
1004
1005/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1006/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1007/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1008/// Returns `None` for non-list values or lists with non-numeric elements.
1009fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1010 match v {
1011 Value::List(items) => items
1012 .iter()
1013 .map(|item| match item {
1014 Value::Float(f) => Some(*f),
1015 Value::Int(i) => Some(*i as f64),
1016 _ => None,
1017 })
1018 .collect(),
1019 _ => None,
1020 }
1021}
1022
1023fn make_graph_mut<'a>(
1024 ids: &'a IdMap,
1025 syms: &'a mut Interner,
1026 labels: &'a [u32],
1027 props: core_storage::v8::seam::ColumnsView<'a>,
1028 topo: &'a mut Topology,
1029 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1030 edge_props: &'a mut EdgeProps,
1031) -> GraphMut<'a> {
1032 GraphMut {
1033 ids,
1034 syms,
1035 labels,
1036 props,
1037 topo,
1038 base_topo: base_csr(base),
1039 edge_props,
1040 }
1041}
1042
1043/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1044///
1045/// A store opened from a snapshot keeps its edges in the mapping and its
1046/// overlay empty, so a rule that reads the graph's shape has to see both.
1047fn base_csr(
1048 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1049) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1050 base.as_ref().map(|b| {
1051 b.topology()
1052 .expect("base topology section bounds validated at open")
1053 })
1054}
1055
1056/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1057///
1058/// Takes explicit field references rather than `&self` so the caller can hold
1059/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1060fn build_props_view<'a>(
1061 props: &'a ColumnStore,
1062 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1063) -> core_storage::v8::seam::ColumnsView<'a> {
1064 match base {
1065 None => core_storage::v8::seam::ColumnsView::owned(props),
1066 Some(b) => {
1067 let archived = b
1068 .columns()
1069 .expect("base columns section bounds validated at open");
1070 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1071 }
1072 }
1073}
1074
1075fn build_topo_view<'a>(
1076 overlay: &'a Topology,
1077 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1078) -> core_storage::v8::seam::TopologyView<'a> {
1079 match base {
1080 None => core_storage::v8::seam::TopologyView::owned(overlay),
1081 Some(b) => {
1082 let archived_csr = b
1083 .topology()
1084 .expect("base topology section bounds validated at open");
1085 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1086 }
1087 }
1088}
1089
1090/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1091///
1092/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1093/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1094/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1095/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1096/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1097#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1098pub enum FsyncPolicy {
1099 /// Every WAL commit calls `fs.sync` (today's behavior).
1100 #[default]
1101 Strict,
1102 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1103 /// this policy is set on the database.
1104 Batched,
1105 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1106 Relaxed,
1107}
1108
1109/// A precondition for a compare-and-set batch write.
1110///
1111/// All preconditions in a [`GraphDb::write_batch_cas`] or
1112/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1113/// any operation in the batch is applied. If any precondition fails, the
1114/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1115/// is written.
1116///
1117/// # Touch definition
1118///
1119/// A node's last-change commit (`last_changed`) is updated when any of the
1120/// following state-changing WAL records touch it:
1121///
1122/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1123/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1124/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1125/// endpoints (an edge change touches both sides).
1126/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1127/// for deleted keys so the pre-deletion entry is never observed.
1128///
1129/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1130/// state no-ops. The underlying mutation that triggered rule firing already
1131/// updated the relevant nodes' last-change entries. Rule-management records
1132/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1133/// do not touch any node's last-change.
1134#[derive(Debug, Clone, PartialEq, Eq)]
1135pub enum Precondition {
1136 /// The node's last-change commit must equal `expected`.
1137 ///
1138 /// Fails with [`GraphError::CasConflict`] when:
1139 /// - The node does not exist (`last_changed` returns `None`), or
1140 /// - The recorded commit seq does not match `expected`.
1141 NodeUnchangedSince { key: String, expected: u64 },
1142 /// The node must not exist (not inserted, or already deleted).
1143 ///
1144 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1145 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1146 NodeAbsent { key: String },
1147}
1148
1149pub struct GraphDb<F: Fs> {
1150 fs: F,
1151 ids: IdMap,
1152 syms: Interner,
1153 topo: Topology,
1154 props: ColumnStore,
1155 labels: Vec<u32>, // node id -> label symbol
1156 edge_props: EdgeProps,
1157 engine: RuleEngine,
1158 view_store: ViewStore,
1159 /// Incremental inverted index for full-text-lite search.
1160 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1161 fulltext: FulltextIndex,
1162 /// Opt-in equality index over scalar node properties.
1163 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1164 /// open end (mirrors `fulltext`).
1165 prop_index: PropertyIndex,
1166 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1167 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1168 fsync: FsyncPolicy,
1169 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1170 /// call increments this once; all events emitted from that call share the same
1171 /// `commit_seq` value.
1172 commit_seq: u64,
1173 /// RBAC role definitions loaded from `roles.json` at open.
1174 ///
1175 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1176 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1177 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1178 roles: Option<Vec<RoleDef>>,
1179 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1180 /// distribute_events call.
1181 subscriptions: Vec<SubEntry>,
1182 /// Live query subscriptions. Re-executed on every commit when non-empty.
1183 /// Dead `Weak` entries are pruned inside `distribute_events`.
1184 query_subscriptions: Vec<QuerySubEntry>,
1185 /// Queue capacity for new subscriptions created by this db. Default is
1186 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1187 /// to test Lagged behaviour with small queues.
1188 sub_capacity: usize,
1189 /// True for as-of instances opened via [`GraphDb::open_at`].
1190 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1191 /// when this flag is set.
1192 read_only: bool,
1193 /// Total WAL commit count at the time [`open_at`] was called.
1194 /// 0 for normal (non-as-of) instances.
1195 total_wal_commits: u64,
1196 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1197 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1198 /// reads go through a merged `TopologyView`. `self.props` is always
1199 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1200 base: Option<Arc<core_storage::v8::MappedBase>>,
1201 // ── MVCC epoch reader state ───────────────────────────────────────────────
1202 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1203 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1204 /// `None` only between struct creation and the first fold.
1205 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1206 /// Per-commit deltas accumulated since the last fold.
1207 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1208 /// How many commits have occurred since the last fold.
1209 commits_since_fold: usize,
1210 /// When true, `log_then_apply_with` buffers event notifications instead of
1211 /// firing them immediately. Used by the group-commit drain thread to defer
1212 /// events until after the group fsync (R2: durability before notification).
1213 /// Cleared to false once the drain thread flushes or discards the buffer.
1214 defer_events: bool,
1215 /// Buffered events accumulated while `defer_events` is true.
1216 deferred_events: Vec<DeferredEvent>,
1217 /// Set to true by the group-commit drain thread when a group fsync fails
1218 /// after WAL truncation. All subsequent mutation attempts return an IO
1219 /// error until the database is reopened.
1220 degraded: bool,
1221 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1222 /// HNSW, and IVF sections from the mmap base into the engine's retained
1223 /// fields. `false` on all opens until first use; always `true` for non-V8
1224 /// opens (base is None, fast-path sets flag immediately).
1225 v8_sections_loaded: std::sync::atomic::AtomicBool,
1226 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1227 v8_sections_mutex: std::sync::Mutex<()>,
1228 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1229 /// the node was last modified by commit `seq`.
1230 ///
1231 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1232 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1233 /// nodes return `None` from `last_changed` until they are next mutated.
1234 ///
1235 /// See [`Precondition`] for the full touch definition.
1236 last_change: HashMap<u32, u64>,
1237 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1238 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1239 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1240 wal_archive_retention: Option<u32>,
1241 /// Global frame index of the first commit that is still reachable through
1242 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1243 /// Default 0 = all history reachable.
1244 wal_horizon_floor: u64,
1245 /// True when the surviving archive chain forms a continuous WAL history
1246 /// starting from the store's first commit (the genesis chain).
1247 ///
1248 /// `open_at` may replay archive-resident commits from empty state only when
1249 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1250 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1251 /// already exist (breaks the chain for subsequent archives), or
1252 /// - any archive is pruned (floor advances past zero).
1253 ///
1254 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1255 archive_genesis_chain: bool,
1256 /// Transient write-authz context set by `write_batch_authz` /
1257 /// `query_write_authz` for the duration of ONE mutation call.
1258 /// Always `None` at rest. Never serialized, never WAL-replayed.
1259 pending_write_authz: Option<WriteAuthz>,
1260 /// Slow-query threshold in milliseconds. 0 = disabled.
1261 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1262 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1263 /// — env vars are process-global and race parallel test threads).
1264 slow_query_threshold_ms: u64,
1265 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1266 /// can record entries without requiring `&mut self`).
1267 slow_queries: std::sync::Mutex<SlowQueryLog>,
1268 /// Instant at which the database was opened (used by `/metrics` uptime).
1269 started_at: std::time::Instant,
1270 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1271 /// Byte offset of the WAL prefix already applied to in-memory state.
1272 ///
1273 /// Advanced by exactly the encoded length of every frame this handle
1274 /// appends, and by the decoded byte count of every tail
1275 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1276 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1277 /// drain thread truncates a failed group. Compared against the WAL's
1278 /// on-disk length to decide staleness.
1279 wal_consumed: u64,
1280 /// Identity of the snapshot this handle's base state came from, as
1281 /// `(len, mtime_nanos)`. A different value means another process replaced
1282 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1283 snapshot_ident: Option<(u64, u64)>,
1284 /// The options this handle was opened with. Replayed verbatim when
1285 /// `refresh` has to rebuild from disk.
1286 open_opts: OpenOptions,
1287 /// True when this handle holds the cross-process write lock for its whole
1288 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1289 /// no-op on such a handle, and never releases the lock.
1290 holds_lifetime_lock: bool,
1291 /// True between a failed lock acquisition and the end of the write scope
1292 /// that failed. Makes every WAL-appending mutation in that scope return
1293 /// [`GraphError::Busy`] instead of writing.
1294 lock_denied: bool,
1295 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1296 /// pinned to one commit, so it is never stale and never refreshes — later
1297 /// commits by any process are deliberately invisible to it.
1298 pinned: bool,
1299}
1300
1301/// One group of deferred event notifications, held until the group fsync
1302/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1303struct DeferredEvent {
1304 rec: core_storage::WalRecord,
1305 engine_deltas: Vec<EngineEdgeDelta>,
1306 seq: u64,
1307 ingest: Option<(String, usize)>,
1308}
1309
1310/// Options for [`GraphDb::open_with_options`].
1311#[derive(Clone, Copy, Debug)]
1312pub struct OpenOptions {
1313 /// Rewrite an old-format snapshot to the current VERSION after a
1314 /// successful load (default `true`). The old snapshot is kept as
1315 /// `snapshot.bin.bak` until the next clean open at the current version,
1316 /// at which point the `.bak` is deleted.
1317 ///
1318 /// Set to `false` to open a store without touching any on-disk files
1319 /// (useful for read-only inspection of a store at an older format).
1320 pub auto_migrate: bool,
1321
1322 /// Write the valid WAL prefix back over a torn tail on open (default
1323 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1324 ///
1325 /// Set to `false` for an unattended reader. The valid prefix is still
1326 /// decoded and replayed in memory, but nothing is written: a reader that
1327 /// opens while another process is mid-append would otherwise discard a
1328 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1329 /// every prompt, passes `false` for exactly this reason.
1330 pub repair_wal: bool,
1331
1332 /// Open without ever writing to the store (default `false`).
1333 ///
1334 /// A read-only handle:
1335 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1336 /// `snapshot()`;
1337 /// - performs no disk write at open — no WAL repair write-back and no
1338 /// auto-migration rewrite, whatever the other two flags say;
1339 /// - never takes the cross-process write lock, so it opens immediately even
1340 /// while another process is writing, and never makes a writer wait.
1341 ///
1342 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1343 /// normally, so a read-only handle can follow another process's commits.
1344 pub read_only: bool,
1345}
1346
1347impl Default for OpenOptions {
1348 fn default() -> Self {
1349 Self {
1350 auto_migrate: true,
1351 repair_wal: true,
1352 read_only: false,
1353 }
1354 }
1355}
1356
1357/// How long a writer polls for the cross-process write lock before giving up
1358/// with [`GraphError::Busy`].
1359///
1360/// Long enough to ride out another process's commit (a batch apply plus one
1361/// fsync), short enough that a stuck peer surfaces as an error rather than a
1362/// hang.
1363pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1364
1365/// Interval between poll attempts while waiting for the cross-process lock.
1366pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1367
1368/// Why `load_from_disk` is running, which decides whether it may repair.
1369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1370enum LoadOrigin {
1371 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1372 /// the signature of a crash and truncating it is correct, and archives
1373 /// orphaned by an interrupted prune can be swept.
1374 Open,
1375 /// A reload driven by [`GraphDb::refresh`], because another process
1376 /// replaced the snapshot. Nothing here is crash recovery — the store is
1377 /// live and someone else is writing it — so this origin writes nothing.
1378 Reload,
1379}
1380
1381/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1382///
1383/// `None` at the call site = full authority (today's zero-cost behavior).
1384/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1385/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1386/// record is built. A denial returns an error with no WAL frame written.
1387///
1388/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1389/// hidden-node existence to callers.
1390#[derive(Clone, Debug)]
1391pub struct WriteAuthz {
1392 pub role: String,
1393 pub scope: WriteScope,
1394 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1395 /// Always `Omit`-mode — never `Stub`.
1396 pub mask: crate::mask::NodeMask,
1397}
1398
1399/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1400///
1401/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1402/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1403/// syncs the directory entry. This is the only correct path for writing the
1404/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1405/// the directory sync.
1406pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1407 use core_storage::fs::{FileId, Fs as _};
1408 RealFs::new(dir)
1409 .map_err(core_storage::GraphError::Io)?
1410 .write_atomic(FileId::SnapshotBak, bytes)
1411 .map_err(core_storage::GraphError::Io)
1412}
1413
1414/// Return the on-disk snapshot format version without decoding the full snapshot.
1415///
1416/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1417/// snapshot file exists (WAL-only store). Returns an error if the header is
1418/// malformed.
1419pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1420 use std::io::Read as _;
1421 let path = dir.join("snapshot.bin");
1422 let mut header = [0u8; 6];
1423 let n = match std::fs::File::open(&path) {
1424 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1425 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1426 Err(e) => return Err(core_storage::GraphError::Io(e)),
1427 };
1428 core_storage::snapshot::peek_version(&header[..n])
1429}
1430
1431/// Options for [`GraphDb::snapshot_with`].
1432#[derive(Debug, Clone, Default)]
1433pub struct SnapshotOptions {
1434 /// When `true`, the WAL is preserved after the snapshot write.
1435 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1436 /// When `false` (the default), the WAL is truncated to a minimal
1437 /// baseline so cold-start replay stays fast.
1438 pub keep_wal: bool,
1439 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1440 /// before a fresh WAL baseline is written (history-preserving snapshot).
1441 ///
1442 /// This is the feature opt-in: `false` (the default) leaves the existing
1443 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1444 /// precedence over `keep_wal` when both are set.
1445 ///
1446 /// Archives can be scanned by [`GraphDb::node_history`],
1447 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1448 /// [`GraphDb::open_at`], extending the reachable history horizon across
1449 /// snapshot boundaries.
1450 pub archive_wal: bool,
1451}
1452
1453/// Derive the scan-label sym for the commit-skip fast-path.
1454///
1455/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1456/// or `IndexIntersect`) with a concrete label string, then interns it.
1457///
1458/// Returns `None` in all cases where skipping is unsafe:
1459/// - Any `Expand` op is present (edge traversal; edges change results regardless
1460/// of node labels).
1461/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1462/// - No recognizable leading scan op is found.
1463///
1464/// This is the conservative v0.4.3 boundary. The caller stores the result in
1465/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1466fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1467 // Any Expand → must always re-execute (edges can change join results).
1468 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1469 return None;
1470 }
1471 for op in ops {
1472 match op {
1473 PlanOp::ScanLabel {
1474 label: Some(label), ..
1475 } => return Some(syms.intern(label)),
1476 PlanOp::IndexScan {
1477 label: Some(label), ..
1478 } => return Some(syms.intern(label)),
1479 PlanOp::IndexIntersect {
1480 label: Some(label), ..
1481 } => return Some(syms.intern(label)),
1482 _ => {}
1483 }
1484 }
1485 None
1486}
1487
1488impl GraphDb<RealFs> {
1489 /// Open the database at `dir` with default options.
1490 ///
1491 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1492 /// Old-format snapshots (V5, V6) are automatically migrated to the
1493 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1494 pub fn open(dir: &std::path::Path) -> Result<Self> {
1495 Self::open_with_options(dir, OpenOptions::default())
1496 }
1497
1498 /// Open the database at `dir` with explicit options.
1499 ///
1500 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1501 /// snapshot is an older format version, this function:
1502 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1503 /// + fsynced) before any modification.
1504 /// 2. Rewrites `snapshot.bin` at the current format version via
1505 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1506 ///
1507 /// If migration fails the error is returned and the original files are
1508 /// intact (the `.bak` was written before the new snapshot was attempted).
1509 ///
1510 /// A clean open that finds the snapshot already at the current version
1511 /// deletes any leftover `.bak` file.
1512 ///
1513 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1514 ///
1515 /// `opts.repair_wal` controls the other write this function can make; see
1516 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1517 /// no file on disk.
1518 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1519 Self::open_dir(dir, opts, true)
1520 }
1521
1522 /// Open without taking the cross-process write lock for the handle's
1523 /// lifetime.
1524 ///
1525 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1526 /// its handle open indefinitely, so it takes the lock per write instead of
1527 /// keeping every other process out of the store for as long as it runs.
1528 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1529 Self::open_dir(dir, OpenOptions::default(), false)
1530 }
1531
1532 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1533 // Header-only peek — 6 bytes, no full decode.
1534 let snap_version = snapshot_version_at(dir)?;
1535
1536 // Full load: decode snapshot + replay WAL + rebuild indexes.
1537 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1538
1539 // A read-only handle writes nothing at open, so it never migrates —
1540 // the old-format snapshot is loaded and left exactly as it is.
1541 if opts.auto_migrate && !opts.read_only {
1542 match snap_version {
1543 Some(ver) if ver < core_storage::snapshot::VERSION => {
1544 let _tm = std::time::Instant::now();
1545 // Copy the original snapshot to .bak at OS level — no in-memory
1546 // buffer required for a 2+ GiB file.
1547 //
1548 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1549 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1550 // A torn .bak on crash is acceptable because the original
1551 // snapshot.bin is the authoritative source until after the rename.
1552 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1553 .map_err(core_storage::GraphError::Io)?;
1554 trace_migrate!("bak copy done", _tm);
1555 // Rewrite snapshot at current version; keep WAL intact.
1556 db.snapshot_with(SnapshotOptions {
1557 keep_wal: true,
1558 ..SnapshotOptions::default()
1559 })?;
1560 trace_migrate!("snapshot_with done", _tm);
1561 }
1562 Some(_) => {
1563 // Already current version: remove any leftover .bak.
1564 let bak = dir.join("snapshot.bin.bak");
1565 if bak.exists() {
1566 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1567 }
1568 }
1569 None => {
1570 // WAL-only store — nothing to migrate on open.
1571 }
1572 }
1573 }
1574
1575 Ok(db)
1576 }
1577
1578 /// Open a read-only view of the database as it existed after `commit`.
1579 ///
1580 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1581 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1582 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1583 ///
1584 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1585 /// so as-of can only reach commits recorded in the current WAL (those
1586 /// written after the most recent snapshot, or all commits if no snapshot
1587 /// was ever taken). Commit 0 in `open_at` always refers to the first
1588 /// frame in the WAL that exists on disk, not the first ever write to the
1589 /// database. When the on-disk snapshot recorded that it truncated the
1590 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1591 /// before frame replay, so the as-of view includes all pre-snapshot data.
1592 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1593 /// are ignored and replay is WAL-only, as before.
1594 ///
1595 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1596 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1597 /// `stats()` work normally.
1598 ///
1599 /// # Errors
1600 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1601 /// when the WAL is empty after a snapshot).
1602 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1603 Self::open_at_with(RealFs::new(dir)?, commit)
1604 }
1605
1606 /// Run a **read-only** Cypher query against the graph as it existed at
1607 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1608 /// of this store's directory at that commit and executes the read there.
1609 ///
1610 /// The current instance is unaffected. Write statements are rejected (the
1611 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1612 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1613 /// state. Prefer this over holding many historical instances open.
1614 ///
1615 /// # Errors
1616 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1617 /// - A query error for a malformed or write query.
1618 pub fn query_at(
1619 &self,
1620 commit: u64,
1621 cypher: &str,
1622 params: &std::collections::BTreeMap<String, Value>,
1623 ) -> Result<ResultSet> {
1624 let dir = self.fs.dir().to_path_buf();
1625 let temporal = Self::open_at(&dir, commit)?;
1626 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1627 detail: format!("lex: {e}"),
1628 })?) {
1629 return Err(GraphError::QueryError {
1630 detail: "query_at is read-only: write statements are not permitted in a \
1631 time-travel query"
1632 .into(),
1633 });
1634 }
1635 temporal.query(cypher, params)
1636 }
1637}
1638
1639impl<F: Fs> GraphDb<F> {
1640 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1641 pub fn open_with(fs: F) -> Result<Self> {
1642 Self::open_with_repair(fs, true)
1643 }
1644
1645 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1646 /// prefix without writing the truncation back. See
1647 /// [`OpenOptions::repair_wal`].
1648 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1649 Self::open_generic(
1650 fs,
1651 OpenOptions {
1652 repair_wal,
1653 ..OpenOptions::default()
1654 },
1655 true,
1656 )
1657 }
1658
1659 /// Shared open path.
1660 ///
1661 /// `hold_lock` requests the cross-process write lock for the whole handle
1662 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1663 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1664 /// `false` and takes the lock per write instead, so that a long-lived
1665 /// server does not keep every other process out of the store.
1666 ///
1667 /// A read-only open never takes the lock regardless of `hold_lock`.
1668 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1669 let mut db = Self::new_empty(fs, opts);
1670 db.read_only = opts.read_only;
1671 if hold_lock && !opts.read_only {
1672 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1673 return Err(GraphError::Busy { holder: None });
1674 }
1675 db.holds_lifetime_lock = true;
1676 }
1677 db.load_from_disk(LoadOrigin::Open)?;
1678 Ok(db)
1679 }
1680
1681 /// A handle with no state loaded: every field at its empty value, the
1682 /// filesystem and options in place. Only [`load_from_disk`] makes it
1683 /// usable.
1684 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1685 Self {
1686 fs,
1687 ids: IdMap::new(),
1688 syms: Interner::new(),
1689 topo: Topology::new(),
1690 props: ColumnStore::new(),
1691 labels: Vec::new(),
1692 edge_props: EdgeProps::new(),
1693 engine: RuleEngine::new(),
1694 view_store: ViewStore::new(),
1695 fulltext: FulltextIndex::new(),
1696 prop_index: PropertyIndex::new(),
1697 event_sink: None,
1698 fsync: FsyncPolicy::Strict,
1699 commit_seq: 0,
1700 roles: Some(vec![]),
1701 subscriptions: Vec::new(),
1702 query_subscriptions: Vec::new(),
1703 sub_capacity: DEFAULT_SUB_CAPACITY,
1704 read_only: false,
1705 total_wal_commits: 0,
1706 base: None,
1707 fold_overlay: None,
1708 delta_tail: Vec::new(),
1709 commits_since_fold: 0,
1710 defer_events: false,
1711 deferred_events: Vec::new(),
1712 degraded: false,
1713 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1714 v8_sections_mutex: std::sync::Mutex::new(()),
1715 last_change: HashMap::new(),
1716 wal_archive_retention: None,
1717 wal_horizon_floor: 0,
1718 archive_genesis_chain: false,
1719 pending_write_authz: None,
1720 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1721 .ok()
1722 .and_then(|v| v.parse().ok())
1723 .unwrap_or(100),
1724 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1725 entries: std::collections::VecDeque::new(),
1726 total: 0,
1727 }),
1728 started_at: std::time::Instant::now(),
1729 wal_consumed: 0,
1730 snapshot_ident: None,
1731 open_opts: opts,
1732 holds_lifetime_lock: false,
1733 lock_denied: false,
1734 pinned: false,
1735 }
1736 }
1737
1738 /// Return every field describing stored graph state to its empty value,
1739 /// leaving this handle's own identity alone.
1740 ///
1741 /// Preserved on purpose: the filesystem, open options, lock ownership, the
1742 /// event sink and subscriptions, fsync policy, degraded flag, and the
1743 /// slow-query configuration and log. A caller that registered a sink or a
1744 /// subscription keeps it across a reload.
1745 fn reset_for_reload(&mut self) {
1746 self.ids = IdMap::new();
1747 self.syms = Interner::new();
1748 self.topo = Topology::new();
1749 self.props = ColumnStore::new();
1750 self.labels = Vec::new();
1751 self.edge_props = EdgeProps::new();
1752 self.engine = RuleEngine::new();
1753 self.view_store = ViewStore::new();
1754 self.fulltext = FulltextIndex::new();
1755 self.prop_index = PropertyIndex::new();
1756 self.commit_seq = 0;
1757 self.roles = Some(vec![]);
1758 self.total_wal_commits = 0;
1759 self.base = None;
1760 self.fold_overlay = None;
1761 self.delta_tail = Vec::new();
1762 self.commits_since_fold = 0;
1763 self.deferred_events = Vec::new();
1764 self.v8_sections_loaded
1765 .store(false, std::sync::atomic::Ordering::Release);
1766 self.last_change = HashMap::new();
1767 self.wal_horizon_floor = 0;
1768 self.archive_genesis_chain = false;
1769 self.pending_write_authz = None;
1770 self.wal_consumed = 0;
1771 self.snapshot_ident = None;
1772 }
1773
1774 /// Load the snapshot base and replay the WAL into an empty handle — the
1775 /// whole of what opening a store does after the struct exists.
1776 ///
1777 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1778 /// rebuild a handle in place, without ownership of `F`, when another
1779 /// process replaces the snapshot underneath it.
1780 ///
1781 /// `origin` decides whether the two repair writes this function can make
1782 /// are appropriate; see [`LoadOrigin`].
1783 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1784 // Both writes below are crash recovery, and only an open is entitled to
1785 // perform them. A read-only handle promises to touch nothing, and a
1786 // reload driven by `refresh` is looking at a store another process is
1787 // actively writing: what looks like a torn tail there is a peer
1788 // mid-append, and what looks like an orphaned archive may be one that
1789 // peer is about to reference.
1790 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1791 let repair_wal = self.open_opts.repair_wal && may_repair;
1792 let db = self;
1793 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1794 db.archive_genesis_chain = db.fs.has_genesis_marker();
1795 // Opening cleanup: remove orphaned archives — archives whose frames all
1796 // fall below the horizon floor. Orphans arise when a crash interrupted
1797 // the retention-prune sequence after the floor was written but before
1798 // all surplus archives were deleted. Safe to delete: floor already
1799 // accounts for their frames.
1800 if may_repair {
1801 db.cleanup_orphaned_archives()?;
1802 }
1803 let _t0 = std::time::Instant::now();
1804 // Peek 6 bytes to determine snapshot version without reading the full
1805 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1806 // default impl reads all bytes and truncates (still correct).
1807 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1808 let is_v8 = snap_header.len() >= 6
1809 && &snap_header[0..4] == b"GDB1"
1810 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1811 == core_storage::snapshot::VERSION_8;
1812 if is_v8 {
1813 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1814 // No 2.4GB heap Vec is allocated on RealFs.
1815 let mapped = Arc::new(
1816 if let Some(snap_path) = db.fs.snapshot_path() {
1817 core_storage::v8::MappedBase::map(&snap_path)
1818 } else {
1819 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1820 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1821 }
1822 .map_err(|e| GraphError::Corrupt {
1823 detail: format!("v8: mmap open: {e:?}"),
1824 })?,
1825 );
1826 db.restore_v8_base(Arc::clone(&mapped))?;
1827 trace_open!("restore_v8_base", _t0);
1828 db.base = Some(mapped);
1829 trace_open!("base assigned", _t0);
1830 } else if !snap_header.is_empty() {
1831 // Legacy V5-V7: full read required for decode.
1832 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1833 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1834 db.restore_snapshot_state(state)?;
1835 }
1836 }
1837 // else: snap_header is empty = no snapshot file, fresh store.
1838 //
1839 // Seed commit_seq from the highest seq persisted in last_change so that
1840 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1841 // already stored in the snapshot. Without this, a db with one snapshot
1842 // commit would save last_change["a"]=1, then on reopen the first WAL
1843 // frame would replay at seq=1 again — colliding and making WAL-tail
1844 // mutations indistinguishable from the snapshot baseline.
1845 //
1846 // Safety invariant (seq-recycling):
1847 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1848 // last_change because they belonged to a previous db lifetime — a new
1849 // db starts at commit_seq=0 with an empty last_change. Therefore no
1850 // CAS precondition can carry a recycled seq as its `expected` value
1851 // and accidentally match a live node's last_change entry.
1852 //
1853 // `expected:0` on a deleted-then-reinserted node:
1854 // After deletion, last_changed() returns None; callers that call
1855 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1856 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1857 // expected=0 correctly conflicts. The only way to observe actual=0 in
1858 // a CasConflict would be a caller that invented expected=0 without ever
1859 // calling last_changed() — unreachable via the documented API contract.
1860 if let Some(&max_seq) = db.last_change.values().max() {
1861 db.commit_seq = db.commit_seq.max(max_seq);
1862 }
1863 let bytes = db.fs.read(FileId::Wal)?;
1864 let (records, valid_len) = decode_all(&bytes);
1865 // The valid prefix is replayed either way; `repair_wal` only decides
1866 // whether the truncation is written back. A reader that races a live
1867 // appender must not persist a truncation the writer never asked for.
1868 if valid_len < bytes.len() && repair_wal {
1869 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1870 }
1871 // WAL-present path: build indexes eagerly BEFORE replay so that the
1872 // first replayed record does not trigger the lazy-init guard (which
1873 // would call reindex_all_load_ivf on an empty graph, defeating the
1874 // point of restoring IVF/HNSW blobs from the snapshot).
1875 if !records.is_empty() {
1876 db.ensure_v8_base_sections_loaded();
1877 trace_open!("lazy sections loaded (WAL path)", _t0);
1878 }
1879 let replayed = db.apply_frames(records)?;
1880 // The cursor sits at the end of the valid prefix, not the end of the
1881 // file: a torn or still-being-written tail is unconsumed by definition
1882 // and stays visible to `is_stale` until it decodes.
1883 db.wal_consumed = valid_len as u64;
1884 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
1885 trace_open!("wal replay done", _t0);
1886 // Rebuild view values after WAL replay only when there is no V8 base.
1887 // With a V8 base, view values are correct in the snapshot and are updated
1888 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1889 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1890 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1891 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1892 // base value).
1893 if db.base.is_none() {
1894 let topo_view = TopologyView::owned(&db.topo);
1895 db.view_store
1896 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1897 }
1898 // Rebuild full-text index after WAL replay. Corrects drift from
1899 // per-record incremental apply during replay.
1900 db.fulltext.rebuild_all(
1901 &db.ids,
1902 &db.labels,
1903 &db.syms,
1904 build_props_view(&db.props, &db.base),
1905 );
1906 db.prop_index.rebuild_all(
1907 &db.ids,
1908 &db.labels,
1909 &db.syms,
1910 build_props_view(&db.props, &db.base),
1911 );
1912 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1913 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1914 db.roles = Self::load_roles_from_fs(&db.fs)?;
1915 // Capture the initial MVCC fold so reader() is ready immediately.
1916 db.fold_now();
1917 trace_open!("open_with complete", _t0);
1918 Ok(replayed)
1919 }
1920
1921 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
1922 /// replay does — same `apply` calls, same per-frame delta drain, same
1923 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
1924 /// edges appear identically whether a frame arrives at open, from a local
1925 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
1926 ///
1927 /// Returns the number of frames applied.
1928 ///
1929 /// Deltas are drained and discarded per frame: replayed frames are already
1930 /// reflected on disk, so they are not news to a subscriber, and draining
1931 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
1932 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
1933 if records.is_empty() {
1934 return Ok(0);
1935 }
1936 // Materialize any state retained in the mmap base before the first
1937 // frame lands, so a replayed record cannot trip the lazy-init guard and
1938 // rebuild indexes from an empty graph. Both calls are idempotent.
1939 self.ensure_v8_base_sections_loaded();
1940 self.engine.consume_retained_state_eager(
1941 &self.ids,
1942 &self.syms,
1943 &self.labels,
1944 build_props_view(&self.props, &self.base),
1945 );
1946 let applied = records.len();
1947 for rec in records {
1948 self.apply(&rec)?;
1949 let _ = self.engine.drain_deltas();
1950 // Track commit_seq during replay so last_change entries are
1951 // consistent with the seqs assigned by log_then_apply_with on
1952 // subsequent live commits. After N replayed frames, commit_seq=N;
1953 // live commits begin at N+1.
1954 self.commit_seq += 1;
1955 let replay_seq = self.commit_seq;
1956 self.update_last_change_from_rec(&rec, replay_seq);
1957 }
1958 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1959 // this assert catches the regression in debug builds immediately.
1960 debug_assert_eq!(
1961 self.engine.pending_delta_count(),
1962 0,
1963 "pending_deltas non-empty after replay — \
1964 per-frame drain must run inside the loop to keep memory O(1)"
1965 );
1966 // T2 note: the per-frame drain IS the suppression seam for replay.
1967 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1968 // replaying subscribers; the mechanism is already in place.
1969 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1970 Ok(applied)
1971 }
1972
1973 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
1974 //
1975 // mushroomdb is many-readers / one-writer across processes. Writers take an
1976 // advisory exclusive lock on the store's `LOCK` file; readers never do.
1977 // Every handle tracks how much of the WAL it has consumed, so it can pick
1978 // up another process's commits by decoding only the new tail rather than
1979 // reopening. See `docs/site/concurrency.md`.
1980
1981 /// Whether the store on disk has moved ahead of (or out from under) this
1982 /// handle's in-memory state.
1983 ///
1984 /// True when the WAL's length differs from this handle's cursor — another
1985 /// process committed, or is mid-append — or when the snapshot file's
1986 /// identity changed. Costs two metadata lookups and reads no file contents,
1987 /// so it is cheap enough for a read path to call.
1988 ///
1989 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
1990 /// pinned to one commit and later commits are deliberately invisible to it.
1991 pub fn is_stale(&self) -> Result<bool> {
1992 if self.pinned {
1993 return Ok(false);
1994 }
1995 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
1996 return Ok(true);
1997 }
1998 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
1999 }
2000
2001 /// Bring this handle up to date with every commit other processes have made,
2002 /// and return how many frames were applied.
2003 ///
2004 /// The WAL tail is decoded from this handle's cursor and applied through the
2005 /// same path the open replay uses, so rules fire and derived edges appear
2006 /// exactly as they would on a fresh open. Interners, id maps and indexes
2007 /// stay valid for the same reason.
2008 ///
2009 /// A frame another process is still writing is left alone: a trailing
2010 /// partial frame is a wait, not a corruption, and the handle stays stale
2011 /// until that frame is complete. Nothing is written to disk, so a read-only
2012 /// handle can refresh freely.
2013 ///
2014 /// When the snapshot file's identity changed, or the WAL is shorter than
2015 /// this handle's cursor, the WAL no longer continues our state — another
2016 /// process snapshotted or archived. The handle is then rebuilt from disk
2017 /// with the options it was opened with, and the return value is the number
2018 /// of frames in the new WAL.
2019 ///
2020 /// Returns 0 for an as-of view, which never follows later commits.
2021 ///
2022 /// # Errors
2023 ///
2024 /// An error here leaves the handle **degraded**: it got partway through
2025 /// applying the tail, or partway through a reload, so its in-memory state
2026 /// no longer matches any point on disk. Further mutations are refused and
2027 /// the handle must be reopened. Nothing on disk was damaged — the store
2028 /// itself is fine, and a fresh open recovers it.
2029 pub fn refresh(&mut self) -> Result<u64> {
2030 if self.pinned {
2031 return Ok(0);
2032 }
2033 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2034 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2035 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2036 // The WAL no longer continues our state: rebuild from disk. State
2037 // is cleared first, so a failed load leaves an empty handle — mark
2038 // it degraded rather than let a caller read an empty graph as if
2039 // it were the store's contents.
2040 self.reset_for_reload();
2041 return match self.load_from_disk(LoadOrigin::Reload) {
2042 Ok(frames) => Ok(frames as u64),
2043 Err(e) => {
2044 self.degraded = true;
2045 Err(e)
2046 }
2047 };
2048 }
2049 if wal_len == self.wal_consumed {
2050 return Ok(0);
2051 }
2052 let tail = self
2053 .fs
2054 .read_range(FileId::Wal, self.wal_consumed)
2055 .map_err(GraphError::Io)?;
2056 let (records, valid_len) = decode_all(&tail);
2057 let applied = match self.apply_frames(records) {
2058 Ok(n) => n,
2059 Err(e) => {
2060 // Some frames landed and some did not, and the cursor cannot
2061 // say how many. Advancing it would skip the rest; leaving it
2062 // would replay what already applied. Neither is recoverable in
2063 // place, so refuse further writes and require a reopen.
2064 self.degraded = true;
2065 return Err(e);
2066 }
2067 };
2068 // Advance by the bytes actually decoded, never by the file length: an
2069 // incomplete trailing frame stays unconsumed for the next refresh.
2070 self.wal_consumed += valid_len as u64;
2071 if applied > 0 {
2072 // Peer commits must reach `reader()` snapshots taken from here on.
2073 // A full fold is what open does; refresh does not build per-commit
2074 // deltas, so there is nothing cheaper that stays correct.
2075 self.fold_now();
2076 }
2077 Ok(applied as u64)
2078 }
2079
2080 /// Byte offset of the WAL prefix this handle has applied.
2081 ///
2082 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2083 #[doc(hidden)]
2084 pub fn wal_consumed(&self) -> u64 {
2085 self.wal_consumed
2086 }
2087
2088 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2089 /// failed group off the tail, so the cursor still describes the file.
2090 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2091 self.wal_consumed = len;
2092 }
2093
2094 /// One non-blocking attempt at the cross-process write lock.
2095 ///
2096 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2097 /// in-process write guard. That ordering is what keeps a busy peer in
2098 /// another process from stalling this process's readers.
2099 ///
2100 /// A handle that owns the lock for its lifetime always succeeds.
2101 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2102 if self.holds_lifetime_lock {
2103 return Ok(true);
2104 }
2105 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2106 }
2107
2108 /// Poll for the cross-process write lock until `wait` elapses.
2109 ///
2110 /// One attempt is always made, so a zero wait is a single try. Returns
2111 /// `false` when the lock is still held elsewhere at the deadline; nothing
2112 /// has been written and retrying later is safe.
2113 ///
2114 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2115 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2116 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2117 /// that it holds no in-process guard while it waits.
2118 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2119 let deadline = std::time::Instant::now() + wait;
2120 loop {
2121 if self.try_cross_process_lock()? {
2122 return Ok(true);
2123 }
2124 let now = std::time::Instant::now();
2125 if now >= deadline {
2126 return Ok(false);
2127 }
2128 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2129 }
2130 }
2131
2132 /// Open a cross-process write scope, given the outcome of an already-made
2133 /// lock attempt.
2134 ///
2135 /// The caller polls for the lock first — outside any in-process guard — and
2136 /// passes what it got. On success this refreshes, so the writes about to
2137 /// happen land on top of every other process's commits. On failure the
2138 /// handle refuses WAL-appending mutations and `snapshot()` with
2139 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2140 /// closes the scope, so a caller holding a guard cannot write behind
2141 /// another process's back.
2142 ///
2143 /// A handle that already owns the lock for its lifetime skips the refresh:
2144 /// no other process can have written, so there is nothing to pick up.
2145 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2146 self.lock_denied = !acquired;
2147 if !acquired || self.holds_lifetime_lock {
2148 return Ok(());
2149 }
2150 if let Err(e) = self.refresh() {
2151 // Do not hold a lock we cannot use: release it and let the caller
2152 // see the underlying failure.
2153 let _ = self.fs.unlock();
2154 self.lock_denied = true;
2155 return Err(e);
2156 }
2157 Ok(())
2158 }
2159
2160 /// Close a cross-process write scope opened by
2161 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2162 /// clear the Busy latch. Safe to call when the lock was never taken.
2163 pub(crate) fn end_write_lock(&mut self) {
2164 self.lock_denied = false;
2165 if !self.holds_lifetime_lock {
2166 // Releasing a lock we do not hold is a no-op; a failure to release
2167 // is reported by the OS closing the descriptor at handle drop.
2168 let _ = self.fs.unlock();
2169 }
2170 }
2171
2172 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2173 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2174 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2175 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2176 /// Restore all persisted state from a decoded snapshot. Shared by
2177 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2178 fn restore_snapshot_state(
2179 &mut self,
2180 state: core_storage::snapshot::SnapshotState,
2181 ) -> Result<()> {
2182 self.ids = state.ids;
2183 self.syms = state.syms;
2184 self.topo = state.topo;
2185 self.props = state.props;
2186 self.labels = state.labels;
2187 self.edge_props = state.edge_props;
2188 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2189 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2190 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2191 // open successfully and panic later in `NodeRef::label()` or
2192 // `neighborhood_masked()`. Catching it here turns those into typed
2193 // `GraphError::Corrupt` at open time.
2194 {
2195 let ids_len = self.ids.len();
2196 if self.labels.len() != ids_len {
2197 return Err(GraphError::Corrupt {
2198 detail: format!(
2199 "snapshot: labels vec has {} entries but id table has {} total slots",
2200 self.labels.len(),
2201 ids_len,
2202 ),
2203 });
2204 }
2205 let syms_len = self.syms.len() as u32;
2206 for (i, &sym) in self.labels.iter().enumerate() {
2207 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2208 if sym == u32::MAX {
2209 if !is_tombstoned {
2210 return Err(GraphError::Corrupt {
2211 detail: format!(
2212 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2213 ),
2214 });
2215 }
2216 } else if sym >= syms_len {
2217 return Err(GraphError::Corrupt {
2218 detail: format!(
2219 "snapshot: label at id slot {i} references sym {sym} \
2220 which is out of interner range ({syms_len})"
2221 ),
2222 });
2223 }
2224 }
2225 }
2226 let defs: Vec<RuleDef> = state
2227 .rule_defs
2228 .iter()
2229 .map(|b| {
2230 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2231 detail: format!("snapshot rule_def deserialize: {e}"),
2232 })
2233 })
2234 .collect::<Result<Vec<_>>>()?;
2235 self.engine =
2236 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2237 // Candidate indexes are rebuilt lazily on the first mutation (see
2238 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2239 // snapshot are retained without deserializing so that:
2240 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2241 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2242 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
2243 // - WAL-present: open_with calls consume_retained_state_eager before
2244 // replay so HNSW/IVF are live before any record fires the hooks.
2245 let ivf_bytes = if state.ivf_state.is_empty() {
2246 Vec::new()
2247 } else {
2248 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2249 };
2250 // Store blobs without eagerly deserializing them.
2251 self.engine
2252 .store_snapshot_state(state.hnsw_state, ivf_bytes);
2253 // Restore view defs from snapshot (V5).
2254 // The ColumnStore already contains view values from the snapshot;
2255 // use restore_view (no collision check, no backfill) so the store
2256 // is aware of the definitions. rebuild_all runs after WAL replay.
2257 for def_bytes in &state.view_defs {
2258 let def: ViewDef =
2259 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2260 detail: format!("snapshot view_def deserialize: {e}"),
2261 })?;
2262 self.view_store
2263 .restore_view(def)
2264 .map_err(|e| GraphError::Corrupt {
2265 detail: format!("snapshot view restore: {e}"),
2266 })?;
2267 }
2268 Ok(())
2269 }
2270
2271 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2272 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2273 ///
2274 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2275 /// deserialization and view rebuild have access to all column data.
2276 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2277 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2278 detail: format!("v8: ids section: {e:?}"),
2279 })?);
2280 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2281 detail: format!("v8: syms section: {e:?}"),
2282 })?);
2283
2284 // C1: self.props is left as an empty overlay. Column reads go through
2285 // props_view() (ColumnsView::with_base), which consults the archived base
2286 // section zero-copy. This avoids the O(columns) heap copy at every open.
2287
2288 // self.topo deliberately left as Topology::new() — overlay path.
2289
2290 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2291 detail: format!("v8: meta section: {e:?}"),
2292 })?)
2293 .map_err(|e| GraphError::Corrupt {
2294 detail: format!("v8: meta decode: {e:?}"),
2295 })?;
2296 self.labels = meta.labels;
2297 // Cross-section label integrity: labels must cover every id slot (live
2298 // and tombstoned), every non-sentinel sym must be within the interner's
2299 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2300 // sentinel label. Without this check, a crafted snapshot where the META
2301 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2302 // sym ids, or a sentinel label on a live node, would open successfully
2303 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2304 // related read paths. Catching the inconsistency here converts those
2305 // panics into typed `GraphError::Corrupt` at open time.
2306 {
2307 let ids_len = self.ids.len();
2308 if self.labels.len() != ids_len {
2309 return Err(GraphError::Corrupt {
2310 detail: format!(
2311 "v8: labels section has {} entries but id table has {} total slots",
2312 self.labels.len(),
2313 ids_len,
2314 ),
2315 });
2316 }
2317 let syms_len = self.syms.len() as u32;
2318 for (i, &sym) in self.labels.iter().enumerate() {
2319 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2320 if sym == u32::MAX {
2321 // Sentinel is only valid for tombstoned slots.
2322 if !is_tombstoned {
2323 return Err(GraphError::Corrupt {
2324 detail: format!(
2325 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2326 ),
2327 });
2328 }
2329 } else if sym >= syms_len {
2330 return Err(GraphError::Corrupt {
2331 detail: format!(
2332 "v8: label at id slot {i} references sym {sym} \
2333 which is out of interner range ({syms_len})"
2334 ),
2335 });
2336 }
2337 }
2338 }
2339 // C3: self.edge_props stays as an empty overlay. Reads go through
2340 // edge_props_view() which consults the mmap'd base section zero-copy
2341 // via EdgePropsView::with_base. No heap decode at open time.
2342
2343 // Restore rule engine.
2344 let (rule_def_bytes, rule_tripped, rule_fires) =
2345 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2346 GraphError::Corrupt {
2347 detail: format!("v8: rules_meta section: {e:?}"),
2348 }
2349 })?);
2350 let defs: Vec<RuleDef> = rule_def_bytes
2351 .iter()
2352 .map(|b| {
2353 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2354 detail: format!("v8: rule_def deserialize: {e}"),
2355 })
2356 })
2357 .collect::<Result<Vec<_>>>()?;
2358 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2359 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2360 // `ensure_v8_base_sections_loaded` reads them on first use from
2361 // `self.base` (set by the caller immediately after this returns).
2362 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2363
2364 // Restore view definitions.
2365 let view_defs =
2366 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2367 detail: format!("v8: views section: {e:?}"),
2368 })?);
2369 for def_bytes in &view_defs {
2370 let def: ViewDef =
2371 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2372 detail: format!("v8: view_def deserialize: {e}"),
2373 })?;
2374 self.view_store
2375 .restore_view(def)
2376 .map_err(|e| GraphError::Corrupt {
2377 detail: format!("v8: view restore: {e}"),
2378 })?;
2379 }
2380 // Load the last-change map from section 11 (small section; load eagerly).
2381 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2382 // in that case and `decode_last_change_bytes` returns an empty map.
2383 let last_change_raw = mapped
2384 .last_change_bytes()
2385 .map_err(|e| GraphError::Corrupt {
2386 detail: format!("v8: last_change section: {e:?}"),
2387 })?;
2388 self.last_change = decode_last_change_bytes(last_change_raw);
2389
2390 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2391 // the file. Pure bounds check — no bytes read, no page faults triggered.
2392 // Catches truncated snapshots at open time before the lazy deferred reads.
2393 mapped.validate_section_bounds().map_err(|e| match e {
2394 GraphError::Corrupt { detail } => GraphError::Corrupt {
2395 detail: format!("v8: section bounds: {detail}"),
2396 },
2397 other => other,
2398 })?;
2399 Ok(())
2400 }
2401
2402 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2403 /// engine's retained fields on first call. Subsequent calls are a no-op
2404 /// (AtomicBool fast-path).
2405 ///
2406 /// Must be called before any code path that reads or mutates engine
2407 /// provenance, HNSW, or IVF state:
2408 /// - WAL replay (before `consume_retained_state_eager`)
2409 /// - First mutation (`log_then_apply_with`)
2410 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2411 /// - Snapshot (`snapshot_with`)
2412 ///
2413 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2414 fn ensure_v8_base_sections_loaded(&self) {
2415 use std::sync::atomic::Ordering;
2416 if self.v8_sections_loaded.load(Ordering::Acquire) {
2417 return;
2418 }
2419 let _guard = self
2420 .v8_sections_mutex
2421 .lock()
2422 .expect("v8 sections mutex poisoned");
2423 if self.v8_sections_loaded.load(Ordering::Acquire) {
2424 return; // another caller populated while we waited
2425 }
2426 let _t = std::time::Instant::now();
2427 if let Some(base) = &self.base {
2428 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2429 // Bounds are already validated at open time (restore_v8_base →
2430 // validate_section_bounds) — unreachable post-validate_section_bounds;
2431 // unwrap_or_default is a safety belt against impossible errors.
2432 let prov_bytes = base
2433 .provenance_raw_bytes()
2434 .map(|b| b.to_vec())
2435 .unwrap_or_default();
2436 self.engine.store_provenance_bytes(prov_bytes);
2437 // HNSW: decode rkyv blobs into owned map.
2438 let hnsw_state = base
2439 .hnsw_section()
2440 .map(archived_hnsw_to_owned)
2441 .unwrap_or_default();
2442 // IVF: raw bincode bytes; deserialized on first mutation/query.
2443 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2444 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2445 }
2446 self.v8_sections_loaded.store(true, Ordering::Release);
2447 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2448 eprintln!(
2449 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2450 _t.elapsed()
2451 );
2452 }
2453 }
2454
2455 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2456 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2457 /// the full merged topology without going through `self.view()`.
2458 fn topo_view(&self) -> TopologyView<'_> {
2459 match self.base {
2460 None => TopologyView::owned(&self.topo),
2461 Some(ref base) => {
2462 // SAFETY: base lives as long as self; section bounds validated at open.
2463 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2464 let archived = base
2465 .topology()
2466 .expect("base topology section bounds validated at open");
2467 TopologyView::with_base(&self.topo, archived)
2468 }
2469 }
2470 }
2471
2472 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2473 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2474 /// overlay first, then fall through to the archived base section zero-copy.
2475 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2476 match self.base {
2477 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2478 Some(ref base) => {
2479 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2480 let archived = base
2481 .columns()
2482 .expect("base columns section bounds validated at open");
2483 core_storage::v8::seam::ColumnsView::with_base_cached(
2484 &self.props,
2485 archived,
2486 base.mixed_cache(),
2487 )
2488 }
2489 }
2490 }
2491
2492 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2493 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2494 ///
2495 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2496 /// through to the archived base section zero-copy. Tombstones in the
2497 /// overlay mask deleted-from-base entries.
2498 fn edge_props_view(&self) -> EdgePropsView<'_> {
2499 match self.base {
2500 None => EdgePropsView::owned(&self.edge_props),
2501 Some(ref base) => {
2502 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2503 let archived = base
2504 .edge_props_section()
2505 .expect("base edge_props section bounds validated at open");
2506 EdgePropsView::with_base(&self.edge_props, archived)
2507 }
2508 }
2509 }
2510
2511 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2512 // An as-of view never writes and is pinned to one commit: it takes no
2513 // cross-process lock and does not follow later commits.
2514 let mut db = Self::new_empty(
2515 fs,
2516 OpenOptions {
2517 repair_wal: false,
2518 auto_migrate: false,
2519 read_only: true,
2520 },
2521 );
2522 db.pinned = true; // read_only is set after replay, but pinning is immediate
2523 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2524 db.archive_genesis_chain = db.fs.has_genesis_marker();
2525 // Same orphaned-archive cleanup as open_with: floor was written first
2526 // during pruning, so a crash may have left stale archives below floor.
2527 db.cleanup_orphaned_archives()?;
2528 // Collect archive frames (oldest-first) and live WAL frames.
2529 // Archives represent pre-snapshot history; the snapshot captures the
2530 // cumulative state at the time of archiving. Crash-window guarantee:
2531 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2532 // B: crash after rename, before new WAL → archive present, WAL
2533 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2534 // C: crash after new baseline WAL written → normal post-archive.
2535 let archive_ns = db.fs.list_archives()?;
2536 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2537 for n in &archive_ns {
2538 let arc_bytes = db.fs.read_archive(*n)?;
2539 let (arc_frames, _) = decode_all(&arc_bytes);
2540 archive_frames_all.extend(arc_frames);
2541 }
2542 let total_archive_frames = archive_frames_all.len() as u64;
2543
2544 let live_bytes = db.fs.read(FileId::Wal)?;
2545 let (live_records, _valid_len) = decode_all(&live_bytes);
2546 let total_surviving = total_archive_frames + live_records.len() as u64;
2547 // Global total including any pruned history below the horizon floor.
2548 let total = db.wal_horizon_floor + total_surviving;
2549
2550 // Horizon and range check.
2551 if commit < db.wal_horizon_floor {
2552 return Err(GraphError::CommitOutOfRange { commit, total });
2553 }
2554 if commit >= total {
2555 return Err(GraphError::CommitOutOfRange { commit, total });
2556 }
2557
2558 // Local index into surviving frames (0 = first frame of oldest archive).
2559 let local = commit - db.wal_horizon_floor;
2560
2561 if local < total_archive_frames {
2562 // Target commit is in an archive. Correct replay from empty state
2563 // is only possible when the archive chain is an uninterrupted
2564 // genesis chain (first archive taken from a fresh store, no prior
2565 // WAL truncation) and no archives have been pruned (floor == 0).
2566 //
2567 // If either condition is violated the prefix needed to reconstruct
2568 // the requested state is gone; refuse rather than return wrong data.
2569 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2570 return Err(GraphError::CommitOutOfRange { commit, total });
2571 }
2572 // Replay all archive frames up to and including the target commit
2573 // from an empty database state. Archives must be replayed in order
2574 // so that dense-id intern tables are built up correctly.
2575 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2576 db.apply(&rec)?;
2577 let _ = db.engine.drain_deltas();
2578 }
2579 } else {
2580 // Target commit is in the live WAL: load snapshot as base, then
2581 // replay the needed live WAL prefix.
2582 //
2583 // Base state: a truncating snapshot (wal_truncated=true) compacts
2584 // all pre-truncation / pre-archive commits. Dense-id records in
2585 // the live WAL reference ids/interns that the snapshot provides.
2586 // Peek 6 bytes (same pattern as open_with).
2587 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2588 let is_v8 = snap_header.len() >= 6
2589 && &snap_header[0..4] == b"GDB1"
2590 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2591 == core_storage::snapshot::VERSION_8;
2592 if is_v8 {
2593 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2594 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2595 GraphError::Corrupt {
2596 detail: format!("v8: open_at mmap: {e:?}"),
2597 }
2598 })?;
2599 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2600 } else {
2601 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2602 core_storage::snapshot::decode(&snap_bytes)?
2603 };
2604 if let Some(state) = state {
2605 if state.wal_truncated {
2606 db.restore_snapshot_state(state)?;
2607 }
2608 }
2609 } else if !snap_header.is_empty() {
2610 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2611 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2612 if state.wal_truncated {
2613 db.restore_snapshot_state(state)?;
2614 }
2615 }
2616 }
2617 // else: snap_header empty = no snapshot file.
2618 let live_local = local - total_archive_frames;
2619 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2620 db.apply(&rec)?;
2621 let _ = db.engine.drain_deltas();
2622 }
2623 }
2624 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2625 // post-loop assert in open_with.
2626 debug_assert_eq!(
2627 db.engine.pending_delta_count(),
2628 0,
2629 "pending_deltas non-empty after open_at replay — \
2630 per-frame drain must run inside the loop to keep memory O(1)"
2631 );
2632 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2633 // Rebuild view values after WAL replay so derived-edge-driven views
2634 // reflect the as-of state. open_at always uses the legacy path (no V8
2635 // base), so topo_view is always owned.
2636 {
2637 let topo_view = TopologyView::owned(&db.topo);
2638 db.view_store
2639 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2640 }
2641 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2642 db.fulltext.rebuild_all(
2643 &db.ids,
2644 &db.labels,
2645 &db.syms,
2646 build_props_view(&db.props, &db.base),
2647 );
2648 db.prop_index.rebuild_all(
2649 &db.ids,
2650 &db.labels,
2651 &db.syms,
2652 build_props_view(&db.props, &db.base),
2653 );
2654 // Load roles sidecar (current roles, not point-in-time).
2655 db.roles = Self::load_roles_from_fs(&db.fs)?;
2656 db.read_only = true;
2657 db.total_wal_commits = total;
2658 // Capture initial fold so reader() is immediately usable.
2659 db.fold_now();
2660 Ok(db)
2661 }
2662
2663 /// Whether this instance is a read-only as-of view.
2664 pub fn is_read_only(&self) -> bool {
2665 self.read_only
2666 }
2667
2668 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2669
2670 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2671 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2672 /// the end of `open_with` / `open_at_with` to prime the reader.
2673 fn fold_now(&mut self) {
2674 let frozen = crate::reader::FrozenOverlay {
2675 ids: self.ids.clone(),
2676 syms: self.syms.clone(),
2677 topo: self.topo.clone(),
2678 props: self.props.clone(),
2679 labels: self.labels.clone(),
2680 edge_props: self.edge_props.clone(),
2681 roles: self.roles.clone(),
2682 fulltext: self.fulltext.clone(),
2683 };
2684 self.fold_overlay = Some(Arc::new(frozen));
2685 self.delta_tail.clear();
2686 self.commits_since_fold = 0;
2687 }
2688
2689 /// Capture a lock-free reader snapshot of the current db state.
2690 ///
2691 /// The read lock is held only for the duration of this call (to clone a
2692 /// handful of `Arc` handles). Subsequent query operations run without any
2693 /// lock.
2694 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2695 crate::reader::ReaderSnapshot::new(
2696 self.fold_overlay
2697 .clone()
2698 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2699 self.base.clone(),
2700 self.delta_tail.clone(),
2701 )
2702 }
2703
2704 /// Total number of WAL commits at the time [`open_at`] was called.
2705 /// Returns 0 for normal (non-as-of) instances.
2706 pub fn total_wal_commits(&self) -> u64 {
2707 self.total_wal_commits
2708 }
2709
2710 /// Apply a record to in-memory state. Used by both live writes and replay,
2711 /// so replay is definitionally identical to the original execution.
2712 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2713 match rec {
2714 WalRecord::InsertNode { label, key, props } => {
2715 let id = self.ids.try_insert(key)?;
2716 let sym = self.syms.intern(label);
2717 if self.labels.len() <= id as usize {
2718 // gap slots are sentinels, never valid label symbols
2719 self.labels.resize(id as usize + 1, u32::MAX);
2720 }
2721 self.labels[id as usize] = sym;
2722 for (field, value) in props {
2723 self.props.set(id, field, value.clone());
2724 }
2725 // Initialize view values for the new node before the engine runs so
2726 // delta-based increments start from a known zero baseline.
2727 self.view_store
2728 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2729 // Fire rules for the newly inserted node.
2730 let cursor = self.engine.pending_delta_count();
2731 let mut eng = std::mem::take(&mut self.engine);
2732 {
2733 let mut gm = make_graph_mut(
2734 &self.ids,
2735 &mut self.syms,
2736 &self.labels,
2737 build_props_view(&self.props, &self.base),
2738 &mut self.topo,
2739 &self.base,
2740 &mut self.edge_props,
2741 );
2742 eng.on_node_changed(id, None, &mut gm);
2743 }
2744 self.engine = eng;
2745 // Process derived-edge deltas for view maintenance.
2746 // Fast path: skip the O(delta_count) allocation when no views exist.
2747 if !self.view_store.is_empty() {
2748 #[cfg(test)]
2749 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2750 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2751 for d in &new_deltas {
2752 self.view_store.on_edge_changed(
2753 d.etype_sym,
2754 d.src_id,
2755 d.dst_id,
2756 d.fired,
2757 &mut self.props,
2758 &build_topo_view(&self.topo, &self.base),
2759 &self.ids,
2760 &self.syms,
2761 &self.labels,
2762 self.base.as_ref().map(|b| {
2763 b.columns()
2764 .expect("base columns section bounds validated at open")
2765 }),
2766 );
2767 }
2768 }
2769 // Full-text index maintenance: index enabled fields for this label.
2770 if self.fulltext.has_label(label) {
2771 for (field, value) in props {
2772 if self.fulltext.is_enabled(label, field) {
2773 self.fulltext.add_tokens(id, field, value);
2774 }
2775 }
2776 }
2777 // Property (equality) index maintenance.
2778 if self.prop_index.has_label(label) {
2779 for (field, value) in props {
2780 self.prop_index.set(label, field, id, value);
2781 }
2782 }
2783 }
2784 WalRecord::InsertEdge {
2785 edge_type,
2786 src_key,
2787 dst_key,
2788 } => {
2789 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2790 detail: format!("wal replay references unknown key {src_key}"),
2791 })?;
2792 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2793 detail: format!("wal replay references unknown key {dst_key}"),
2794 })?;
2795 let etype = self.syms.intern(edge_type);
2796 // Skip if the edge is already visible in the merged base+overlay
2797 // view. This keeps WAL replay idempotent when the WAL contains
2798 // pre-snapshot records that are already encoded in a V8 base
2799 // (keep_wal=true opens and crash-before-truncation scenarios).
2800 if self.base.is_some()
2801 && self
2802 .topo_view()
2803 .neighbors(etype, Direction::Out, src)
2804 .contains(&dst)
2805 {
2806 return Ok(());
2807 }
2808 self.topo.add_edge(etype, src, dst);
2809 // View maintenance for manual edge insert.
2810 self.view_store.on_edge_changed(
2811 etype,
2812 src,
2813 dst,
2814 true,
2815 &mut self.props,
2816 &build_topo_view(&self.topo, &self.base),
2817 &self.ids,
2818 &self.syms,
2819 &self.labels,
2820 self.base.as_ref().map(|b| {
2821 b.columns()
2822 .expect("base columns section bounds validated at open")
2823 }),
2824 );
2825 // Rule engine: via-hop rules must update when user edges change.
2826 let cursor = self.engine.pending_delta_count();
2827 let mut eng = std::mem::take(&mut self.engine);
2828 {
2829 let mut gm = make_graph_mut(
2830 &self.ids,
2831 &mut self.syms,
2832 &self.labels,
2833 build_props_view(&self.props, &self.base),
2834 &mut self.topo,
2835 &self.base,
2836 &mut self.edge_props,
2837 );
2838 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2839 }
2840 self.engine = eng;
2841 if !self.view_store.is_empty() {
2842 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2843 for d in &new_deltas {
2844 self.view_store.on_edge_changed(
2845 d.etype_sym,
2846 d.src_id,
2847 d.dst_id,
2848 d.fired,
2849 &mut self.props,
2850 &build_topo_view(&self.topo, &self.base),
2851 &self.ids,
2852 &self.syms,
2853 &self.labels,
2854 self.base.as_ref().map(|b| {
2855 b.columns()
2856 .expect("base columns section bounds validated at open")
2857 }),
2858 );
2859 }
2860 }
2861 }
2862 WalRecord::SetProp { key, field, value } => {
2863 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2864 detail: format!("wal replay references unknown key {key}"),
2865 })?;
2866 let old_value = build_props_view(&self.props, &self.base)
2867 .get(id, field)
2868 .map(|vr| vr.into_value());
2869 self.props.set(id, field, value.clone());
2870 // Fire rules for the changed field.
2871 let cursor = self.engine.pending_delta_count();
2872 let mut eng = std::mem::take(&mut self.engine);
2873 {
2874 let mut gm = make_graph_mut(
2875 &self.ids,
2876 &mut self.syms,
2877 &self.labels,
2878 build_props_view(&self.props, &self.base),
2879 &mut self.topo,
2880 &self.base,
2881 &mut self.edge_props,
2882 );
2883 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2884 }
2885 self.engine = eng;
2886 // Derived-edge deltas → view updates.
2887 if !self.view_store.is_empty() {
2888 #[cfg(test)]
2889 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2890 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2891 for d in &new_deltas {
2892 self.view_store.on_edge_changed(
2893 d.etype_sym,
2894 d.src_id,
2895 d.dst_id,
2896 d.fired,
2897 &mut self.props,
2898 &build_topo_view(&self.topo, &self.base),
2899 &self.ids,
2900 &self.syms,
2901 &self.labels,
2902 self.base.as_ref().map(|b| {
2903 b.columns()
2904 .expect("base columns section bounds validated at open")
2905 }),
2906 );
2907 }
2908 }
2909 // Neighbor-aggregate views that read `field` must also update.
2910 self.view_store.on_prop_changed(
2911 id,
2912 field,
2913 &mut self.props,
2914 &build_topo_view(&self.topo, &self.base),
2915 &self.ids,
2916 &self.syms,
2917 &self.labels,
2918 self.base.as_ref().map(|b| {
2919 b.columns()
2920 .expect("base columns section bounds validated at open")
2921 }),
2922 );
2923 // Full-text index maintenance: update tokens for this field if indexed.
2924 if self.fulltext.field_indexed(field) {
2925 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2926 if sym == u32::MAX {
2927 None
2928 } else {
2929 self.syms.resolve(sym)
2930 }
2931 });
2932 if let Some(label) = label_opt {
2933 if self.fulltext.is_enabled(label, field) {
2934 self.fulltext.remove_node_field(id, field);
2935 self.fulltext.add_tokens(id, field, value);
2936 }
2937 }
2938 }
2939 // Property (equality) index maintenance: re-key this node's value.
2940 if self.prop_index.field_indexed(field) {
2941 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2942 if sym == u32::MAX {
2943 None
2944 } else {
2945 self.syms.resolve(sym)
2946 }
2947 });
2948 if let Some(label) = label_opt {
2949 self.prop_index.set(label, field, id, value);
2950 }
2951 }
2952 }
2953 WalRecord::Intern { id, text } => {
2954 if let Some(existing) = self.syms.get(text) {
2955 if existing != *id {
2956 return Err(GraphError::Corrupt {
2957 detail: format!(
2958 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2959 ),
2960 });
2961 }
2962 } else {
2963 let got = self.syms.intern(text);
2964 if got != *id {
2965 return Err(GraphError::Corrupt {
2966 detail: format!(
2967 "wal intern assigned {got} for {text:?}, record wanted {id}"
2968 ),
2969 });
2970 }
2971 }
2972 }
2973 WalRecord::InsertNodeId { label, key, props } => {
2974 let id = self.ids.try_insert(key)?;
2975 if self.labels.len() <= id as usize {
2976 self.labels.resize(id as usize + 1, u32::MAX);
2977 }
2978 self.labels[id as usize] = *label;
2979 let label_str = self
2980 .syms
2981 .resolve(*label)
2982 .ok_or_else(|| GraphError::Corrupt {
2983 detail: format!("wal InsertNodeId unknown label intern {label}"),
2984 })?
2985 .to_string();
2986 for (field_sym, value) in props {
2987 let field =
2988 self.syms
2989 .resolve(*field_sym)
2990 .ok_or_else(|| GraphError::Corrupt {
2991 detail: format!(
2992 "wal InsertNodeId unknown field intern {field_sym}"
2993 ),
2994 })?;
2995 self.props.set(id, field, value.clone());
2996 }
2997 self.view_store
2998 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2999 let cursor = self.engine.pending_delta_count();
3000 let mut eng = std::mem::take(&mut self.engine);
3001 {
3002 let mut gm = make_graph_mut(
3003 &self.ids,
3004 &mut self.syms,
3005 &self.labels,
3006 build_props_view(&self.props, &self.base),
3007 &mut self.topo,
3008 &self.base,
3009 &mut self.edge_props,
3010 );
3011 eng.on_node_changed(id, None, &mut gm);
3012 }
3013 self.engine = eng;
3014 if !self.view_store.is_empty() {
3015 #[cfg(test)]
3016 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3017 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3018 for d in &new_deltas {
3019 self.view_store.on_edge_changed(
3020 d.etype_sym,
3021 d.src_id,
3022 d.dst_id,
3023 d.fired,
3024 &mut self.props,
3025 &build_topo_view(&self.topo, &self.base),
3026 &self.ids,
3027 &self.syms,
3028 &self.labels,
3029 self.base.as_ref().map(|b| {
3030 b.columns()
3031 .expect("base columns section bounds validated at open")
3032 }),
3033 );
3034 }
3035 }
3036 if self.fulltext.has_label(&label_str) {
3037 for (field_sym, value) in props {
3038 let Some(field) = self.syms.resolve(*field_sym) else {
3039 continue;
3040 };
3041 if self.fulltext.is_enabled(&label_str, field) {
3042 self.fulltext.add_tokens(id, field, value);
3043 }
3044 }
3045 }
3046 if self.prop_index.has_label(&label_str) {
3047 for (field_sym, value) in props {
3048 let Some(field) = self.syms.resolve(*field_sym) else {
3049 continue;
3050 };
3051 self.prop_index.set(&label_str, field, id, value);
3052 }
3053 }
3054 }
3055 WalRecord::InsertEdgeId { etype, src, dst } => {
3056 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3057 // already be tombstoned. Skip rather than attaching edges to
3058 // dead ids (DeleteNode keys the live re-insert, not the old id).
3059 if self.ids.is_tombstoned(*src)
3060 || self.ids.is_tombstoned(*dst)
3061 || self.ids.key_of(*src).is_none()
3062 || self.ids.key_of(*dst).is_none()
3063 {
3064 return Ok(());
3065 }
3066 // Skip if already visible in the merged view (same idempotency
3067 // guard as InsertEdge above: prevents double-counting when
3068 // pre-snapshot WAL records are replayed over a V8 base).
3069 if self.base.is_some()
3070 && self
3071 .topo_view()
3072 .neighbors(*etype, Direction::Out, *src)
3073 .contains(dst)
3074 {
3075 return Ok(());
3076 }
3077 self.topo.add_edge(*etype, *src, *dst);
3078 self.view_store.on_edge_changed(
3079 *etype,
3080 *src,
3081 *dst,
3082 true,
3083 &mut self.props,
3084 &build_topo_view(&self.topo, &self.base),
3085 &self.ids,
3086 &self.syms,
3087 &self.labels,
3088 self.base.as_ref().map(|b| {
3089 b.columns()
3090 .expect("base columns section bounds validated at open")
3091 }),
3092 );
3093 // Rule engine: via-hop rules fire when user via-edges are inserted.
3094 // Resolve etype back to string so on_edge_changed can match rules by name.
3095 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3096 let cursor = self.engine.pending_delta_count();
3097 let mut eng = std::mem::take(&mut self.engine);
3098 {
3099 let mut gm = make_graph_mut(
3100 &self.ids,
3101 &mut self.syms,
3102 &self.labels,
3103 build_props_view(&self.props, &self.base),
3104 &mut self.topo,
3105 &self.base,
3106 &mut self.edge_props,
3107 );
3108 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3109 }
3110 self.engine = eng;
3111 if !self.view_store.is_empty() {
3112 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3113 for d in &new_deltas {
3114 self.view_store.on_edge_changed(
3115 d.etype_sym,
3116 d.src_id,
3117 d.dst_id,
3118 d.fired,
3119 &mut self.props,
3120 &build_topo_view(&self.topo, &self.base),
3121 &self.ids,
3122 &self.syms,
3123 &self.labels,
3124 self.base.as_ref().map(|b| {
3125 b.columns()
3126 .expect("base columns section bounds validated at open")
3127 }),
3128 );
3129 }
3130 }
3131 }
3132 }
3133 WalRecord::SetPropId { id, field, value } => {
3134 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3135 return Ok(());
3136 }
3137 let field_str = self
3138 .syms
3139 .resolve(*field)
3140 .ok_or_else(|| GraphError::Corrupt {
3141 detail: format!("wal SetPropId unknown field intern {field}"),
3142 })?
3143 .to_string();
3144 let old_value = build_props_view(&self.props, &self.base)
3145 .get(*id, &field_str)
3146 .map(|vr| vr.into_value());
3147 self.props.set(*id, &field_str, value.clone());
3148 let cursor = self.engine.pending_delta_count();
3149 let mut eng = std::mem::take(&mut self.engine);
3150 {
3151 let mut gm = make_graph_mut(
3152 &self.ids,
3153 &mut self.syms,
3154 &self.labels,
3155 build_props_view(&self.props, &self.base),
3156 &mut self.topo,
3157 &self.base,
3158 &mut self.edge_props,
3159 );
3160 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3161 }
3162 self.engine = eng;
3163 if !self.view_store.is_empty() {
3164 #[cfg(test)]
3165 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3166 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3167 for d in &new_deltas {
3168 self.view_store.on_edge_changed(
3169 d.etype_sym,
3170 d.src_id,
3171 d.dst_id,
3172 d.fired,
3173 &mut self.props,
3174 &build_topo_view(&self.topo, &self.base),
3175 &self.ids,
3176 &self.syms,
3177 &self.labels,
3178 self.base.as_ref().map(|b| {
3179 b.columns()
3180 .expect("base columns section bounds validated at open")
3181 }),
3182 );
3183 }
3184 }
3185 self.view_store.on_prop_changed(
3186 *id,
3187 &field_str,
3188 &mut self.props,
3189 &build_topo_view(&self.topo, &self.base),
3190 &self.ids,
3191 &self.syms,
3192 &self.labels,
3193 self.base.as_ref().map(|b| {
3194 b.columns()
3195 .expect("base columns section bounds validated at open")
3196 }),
3197 );
3198 if self.fulltext.field_indexed(&field_str) {
3199 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3200 if sym == u32::MAX {
3201 None
3202 } else {
3203 self.syms.resolve(sym)
3204 }
3205 });
3206 if let Some(label) = label_opt {
3207 if self.fulltext.is_enabled(label, &field_str) {
3208 self.fulltext.remove_node_field(*id, &field_str);
3209 self.fulltext.add_tokens(*id, &field_str, value);
3210 }
3211 }
3212 }
3213 if self.prop_index.field_indexed(&field_str) {
3214 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3215 if sym == u32::MAX {
3216 None
3217 } else {
3218 self.syms.resolve(sym)
3219 }
3220 });
3221 if let Some(label) = label_opt {
3222 self.prop_index.set(label, &field_str, *id, value);
3223 }
3224 }
3225 }
3226 WalRecord::CreateRule { def_bytes } => {
3227 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3228 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3229 })?;
3230 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3231 // so the engine already has it; silently skip to avoid a spurious
3232 // RuleInvalid error in the crash window between snapshot write and WAL
3233 // truncation.
3234 if self.engine.rules().any(|r| r.name == def.name) {
3235 return Ok(());
3236 }
3237 let cursor = self.engine.pending_delta_count();
3238 let mut eng = std::mem::take(&mut self.engine);
3239 let result = {
3240 let mut gm = make_graph_mut(
3241 &self.ids,
3242 &mut self.syms,
3243 &self.labels,
3244 build_props_view(&self.props, &self.base),
3245 &mut self.topo,
3246 &self.base,
3247 &mut self.edge_props,
3248 );
3249 eng.create_rule(def, &mut gm)
3250 };
3251 self.engine = eng;
3252 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3253 // Derived-edge fires from backfill → view updates.
3254 // Fast path: skip O(edge_count) allocation when no views exist.
3255 if !self.view_store.is_empty() {
3256 #[cfg(test)]
3257 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3258 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3259 for d in &new_deltas {
3260 self.view_store.on_edge_changed(
3261 d.etype_sym,
3262 d.src_id,
3263 d.dst_id,
3264 d.fired,
3265 &mut self.props,
3266 &build_topo_view(&self.topo, &self.base),
3267 &self.ids,
3268 &self.syms,
3269 &self.labels,
3270 self.base.as_ref().map(|b| {
3271 b.columns()
3272 .expect("base columns section bounds validated at open")
3273 }),
3274 );
3275 }
3276 }
3277 }
3278 WalRecord::DeleteRule { name } => {
3279 // Replay-over-snapshot idempotency: the snapshot already captured the
3280 // post-delete state so the rule is absent; silently skip to avoid a
3281 // spurious RuleNotFound error in the crash window between snapshot write
3282 // and WAL truncation.
3283 if !self.engine.rules().any(|r| r.name == *name) {
3284 return Ok(());
3285 }
3286 let cursor = self.engine.pending_delta_count();
3287 let mut eng = std::mem::take(&mut self.engine);
3288 let result = {
3289 let mut gm = make_graph_mut(
3290 &self.ids,
3291 &mut self.syms,
3292 &self.labels,
3293 build_props_view(&self.props, &self.base),
3294 &mut self.topo,
3295 &self.base,
3296 &mut self.edge_props,
3297 );
3298 eng.delete_rule(name, &mut gm)
3299 };
3300 self.engine = eng;
3301 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3302 // Derived-edge retractions → view updates.
3303 if !self.view_store.is_empty() {
3304 #[cfg(test)]
3305 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3306 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3307 for d in &new_deltas {
3308 self.view_store.on_edge_changed(
3309 d.etype_sym,
3310 d.src_id,
3311 d.dst_id,
3312 d.fired,
3313 &mut self.props,
3314 &build_topo_view(&self.topo, &self.base),
3315 &self.ids,
3316 &self.syms,
3317 &self.labels,
3318 self.base.as_ref().map(|b| {
3319 b.columns()
3320 .expect("base columns section bounds validated at open")
3321 }),
3322 );
3323 }
3324 }
3325 }
3326 WalRecord::RemoveProp { key, field } => {
3327 // Recovery-safe: unknown key or already-absent field is a
3328 // clean no-op. Crash-window replay over a snapshot that
3329 // already applied this record must not Err.
3330 let Some(id) = self.ids.get(key) else {
3331 return Ok(());
3332 };
3333 // Read old value through the seam for rule retraction.
3334 let old = build_props_view(&self.props, &self.base)
3335 .get(id, field)
3336 .map(|vr| vr.into_value());
3337 self.props.remove(id, field);
3338 // If the base still supplies the value after the overlay removal,
3339 // record a tombstone so ColumnsView::get does not resurrect it.
3340 // This covers both the base-only case AND the both-resident case:
3341 // base-only (in_overlay=false): old prop was only in base, remove
3342 // is a no-op on overlay, base still visible → tombstone needed.
3343 // both-resident (in_overlay=true): overlay had v2, base has v1;
3344 // removing overlay uncovers v1 → tombstone needed.
3345 // Idempotent on double-replay: second pass sees the tombstone →
3346 // get() returns None → condition is false → no duplicate tombstone.
3347 if build_props_view(&self.props, &self.base)
3348 .get(id, field)
3349 .is_some()
3350 {
3351 self.props.record_prop_tombstone(id, field);
3352 }
3353 let cursor = self.engine.pending_delta_count();
3354 let mut eng = std::mem::take(&mut self.engine);
3355 {
3356 let mut gm = make_graph_mut(
3357 &self.ids,
3358 &mut self.syms,
3359 &self.labels,
3360 build_props_view(&self.props, &self.base),
3361 &mut self.topo,
3362 &self.base,
3363 &mut self.edge_props,
3364 );
3365 eng.on_node_changed(id, Some((field, old)), &mut gm);
3366 }
3367 self.engine = eng;
3368 // Derived-edge deltas → view updates.
3369 if !self.view_store.is_empty() {
3370 #[cfg(test)]
3371 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3372 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3373 for d in &new_deltas {
3374 self.view_store.on_edge_changed(
3375 d.etype_sym,
3376 d.src_id,
3377 d.dst_id,
3378 d.fired,
3379 &mut self.props,
3380 &build_topo_view(&self.topo, &self.base),
3381 &self.ids,
3382 &self.syms,
3383 &self.labels,
3384 self.base.as_ref().map(|b| {
3385 b.columns()
3386 .expect("base columns section bounds validated at open")
3387 }),
3388 );
3389 }
3390 }
3391 // Neighbor-aggregate views that read `field` must also update.
3392 self.view_store.on_prop_changed(
3393 id,
3394 field,
3395 &mut self.props,
3396 &build_topo_view(&self.topo, &self.base),
3397 &self.ids,
3398 &self.syms,
3399 &self.labels,
3400 self.base.as_ref().map(|b| {
3401 b.columns()
3402 .expect("base columns section bounds validated at open")
3403 }),
3404 );
3405 // Full-text index maintenance: remove tokens for this field.
3406 if self.fulltext.field_indexed(field) {
3407 self.fulltext.remove_node_field(id, field);
3408 }
3409 // Property (equality) index maintenance: drop this node's entry.
3410 if self.prop_index.field_indexed(field) {
3411 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3412 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3413 }) {
3414 self.prop_index.remove_node(label, field, id);
3415 }
3416 }
3417 }
3418 WalRecord::DeleteEdge {
3419 edge_type,
3420 src_key,
3421 dst_key,
3422 } => {
3423 // Recovery-safe: unknown keys, unknown etype, or already-
3424 // absent edge is a clean no-op (remove_edge returns false).
3425 let Some(src) = self.ids.get(src_key) else {
3426 return Ok(());
3427 };
3428 let Some(dst) = self.ids.get(dst_key) else {
3429 return Ok(());
3430 };
3431 let Some(etype) = self.syms.get(edge_type) else {
3432 return Ok(());
3433 };
3434 // I3: phantom-tombstone guard. When a V8 base is present, a
3435 // DeleteEdge WAL record for an edge that was already absorbed into
3436 // the new base (i.e. neither in overlay nor in base) must be skipped.
3437 // Without this guard, remove_edge records a tombstone for an edge
3438 // that no longer exists, incorrectly understating edge_count.
3439 if self.base.is_some()
3440 && !self
3441 .topo_view()
3442 .neighbors(etype, core_storage::topology::Direction::Out, src)
3443 .contains(&dst)
3444 {
3445 return Ok(());
3446 }
3447 self.topo.remove_edge(etype, src, dst);
3448 self.edge_props.remove_edge(etype, src, dst);
3449 // View maintenance for manual edge delete (topo already updated above).
3450 self.view_store.on_edge_changed(
3451 etype,
3452 src,
3453 dst,
3454 false,
3455 &mut self.props,
3456 &build_topo_view(&self.topo, &self.base),
3457 &self.ids,
3458 &self.syms,
3459 &self.labels,
3460 self.base.as_ref().map(|b| {
3461 b.columns()
3462 .expect("base columns section bounds validated at open")
3463 }),
3464 );
3465 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3466 let cursor = self.engine.pending_delta_count();
3467 let mut eng = std::mem::take(&mut self.engine);
3468 {
3469 let mut gm = make_graph_mut(
3470 &self.ids,
3471 &mut self.syms,
3472 &self.labels,
3473 build_props_view(&self.props, &self.base),
3474 &mut self.topo,
3475 &self.base,
3476 &mut self.edge_props,
3477 );
3478 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3479 }
3480 self.engine = eng;
3481 if !self.view_store.is_empty() {
3482 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3483 for d in &new_deltas {
3484 self.view_store.on_edge_changed(
3485 d.etype_sym,
3486 d.src_id,
3487 d.dst_id,
3488 d.fired,
3489 &mut self.props,
3490 &build_topo_view(&self.topo, &self.base),
3491 &self.ids,
3492 &self.syms,
3493 &self.labels,
3494 self.base.as_ref().map(|b| {
3495 b.columns()
3496 .expect("base columns section bounds validated at open")
3497 }),
3498 );
3499 }
3500 }
3501 }
3502 WalRecord::DeleteNode { key } => {
3503 // Recovery-safe: already-tombstoned / unknown key is a clean
3504 // no-op. Crash-window replay over a snapshot that already
3505 // applied this record cannot recover the retired id from the
3506 // key (`IdMap::get` is None), so every subsequent step is
3507 // skipped. Each step is independently idempotent if invoked
3508 // twice on a still-live id: retraction is a no-op on empty
3509 // provenance, `remove_edge` returns false, `remove_all` is a
3510 // no-op, `ids.delete` returns None, label sentinel is sticky.
3511 let Some(n) = self.ids.get(key) else {
3512 return Ok(());
3513 };
3514
3515 // (1) Retract derived edges + de-index while props/labels live.
3516 let cursor = self.engine.pending_delta_count();
3517 let mut eng = std::mem::take(&mut self.engine);
3518 {
3519 let mut gm = make_graph_mut(
3520 &self.ids,
3521 &mut self.syms,
3522 &self.labels,
3523 build_props_view(&self.props, &self.base),
3524 &mut self.topo,
3525 &self.base,
3526 &mut self.edge_props,
3527 );
3528 eng.on_node_removed(n, &mut gm);
3529 }
3530 self.engine = eng;
3531 // Derived-edge retractions → view updates for neighbors.
3532 if !self.view_store.is_empty() {
3533 #[cfg(test)]
3534 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3535 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3536 for d in &new_deltas {
3537 self.view_store.on_edge_changed(
3538 d.etype_sym,
3539 d.src_id,
3540 d.dst_id,
3541 d.fired,
3542 &mut self.props,
3543 &build_topo_view(&self.topo, &self.base),
3544 &self.ids,
3545 &self.syms,
3546 &self.labels,
3547 self.base.as_ref().map(|b| {
3548 b.columns()
3549 .expect("base columns section bounds validated at open")
3550 }),
3551 );
3552 }
3553 }
3554
3555 // (2) Sweep ALL remaining edges incident to n, both directions,
3556 // every etype. This cascade is intentionally mask-independent:
3557 // topology integrity requires removing every edge touching the
3558 // deleted node regardless of the caller's visibility scope.
3559 // (The mask limits which nodes a role's read phase can return;
3560 // the WAL delete always executes with full storage authority.)
3561 // Collect then remove so neighbor slices stay valid during
3562 // iteration. Remove from topo first, then call view maintenance
3563 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3564 let etypes: Vec<u32> = self.topo.etypes().collect();
3565 let mut doomed = Vec::new();
3566 for et in &etypes {
3567 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3568 doomed.push((*et, n, dst));
3569 }
3570 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3571 doomed.push((*et, src, n));
3572 }
3573 }
3574 for (et, s, d) in doomed {
3575 self.topo.remove_edge(et, s, d);
3576 self.edge_props.remove_edge(et, s, d);
3577 // View maintenance: n's own view values will be cleared by
3578 // remove_all below; only update surviving neighbors.
3579 self.view_store.on_edge_changed(
3580 et,
3581 s,
3582 d,
3583 false,
3584 &mut self.props,
3585 &build_topo_view(&self.topo, &self.base),
3586 &self.ids,
3587 &self.syms,
3588 &self.labels,
3589 self.base.as_ref().map(|b| {
3590 b.columns()
3591 .expect("base columns section bounds validated at open")
3592 }),
3593 );
3594 }
3595
3596 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3597 self.props.remove_all(n);
3598 // Full-text index maintenance: remove all tokens for this node.
3599 self.fulltext.remove_node(n);
3600 // Property (equality) index maintenance: drop all entries for n.
3601 self.prop_index.remove_node_all(n);
3602
3603 // (4) Retire the dense id and stamp the label sentinel.
3604 self.ids.delete(key);
3605 if let Some(slot) = self.labels.get_mut(n as usize) {
3606 *slot = u32::MAX;
3607 }
3608 }
3609 WalRecord::Batch(inner) => {
3610 // Apply each inner record in order through the same apply path.
3611 // Inner records are validated free of nested Batch by encode_record.
3612 for rec in inner {
3613 self.apply(rec)?;
3614 }
3615 }
3616 WalRecord::RebuildRule { name } => {
3617 // Replay-over-snapshot idempotency: the snapshot may already
3618 // reflect a later delete_rule, so the rule is absent; skip.
3619 if !self.engine.rules().any(|r| r.name == *name) {
3620 return Ok(());
3621 }
3622 let cursor = self.engine.pending_delta_count();
3623 let mut eng = std::mem::take(&mut self.engine);
3624 let result = {
3625 let mut gm = make_graph_mut(
3626 &self.ids,
3627 &mut self.syms,
3628 &self.labels,
3629 build_props_view(&self.props, &self.base),
3630 &mut self.topo,
3631 &self.base,
3632 &mut self.edge_props,
3633 );
3634 eng.rebuild(name, &mut gm)
3635 };
3636 self.engine = eng;
3637 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3638 // Derived-edge delta changes → view updates.
3639 if !self.view_store.is_empty() {
3640 #[cfg(test)]
3641 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3642 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3643 for d in &new_deltas {
3644 self.view_store.on_edge_changed(
3645 d.etype_sym,
3646 d.src_id,
3647 d.dst_id,
3648 d.fired,
3649 &mut self.props,
3650 &build_topo_view(&self.topo, &self.base),
3651 &self.ids,
3652 &self.syms,
3653 &self.labels,
3654 self.base.as_ref().map(|b| {
3655 b.columns()
3656 .expect("base columns section bounds validated at open")
3657 }),
3658 );
3659 }
3660 }
3661 }
3662 WalRecord::CreateView { def_bytes } => {
3663 let def: ViewDef =
3664 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3665 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3666 })?;
3667 // Replay-over-snapshot idempotency: view already present → skip.
3668 if self.view_store.has_view(&def.name) {
3669 return Ok(());
3670 }
3671 self.view_store
3672 .create_view(
3673 def,
3674 &mut self.props,
3675 &build_topo_view(&self.topo, &self.base),
3676 &self.ids,
3677 &self.syms,
3678 &self.labels,
3679 )
3680 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3681 }
3682 WalRecord::DeleteView { name } => {
3683 // Replay-over-snapshot idempotency: view already absent → skip.
3684 if !self.view_store.has_view(name) {
3685 return Ok(());
3686 }
3687 self.view_store
3688 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3689 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3690 }
3691 WalRecord::EnableFulltext { label, field } => {
3692 // Replay-over-snapshot idempotency: already enabled → skip.
3693 if self.fulltext.is_enabled(label, field) {
3694 return Ok(());
3695 }
3696 self.fulltext.enable(label, field);
3697 // Backfill: index all live nodes of this label that have the field.
3698 let n = self.ids.len() as u32;
3699 for id in 0..n {
3700 let Some(&sym) = self.labels.get(id as usize) else {
3701 continue;
3702 };
3703 if sym == u32::MAX {
3704 continue; // tombstoned
3705 }
3706 let Some(lbl) = self.syms.resolve(sym) else {
3707 continue;
3708 };
3709 if lbl != label {
3710 continue;
3711 }
3712 if let Some(value) = build_props_view(&self.props, &self.base)
3713 .get(id, field)
3714 .map(|vr| vr.into_value())
3715 {
3716 self.fulltext.add_tokens(id, field, &value);
3717 }
3718 }
3719 }
3720 WalRecord::DisableFulltext { label, field } => {
3721 // Replay-over-snapshot idempotency: already disabled → skip.
3722 if !self.fulltext.is_enabled(label, field) {
3723 return Ok(());
3724 }
3725 // If another label still indexes this field, the postings column
3726 // is kept — but it must not contain node_ids from the now-disabled
3727 // label. Remove them before calling disable() so the field_indexed
3728 // guard inside disable() sees the correct post-removal state.
3729 if self.fulltext.field_indexed_by_other(label, field) {
3730 if let Some(label_sym) = self.syms.get(label) {
3731 for (node_id, &lsym) in self.labels.iter().enumerate() {
3732 if lsym == label_sym {
3733 self.fulltext.remove_node_field(node_id as u32, field);
3734 }
3735 }
3736 }
3737 }
3738 self.fulltext.disable(label, field);
3739 }
3740 WalRecord::EnableIndex { label, field } => {
3741 // Replay-over-snapshot idempotency: already enabled → skip.
3742 if self.prop_index.is_enabled(label, field) {
3743 return Ok(());
3744 }
3745 self.prop_index.enable(label, field);
3746 // Backfill: index all live nodes of this label that have the field.
3747 let n = self.ids.len() as u32;
3748 for id in 0..n {
3749 let Some(&sym) = self.labels.get(id as usize) else {
3750 continue;
3751 };
3752 if sym == u32::MAX {
3753 continue; // tombstoned
3754 }
3755 let Some(lbl) = self.syms.resolve(sym) else {
3756 continue;
3757 };
3758 if lbl != label {
3759 continue;
3760 }
3761 if let Some(value) = build_props_view(&self.props, &self.base)
3762 .get(id, field)
3763 .map(|vr| vr.into_value())
3764 {
3765 self.prop_index.set(label, field, id, &value);
3766 }
3767 }
3768 }
3769 WalRecord::DisableIndex { label, field } => {
3770 self.prop_index.disable(label, field);
3771 }
3772 // History markers carry no replay state — rules re-derive edges
3773 // deterministically on open/replay. Skip unconditionally.
3774 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3775 // ── rename_node ──────────────────────────────────────────────────
3776 WalRecord::RenameNode { old_key, new_key } => {
3777 // Recovery-safe: if old_key is already gone (key was renamed
3778 // by a snapshot or a prior replay frame), skip cleanly.
3779 if self.ids.get(old_key).is_none() {
3780 return Ok(());
3781 }
3782 // The rename only updates the key-table; the dense id, all
3783 // topo edges, props, labels, and rule state are id-indexed and
3784 // require no change.
3785 self.ids
3786 .rename(old_key, new_key)
3787 .map_err(|e| GraphError::Corrupt {
3788 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3789 })?;
3790 }
3791 }
3792 Ok(())
3793 }
3794
3795 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3796 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3797 /// idempotent when the string is already bound. Always emit: after
3798 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3799 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3800 let id = if let Some(id) = self.syms.get(s) {
3801 id
3802 } else {
3803 self.syms.intern(s)
3804 };
3805 (
3806 id,
3807 WalRecord::Intern {
3808 id,
3809 text: s.to_string(),
3810 },
3811 )
3812 }
3813
3814 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3815 /// state is left mutated: speculative interns made while building the
3816 /// output are rolled back, so a later successful mutation cannot log an
3817 /// `Intern` record whose id replay would never reproduce.
3818 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3819 let syms_checkpoint = self.syms.len();
3820 let result = self.rewrite_wal_dense_inner(recs);
3821 if result.is_err() {
3822 self.syms.truncate(syms_checkpoint);
3823 }
3824 result
3825 }
3826
3827 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3828 let mut out = Vec::with_capacity(recs.len());
3829 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3830 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3831 let mut interned = std::collections::HashSet::<u32>::new();
3832 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3833 detail: "id space exhausted".into(),
3834 })?;
3835 let lookup = |ids: &IdMap,
3836 pending: &std::collections::HashMap<String, u32>,
3837 key: &str|
3838 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3839 for rec in recs {
3840 match rec {
3841 WalRecord::InsertNode { label, key, props } => {
3842 let (label_id, intern) = self.intern_wal(&label);
3843 if interned.insert(label_id) {
3844 out.push(intern);
3845 }
3846 let mut props_id = Vec::with_capacity(props.len());
3847 for (field, value) in props {
3848 let (field_id, intern) = self.intern_wal(&field);
3849 if interned.insert(field_id) {
3850 out.push(intern);
3851 }
3852 props_id.push((field_id, value));
3853 }
3854 if lookup(&self.ids, &pending, &key).is_none() {
3855 pending.insert(key.clone(), next);
3856 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3857 detail: "id space exhausted".into(),
3858 })?;
3859 }
3860 out.push(WalRecord::InsertNodeId {
3861 label: label_id,
3862 key,
3863 props: props_id,
3864 });
3865 }
3866 WalRecord::SetProp { key, field, value } => {
3867 let id =
3868 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3869 detail: format!("dense WAL rewrite missing key {key}"),
3870 })?;
3871 let (field_id, intern) = self.intern_wal(&field);
3872 if interned.insert(field_id) {
3873 out.push(intern);
3874 }
3875 out.push(WalRecord::SetPropId {
3876 id,
3877 field: field_id,
3878 value,
3879 });
3880 }
3881 WalRecord::InsertEdge {
3882 edge_type,
3883 src_key,
3884 dst_key,
3885 } => {
3886 let (etype, intern) = self.intern_wal(&edge_type);
3887 if interned.insert(etype) {
3888 out.push(intern);
3889 }
3890 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3891 GraphError::Corrupt {
3892 detail: format!("dense WAL rewrite missing src {src_key}"),
3893 }
3894 })?;
3895 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3896 GraphError::Corrupt {
3897 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3898 }
3899 })?;
3900 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3901 }
3902 WalRecord::RenameNode {
3903 ref old_key,
3904 ref new_key,
3905 } => {
3906 // Track the rename in `pending` so subsequent InsertEdge /
3907 // SetProp records in this batch can resolve the new key.
3908 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3909 GraphError::Corrupt {
3910 detail: format!(
3911 "dense WAL rewrite: RenameNode old key {old_key} not found"
3912 ),
3913 }
3914 })?;
3915 pending.remove(old_key.as_str());
3916 pending.insert(new_key.clone(), id);
3917 out.push(rec);
3918 }
3919 // # Symbol-order invariant (load-bearing)
3920 //
3921 // Write-time and replay-time symbol assignment must agree: every
3922 // symbol in a `Batch` frame has to receive the same dense id when
3923 // the frame's records are replayed in order as it received when
3924 // the frame was written.
3925 //
3926 // A rule's backfill interns its `edge_type` lazily
3927 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3928 // site), and that backfill runs from `apply` — during the
3929 // `CreateRule` record itself, and again from any later
3930 // `InsertNodeId` in the same frame that makes the rule fire. At
3931 // write time the whole batch is rewritten before any of it is
3932 // applied, so a later `InsertEdge` in the same batch would win the
3933 // lower id for its edge type; on replay the rule's lazy intern
3934 // gets there first and steals it, and the `Intern` record fails at
3935 // the `wal intern assigned …` check in `apply`.
3936 //
3937 // Pre-interning the rule's `edge_type` here, and emitting its
3938 // `Intern` record ahead of the `CreateRule` record, makes both
3939 // orders identical. `weight_prop` needs no pre-intern:
3940 // `EdgeProps::set` keys props by `String`, never through the
3941 // interner. `via_edge` needs none either: via-hop rules resolve it
3942 // with `syms.get` and skip when it is absent.
3943 //
3944 // `RebuildRule` and `DeleteRule` need no such handling here:
3945 // `RebuildRule` has no `BatchOp` variant, so it never appears
3946 // inside a `Batch` today — it is only ever issued as its own
3947 // standalone commit (`rebuild_rule`, or the auto-rebuild path
3948 // that logs it as a second commit after the triggering op).
3949 // `DeleteRule` does have a `BatchOp` variant and can appear
3950 // inside a `Batch`, but it carries only a rule `name` — no
3951 // `edge_type` or other symbol that needs pre-interning — so
3952 // only `CreateRule` needs this arm.
3953 WalRecord::CreateRule { ref def_bytes } => {
3954 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3955 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3956 })?;
3957 let (etype, intern) = self.intern_wal(&def.edge_type);
3958 if interned.insert(etype) {
3959 out.push(intern);
3960 }
3961 out.push(rec);
3962 }
3963 other => out.push(other),
3964 }
3965 }
3966 Ok(out)
3967 }
3968
3969 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3970 let recs = self.rewrite_wal_dense(recs)?;
3971 match recs.len() {
3972 0 => Ok(()),
3973 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3974 _ => self.log_then_apply(WalRecord::Batch(recs)),
3975 }
3976 }
3977
3978 /// Durable write, then notify the event sink. Replay (`apply` during
3979 /// `open`) never enters this function, so it is the replay-silent seam.
3980 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3981 self.log_then_apply_with(rec, None, self.fsync)
3982 }
3983
3984 /// Whether this frame must fsync under `policy`.
3985 ///
3986 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3987 /// mutations do not. The dense rewrite wraps a single mutation in a
3988 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3989 /// from the count — removing that filter would make every single-op write
3990 /// fsync under Batched (or, if the threshold were raised instead, skip a
3991 /// needed fsync for real two-op batches).
3992 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3993 match policy {
3994 FsyncPolicy::Relaxed => false,
3995 FsyncPolicy::Strict => true,
3996 FsyncPolicy::Batched => match rec {
3997 // Intern + one mutation is the single-op rewrite, not a user batch.
3998 WalRecord::Batch(inner) => {
3999 inner
4000 .iter()
4001 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4002 .count()
4003 > 1
4004 }
4005 _ => false,
4006 },
4007 }
4008 }
4009
4010 /// # Apply-infallibility invariant (load-bearing)
4011 ///
4012 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4013 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4014 /// the full frame while in-memory state would reflect only the ops before
4015 /// the failure. On reopen, WAL replay would then apply the entire batch —
4016 /// diverging permanently from what the pre-crash process had in memory.
4017 ///
4018 /// For `Batch` frames this situation cannot arise because:
4019 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4020 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4021 /// use, with no concurrent mutation between validation exit and apply entry.
4022 /// - Every `apply` arm for a validated op is either infallible by construction
4023 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4024 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4025 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4026 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4027 ///
4028 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4029 /// `Err` for a `Batch` frame, making any future regression immediately visible
4030 /// in tests rather than silently diverging crash-recovery behaviour.
4031 fn log_then_apply_with(
4032 &mut self,
4033 rec: WalRecord,
4034 ingest: Option<(String, usize)>,
4035 policy: FsyncPolicy,
4036 ) -> Result<()> {
4037 // Read-only guard: as-of instances must never write the WAL.
4038 if self.read_only {
4039 return Err(GraphError::ReadOnly);
4040 }
4041 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4042 // partway; in-memory state is ahead of (or out of step with) the
4043 // on-disk WAL, so further mutations would deepen the divergence.
4044 // Reopen the database to recover. Checked before the lock guard: this
4045 // is the more serious condition and the more useful error.
4046 if self.degraded {
4047 return Err(GraphError::Io(std::io::Error::other(
4048 "database degraded after group-commit fsync failure; reopen required",
4049 )));
4050 }
4051 // Cross-process guard: this write scope asked for the store's write
4052 // lock and did not get it. Writing anyway would append frames on top of
4053 // a WAL another process is extending, so refuse instead.
4054 if self.lock_denied {
4055 return Err(GraphError::Busy { holder: None });
4056 }
4057 // Ensure retained provenance bytes are decoded into the live mutable
4058 // fields before any mutation touches self.engine.provenance. This is a
4059 // no-op if provenance was never stored (fresh store) or has already been
4060 // consumed (subsequent mutations). WAL replay calls apply() directly
4061 // and is covered by consume_retained_state_eager before replay.
4062 self.ensure_v8_base_sections_loaded();
4063 self.engine.ensure_provenance_loaded_mut();
4064 // Invariant (I-1): no stale deltas may enter from a previous apply.
4065 // If any engine method ever accumulates deltas before erroring, they would
4066 // contaminate the *next* commit's event stream. This assert fires in debug
4067 // builds, making any future regression visible at the earliest point.
4068 debug_assert_eq!(
4069 self.engine.pending_delta_count(),
4070 0,
4071 "stale engine deltas at log_then_apply_with entry — \
4072 a previous apply arm may have accumulated deltas before erroring; \
4073 the caller must drain_deltas() on any error path before returning"
4074 );
4075 let frame = encode_record(&rec);
4076 self.fs.append(FileId::Wal, &frame)?;
4077 // The cursor advances by exactly the bytes appended: these frames are
4078 // ours and already applied, so a later refresh must not replay them.
4079 self.wal_consumed += frame.len() as u64;
4080 if Self::wal_needs_sync(policy, &rec) {
4081 self.fs.sync(FileId::Wal)?;
4082 }
4083 // Marker writing always needs the engine deltas, but the engine only
4084 // accumulates them when emit_deltas is true (normally gated on subscribers
4085 // or views being present). Enable emission for this apply if it is
4086 // currently off, then restore the original state unconditionally via an
4087 // RAII guard — this prevents a panic in apply() from leaking the flag.
4088 // The same guard resets the engine's transient chaining state. A panic
4089 // unwinding out of a rule hook would otherwise leave `chain_depth`
4090 // non-zero, which makes every later `begin_chain` decide chaining is
4091 // already running and silently switch it off for good.
4092 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4093 impl Drop for RestoreEmitDeltas {
4094 fn drop(&mut self) {
4095 // SAFETY: pointer into self (GraphDb); guard is dropped within
4096 // this frame before log_then_apply_with returns.
4097 unsafe {
4098 (*self.0).set_emit_deltas(self.1);
4099 (*self.0).reset_chain_state();
4100 }
4101 }
4102 }
4103 let original_emit = self.engine.emit_deltas();
4104 if !original_emit {
4105 self.engine.set_emit_deltas(true);
4106 }
4107 // SAFETY: raw pointer into self; guard dropped within this frame.
4108 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4109
4110 let apply_result = self.apply(&rec);
4111 // For Batch frames, post-validation apply must be infallible (see above).
4112 // A debug_assert here catches any future change that makes apply fallible
4113 // before the caller notices via silent WAL/memory divergence.
4114 if matches!(&rec, WalRecord::Batch(_)) {
4115 debug_assert!(
4116 apply_result.is_ok(),
4117 "Batch apply returned Err after successful WAL write — \
4118 the validate-then-apply invariant has been violated; \
4119 see log_then_apply_with invariant doc"
4120 );
4121 }
4122 if apply_result.is_err() {
4123 // Discard any partial deltas accumulated by the failed apply.
4124 // They must not ride the next commit's event stream (I-1).
4125 // _emit_guard restores emit_deltas on drop automatically.
4126 let _ = self.engine.drain_deltas();
4127 let _ = self.engine.take_rebuild_needed();
4128 apply_result?;
4129 }
4130 self.commit_seq += 1;
4131 let seq = self.commit_seq;
4132 // Update per-node last-change map for the committed record.
4133 // Must happen after commit_seq is incremented so the seq is correct.
4134 self.update_last_change_from_rec(&rec, seq);
4135 // Drain engine deltas and distribute to subscribers before the existing
4136 // MutationEvent sink fires — both happen post-fsync, post-apply.
4137 // _emit_guard restores emit_deltas after this line when it drops.
4138 let engine_deltas = self.engine.drain_deltas();
4139
4140 // Append history-marker WAL records for any derived-edge changes so
4141 // that `edge_history` and `was_linked` can surface rule-attributed
4142 // events. Markers are STATE NO-OPS during replay; they are written
4143 // without an additional fsync (the triggering commit's sync already
4144 // happened; the next commit's sync covers these lazily).
4145 if !engine_deltas.is_empty() {
4146 let markers: Vec<WalRecord> = engine_deltas
4147 .iter()
4148 .map(|d| {
4149 if d.fired {
4150 WalRecord::DerivedEdgeAdded {
4151 rule: d.rule.clone(),
4152 edge_type: d.edge_type.clone(),
4153 src_key: d.src_key.clone(),
4154 dst_key: d.dst_key.clone(),
4155 }
4156 } else {
4157 WalRecord::DerivedEdgeRetracted {
4158 rule: d.rule.clone(),
4159 edge_type: d.edge_type.clone(),
4160 src_key: d.src_key.clone(),
4161 dst_key: d.dst_key.clone(),
4162 }
4163 }
4164 })
4165 .collect();
4166 let marker_frame = if markers.len() == 1 {
4167 markers.into_iter().next().unwrap()
4168 } else {
4169 WalRecord::Batch(markers)
4170 };
4171 // Ignore append errors: markers are best-effort history
4172 // annotations. Losing them does not affect state correctness.
4173 // The cursor only advances when the bytes actually landed.
4174 let marker_bytes = encode_record(&marker_frame);
4175 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4176 self.wal_consumed += marker_bytes.len() as u64;
4177 }
4178 }
4179
4180 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4181 // stored as-is (including any nested Batch / Intern records); the
4182 // ReaderSnapshot's apply_one function handles all variants.
4183 {
4184 let derived_inserts = engine_deltas
4185 .iter()
4186 .filter(|d| d.fired)
4187 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4188 .collect();
4189 let derived_deletes = engine_deltas
4190 .iter()
4191 .filter(|d| !d.fired)
4192 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4193 .collect();
4194 let delta = Arc::new(crate::reader::CommitDelta {
4195 records: vec![rec.clone()],
4196 derived_inserts,
4197 derived_deletes,
4198 });
4199 self.delta_tail.push(delta);
4200 self.commits_since_fold += 1;
4201 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4202 self.fold_now();
4203 }
4204 }
4205
4206 if self.defer_events {
4207 // Group-commit drain thread: hold events until after the group
4208 // fsync so subscribers only observe durable data (R2).
4209 self.deferred_events.push(DeferredEvent {
4210 rec: rec.clone(),
4211 engine_deltas,
4212 seq,
4213 ingest,
4214 });
4215 } else {
4216 self.distribute_events(&rec, &engine_deltas, seq);
4217 self.emit_committed(&rec, ingest);
4218 }
4219 // Drift is only known after apply, so auto-rebuild cannot join the
4220 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4221 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4222 // retrigger loop is impossible if the fit succeeded, but we still
4223 // drain the flag so a leftover cannot re-enter.
4224 let rebuilds = self.engine.take_rebuild_needed();
4225 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4226 let mut failed = Vec::new();
4227 for name in rebuilds {
4228 if self.engine.rules().any(|r| r.name == name) {
4229 // User op is already durable. A failed second commit must
4230 // not surface as the caller's error.
4231 if let Err(e) =
4232 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4233 {
4234 eprintln!(
4235 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4236 );
4237 failed.push(name);
4238 }
4239 }
4240 }
4241 for name in failed {
4242 self.engine.queue_rebuild_needed(name);
4243 }
4244 }
4245 Ok(())
4246 }
4247
4248 /// Install a post-commit hook. Replaces any previous sink.
4249 ///
4250 /// The sink runs inside `log_then_apply` after a successful
4251 /// durable commit, while the caller still holds `&mut self`. When this
4252 /// database is behind a [`crate::SharedDb`], that means the **write
4253 /// guard is held**. The sink must never call `read` / `write` (or any
4254 /// other method) on the same `SharedDb` — the `RwLock` is not
4255 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4256 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4257 /// Intended examples: `std::sync::mpsc::SyncSender`,
4258 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4259 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4260 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4261 self.event_sink = Some(sink);
4262 }
4263
4264 /// Whether a post-commit event sink is currently installed.
4265 pub fn has_event_sink(&self) -> bool {
4266 self.event_sink.is_some()
4267 }
4268
4269 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4270 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4271 self.fsync = p;
4272 }
4273
4274 /// Return the current WAL fsync cadence.
4275 pub fn fsync_policy(&self) -> FsyncPolicy {
4276 self.fsync
4277 }
4278
4279 // ── Group-commit event deferral ───────────────────────────────────────────
4280
4281 /// Enable or disable deferred event mode.
4282 ///
4283 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4284 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4285 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4286 /// or [`discard_deferred_events`] if the fsync failed and the group must
4287 /// be treated as lost.
4288 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4289 self.defer_events = defer;
4290 }
4291
4292 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4293 /// was set to true. Clears the buffer.
4294 ///
4295 /// Called by the drain thread AFTER a successful group fsync, so
4296 /// subscribers observe only data that is durably on disk.
4297 pub fn flush_deferred_events(&mut self) {
4298 let events = std::mem::take(&mut self.deferred_events);
4299 for de in events {
4300 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4301 self.emit_committed(&de.rec, de.ingest);
4302 }
4303 }
4304
4305 /// Discard all buffered events without firing them.
4306 ///
4307 /// Called by the drain thread when a group fsync fails: the WAL has been
4308 /// truncated back to the pre-group offset, so the committed-but-unsynced
4309 /// ops must not be observable to subscribers.
4310 pub fn discard_deferred_events(&mut self) {
4311 self.deferred_events.clear();
4312 }
4313
4314 // ── Degraded state ────────────────────────────────────────────────────────
4315
4316 /// Mark this database as degraded.
4317 ///
4318 /// Called by the group-commit drain thread after a group fsync failure and
4319 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4320 /// further mutations would deepen the divergence. All subsequent calls to
4321 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4322 pub fn set_degraded(&mut self) {
4323 self.degraded = true;
4324 }
4325
4326 fn emit(&self, ev: MutationEvent) {
4327 if let Some(sink) = &self.event_sink {
4328 sink(ev);
4329 }
4330 }
4331
4332 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4333 match rec {
4334 WalRecord::Batch(inner) => {
4335 for r in inner {
4336 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4337 self.emit(ev);
4338 }
4339 }
4340 match ingest {
4341 Some((label, inserted)) => {
4342 self.emit(MutationEvent::Ingested { label, inserted })
4343 }
4344 None => {
4345 let ops = inner
4346 .iter()
4347 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4348 .count();
4349 if ops > 1 {
4350 self.emit(MutationEvent::BatchApplied { ops });
4351 }
4352 }
4353 }
4354 }
4355 other => {
4356 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4357 self.emit(ev);
4358 }
4359 }
4360 }
4361 }
4362
4363 // -----------------------------------------------------------------------
4364 // Subscription API
4365 // -----------------------------------------------------------------------
4366
4367 /// Distribute post-commit events to all live subscribers.
4368 ///
4369 /// Build a row-key → row-data map from a [`ResultSet`].
4370 ///
4371 /// Each row is serialized to JSON to form its key; a debug fallback is used
4372 /// if serialization fails. Used by both the initial-seed path in
4373 /// [`Self::subscribe_query`] and the per-commit diff path in
4374 /// [`Self::distribute_events`] to keep the two in sync.
4375 fn result_to_row_map(
4376 result: &core_query::ResultSet,
4377 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4378 (0..result.len())
4379 .map(|i| {
4380 let row = result.row(i).to_vec();
4381 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4382 (key, row)
4383 })
4384 .collect()
4385 }
4386
4387 /// Collect the set of label syms touched by a WAL record.
4388 ///
4389 /// Returns `Some(set)` when every record in this commit can be attributed to
4390 /// a known label sym. Returns `None` when the commit must not be skipped:
4391 /// edge records, unresolvable key→label lookups, or any record type not in
4392 /// the explicit handled set.
4393 ///
4394 /// Handled record types and their actions:
4395 /// - `InsertNode` → look up label in interner (fails → None)
4396 /// - `InsertNodeId` → label sym is carried directly
4397 /// - `SetProp` → resolve key→id→label (fails → None)
4398 /// - `DeleteNode` → resolve key→id→label (fails → None)
4399 /// - `Batch` → recurse into every inner record
4400 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4401 /// - everything else → None (conservative)
4402 fn commit_touched_labels(
4403 rec: &WalRecord,
4404 syms: &Interner,
4405 ids: &IdMap,
4406 labels: &[u32],
4407 ) -> Option<BTreeSet<u32>> {
4408 let mut out = BTreeSet::new();
4409 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4410 Some(out)
4411 } else {
4412 None
4413 }
4414 }
4415
4416 fn collect_touched_labels(
4417 rec: &WalRecord,
4418 syms: &Interner,
4419 ids: &IdMap,
4420 labels: &[u32],
4421 out: &mut BTreeSet<u32>,
4422 ) -> bool {
4423 match rec {
4424 // String-key insert: the dense rewrite converts this to
4425 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4426 // records written before the dense path was added.
4427 WalRecord::InsertNode { label, .. } => {
4428 if let Some(sym) = syms.get(label) {
4429 out.insert(sym);
4430 true
4431 } else {
4432 false
4433 }
4434 }
4435 // Dense-id insert (produced by rewrite_wal_dense for every
4436 // insert_node call in the current codebase).
4437 WalRecord::InsertNodeId { label, .. } => {
4438 out.insert(*label);
4439 true
4440 }
4441 // String-key prop set: dense path converts to [Intern, SetPropId].
4442 WalRecord::SetProp { key, .. } => {
4443 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4444 out.insert(sym);
4445 true
4446 } else {
4447 false
4448 }
4449 }
4450 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4451 WalRecord::SetPropId { id, .. } => {
4452 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4453 out.insert(sym);
4454 true
4455 } else {
4456 false
4457 }
4458 }
4459 WalRecord::DeleteNode { key } => {
4460 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4461 out.insert(sym);
4462 true
4463 } else {
4464 false
4465 }
4466 }
4467 WalRecord::Batch(inner) => inner
4468 .iter()
4469 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4470 // Intern is a pure metadata record — it does not touch any node's
4471 // label and is safe to skip for the label-skip predicate.
4472 WalRecord::Intern { .. } => true,
4473 // Edge records: always re-execute (edges can change join results).
4474 WalRecord::InsertEdge { .. }
4475 | WalRecord::DeleteEdge { .. }
4476 | WalRecord::InsertEdgeId { .. } => false,
4477 _ => false,
4478 }
4479 }
4480
4481 /// Resolve a node key to its label sym via the dense id table.
4482 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4483 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4484 let id = ids.get(key)?;
4485 let sym = labels.get(id as usize).copied()?;
4486 (sym != u32::MAX).then_some(sym)
4487 }
4488
4489 /// Distribute post-commit events to all live subscribers.
4490 ///
4491 /// Called from `log_then_apply_with` after apply + fsync, before the
4492 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4493 ///
4494 /// Query subscriptions (subscribe_query) re-execute their plan on every
4495 /// call and diff the result against the previous run. Zero overhead when
4496 /// no query subscriptions are active.
4497 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4498 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4499 return;
4500 }
4501
4502 if !self.subscriptions.is_empty() {
4503 // Build write events from the WAL record.
4504 let write_events: Vec<DbEvent> =
4505 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4506
4507 // Build edge events from engine deltas. Weight is looked up from
4508 // edge_props at distribution time (after apply), so it's always fresh.
4509 let edge_events: Vec<DbEvent> = engine_deltas
4510 .iter()
4511 .map(|d| {
4512 if d.fired {
4513 // The score lives under the rule's declared weight_prop,
4514 // which is not always the literal "weight".
4515 let prop = self
4516 .engine
4517 .rules()
4518 .find(|r| r.name == d.rule)
4519 .and_then(|r| r.weight_prop.as_deref());
4520 let weight = prop.and_then(|p| {
4521 self.edge_props
4522 .get(d.etype_sym, d.src_id, d.dst_id, p)
4523 .and_then(|v| {
4524 if let core_storage::Value::Float(f) = v {
4525 Some(*f)
4526 } else {
4527 None
4528 }
4529 })
4530 });
4531 DbEvent::EdgeFired {
4532 rule: d.rule.clone(),
4533 src_key: d.src_key.clone(),
4534 dst_key: d.dst_key.clone(),
4535 edge_type: d.edge_type.clone(),
4536 weight,
4537 commit_seq: seq,
4538 }
4539 } else {
4540 DbEvent::EdgeRetracted {
4541 rule: d.rule.clone(),
4542 src_key: d.src_key.clone(),
4543 dst_key: d.dst_key.clone(),
4544 edge_type: d.edge_type.clone(),
4545 commit_seq: seq,
4546 }
4547 }
4548 })
4549 .collect();
4550
4551 // Prune dead entries; push matching events to live ones.
4552 self.subscriptions.retain(|entry| {
4553 let Some(inner) = entry.inner.upgrade() else {
4554 return false;
4555 };
4556 for ev in &write_events {
4557 if event_matches(ev, &entry.filter) {
4558 inner.push(ev.clone());
4559 }
4560 }
4561 for ev in &edge_events {
4562 if event_matches(ev, &entry.filter) {
4563 inner.push(ev.clone());
4564 }
4565 }
4566 true
4567 });
4568
4569 // Turn off delta accumulation if all subscribers dropped and no views remain.
4570 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4571 self.engine.set_emit_deltas(false);
4572 }
4573 }
4574
4575 // Query subscriptions: full re-run per commit, then diff rows.
4576 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4577 // Differential evaluation is roadmap / Phase 5.
4578 if !self.query_subscriptions.is_empty() {
4579 // Take the list out so we can call self.view() without borrow conflict.
4580 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4581 let empty_params = BTreeMap::new();
4582 query_subs.retain_mut(|entry| {
4583 let Some(inner) = entry.inner.upgrade() else {
4584 return false; // subscriber dropped — prune
4585 };
4586 // Label-skip: if the plan has a known scan label and this commit
4587 // can be proven to touch only different labels (and no rule-derived
4588 // edge deltas fired), the result set cannot have changed — skip.
4589 if let Some(scan_sym) = entry.scan_label {
4590 if engine_deltas.is_empty() {
4591 let touched =
4592 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4593 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4594 return true; // safe to skip — result set unchanged
4595 }
4596 }
4597 }
4598 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4599 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4600 Ok(r) => r,
4601 Err(e) => {
4602 // Keep the subscription alive; skip the diff for this commit.
4603 // Re-run errors are transient (e.g., planner change) and
4604 // self-heal when the next commit succeeds.
4605 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4606 return true;
4607 }
4608 };
4609 // Build new row map: serialized-key → row data.
4610 let new_row_map = Self::result_to_row_map(&result);
4611 // Removed rows: in prev but not in new.
4612 for (key, row) in &entry.prev_row_map {
4613 if !new_row_map.contains_key(key) {
4614 inner.push(DbEvent::QueryRowRemoved {
4615 columns: entry.columns.clone(),
4616 row: row.clone(),
4617 });
4618 }
4619 }
4620 // Added rows: in new but not in prev.
4621 for (key, row) in &new_row_map {
4622 if !entry.prev_row_map.contains_key(key) {
4623 inner.push(DbEvent::QueryRowAdded {
4624 columns: entry.columns.clone(),
4625 row: row.clone(),
4626 });
4627 }
4628 }
4629 entry.prev_row_map = new_row_map;
4630 true
4631 });
4632 self.query_subscriptions = query_subs;
4633 }
4634 }
4635
4636 /// Returns `true` if any live subscriber or view definition requires delta
4637 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4638 fn needs_emit_deltas(&self) -> bool {
4639 !self.view_store.is_empty()
4640 || self
4641 .subscriptions
4642 .iter()
4643 .any(|e| e.inner.upgrade().is_some())
4644 }
4645
4646 /// Convert a WAL record into `DbEvent` write events with the given seq.
4647 fn write_events_from_record(
4648 rec: &WalRecord,
4649 seq: u64,
4650 intern: &Interner,
4651 ids: &IdMap,
4652 ) -> Vec<DbEvent> {
4653 match rec {
4654 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4655 label: label.clone(),
4656 key: key.clone(),
4657 commit_seq: seq,
4658 }],
4659 // *Id arms run after a successful apply, so resolution can only
4660 // fail on a programming error. Skip the event rather than emit a
4661 // fabricated "" that clients can't tell from a real empty value
4662 // (mirrors event_from_record returning None).
4663 WalRecord::InsertNodeId { label, key, .. } => intern
4664 .resolve(*label)
4665 .map(|label| DbEvent::NodeInserted {
4666 label: label.to_string(),
4667 key: key.clone(),
4668 commit_seq: seq,
4669 })
4670 .into_iter()
4671 .collect(),
4672 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4673 key: key.clone(),
4674 field: field.clone(),
4675 commit_seq: seq,
4676 }],
4677 WalRecord::SetPropId { id, field, .. } => ids
4678 .key_of(*id)
4679 .zip(intern.resolve(*field))
4680 .map(|(key, field)| DbEvent::PropSet {
4681 key: key.to_string(),
4682 field: field.to_string(),
4683 commit_seq: seq,
4684 })
4685 .into_iter()
4686 .collect(),
4687 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4688 key: key.clone(),
4689 field: field.clone(),
4690 commit_seq: seq,
4691 }],
4692 WalRecord::InsertEdge {
4693 edge_type,
4694 src_key,
4695 dst_key,
4696 } => vec![DbEvent::EdgeInserted {
4697 edge_type: edge_type.clone(),
4698 src: src_key.clone(),
4699 dst: dst_key.clone(),
4700 commit_seq: seq,
4701 }],
4702 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4703 Some(DbEvent::EdgeInserted {
4704 edge_type: intern.resolve(*etype)?.to_string(),
4705 src: ids.key_of(*src)?.to_string(),
4706 dst: ids.key_of(*dst)?.to_string(),
4707 commit_seq: seq,
4708 })
4709 })()
4710 .into_iter()
4711 .collect(),
4712 WalRecord::DeleteEdge {
4713 edge_type,
4714 src_key,
4715 dst_key,
4716 } => vec![DbEvent::EdgeDeleted {
4717 edge_type: edge_type.clone(),
4718 src: src_key.clone(),
4719 dst: dst_key.clone(),
4720 commit_seq: seq,
4721 }],
4722 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4723 key: key.clone(),
4724 commit_seq: seq,
4725 }],
4726 WalRecord::Batch(inner) => inner
4727 .iter()
4728 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4729 .collect(),
4730 WalRecord::CreateRule { .. }
4731 | WalRecord::DeleteRule { .. }
4732 | WalRecord::RebuildRule { .. }
4733 | WalRecord::CreateView { .. }
4734 | WalRecord::DeleteView { .. }
4735 | WalRecord::EnableFulltext { .. }
4736 | WalRecord::DisableFulltext { .. }
4737 | WalRecord::EnableIndex { .. }
4738 | WalRecord::DisableIndex { .. }
4739 | WalRecord::Intern { .. }
4740 // History markers produce no DbEvent — the engine delta already
4741 // fired the EdgeFired/EdgeRetracted subscription events.
4742 | WalRecord::DerivedEdgeAdded { .. }
4743 | WalRecord::DerivedEdgeRetracted { .. }
4744 | WalRecord::RenameNode { .. } => vec![],
4745 }
4746 }
4747
4748 /// Subscribe to edge-fire and edge-retract events for one named rule.
4749 ///
4750 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4751 /// currently registered. Dropping the returned [`Subscription`] handle
4752 /// unregisters the subscriber — no further events are queued, no
4753 /// resources leak.
4754 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4755 if self.read_only {
4756 return Err(core_storage::GraphError::ReadOnly);
4757 }
4758 if !self.engine.rules().any(|r| r.name == rule_name) {
4759 return Err(core_storage::GraphError::RuleNotFound {
4760 name: rule_name.to_string(),
4761 });
4762 }
4763 let inner = SubInner::new(self.sub_capacity());
4764 self.subscriptions.push(SubEntry {
4765 filter: SubFilter::Rule(rule_name.to_string()),
4766 inner: std::sync::Arc::downgrade(&inner),
4767 });
4768 self.engine.set_emit_deltas(true);
4769 Ok(Subscription(inner))
4770 }
4771
4772 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4773 ///
4774 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4775 /// as-of instances never commit, so `distribute_events` never runs and the
4776 /// subscription would never deliver events.
4777 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4778 if self.read_only {
4779 return Err(core_storage::GraphError::ReadOnly);
4780 }
4781 let inner = SubInner::new(self.sub_capacity());
4782 self.subscriptions.push(SubEntry {
4783 filter: SubFilter::AllRules,
4784 inner: std::sync::Arc::downgrade(&inner),
4785 });
4786 self.engine.set_emit_deltas(true);
4787 Ok(Subscription(inner))
4788 }
4789
4790 /// Subscribe to write events: node insert/delete, prop set/remove.
4791 ///
4792 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4793 ///
4794 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4795 /// as-of instances never commit, so `distribute_events` never runs and the
4796 /// subscription would never deliver events.
4797 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4798 if self.read_only {
4799 return Err(core_storage::GraphError::ReadOnly);
4800 }
4801 let inner = SubInner::new(self.sub_capacity());
4802 self.subscriptions.push(SubEntry {
4803 filter: SubFilter::Writes,
4804 inner: std::sync::Arc::downgrade(&inner),
4805 });
4806 self.engine.set_emit_deltas(true);
4807 Ok(Subscription(inner))
4808 }
4809
4810 /// Subscribe to incremental Cypher query results.
4811 ///
4812 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4813 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4814 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4815 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4816 ///
4817 /// SKIP is not supported — it shifts the result window on every commit,
4818 /// causing spurious Added/Removed churn for rows whose data never changed.
4819 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4820 /// widens scope beyond the documented single-scan / single-hop subset.
4821 ///
4822 /// After each successful commit, the plan is **fully re-executed** and the
4823 /// result is diffed against the previous run. Added rows produce
4824 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4825 /// [`DbEvent::QueryRowRemoved`].
4826 ///
4827 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4828 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4829 /// is roadmap / Phase 5.
4830 ///
4831 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4832 /// as-of instances never commit, so `distribute_events` never runs and the
4833 /// subscription would never deliver events.
4834 ///
4835 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4836 /// or if the plan shape is not in the allowlist.
4837 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4838 if self.read_only {
4839 return Err(GraphError::ReadOnly);
4840 }
4841 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4842 detail: format!("lex: {e}"),
4843 })?;
4844 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4845 detail: format!("parse: {e}"),
4846 })?;
4847 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4848 detail: format!("plan: {e}"),
4849 })?;
4850 if !is_subscribable(&ops) {
4851 return Err(GraphError::QueryError {
4852 detail: "subscribe_query only supports allowlisted plan shapes: \
4853 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4854 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4855 Not supported: multi-hop Expand chains, SKIP (creates \
4856 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4857 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4858 Use LIMIT to bound re-execution cost."
4859 .to_string(),
4860 });
4861 }
4862 // Execute once to capture initial state (initial rows are not emitted as
4863 // events — the subscriber learns the baseline via the first query call).
4864 let empty_params = BTreeMap::new();
4865 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4866 GraphError::QueryError {
4867 detail: format!("execute: {e}"),
4868 }
4869 })?;
4870 let columns = initial.columns().to_vec();
4871 let prev_row_map = Self::result_to_row_map(&initial);
4872 let inner = SubInner::new(self.sub_capacity());
4873 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4874 // or unrecognized leading scan → None (always re-execute).
4875 let scan_label = extract_scan_label(&ops, &mut self.syms);
4876 self.query_subscriptions.push(QuerySubEntry {
4877 ops,
4878 columns,
4879 prev_row_map,
4880 inner: std::sync::Arc::downgrade(&inner),
4881 scan_label,
4882 });
4883 Ok(Subscription(inner))
4884 }
4885
4886 /// Queue capacity used for new subscriptions.
4887 fn sub_capacity(&self) -> usize {
4888 self.sub_capacity
4889 }
4890
4891 /// Override per-subscriber queue capacity for subsequently created
4892 /// subscriptions on this db instance.
4893 ///
4894 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4895 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4896 /// generating tens of thousands of events.
4897 ///
4898 /// This is a test-support escape hatch. Calling it in production reduces
4899 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4900 /// to discourage accidental production use.
4901 #[doc(hidden)]
4902 pub fn set_sub_capacity(&mut self, capacity: usize) {
4903 self.sub_capacity = capacity;
4904 }
4905
4906 // -----------------------------------------------------------------------
4907
4908 /// Start an atomic batch.
4909 ///
4910 /// The returned [`BatchBuilder`] borrows `self` mutably until
4911 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4912 /// validation, no WAL I/O. `commit` validates every queued op against
4913 /// live state plus preceding ops in this batch (duplicate key inside
4914 /// the batch is `Err`; an edge between two nodes created earlier in
4915 /// the batch is valid; `delete_node` then insert of the same key is a
4916 /// fresh identity). Validation never mutates the database. Any failure
4917 /// leaves WAL bytes and in-memory state identical to before `commit`.
4918 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4919 /// and each inner record is applied in order so rules fire per record.
4920 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4921 ///
4922 /// **Rule-window limitation:** batch validation cannot see edges that a
4923 /// rule created earlier in the *same* batch will derive at apply time, so
4924 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4925 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4926 /// is unaffected (idempotent apply, provenance intact). Create rules in
4927 /// their own batch, or sequentially, when later ops may touch derived
4928 /// edges.
4929 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4930 BatchBuilder {
4931 db: self,
4932 ops: Vec::new(),
4933 }
4934 }
4935
4936 /// Closure-style atomic write batch.
4937 ///
4938 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4939 /// then committing. All ops queued inside `build` are validated in order and
4940 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4941 /// once per inner record, in order, after commit — semantically identical to
4942 /// sequential single-op writes.
4943 ///
4944 /// **Error semantics — validate-then-apply.** `build` queues ops without
4945 /// touching the database. [`BatchBuilder::commit`] validates every op against
4946 /// live state plus earlier ops in this batch before writing anything. If op N
4947 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4948 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4949 /// changes. The database is identical to its state before `write_batch` was
4950 /// called.
4951 ///
4952 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4953 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4954 /// either fully applied or not at all. However, while applying a committed
4955 /// batch, concurrent readers may observe intermediate states as ops are applied
4956 /// sequentially in memory. There is no interactive transaction isolation in v1.
4957 /// This is documented as "crash-atomic write batches; no interactive
4958 /// transactions or read isolation."
4959 ///
4960 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4961 /// writes zero WAL bytes and returns `(0, 0)`.
4962 ///
4963 /// # Example
4964 ///
4965 /// ```rust,ignore
4966 /// let (nodes, edges) = db.write_batch(|b| {
4967 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4968 /// b.insert_node("Person", "bob", vec![]);
4969 /// b.insert_edge("KNOWS", "alice", "bob");
4970 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4971 /// b.delete_node("old_key");
4972 /// })?;
4973 /// // One fsync; on crash replay: all five ops land or none do.
4974 /// ```
4975 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4976 where
4977 C: FnOnce(&mut BatchBuilder<'_, F>),
4978 {
4979 let mut b = self.batch();
4980 build(&mut b);
4981 b.commit()
4982 }
4983
4984 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4985 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4986 /// inserts, so incremental fire sees the new rules. Per-row key problems
4987 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4988 /// `Err` means nothing was applied.
4989 ///
4990 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4991 /// distinct source labels sharing an FK field each get their own rule.
4992 pub fn ingest(
4993 &mut self,
4994 label: &str,
4995 rows: Vec<BTreeMap<String, Value>>,
4996 opts: &IngestOptions,
4997 ) -> Result<IngestReport> {
4998 self.ingest_with_edges(label, rows, opts, &[])
4999 }
5000
5001 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5002 /// A failing edge rejects the whole request; nothing is applied.
5003 pub fn ingest_with_edges(
5004 &mut self,
5005 label: &str,
5006 rows: Vec<BTreeMap<String, Value>>,
5007 opts: &IngestOptions,
5008 edges: &[(String, String, String)],
5009 ) -> Result<IngestReport> {
5010 crate::ingest::run(self, label, rows, opts, edges)
5011 }
5012
5013 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5014 ///
5015 /// JSON `null` fields are silently omitted (not stored, not a row error).
5016 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5017 /// Parse failures and a top-level value that is not an array of objects
5018 /// return [`GraphError::IngestError`].
5019 pub fn ingest_json(
5020 &mut self,
5021 label: &str,
5022 json: &str,
5023 opts: &IngestOptions,
5024 ) -> Result<IngestReport> {
5025 crate::ingest::run_json(self, label, json, opts)
5026 }
5027
5028 fn commit_logged_batch(
5029 &mut self,
5030 ops: Vec<BatchOp>,
5031 ingest: Option<(String, usize)>,
5032 // Two-source rule: write_batch_authz threads authz here directly (never
5033 // touches pending_write_authz); query_write_authz sets the field instead
5034 // and passes None. Only one source is non-None per call.
5035 param_authz: Option<WriteAuthz>,
5036 ) -> Result<(usize, usize)> {
5037 // Read-only guard: catches empty-batch calls before the early-return
5038 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5039 if self.read_only {
5040 return Err(GraphError::ReadOnly);
5041 }
5042 // Ensure provenance is decoded before MutPreview accesses it
5043 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5044 self.engine.ensure_provenance_loaded_mut();
5045
5046 // ── Authz pre-check ──────────────────────────────────────────────────
5047 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5048 // produces no WAL frame (all-or-nothing at the authz boundary extends
5049 // the existing validate-then-apply contract to role-scope checks).
5050 //
5051 // `batch_created` tracks key→label for nodes created by earlier ops in
5052 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5053 // as visible without needing to call `self.ids.get` on not-yet-committed
5054 // keys (they won't be there yet).
5055 //
5056 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5057 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5058 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5059 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5060 if let Some(ref authz) = authz_opt {
5061 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5062 for op in &ops {
5063 self.check_single_op_authz(authz, op, &batch_created)?;
5064 // Update batch_created after a passing authz check so that
5065 // subsequent ops in this batch see the nodes as "about to exist".
5066 match op {
5067 BatchOp::InsertNode { label, key, .. } => {
5068 // Only track genuinely new nodes (absent from the
5069 // snapshot at authz-check time). A pre-existing visible
5070 // key would be a DuplicateKey — not a real creation —
5071 // so MutPreview handles it. Letting it into batch_created
5072 // would allow a later SetProp to bypass update_labels
5073 // via the "batch-created → always updatable" ruling
5074 // (delete+recreate exploit, fix for I1 review round 2).
5075 //
5076 // Accepted edge: for a delete+recreate-with-different-
5077 // label batch, node_status resolves the pre-delete
5078 // (store) label for any subsequent update checks. This
5079 // grants no net-new capability — a role that can delete+
5080 // create can already place arbitrary props via
5081 // InsertNode's own props field.
5082 if self.ids.get(key.as_str()).is_none() {
5083 batch_created.insert(key.clone(), label.clone());
5084 }
5085 }
5086 BatchOp::InsertEdgeUpsert {
5087 placeholder_label,
5088 src_key,
5089 dst_key,
5090 ..
5091 } => {
5092 // Both endpoints will be created if not already in store.
5093 for ep_key in [src_key, dst_key] {
5094 if self.ids.get(ep_key.as_str()).is_none()
5095 && !batch_created.contains_key(ep_key.as_str())
5096 {
5097 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5098 }
5099 }
5100 }
5101 _ => {}
5102 }
5103 }
5104 }
5105
5106 let recs = {
5107 let mut preview = MutPreview::new(self);
5108 let mut recs = Vec::with_capacity(ops.len());
5109 for op in ops {
5110 match op {
5111 BatchOp::InsertNode { label, key, props } => {
5112 preview.check_insert_node(&key)?;
5113 preview.note_insert_node(&key, &props);
5114 recs.push(WalRecord::InsertNode { label, key, props });
5115 }
5116 BatchOp::InsertEdge {
5117 edge_type,
5118 src_key,
5119 dst_key,
5120 } => {
5121 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5122 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5123 recs.push(WalRecord::InsertEdge {
5124 edge_type,
5125 src_key,
5126 dst_key,
5127 });
5128 }
5129 }
5130 BatchOp::SetProp { key, field, value } => {
5131 preview.check_live_key(&key)?;
5132 preview.note_set_prop(&key, &field, &value);
5133 recs.push(WalRecord::SetProp { key, field, value });
5134 }
5135 BatchOp::RemoveProp { key, field } => {
5136 if preview.prepare_remove_prop(&key, &field)? {
5137 preview.note_remove_prop(&key, &field);
5138 recs.push(WalRecord::RemoveProp { key, field });
5139 }
5140 }
5141 BatchOp::DeleteEdge {
5142 edge_type,
5143 src_key,
5144 dst_key,
5145 } => {
5146 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5147 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5148 recs.push(WalRecord::DeleteEdge {
5149 edge_type,
5150 src_key,
5151 dst_key,
5152 });
5153 }
5154 }
5155 BatchOp::DeleteNode { key } => {
5156 preview.check_live_key(&key)?;
5157 preview.note_delete_node(&key);
5158 recs.push(WalRecord::DeleteNode { key });
5159 }
5160 BatchOp::CreateRule(def) => {
5161 preview.check_create_rule(&def)?;
5162 let def_bytes =
5163 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5164 detail: format!("serialize rule: {e}"),
5165 })?;
5166 preview.note_create_rule(&def);
5167 recs.push(WalRecord::CreateRule { def_bytes });
5168 }
5169 BatchOp::DeleteRule { name } => {
5170 preview.check_delete_rule(&name)?;
5171 preview.note_delete_rule(&name);
5172 recs.push(WalRecord::DeleteRule { name });
5173 }
5174 BatchOp::RenameNode { old_key, new_key } => {
5175 preview.check_rename_node(&old_key, &new_key)?;
5176 preview.note_rename_node(&old_key, &new_key);
5177 recs.push(WalRecord::RenameNode { old_key, new_key });
5178 }
5179 BatchOp::InsertEdgeUpsert {
5180 edge_type,
5181 src_key,
5182 dst_key,
5183 placeholder_label,
5184 } => {
5185 // Auto-create any missing endpoints as plain InsertNode ops.
5186 // Rules fire and last-change is updated for each created node.
5187 for key in [&src_key, &dst_key] {
5188 if !preview.has_key(key) {
5189 preview.check_insert_node(key)?;
5190 preview.note_insert_node(key, &[]);
5191 recs.push(WalRecord::InsertNode {
5192 label: placeholder_label.clone(),
5193 key: key.clone(),
5194 props: vec![],
5195 });
5196 }
5197 }
5198 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5199 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5200 recs.push(WalRecord::InsertEdge {
5201 edge_type,
5202 src_key,
5203 dst_key,
5204 });
5205 }
5206 }
5207 }
5208 }
5209 recs
5210 };
5211 if recs.is_empty() {
5212 return Ok((0, 0));
5213 }
5214 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5215 // *Id form, so only the dense variants can appear in `recs` here.
5216 let recs = self.rewrite_wal_dense(recs)?;
5217 let nodes_inserted = recs
5218 .iter()
5219 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5220 .count();
5221 let edges_inserted = recs
5222 .iter()
5223 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5224 .count();
5225 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5226 // under Strict. Pass self.fsync directly so Strict stays Strict —
5227 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5228 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5229 // short-circuit on single-op batches and silently skip the fsync.
5230 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5231 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5232 Ok((nodes_inserted, edges_inserted))
5233 }
5234
5235 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5236 self.commit_logged_batch(ops, None, None)
5237 }
5238
5239 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5240 /// and the group-commit drain thread, which do a single group fsync later.
5241 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5242 // Restore fsync policy even on panic via a raw-pointer drop guard.
5243 // A panic here would poison the RwLock anyway, but the correct policy
5244 // must be in place if the guard is ever unwrapped.
5245 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5246 impl Drop for RestoreFsync {
5247 fn drop(&mut self) {
5248 // SAFETY: the pointer is valid for the full duration of
5249 // commit_batch_nosync; the guard is dropped before the frame
5250 // returns, and GraphDb outlives this frame.
5251 unsafe {
5252 *self.0 = self.1;
5253 }
5254 }
5255 }
5256 let saved = self.fsync;
5257 // SAFETY: raw pointer into self; guard dropped within this frame.
5258 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5259 self.fsync = FsyncPolicy::Relaxed;
5260 self.commit_logged_batch(ops, None, None)
5261 }
5262
5263 /// Commit multiple op-batches as a **group**: each submission gets its own
5264 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5265 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5266 ///
5267 /// # Durability semantics
5268 ///
5269 /// A crash before the group fsync may lose **all** submissions in the group.
5270 /// A crash after the group fsync preserves all of them. No submission is
5271 /// ever torn: each WAL frame is either fully applied on replay or dropped
5272 /// in its entirety (CRC-protected frame boundaries).
5273 ///
5274 /// Events and subscription notifications fire per-submission immediately
5275 /// after apply, which may be before the group fsync. From a subscriber's
5276 /// perspective this is equivalent to the `Relaxed` durability window.
5277 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5278 /// fsync, so from their perspective durability is fully guaranteed.
5279 ///
5280 /// # MVCC interplay
5281 ///
5282 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5283 /// increments per submission (not per group), preserving existing reader
5284 /// snapshot semantics.
5285 ///
5286 /// # Returns
5287 ///
5288 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5289 /// in order. Failures are per-submission (validation errors); the group
5290 /// fsync error (if any) is returned as the second tuple element.
5291 pub fn commit_group(
5292 &mut self,
5293 groups: Vec<Vec<BatchOp>>,
5294 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5295 let mut results = Vec::with_capacity(groups.len());
5296 for ops in groups {
5297 results.push(self.commit_batch_nosync(ops));
5298 }
5299 let any_ok = results.iter().any(|r| r.is_ok());
5300 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5301 self.fs
5302 .sync(core_storage::fs::FileId::Wal)
5303 .map_err(GraphError::Io)
5304 .err()
5305 } else {
5306 None
5307 };
5308 (results, sync_err)
5309 }
5310
5311 /// Like [`commit_group`] but skips the group fsync entirely.
5312 ///
5313 /// Used by the drain thread to apply submissions under the write lock and
5314 /// then perform the single fsync OUTSIDE the lock (via
5315 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5316 /// to concurrent readers.
5317 pub fn commit_group_nosync(
5318 &mut self,
5319 groups: Vec<Vec<BatchOp>>,
5320 ) -> Vec<Result<(usize, usize)>> {
5321 let mut results = Vec::with_capacity(groups.len());
5322 for ops in groups {
5323 results.push(self.commit_batch_nosync(ops));
5324 }
5325 results
5326 }
5327
5328 pub fn insert_node(
5329 &mut self,
5330 label: &str,
5331 key: &str,
5332 props: Vec<(String, Value)>,
5333 ) -> Result<()> {
5334 if self.read_only {
5335 return Err(GraphError::ReadOnly);
5336 }
5337 MutPreview::new(self).check_insert_node(key)?;
5338 self.log_dense(vec![WalRecord::InsertNode {
5339 label: label.into(),
5340 key: key.into(),
5341 props,
5342 }])
5343 }
5344
5345 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5346 if self.read_only {
5347 return Err(GraphError::ReadOnly);
5348 }
5349 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5350 return Ok(false);
5351 }
5352 self.log_dense(vec![WalRecord::InsertEdge {
5353 edge_type: edge_type.into(),
5354 src_key: src_key.into(),
5355 dst_key: dst_key.into(),
5356 }])?;
5357 Ok(true)
5358 }
5359
5360 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5361 if self.read_only {
5362 return Err(GraphError::ReadOnly);
5363 }
5364 if let Some(view_name) = self.view_store.view_for_prop(field) {
5365 return Err(GraphError::ViewPropReadOnly {
5366 view_name: view_name.to_string(),
5367 });
5368 }
5369 MutPreview::new(self).check_live_key(key)?;
5370 self.log_dense(vec![WalRecord::SetProp {
5371 key: key.into(),
5372 field: field.into(),
5373 value,
5374 }])
5375 }
5376
5377 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5378 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5379 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5380 if self.read_only {
5381 return Err(GraphError::ReadOnly);
5382 }
5383 if let Some(view_name) = self.view_store.view_for_prop(field) {
5384 return Err(GraphError::ViewPropReadOnly {
5385 view_name: view_name.to_string(),
5386 });
5387 }
5388 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5389 return Ok(false);
5390 }
5391 self.log_then_apply(WalRecord::RemoveProp {
5392 key: key.into(),
5393 field: field.into(),
5394 })?;
5395 Ok(true)
5396 }
5397
5398 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5399 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5400 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5401 /// (the rule would just put the edge back; delete or change the rule).
5402 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5403 if self.read_only {
5404 return Err(GraphError::ReadOnly);
5405 }
5406 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5407 return Ok(false);
5408 }
5409 self.log_then_apply(WalRecord::DeleteEdge {
5410 edge_type: edge_type.into(),
5411 src_key: src_key.into(),
5412 dst_key: dst_key.into(),
5413 })?;
5414 Ok(true)
5415 }
5416
5417 /// Delete a live node. Unknown or already-tombstoned keys are
5418 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5419 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5420 /// (crash window) is a clean no-op.
5421 ///
5422 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5423 /// removed (computed from live state before the deletion is applied).
5424 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5425 if self.read_only {
5426 return Err(GraphError::ReadOnly);
5427 }
5428 // Provenance must be loaded before we query provenance_touching.
5429 self.engine.ensure_provenance_loaded_mut();
5430 let id = self
5431 .ids
5432 .get(key)
5433 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5434
5435 // Count edges before the delete is applied so we can report counts.
5436 let derived_set: BTreeSet<(u32, u32, u32)> = self
5437 .engine
5438 .provenance_touching(id)
5439 .map(|(_, etype, src, dst)| (etype, src, dst))
5440 .collect();
5441 let derived_edges = derived_set.len() as u64;
5442
5443 let mut total_topo = 0u64;
5444 let tv = self.topo_view();
5445 for et in tv.etypes() {
5446 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5447 + tv.neighbors(et, Direction::In, id).len() as u64;
5448 }
5449 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5450 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5451 // The subtraction remains correct because both counts include both directions.
5452 let manual_edges = total_topo.saturating_sub(derived_edges);
5453
5454 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5455 Ok(DeleteReport {
5456 manual_edges,
5457 derived_edges,
5458 })
5459 }
5460
5461 /// Rename a live node's key. The dense id (and therefore all edges,
5462 /// props, history, and last-change tracking) is unaffected.
5463 ///
5464 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5465 /// Returns `Err(DuplicateKey)` if `new` is already live.
5466 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5467 if self.read_only {
5468 return Err(GraphError::ReadOnly);
5469 }
5470 MutPreview::new(self).check_rename_node(old, new)?;
5471 self.log_then_apply(WalRecord::RenameNode {
5472 old_key: old.into(),
5473 new_key: new.into(),
5474 })
5475 }
5476
5477 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5478 /// `None` if the rule does not exist or is not approximate.
5479 ///
5480 /// The drift counter increments on IVF insert/remove after the last fit.
5481 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5482 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5483 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5484 // SideIvfExport = (centroids, node→cluster, drift)
5485 self.engine
5486 .export_ivf_state()
5487 .remove(rule)
5488 .map(|(_src, dst)| dst.2)
5489 }
5490
5491 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5492 /// Validation and duplicate-name check run before logging so invalid rules
5493 /// never enter the WAL.
5494 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5495 if self.read_only {
5496 return Err(GraphError::ReadOnly);
5497 }
5498 MutPreview::new(self).check_create_rule(&def)?;
5499 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5500 detail: format!("serialize rule: {e}"),
5501 })?;
5502 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5503 }
5504
5505 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5506 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5507 if self.read_only {
5508 return Err(GraphError::ReadOnly);
5509 }
5510 MutPreview::new(self).check_delete_rule(name)?;
5511 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5512 }
5513
5514 /// Return a snapshot of all registered rules.
5515 pub fn rules(&self) -> Vec<RuleDef> {
5516 self.engine.rules().cloned().collect()
5517 }
5518
5519 // -----------------------------------------------------------------------
5520 // Rule suggestion API
5521 // -----------------------------------------------------------------------
5522
5523 /// Profile the database and suggest linking rules with previewed edge counts.
5524 ///
5525 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5526 /// sampling. Suggestions are sorted by estimated edge count (descending).
5527 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5528 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5529 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5530 }
5531
5532 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5533 /// reproducibility. Same seed + same data = identical output.
5534 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5535 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5536 .suggestions
5537 }
5538
5539 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5540 ///
5541 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5542 /// and a `truncated` flag indicating whether the global budget fired before all
5543 /// candidates were evaluated.
5544 pub fn suggest_rules_with_config(
5545 &self,
5546 config: &core_rules::suggest::SuggestConfig,
5547 seed: u64,
5548 ) -> core_rules::SuggestReport {
5549 use std::collections::BTreeMap;
5550
5551 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5552 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5553 for id in 0..self.ids.len() as u32 {
5554 let Some(key) = self.ids.key_of(id) else {
5555 continue;
5556 };
5557 let Some(&sym) = self.labels.get(id as usize) else {
5558 continue;
5559 };
5560 if sym == u32::MAX {
5561 continue; // tombstoned
5562 }
5563 let Some(label) = self.syms.resolve(sym) else {
5564 continue;
5565 };
5566 label_nodes
5567 .entry(label.to_string())
5568 .or_default()
5569 .push((id, key.to_string()));
5570 }
5571
5572 let existing = self.rules();
5573 let pv = build_props_view(&self.props, &self.base);
5574 let all_fields: Vec<String> = pv.field_names();
5575
5576 core_rules::suggest::suggest_rules(
5577 &label_nodes,
5578 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5579 &all_fields,
5580 &existing,
5581 config,
5582 seed,
5583 )
5584 }
5585
5586 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5587 /// plus later mutations replay identically (rebuild is a pure function
5588 /// of state).
5589 ///
5590 /// Only exit from the tripped latch: if the full desired set fits the
5591 /// budget, it is applied completely and `tripped` clears; if it still
5592 /// exceeds the budget, provenance is left untouched and `tripped` stays
5593 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5594 /// Unknown rule → `RuleNotFound`, nothing logged.
5595 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5596 if self.read_only {
5597 return Err(GraphError::ReadOnly);
5598 }
5599 if !self.engine.rules().any(|r| r.name == name) {
5600 return Err(GraphError::RuleNotFound { name: name.into() });
5601 }
5602 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5603 }
5604
5605 // -----------------------------------------------------------------------
5606 // Materialized view API
5607 // -----------------------------------------------------------------------
5608
5609 /// Register a new materialized property view, backfill its values for all
5610 /// existing nodes, and WAL-log the definition.
5611 ///
5612 /// # Errors
5613 /// - `ReadOnly`: called on an as-of instance.
5614 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5615 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5616 if self.read_only {
5617 return Err(GraphError::ReadOnly);
5618 }
5619 // Pre-validate before WAL write.
5620 def.validate()
5621 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5622 if self.view_store.has_view(&def.name) {
5623 return Err(GraphError::RuleInvalid {
5624 detail: format!("view {:?} already exists", def.name),
5625 });
5626 }
5627 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5628 return Err(GraphError::RuleInvalid {
5629 detail: format!(
5630 "view_prop {:?} is already used by view {:?}",
5631 def.view_prop, existing
5632 ),
5633 });
5634 }
5635 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5636 detail: format!("serialize view: {e}"),
5637 })?;
5638 // Enable delta accumulation before the view is registered so subsequent
5639 // incremental edge events reach view maintenance from this point onward.
5640 // (The backfill inside create_view reads topo directly; it does not rely
5641 // on pending deltas.)
5642 self.engine.set_emit_deltas(true);
5643 self.log_then_apply(WalRecord::CreateView { def_bytes })
5644 }
5645
5646 /// Remove a named view and delete its values from every node.
5647 ///
5648 /// # Errors
5649 /// - `ReadOnly`: called on an as-of instance.
5650 /// - `RuleNotFound`: view does not exist.
5651 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5652 if self.read_only {
5653 return Err(GraphError::ReadOnly);
5654 }
5655 if !self.view_store.has_view(name) {
5656 return Err(GraphError::RuleNotFound { name: name.into() });
5657 }
5658 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5659 // After deletion, disable accumulation if no listeners remain.
5660 if !self.needs_emit_deltas() {
5661 self.engine.set_emit_deltas(false);
5662 }
5663 result
5664 }
5665
5666 /// Snapshot of all registered view definitions.
5667 pub fn views(&self) -> Vec<ViewDef> {
5668 self.view_store.views().cloned().collect()
5669 }
5670
5671 // -----------------------------------------------------------------------
5672 // Full-text-lite API
5673 // -----------------------------------------------------------------------
5674
5675 /// Enable full-text indexing for all nodes of `label` on property `field`.
5676 ///
5677 /// After this call, every subsequent write to `(label, field)` is reflected
5678 /// in the index incrementally. Existing nodes are backfilled immediately.
5679 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5680 /// from scratch on re-open (no snapshot format changes).
5681 ///
5682 /// # Errors
5683 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5684 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5685 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5686 if self.read_only {
5687 return Err(GraphError::ReadOnly);
5688 }
5689 if self.fulltext.is_enabled(label, field) {
5690 return Err(GraphError::RuleInvalid {
5691 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5692 });
5693 }
5694 self.log_then_apply(WalRecord::EnableFulltext {
5695 label: label.into(),
5696 field: field.into(),
5697 })
5698 }
5699
5700 /// Disable full-text indexing for `(label, field)` and drop its postings.
5701 ///
5702 /// # Errors
5703 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5704 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5705 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5706 if self.read_only {
5707 return Err(GraphError::ReadOnly);
5708 }
5709 if !self.fulltext.is_enabled(label, field) {
5710 return Err(GraphError::RuleNotFound {
5711 name: format!("fulltext({label},{field})"),
5712 });
5713 }
5714 self.log_then_apply(WalRecord::DisableFulltext {
5715 label: label.into(),
5716 field: field.into(),
5717 })
5718 }
5719
5720 /// Whether `(label, field)` is currently indexed for full-text search.
5721 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5722 self.fulltext.is_enabled(label, field)
5723 }
5724
5725 /// Every `(label, field)` pair with a live full-text index, sorted.
5726 ///
5727 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5728 /// declares which nodes are *indexed*, so callers that want to search
5729 /// everything indexed should query each distinct field once.
5730 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5731 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5732 v.sort();
5733 v
5734 }
5735
5736 /// Enable an equality index for all nodes of `label` on scalar property
5737 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5738 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5739 /// declaration persists via WAL and the postings rebuild on re-open.
5740 ///
5741 /// # Errors
5742 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5743 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5744 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5745 if self.read_only {
5746 return Err(GraphError::ReadOnly);
5747 }
5748 if self.prop_index.is_enabled(label, field) {
5749 return Err(GraphError::RuleInvalid {
5750 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5751 });
5752 }
5753 self.log_then_apply(WalRecord::EnableIndex {
5754 label: label.into(),
5755 field: field.into(),
5756 })
5757 }
5758
5759 /// Disable the equality index for `(label, field)` and drop its postings.
5760 ///
5761 /// # Errors
5762 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5763 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5764 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5765 if self.read_only {
5766 return Err(GraphError::ReadOnly);
5767 }
5768 if !self.prop_index.is_enabled(label, field) {
5769 return Err(GraphError::RuleNotFound {
5770 name: format!("index({label},{field})"),
5771 });
5772 }
5773 self.log_then_apply(WalRecord::DisableIndex {
5774 label: label.into(),
5775 field: field.into(),
5776 })
5777 }
5778
5779 /// Whether `(label, field)` currently has an equality index.
5780 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5781 self.prop_index.is_enabled(label, field)
5782 }
5783
5784 /// Search a full-text-indexed field.
5785 ///
5786 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5787 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5788 ///
5789 /// **Query syntax:**
5790 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5791 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5792 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5793 /// - `AND` keyword is accepted explicitly and is the default.
5794 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5795 ///
5796 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5797 /// Pin: this is the documented, tested, stable behavior for v1.
5798 ///
5799 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5800 /// in-memory and proportional to total indexed text across all enabled fields.
5801 ///
5802 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5803 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5804 /// key ascending for deterministic tiebreaking.
5805 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5806 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5807 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5808 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5809 // which diverges from key order when nodes were not inserted in key-lex order.
5810 let mut results: Vec<(String, f64)> = self
5811 .fulltext
5812 .search(field, query, 0)
5813 .into_iter()
5814 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5815 .collect();
5816 results.sort_by(|a, b| {
5817 b.1.partial_cmp(&a.1)
5818 .unwrap_or(std::cmp::Ordering::Equal)
5819 .then(a.0.cmp(&b.0))
5820 });
5821 results
5822 }
5823
5824 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5825 ///
5826 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5827 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5828 /// them with RRF using a fixed constant of 60.
5829 ///
5830 /// ```text
5831 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5832 /// ```
5833 ///
5834 /// Returns the top `k` nodes by fused score, ties broken by node key
5835 /// ascending (deterministic).
5836 ///
5837 /// # Vector leg fallback
5838 ///
5839 /// When `query_vec` is empty the vector leg is skipped entirely and
5840 /// results are ranked by the text list alone through the same RRF path
5841 /// (each text result scores `1/(60 + rank)` from that single list).
5842 ///
5843 /// When `label` is `None`, the vector leg **always** returns empty results.
5844 /// Internally `label` is mapped to `""`, which does not match any rule-created
5845 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5846 /// the brute-force fallback finds no nodes with an empty label. The fused
5847 /// ranking is therefore text-only in this case.
5848 pub fn search_hybrid(
5849 &self,
5850 text_field: &str,
5851 query_text: &str,
5852 vector_field: &str,
5853 query_vec: &[f64],
5854 label: Option<&str>,
5855 k: usize,
5856 ) -> Vec<(String, f64)> {
5857 use std::collections::HashMap;
5858
5859 const RRF_K: f64 = 60.0;
5860 let pool = 4 * k;
5861
5862 // Accumulate per-node RRF scores.
5863 let mut scores: HashMap<String, f64> = HashMap::new();
5864
5865 // Text leg.
5866 let text_hits = self.search(text_field, query_text);
5867 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5868 let rank = (rank0 + 1) as f64;
5869 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5870 }
5871
5872 // Vector leg (skipped when query_vec is empty).
5873 if !query_vec.is_empty() {
5874 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5875 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5876 let rank = (rank0 + 1) as f64;
5877 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5878 }
5879 }
5880
5881 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5882 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5883 ranked.sort_by(|a, b| {
5884 b.1.partial_cmp(&a.1)
5885 .unwrap_or(std::cmp::Ordering::Equal)
5886 .then(a.0.cmp(&b.0))
5887 });
5888 ranked.truncate(k);
5889 ranked
5890 }
5891
5892 /// For DST/testing: scratch BM25 search over live nodes without the index.
5893 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5894 /// returns BM25-ranked results.
5895 ///
5896 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5897 /// `scratch_search(field, q)` at every quiescent state.
5898 #[doc(hidden)]
5899 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5900 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5901 use std::collections::BTreeMap;
5902
5903 let groups = parse_query(query);
5904 if groups.is_empty() {
5905 return vec![];
5906 }
5907
5908 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5909 struct NodeData {
5910 key: String,
5911 /// stemmed_token → positions (sorted)
5912 tokens: BTreeMap<String, Vec<u32>>,
5913 dl: u32,
5914 }
5915
5916 let mut nodes: Vec<NodeData> = Vec::new();
5917 for id in 0..self.ids.len() as u32 {
5918 let Some(key) = self.ids.key_of(id) else {
5919 continue;
5920 };
5921 let Some(&sym) = self.labels.get(id as usize) else {
5922 continue;
5923 };
5924 if sym == u32::MAX {
5925 continue;
5926 }
5927 let label = match self.syms.resolve(sym) {
5928 Some(l) => l,
5929 None => continue,
5930 };
5931 if !self.fulltext.is_enabled(label, field) {
5932 continue;
5933 }
5934 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5935 continue;
5936 };
5937 // Use value_tokens_stemmed_with_positions so list elements are
5938 // separated by POSITION_GAP — identical to the index path, which
5939 // prevents phrase queries from matching across element boundaries.
5940 let stemmed_with_pos = match &value {
5941 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5942 _ => continue,
5943 };
5944 let dl = stemmed_with_pos.len() as u32;
5945 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5946 for (tok, pos) in stemmed_with_pos {
5947 tok_map.entry(tok).or_default().push(pos);
5948 }
5949 nodes.push(NodeData {
5950 key: key.to_string(),
5951 tokens: tok_map,
5952 dl,
5953 });
5954 }
5955
5956 if nodes.is_empty() {
5957 return vec![];
5958 }
5959
5960 // --- BM25 corpus stats ---
5961 let n = nodes.len() as f64;
5962 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5963 // df per stemmed token across all live indexed nodes.
5964 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5965 for nd in &nodes {
5966 for tok in nd.tokens.keys() {
5967 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5968 }
5969 }
5970
5971 const K1: f64 = 1.2;
5972 const B: f64 = 0.75;
5973
5974 // --- Pass 2: score each node against each OR-group ---
5975 let mut results: Vec<(String, f64)> = Vec::new();
5976 for nd in &nodes {
5977 let dl = nd.dl as f64;
5978 let mut total_score = 0.0f64;
5979
5980 'group: for group in &groups {
5981 let mut group_score = 0.0f64;
5982
5983 for term in group {
5984 if term.negated {
5985 // Negated: if doc has this stemmed token → group fails.
5986 let present = if term.prefix {
5987 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5988 } else {
5989 nd.tokens.contains_key(term.token.as_str())
5990 };
5991 if present {
5992 continue 'group;
5993 }
5994 continue;
5995 }
5996 if term.prefix {
5997 // Prefix: sum BM25 for all matching stemmed tokens.
5998 let mut prefix_matched = false;
5999 for (tok, positions) in &nd.tokens {
6000 if tok.starts_with(term.token.as_str()) {
6001 let tf = positions.len() as f64;
6002 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6003 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6004 let tf_norm =
6005 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6006 group_score += idf * tf_norm;
6007 prefix_matched = true;
6008 }
6009 }
6010 if !prefix_matched {
6011 continue 'group;
6012 }
6013 } else {
6014 // term.token is already stemmed by parse_query; use directly.
6015 match nd.tokens.get(term.token.as_str()) {
6016 None => continue 'group,
6017 Some(positions) => {
6018 let tf = positions.len() as f64;
6019 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6020 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6021 let tf_norm =
6022 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6023 group_score += idf * tf_norm;
6024 }
6025 }
6026 }
6027 }
6028
6029 if group_score > 0.0 {
6030 total_score += group_score;
6031 }
6032 }
6033
6034 if total_score > 0.0 {
6035 results.push((nd.key.clone(), total_score));
6036 }
6037 }
6038
6039 results.sort_by(|a, b| {
6040 b.1.partial_cmp(&a.1)
6041 .unwrap_or(std::cmp::Ordering::Equal)
6042 .then(a.0.cmp(&b.0))
6043 });
6044 results
6045 }
6046
6047 /// Return the current view-maintained value of `view_prop` for node `key`.
6048 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6049 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6050 let id = self.ids.get(key)?;
6051 self.props_view()
6052 .get(id, view_prop)
6053 .map(|vr| vr.into_value())
6054 }
6055
6056 /// For testing / DST oracle: scratch recompute of a view value for one node.
6057 ///
6058 /// Returns `None` if the node does not exist, the view does not exist, or
6059 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6060 #[doc(hidden)]
6061 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6062 let node = self.ids.get(key)?;
6063 let def = self.view_store.views().find(|v| v.name == view_name)?;
6064 // Use TopologyView so that NeighborAgg sees base + overlay edges
6065 // without materialising a temporary Topology (I1).
6066 let topo_view = self.topo_view();
6067 core_rules::views::compute_view_value(
6068 def,
6069 node,
6070 self.props_view(),
6071 &topo_view,
6072 &self.ids,
6073 &self.syms,
6074 &self.labels,
6075 )
6076 }
6077
6078 // -----------------------------------------------------------------------
6079 // Graph algorithm API
6080 // -----------------------------------------------------------------------
6081
6082 /// Run PageRank over the unified topology (manual + derived edges).
6083 ///
6084 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6085 /// ascending). Set `config.edge_type` to restrict to one edge type.
6086 /// `config.converged` is `true` only when the power iteration converged
6087 /// within `config.max_iters` and within any time budget.
6088 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6089 let topo = build_topo_view(&self.topo, &self.base);
6090 let edge_props = self.edge_props_view();
6091 crate::algo::pagerank(
6092 &topo,
6093 &self.ids,
6094 &self.syms,
6095 &self.labels,
6096 &edge_props,
6097 config,
6098 )
6099 }
6100
6101 /// Weakly-connected components over the unified topology (treated as
6102 /// undirected regardless of how edges were inserted).
6103 ///
6104 /// Component IDs are the key of the smallest member in the component
6105 /// (deterministic). Result sorted by (component_id, key).
6106 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6107 let topo = build_topo_view(&self.topo, &self.base);
6108 let edge_props = self.edge_props_view();
6109 crate::algo::wcc(
6110 &topo,
6111 &self.ids,
6112 &self.syms,
6113 &self.labels,
6114 &edge_props,
6115 config,
6116 )
6117 }
6118
6119 /// Degree centrality for every live node.
6120 ///
6121 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6122 /// `AlgoDir::Both` = out + in (total directed degree).
6123 ///
6124 /// For one-shot ranking use this; for a live property updated on every
6125 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6126 pub fn degree_centrality(
6127 &self,
6128 config: &crate::algo::DegreeConfig,
6129 ) -> crate::algo::DegreeReport {
6130 let topo = build_topo_view(&self.topo, &self.base);
6131 let edge_props = self.edge_props_view();
6132 crate::algo::degree_centrality(
6133 &topo,
6134 &self.ids,
6135 &self.syms,
6136 &self.labels,
6137 &edge_props,
6138 config,
6139 )
6140 }
6141
6142 /// Louvain community detection over the unified topology (undirected).
6143 ///
6144 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6145 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6146 /// result (communities sorted size-desc, then smallest member key asc).
6147 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6148 let topo = build_topo_view(&self.topo, &self.base);
6149 let edge_props = self.edge_props_view();
6150 crate::algo::louvain(
6151 &topo,
6152 &self.ids,
6153 &self.syms,
6154 &self.labels,
6155 &edge_props,
6156 config,
6157 )
6158 }
6159
6160 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6161 /// atomically via a single write-batch (one WAL frame, one fsync).
6162 ///
6163 /// # Errors
6164 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6165 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6166 /// (collision check mirrors `create_view`).
6167 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6168 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6169 if self.read_only {
6170 return Err(GraphError::ReadOnly);
6171 }
6172 // Collision check: refuse if prop_name is view-managed.
6173 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6174 return Err(GraphError::RuleInvalid {
6175 detail: format!(
6176 "prop {:?} is managed by view {:?} and cannot be written as scores",
6177 prop_name, view_name
6178 ),
6179 });
6180 }
6181 // Refuse if prop_name is a view name itself (confusing namespace collision).
6182 if self.view_store.has_view(prop_name) {
6183 return Err(GraphError::RuleInvalid {
6184 detail: format!(
6185 "prop_name {:?} collides with an existing view name",
6186 prop_name
6187 ),
6188 });
6189 }
6190 // Write all scores in a single crash-atomic batch.
6191 self.write_batch(|b| {
6192 for (key, score) in scores {
6193 b.set_prop(key, prop_name, Value::Float(*score));
6194 }
6195 })?;
6196 Ok(())
6197 }
6198
6199 /// Return the value of `field` for the node with key `key`, or `None` if
6200 /// the node or field is absent. Reads through the overlay-over-base
6201 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6202 /// overlay hits; one clone per base hit).
6203 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6204 let id = self.ids.get(key)?;
6205 self.props_view().get(id, field).map(|vr| vr.into_value())
6206 }
6207
6208 pub fn has_node(&self, key: &str) -> bool {
6209 self.ids.get(key).is_some()
6210 }
6211
6212 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6213 pub(crate) fn ids(&self) -> &IdMap {
6214 &self.ids
6215 }
6216
6217 // -----------------------------------------------------------------------
6218 // RBAC role resolution
6219 // -----------------------------------------------------------------------
6220
6221 /// Parse `roles.json` bytes from `fs`.
6222 ///
6223 /// Return values:
6224 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6225 /// and valid; in both cases `mask_for_role` uses the
6226 /// list normally (an absent file means no roles defined).
6227 /// `Ok(None)` — file present but corrupt or unrecognised version
6228 /// → poisoned state; `mask_for_role` returns `Err` for
6229 /// any role name until the file is fixed and the DB
6230 /// re-opened (or `apply_schema` is called to repair it).
6231 ///
6232 /// Note: `None` signals corruption, not absence — the opposite of what an
6233 /// optional "file missing" convention would suggest. The open path stores
6234 /// this result on `db.roles` directly.
6235 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6236 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6237 if bytes.is_empty() {
6238 // Empty bytes means either the file is absent or zero-byte — both
6239 // are treated identically as "no roles defined". A zero-byte
6240 // roles.json does NOT widen access: an absent file and a zero-byte
6241 // file both resolve to an empty role list (sees nothing by default).
6242 return Ok(Some(vec![]));
6243 }
6244 match serde_json::from_slice::<RolesFile>(&bytes) {
6245 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
6246 // Corrupt or unrecognised version (>2): poison the roles state.
6247 _ => Ok(None),
6248 }
6249 }
6250
6251 /// Resolve a role to a node-visibility mask against the current graph state.
6252 ///
6253 /// Returns `Err` when:
6254 /// - `roles.json` was present but corrupt at open (poisoned state), or
6255 /// - `role` does not match any defined role name.
6256 ///
6257 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6258 /// all live nodes carrying any label in `labels`. Label resolution is live
6259 /// — new nodes of an allowed label are visible without re-applying the
6260 /// schema. An empty union = empty mask = sees nothing.
6261 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6262 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6263 detail:
6264 "roles.json was corrupt at open; fix the file and re-open to restore role access"
6265 .into(),
6266 })?;
6267 let def = roles
6268 .iter()
6269 .find(|r| r.name == role)
6270 .ok_or_else(|| GraphError::KeyNotFound {
6271 key: format!("role:{role}"),
6272 })?;
6273
6274 let mut visible = std::collections::HashSet::new();
6275
6276 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6277 for key in &def.keys {
6278 if let Some(id) = self.ids.get(key) {
6279 visible.insert(id);
6280 }
6281 }
6282
6283 // Label leg: live scan — iterate labels vec for matching symbol.
6284 for label_name in &def.labels {
6285 if let Some(sym) = self.syms.get(label_name) {
6286 for (i, &s) in self.labels.iter().enumerate() {
6287 if s == sym {
6288 visible.insert(i as u32);
6289 }
6290 }
6291 }
6292 }
6293
6294 Ok(crate::mask::NodeMask::from_ids(visible))
6295 }
6296
6297 /// Return the current list of role definitions.
6298 ///
6299 /// Returns an empty list when no roles are defined or when `roles.json`
6300 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6301 /// the fail-loud error in that case).
6302 pub fn roles(&self) -> Vec<RoleDef> {
6303 self.roles.as_deref().unwrap_or(&[]).to_vec()
6304 }
6305
6306 // ── Role-scoped write authz ───────────────────────────────────────────────
6307
6308 /// Execute `ops` with optional role-scoped write authorization.
6309 ///
6310 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6311 /// (zero-cost bypass of all authz checks).
6312 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6313 /// record is built. A denial returns an error with no WAL frame written
6314 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6315 ///
6316 /// See the plan's "authz decision table" section for the full semantics.
6317 pub fn write_batch_authz(
6318 &mut self,
6319 authz: Option<&WriteAuthz>,
6320 ops: Vec<BatchOp>,
6321 ) -> Result<(usize, usize)> {
6322 // Thread authz as a direct parameter — never touches pending_write_authz.
6323 self.commit_logged_batch(ops, None, authz.cloned())
6324 }
6325
6326 /// Execute a Cypher write statement with role-scoped write authorization.
6327 ///
6328 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6329 /// lifetime as execution, satisfying §5 lock discipline). The resolved
6330 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6331 /// call so that all inner `batch.commit()` calls are authz-checked.
6332 ///
6333 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6334 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6335 /// timing-oracle item (hidden ≡ absent for unscoped roles).
6336 ///
6337 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6338 /// "this endpoint is not permitted".
6339 pub fn query_write_authz(
6340 &mut self,
6341 role: &str,
6342 cypher: &str,
6343 params: &BTreeMap<String, Value>,
6344 ) -> Result<ResultSet> {
6345 // Resolve scope (fails fast if role has no write scope).
6346 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6347 let scope =
6348 {
6349 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6350 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6351 })?;
6352 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6353 GraphError::KeyNotFound {
6354 key: format!("role:{role}"),
6355 }
6356 })?;
6357 def.write
6358 .clone()
6359 .ok_or_else(|| GraphError::RoleWriteDenied {
6360 reason: "role-bound token: writes are not permitted".into(),
6361 })?
6362 };
6363 // Resolve mask inside the call (same guard, §5 coherence).
6364 let mask = self.mask_for_role(role)?;
6365 self.pending_write_authz = Some(WriteAuthz {
6366 role: role.into(),
6367 scope,
6368 mask,
6369 });
6370 // RAII guard: always clears pending_write_authz on scope exit, including
6371 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6372 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6373 impl Drop for ClearPendingAuthzOnDrop {
6374 fn drop(&mut self) {
6375 // SAFETY: pointer into the owning GraphDb; guard is dropped
6376 // within this function's frame before it returns.
6377 unsafe { *self.0 = None };
6378 }
6379 }
6380 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6381 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6382 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6383 detail: format!("lex: {e}"),
6384 })?;
6385 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6386 detail: format!("parse: {e}"),
6387 })?;
6388 self.exec_write_stmt(stmt, params)
6389 }
6390
6391 /// Execute `ops` with optional role-scoped write authorization, suppressing
6392 /// fsync (for use inside the group-commit drain thread, which performs one
6393 /// group fsync after releasing the write lock).
6394 ///
6395 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6396 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6397 /// contract established by [`commit_batch_nosync`].
6398 pub(crate) fn write_batch_authz_nosync(
6399 &mut self,
6400 authz: Option<&WriteAuthz>,
6401 ops: Vec<BatchOp>,
6402 ) -> Result<(usize, usize)> {
6403 let saved = self.fsync;
6404 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6405 impl Drop for RestoreFsync {
6406 fn drop(&mut self) {
6407 // SAFETY: pointer into the owning GraphDb; guard is dropped
6408 // within the enclosing function's frame before it returns.
6409 unsafe { *self.0 = self.1 };
6410 }
6411 }
6412 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6413 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6414 self.fsync = FsyncPolicy::Relaxed;
6415 self.commit_logged_batch(ops, None, authz.cloned())
6416 }
6417
6418 /// Execute a `/ingest` request with role-scoped write authorization.
6419 ///
6420 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6421 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6422 /// Sets `pending_write_authz` for the duration of the call so that the
6423 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6424 /// and evaluates the decision table per-op before any WAL write.
6425 ///
6426 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6427 /// denied by the decision table with the appropriate §4.3 scope reason;
6428 /// no special HTTP-layer check is needed.
6429 ///
6430 /// Roles with `write: None` return `RoleWriteDenied` with
6431 /// "writes are not permitted" (byte-identical to v1 blanket 403).
6432 pub fn ingest_with_edges_authz(
6433 &mut self,
6434 role: &str,
6435 label: &str,
6436 rows: Vec<std::collections::BTreeMap<String, Value>>,
6437 opts: &crate::ingest::IngestOptions,
6438 edges: &[(String, String, String)],
6439 ) -> Result<crate::ingest::IngestReport> {
6440 // Resolve scope (fails fast if role has no write scope).
6441 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6442 let scope =
6443 {
6444 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6445 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6446 })?;
6447 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6448 GraphError::KeyNotFound {
6449 key: format!("role:{role}"),
6450 }
6451 })?;
6452 def.write
6453 .clone()
6454 .ok_or_else(|| GraphError::RoleWriteDenied {
6455 reason: "role-bound token: writes are not permitted".into(),
6456 })?
6457 };
6458 let mask = self.mask_for_role(role)?;
6459 self.pending_write_authz = Some(WriteAuthz {
6460 role: role.into(),
6461 scope,
6462 mask,
6463 });
6464 // RAII guard: always clears pending_write_authz on scope exit, including
6465 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6466 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6467 impl Drop for ClearPendingAuthzOnDrop {
6468 fn drop(&mut self) {
6469 // SAFETY: pointer into the owning GraphDb; guard is dropped
6470 // within this function's frame before it returns.
6471 unsafe { *self.0 = None };
6472 }
6473 }
6474 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6475 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6476 self.ingest_with_edges(label, rows, opts, edges)
6477 }
6478
6479 /// Evaluate the write-authz decision table for one `BatchOp`.
6480 ///
6481 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6482 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
6483 /// the remaining ops are not evaluated and no WAL frame is written.
6484 ///
6485 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6486 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
6487 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6488 /// batch creates counts as visible if its label passed the create-class gate").
6489 fn check_single_op_authz(
6490 &self,
6491 authz: &WriteAuthz,
6492 op: &BatchOp,
6493 batch_created: &BTreeMap<String, String>,
6494 ) -> Result<()> {
6495 // Helper: 3-way node status under the authz mask.
6496 //
6497 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6498 // as Visible with their recorded label — their create gate already passed
6499 // and they are not yet in self.ids (not committed). This fixes the
6500 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6501 // the SetProp must not see the node as Absent.
6502 let node_status = |key: &str| -> NodeAuthzStatus {
6503 if let Some(label) = batch_created.get(key) {
6504 return NodeAuthzStatus::Visible(label.clone());
6505 }
6506 match self.ids.get(key) {
6507 None => NodeAuthzStatus::Absent,
6508 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6509 Some(id) => {
6510 let label = self
6511 .labels
6512 .get(id as usize)
6513 .and_then(|&sym| {
6514 if sym == u32::MAX {
6515 None
6516 } else {
6517 self.syms.resolve(sym).map(str::to_string)
6518 }
6519 })
6520 .unwrap_or_default();
6521 NodeAuthzStatus::Visible(label)
6522 }
6523 }
6524 };
6525
6526 // Helper: is an InsertEdgeUpsert endpoint visible?
6527 // A same-batch placeholder counts as visible if its label passed
6528 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6529 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6530 // In store and visible?
6531 if let Some(id) = self.ids.get(ep_key) {
6532 return authz.mask.contains_id(id);
6533 }
6534 // Created by an earlier op in this batch?
6535 if let Some(created_label) = batch_created.get(ep_key) {
6536 return authz.scope.create_labels.contains(created_label);
6537 }
6538 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6539 // must pass the create-class gate.
6540 authz
6541 .scope
6542 .create_labels
6543 .contains(&placeholder_label.to_string())
6544 };
6545
6546 match op {
6547 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6548 // These ops are never routed to role-scoped paths by the HTTP layer,
6549 // but we 403 them here to close any future bypass route.
6550 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6551 return Err(GraphError::RoleWriteDenied {
6552 reason: "role-bound token: this endpoint is not permitted".into(),
6553 });
6554 }
6555
6556 // ── CREATE-class: InsertNode ─────────────────────────────────────
6557 //
6558 // Decision table row 1 (scope-before-lookup): check label in
6559 // create_labels BEFORE any key lookup. This is the structural
6560 // closure of the §6.2 timing-oracle item — the denial fires even
6561 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6562 BatchOp::InsertNode { label, key, .. } => {
6563 if !authz.scope.create_labels.contains(label) {
6564 return Err(GraphError::RoleWriteDenied {
6565 reason: format!(
6566 "role-bound token: label '{}' not in write scope (create_labels)",
6567 label
6568 ),
6569 });
6570 }
6571 // Row 2/3: key lookup.
6572 match self.ids.get(key.as_str()) {
6573 Some(id) if authz.mask.contains_id(id) => {
6574 // Visible: DuplicateKey — let MutPreview handle this.
6575 }
6576 Some(_) => {
6577 // Hidden: indistinguishable from absent to the role.
6578 return Err(GraphError::RoleWriteDenied {
6579 reason: "role-bound token: target node not visible".into(),
6580 });
6581 }
6582 None => {
6583 // Absent: proceed (create).
6584 }
6585 }
6586 }
6587
6588 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6589 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6590 if batch_created.contains_key(key.as_str()) {
6591 // Batch-created node: create gate already passed this batch.
6592 // Updating it in the same batch is always allowed, regardless
6593 // of update_labels (ruling §3.5: "writer just created it").
6594 } else {
6595 let label = match node_status(key) {
6596 NodeAuthzStatus::Visible(lbl) => lbl,
6597 _ => {
6598 return Err(GraphError::RoleWriteDenied {
6599 reason: "role-bound token: target node not visible".into(),
6600 });
6601 }
6602 };
6603 if !authz.scope.update_labels.contains(&label) {
6604 return Err(GraphError::RoleWriteDenied {
6605 reason: format!(
6606 "role-bound token: label '{}' not in write scope (update_labels)",
6607 label
6608 ),
6609 });
6610 }
6611 }
6612 }
6613
6614 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6615 BatchOp::DeleteNode { key } => {
6616 let label = match node_status(key) {
6617 NodeAuthzStatus::Visible(lbl) => lbl,
6618 _ => {
6619 return Err(GraphError::RoleWriteDenied {
6620 reason: "role-bound token: target node not visible".into(),
6621 });
6622 }
6623 };
6624 if !authz.scope.delete_labels.contains(&label) {
6625 return Err(GraphError::RoleWriteDenied {
6626 reason: format!(
6627 "role-bound token: label '{}' not in write scope (delete_labels)",
6628 label
6629 ),
6630 });
6631 }
6632 }
6633
6634 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6635 //
6636 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6637 // check (spec §3.5: "existing derived-edge rejection precedes
6638 // delete_edge_types check").
6639 BatchOp::DeleteEdge {
6640 edge_type,
6641 src_key,
6642 dst_key,
6643 } => {
6644 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6645 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6646 self.ids.get(src_key.as_str()),
6647 self.ids.get(dst_key.as_str()),
6648 self.syms.get(edge_type.as_str()),
6649 ) {
6650 if self.engine.is_owned(et_sym, src_id, dst_id) {
6651 return Err(GraphError::RuleOwned {
6652 detail: format!(
6653 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6654 delete or change the owning rule"
6655 ),
6656 });
6657 }
6658 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6659 let preview = MutPreview::new(self);
6660 if preview.would_derive(edge_type, src_key, dst_key) {
6661 return Err(GraphError::RuleOwned {
6662 detail: format!(
6663 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6664 delete or change the owning rule, or a live rule would \
6665 re-derive it"
6666 ),
6667 });
6668 }
6669 }
6670 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6671 if !authz.scope.delete_edge_types.contains(edge_type) {
6672 return Err(GraphError::RoleWriteDenied {
6673 reason: format!(
6674 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6675 edge_type
6676 ),
6677 });
6678 }
6679 // Both endpoints must be visible.
6680 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6681 match self.ids.get(ep_key) {
6682 None => {
6683 return Err(GraphError::RoleWriteDenied {
6684 reason: "role-bound token: edge endpoint not visible".into(),
6685 });
6686 }
6687 Some(id) if !authz.mask.contains_id(id) => {
6688 return Err(GraphError::RoleWriteDenied {
6689 reason: "role-bound token: edge endpoint not visible".into(),
6690 });
6691 }
6692 _ => {}
6693 }
6694 }
6695 }
6696
6697 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6698 //
6699 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6700 BatchOp::InsertEdge {
6701 edge_type,
6702 src_key,
6703 dst_key,
6704 } => {
6705 if !authz.scope.create_edge_types.contains(edge_type) {
6706 return Err(GraphError::RoleWriteDenied {
6707 reason: format!(
6708 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6709 edge_type
6710 ),
6711 });
6712 }
6713 // Both endpoints must be visible. A node created by an earlier
6714 // InsertNode in the same batch (tracked in batch_created) counts
6715 // as visible if its label passed the create-class gate.
6716 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6717 if batch_created.contains_key(ep_key) {
6718 // Created earlier this batch — already scope-checked.
6719 continue;
6720 }
6721 match self.ids.get(ep_key) {
6722 None => {
6723 return Err(GraphError::RoleWriteDenied {
6724 reason: "role-bound token: edge endpoint not visible".into(),
6725 });
6726 }
6727 Some(id) if !authz.mask.contains_id(id) => {
6728 return Err(GraphError::RoleWriteDenied {
6729 reason: "role-bound token: edge endpoint not visible".into(),
6730 });
6731 }
6732 _ => {}
6733 }
6734 }
6735 }
6736
6737 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6738 //
6739 // Scope check first; then endpoint visibility using same-batch
6740 // placeholder awareness (spec: "a placeholder endpoint the SAME
6741 // batch creates counts as visible if its label passed the
6742 // create-class gate").
6743 BatchOp::InsertEdgeUpsert {
6744 edge_type,
6745 src_key,
6746 dst_key,
6747 placeholder_label,
6748 } => {
6749 if !authz.scope.create_edge_types.contains(edge_type) {
6750 return Err(GraphError::RoleWriteDenied {
6751 reason: format!(
6752 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6753 edge_type
6754 ),
6755 });
6756 }
6757 // Check placeholder label against create_labels (create-class gate).
6758 // This ensures the auto-created endpoints are scope-allowed.
6759 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6760 if !upsert_ep_visible(ep_key, placeholder_label) {
6761 return Err(GraphError::RoleWriteDenied {
6762 reason: "role-bound token: edge endpoint not visible".into(),
6763 });
6764 }
6765 }
6766 }
6767 }
6768 Ok(())
6769 }
6770
6771 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6772 ///
6773 /// Called by `apply_schema` when roles change. Never called on unchanged
6774 /// re-apply — this preserves byte-identical idempotency.
6775 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6776 let file = RolesFile::new_versioned(roles.clone());
6777 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6778 detail: format!("roles serialization: {e}"),
6779 })?;
6780 self.fs
6781 .write_atomic(FileId::Roles, &bytes)
6782 .map_err(GraphError::Io)?;
6783 self.roles = Some(roles);
6784 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6785 // updated role definitions without waiting for the next K-commit fold.
6786 self.fold_now();
6787 Ok(())
6788 }
6789
6790 fn view(&self) -> GraphView<'_> {
6791 GraphView {
6792 ids: &self.ids,
6793 syms: &self.syms,
6794 labels: &self.labels,
6795 props: self.props_view(),
6796 topo: self.topo_view(),
6797 edge_props: self.edge_props_view(),
6798 mask: None,
6799 prop_index: Some(&self.prop_index),
6800 }
6801 }
6802
6803 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6804 GraphView {
6805 ids: &self.ids,
6806 syms: &self.syms,
6807 labels: &self.labels,
6808 props: self.props_view(),
6809 topo: self.topo_view(),
6810 edge_props: self.edge_props_view(),
6811 mask: Some(&mask.visible),
6812 prop_index: Some(&self.prop_index),
6813 }
6814 }
6815
6816 /// Execute a read-only Cypher query with a node visibility mask.
6817 ///
6818 /// Only nodes whose key is in `mask` are accessible: label scans, key
6819 /// lookups, and neighbor expansions all respect the mask. Edges where
6820 /// either endpoint is hidden are silently dropped.
6821 ///
6822 /// Returns `Err` with a "masked queries are read-only" message when
6823 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6824 pub fn query_masked(
6825 &self,
6826 cypher: &str,
6827 params: &std::collections::BTreeMap<String, Value>,
6828 mask: &crate::mask::NodeMask,
6829 ) -> Result<ResultSet> {
6830 // Reject write statements up front.
6831 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6832 detail: format!("lex: {e}"),
6833 })?;
6834 if is_write_tokens(&tokens) {
6835 return Err(GraphError::MaskedReadOnly);
6836 }
6837 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6838 detail: format!("parse: {e}"),
6839 })?;
6840 // Each UNION part executes against the same masked view, so the mask
6841 // applies uniformly across the chain.
6842 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6843 GraphError::QueryError {
6844 detail: format!("execute: {e}"),
6845 }
6846 })
6847 }
6848
6849 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6850 let id = self.ids.get(key)?;
6851 Some(NodeRef { db: self, id })
6852 }
6853
6854 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6855 ///
6856 /// Hidden nodes are never used as traversal intermediaries in either
6857 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6858 /// only through a hidden node will not appear in results.
6859 ///
6860 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6861 /// a visited visible node are appended to the result as stub rows
6862 /// (`label` column is `null`, same key+depth columns as visible rows).
6863 /// They are NOT added to the BFS frontier.
6864 ///
6865 /// Returns `None` when `key` does not exist (caller should 404).
6866 ///
6867 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6868 /// stub rows are never produced on the role path.
6869 pub fn neighborhood_masked(
6870 &self,
6871 key: &str,
6872 depth: u32,
6873 edge_types: Option<&[&str]>,
6874 dir: Dir,
6875 mask: &crate::mask::NodeMask,
6876 ) -> Option<ResultSet> {
6877 let start_id = self.ids.get(key)?;
6878 let view = self.view_masked(mask);
6879 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6880 names
6881 .iter()
6882 .filter_map(|name| view.syms.get(name))
6883 .collect()
6884 });
6885 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6886 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6887 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6888 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6889 visited.push((start_id, 0));
6890 for (nid, d) in &nb.nodes {
6891 let k = view.key_of(*nid);
6892 let label = view
6893 .label_of(*nid)
6894 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6895 rs.push_row(vec![
6896 Some(Value::Str(k.to_string())),
6897 Some(Value::Str(label.to_string())),
6898 Some(Value::Int(*d as i64)),
6899 ]);
6900 visited.push((*nid, *d));
6901 }
6902 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6903 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6904 // frontier, so the BFS never expands through them.
6905 if mask.mode() == crate::mask::MaskMode::Stub {
6906 let raw_view = self.view();
6907 let mut seen: std::collections::HashSet<u32> =
6908 visited.iter().map(|(id, _)| *id).collect();
6909 for (node_id, node_depth) in &visited {
6910 if *node_depth >= depth {
6911 continue;
6912 }
6913 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6914 let nbr = if e.src == *node_id { e.dst } else { e.src };
6915 if !mask.contains_id(nbr) && seen.insert(nbr) {
6916 if let Some(k) = self.ids.key_of(nbr) {
6917 rs.push_row(vec![
6918 Some(Value::Str(k.to_string())),
6919 None,
6920 Some(Value::Int((*node_depth + 1) as i64)),
6921 ]);
6922 }
6923 }
6924 }
6925 }
6926 }
6927 Some(rs)
6928 }
6929
6930 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6931 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6932 let n = self.node_ref(key)?;
6933 Some(NodeInfo {
6934 key: n.key().to_string(),
6935 label: n.label().to_string(),
6936 props: n.props(),
6937 })
6938 }
6939
6940 /// Look up a node with mask awareness.
6941 ///
6942 /// | Key state | Omit mode | Stub mode |
6943 /// |-------------------|-----------------|------------------------|
6944 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6945 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6946 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6947 ///
6948 /// **SECURITY**: only call from client-mask (full-token) paths.
6949 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6950 pub fn node_info_masked(
6951 &self,
6952 key: &str,
6953 mask: &crate::mask::NodeMask,
6954 ) -> Option<MaskedNodeResult> {
6955 let id = self.ids.get(key)?;
6956 if mask.contains_id(id) {
6957 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6958 } else {
6959 match mask.mode() {
6960 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6961 crate::mask::MaskMode::Omit => None,
6962 }
6963 }
6964 }
6965
6966 /// Get edges for `key` with mask-aware hidden-endpoint handling.
6967 ///
6968 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6969 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6970 /// is `true` for each hidden endpoint.
6971 ///
6972 /// Unknown key → [`GraphError::KeyNotFound`].
6973 ///
6974 /// **SECURITY**: only call from client-mask (full-token) paths.
6975 pub fn node_edges_masked(
6976 &self,
6977 key: &str,
6978 mask: &crate::mask::NodeMask,
6979 ) -> Result<Vec<MaskedEdge>> {
6980 self.ensure_v8_base_sections_loaded();
6981 let id = self
6982 .ids
6983 .get(key)
6984 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6985 let derived: BTreeSet<(u32, u32, u32)> = self
6986 .engine
6987 .provenance_touching(id)
6988 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6989 .collect();
6990 let mut edges = Vec::new();
6991 let tv = self.topo_view();
6992 for etype in tv.etypes() {
6993 // etype comes from the archived CSR (access_unchecked, no eager CRC).
6994 // A bit-flip in the large TOPOLOGY section can produce an etype id
6995 // that is not in the interner. Return Corrupt rather than panic.
6996 let edge_type = self
6997 .syms
6998 .resolve(etype)
6999 .ok_or_else(|| GraphError::Corrupt {
7000 detail: format!("v8: topology etype {etype} not in interner"),
7001 })?
7002 .to_string();
7003 for dir in [Direction::Out, Direction::In] {
7004 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7005 let nbr_restricted = !mask.contains_id(nbr);
7006 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7007 continue;
7008 }
7009 let nbr_key = self
7010 .ids
7011 .key_of(nbr)
7012 .ok_or_else(|| GraphError::Corrupt {
7013 detail: format!("topology id {nbr} has no key"),
7014 })?
7015 .to_string();
7016 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7017 match dir {
7018 Direction::Out => {
7019 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7020 }
7021 Direction::In => {
7022 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7023 }
7024 };
7025 edges.push(MaskedEdge {
7026 edge_type: edge_type.clone(),
7027 src_key,
7028 src_restricted,
7029 dst_key,
7030 dst_restricted,
7031 derived: derived.contains(&(etype, src_id, dst_id)),
7032 });
7033 }
7034 }
7035 }
7036 edges.sort_by(|a, b| {
7037 a.edge_type
7038 .cmp(&b.edge_type)
7039 .then(a.src_key.cmp(&b.src_key))
7040 .then(a.dst_key.cmp(&b.dst_key))
7041 });
7042 edges.dedup_by(|a, b| {
7043 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7044 });
7045 Ok(edges)
7046 }
7047
7048 /// Every directed edge incident on `key`, both directions, every etype.
7049 ///
7050 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7051 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7052 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7053 /// Unknown key → [`GraphError::KeyNotFound`].
7054 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7055 self.ensure_v8_base_sections_loaded();
7056 let id = self
7057 .ids
7058 .get(key)
7059 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7060 let derived: BTreeSet<(u32, u32, u32)> = self
7061 .engine
7062 .provenance_touching(id)
7063 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7064 .collect();
7065 let mut edges = Vec::new();
7066 let tv = self.topo_view();
7067 for etype in tv.etypes() {
7068 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7069 let edge_type = self
7070 .syms
7071 .resolve(etype)
7072 .ok_or_else(|| GraphError::Corrupt {
7073 detail: format!("v8: topology etype {etype} not in interner"),
7074 })?
7075 .to_string();
7076 for dir in [Direction::Out, Direction::In] {
7077 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7078 let (src, dst, src_key, dst_key) = match dir {
7079 Direction::Out => (
7080 id,
7081 nbr,
7082 key.to_string(),
7083 self.ids
7084 .key_of(nbr)
7085 .ok_or_else(|| GraphError::Corrupt {
7086 detail: format!("topology id {nbr} has no key"),
7087 })?
7088 .to_string(),
7089 ),
7090 Direction::In => (
7091 nbr,
7092 id,
7093 self.ids
7094 .key_of(nbr)
7095 .ok_or_else(|| GraphError::Corrupt {
7096 detail: format!("topology id {nbr} has no key"),
7097 })?
7098 .to_string(),
7099 key.to_string(),
7100 ),
7101 };
7102 edges.push(EdgeInfo {
7103 edge_type: edge_type.clone(),
7104 src_key,
7105 dst_key,
7106 derived: derived.contains(&(etype, src, dst)),
7107 });
7108 }
7109 }
7110 }
7111 edges.sort_by(|a, b| {
7112 a.edge_type
7113 .cmp(&b.edge_type)
7114 .then(a.src_key.cmp(&b.src_key))
7115 .then(a.dst_key.cmp(&b.dst_key))
7116 });
7117 // Self-loops appear in both Out and In; sort makes the pair adjacent
7118 // (sort key matches PartialEq for this case) so one pass drops the dup.
7119 edges.dedup();
7120 Ok(edges)
7121 }
7122
7123 // ── Backup ────────────────────────────────────────────────────────────────
7124
7125 /// Copy this store to `dest` as a consistent, verified snapshot.
7126 ///
7127 /// Copies every durable file in the database directory — `snapshot.bin`,
7128 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7129 /// `roles.json` — into a freshly created `dest` directory using OS-level
7130 /// `copy` calls (no large in-process buffers).
7131 ///
7132 /// # Consistency guarantee
7133 ///
7134 /// The guarantee is **process-local**: the caller holds `&self`, which
7135 /// prevents any concurrent writer in the **same process** from modifying
7136 /// the files during the copy. Running `mushroomdb backup` against a
7137 /// directory that is **concurrently being written by another process** (e.g.
7138 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
7139 /// `verified: true` result reduces but does not eliminate the risk of a
7140 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7141 /// consistent mid-write snapshot).
7142 ///
7143 /// **The safe path for a live-served store is `POST /backup` on the HTTP
7144 /// server.** That handler acquires the read lock on the shared database
7145 /// before calling this method, which is the correct cross-process
7146 /// synchronisation point because the server is the single process writing
7147 /// the files.
7148 ///
7149 /// After copying, opens the destination read-only and runs the CRC section
7150 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7151 /// `BackupReport::verified` reflects whether both checks passed.
7152 ///
7153 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7154 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7155 // Derive source directory from snapshot_path (RealFs only).
7156 let src_dir = match self.fs.snapshot_path() {
7157 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7158 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7159 })?,
7160 None => {
7161 return Err(GraphError::Io(std::io::Error::other(
7162 "backup_to requires a real filesystem (RealFs)",
7163 )))
7164 }
7165 };
7166
7167 std::fs::create_dir_all(dest)?;
7168
7169 let mut files: Vec<String> = Vec::new();
7170 let mut bytes: u64 = 0;
7171
7172 // Helper: copy src_dir/name → dest/name if the file exists.
7173 let mut try_copy = |name: &str| -> std::io::Result<()> {
7174 let src_path = src_dir.join(name);
7175 if src_path.exists() {
7176 let n = std::fs::copy(&src_path, dest.join(name))?;
7177 bytes += n;
7178 files.push(name.to_string());
7179 }
7180 Ok(())
7181 };
7182
7183 try_copy("snapshot.bin")?;
7184 try_copy("snapshot.bin.bak")?;
7185 try_copy("wal.bin")?;
7186 try_copy("wal.floor")?;
7187 try_copy("wal.genesis")?;
7188 try_copy("roles.json")?;
7189
7190 // Copy WAL archives.
7191 let archives = self.fs.list_archives()?;
7192 for n in &archives {
7193 let name = format!("wal.{n}.archive");
7194 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7195 bytes += n_bytes;
7196 files.push(name);
7197 }
7198
7199 files.sort();
7200
7201 // Post-copy verification: open dest and run CRC checks.
7202 let snap_in_dest = dest.join("snapshot.bin").exists();
7203 let crc_ok = if snap_in_dest {
7204 crate::verify_snapshot(dest)
7205 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7206 .unwrap_or(false)
7207 } else {
7208 true // WAL-only store: nothing to CRC-check in snapshot
7209 };
7210 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7211 let verified = crc_ok && opens_ok;
7212
7213 Ok(BackupReport {
7214 files,
7215 bytes,
7216 verified,
7217 })
7218 }
7219
7220 // ── Export helpers ────────────────────────────────────────────────────────
7221
7222 /// All live nodes, sorted by key (deterministic).
7223 ///
7224 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7225 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7226 self.ensure_v8_base_sections_loaded();
7227 let pv = self.props_view();
7228 let mut nodes = Vec::new();
7229 for id in 0..self.ids.len() as u32 {
7230 let Some(key) = self.ids.key_of(id) else {
7231 continue;
7232 };
7233 let Some(&sym) = self.labels.get(id as usize) else {
7234 continue;
7235 };
7236 if sym == u32::MAX {
7237 continue; // tombstoned
7238 }
7239 let Some(label) = self.syms.resolve(sym) else {
7240 continue;
7241 };
7242 let mut props = BTreeMap::new();
7243 for field in pv.field_names() {
7244 if let Some(vr) = pv.get(id, &field) {
7245 props.insert(field, vr.into_value());
7246 }
7247 }
7248 nodes.push(NodeInfo {
7249 key: key.to_string(),
7250 label: label.to_string(),
7251 props,
7252 });
7253 }
7254 nodes.sort_by(|a, b| a.key.cmp(&b.key));
7255 nodes
7256 }
7257
7258 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7259 ///
7260 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7261 /// Manual edges carry `derived: false` and `rule: None`.
7262 /// `weight` is the creating rule's `weight_prop` value read off the edge
7263 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7264 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7265 /// store state.
7266 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7267 self.ensure_v8_base_sections_loaded();
7268
7269 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7270 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7271 for (rule_name, triples) in self.engine.provenance() {
7272 for &(etype, src, dst) in triples {
7273 prov.insert((etype, src, dst), rule_name.clone());
7274 }
7275 }
7276
7277 // rule_name → weight_prop, for O(1) lookup per derived edge.
7278 let weight_props: HashMap<&str, Option<&str>> = self
7279 .engine
7280 .rules()
7281 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7282 .collect();
7283
7284 let tv = self.topo_view();
7285 let ep = self.edge_props_view();
7286 let mut edges = Vec::new();
7287
7288 for id in 0..self.ids.len() as u32 {
7289 let Some(key) = self.ids.key_of(id) else {
7290 continue;
7291 };
7292 let Some(&lsym) = self.labels.get(id as usize) else {
7293 continue;
7294 };
7295 if lsym == u32::MAX {
7296 continue; // tombstoned
7297 }
7298
7299 for etype_sym in tv.etypes() {
7300 // etype from archived CSR (access_unchecked, no eager CRC).
7301 // Skip edges whose etype is not in the interner; this can only
7302 // occur with a corrupt large TOPOLOGY section (bit-flip on an
7303 // etype field in the archived data). The function returns Vec,
7304 // not Result, so we continue rather than propagate.
7305 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7306 continue;
7307 };
7308 let edge_type = edge_type.to_string();
7309 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7310 let Some(dst_key) = self.ids.key_of(nbr) else {
7311 continue; // skip corrupt entries
7312 };
7313 let prov_key = (etype_sym, id, nbr);
7314 let rule = prov.get(&prov_key).cloned();
7315 let derived = rule.is_some();
7316 let weight = rule
7317 .as_deref()
7318 .and_then(|rn| weight_props.get(rn).copied().flatten())
7319 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7320 Some(Value::Float(f)) => Some(f),
7321 Some(Value::Int(i)) => Some(i as f64),
7322 _ => None,
7323 });
7324 edges.push(ExportEdge {
7325 edge_type: edge_type.clone(),
7326 src: key.to_string(),
7327 dst: dst_key.to_string(),
7328 derived,
7329 rule,
7330 weight,
7331 });
7332 }
7333 }
7334 }
7335
7336 edges.sort_by(|a, b| {
7337 a.edge_type
7338 .cmp(&b.edge_type)
7339 .then(a.src.cmp(&b.src))
7340 .then(a.dst.cmp(&b.dst))
7341 });
7342 edges
7343 }
7344
7345 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7346 /// on each edge when given.
7347 ///
7348 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7349 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7350 /// `None` — callers that want a default weight (e.g. `1.0` for missing
7351 /// props) apply it themselves, matching the convention used internally
7352 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7353 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7354 ///
7355 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7356 /// (manual + rule-derived edges). An unknown `edge_type` returns an
7357 /// empty vec.
7358 pub fn weighted_edges(
7359 &self,
7360 edge_type: &str,
7361 weight_prop: Option<&str>,
7362 ) -> Vec<(String, String, Option<f64>)> {
7363 let Some(etype_sym) = self.syms.get(edge_type) else {
7364 return Vec::new();
7365 };
7366 let tv = self.topo_view();
7367 let ep = self.edge_props_view();
7368 let mut out = Vec::new();
7369 for id in 0..self.ids.len() as u32 {
7370 let Some(key) = self.ids.key_of(id) else {
7371 continue;
7372 };
7373 let Some(&sym) = self.labels.get(id as usize) else {
7374 continue;
7375 };
7376 if sym == u32::MAX {
7377 continue; // tombstoned
7378 }
7379 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7380 let Some(dst_key) = self.ids.key_of(nbr) else {
7381 continue;
7382 };
7383 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7384 Some(Value::Float(f)) => Some(f),
7385 Some(Value::Int(i)) => Some(i as f64),
7386 _ => None,
7387 });
7388 out.push((key.to_string(), dst_key.to_string(), weight));
7389 }
7390 }
7391 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7392 out
7393 }
7394
7395 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7396 self.view()
7397 .nodes_with_label(label)
7398 .into_iter()
7399 .map(|id| NodeRef { db: self, id })
7400 .collect()
7401 }
7402
7403 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7404 let view = self.view();
7405 view.nodes_with_label(label)
7406 .into_iter()
7407 .filter(|&id| {
7408 eval_filter(filter, &|field| {
7409 view.prop(id, field).map(|vr| vr.into_value())
7410 })
7411 })
7412 .map(|id| NodeRef { db: self, id })
7413 .collect()
7414 }
7415
7416 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7417 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
7418 /// with `label = None` will use the native ANN path rather than the O(n)
7419 /// brute-force scan.
7420 pub fn has_vector_rule(&self, field: &str) -> bool {
7421 self.engine.hnsw_has_rule(field)
7422 }
7423
7424 /// Find nodes whose `field` vector is most similar to `q` (cosine
7425 /// similarity), returning up to `k` results with similarity ≥ `min`,
7426 /// sorted descending.
7427 ///
7428 /// When `label` is `None` the search spans all labels (via
7429 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7430 /// `Some(lbl)` it restricts to nodes with that label.
7431 ///
7432 /// Uses the HNSW index when one is available (fast path); otherwise falls
7433 /// back to an O(n) brute-force scan.
7434 pub fn find_similar_vector(
7435 &self,
7436 field: &str,
7437 label: Option<&str>,
7438 q: &[f64],
7439 k: usize,
7440 min: f64,
7441 ) -> Vec<(String, f64)> {
7442 // Ensure any HNSW blobs retained from the snapshot are deserialized
7443 // before the first ANN query on a clean-open (no-WAL) path.
7444 self.engine.ensure_hnsw_loaded();
7445 // L2-normalise query for cosine via dot product.
7446 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7447 if norm == 0.0 {
7448 return vec![];
7449 }
7450 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7451
7452 // Try HNSW fast path.
7453 // `None` label searches across all VectorSimilar rules covering `field`
7454 // (merging their results); `Some(lbl)` restricts to rules whose
7455 // dst_label matches. Returns `None` when no populated HNSW index
7456 // covers the request — the O(n) brute-force fallback handles that case.
7457 let hnsw_hits = match label {
7458 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7459 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7460 };
7461 if let Some(hits) = hnsw_hits {
7462 let mut out: Vec<(String, f64)> = hits
7463 .into_iter()
7464 .filter(|&(_, sim)| sim >= min)
7465 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7466 .collect();
7467 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7468 out.truncate(k);
7469 return out;
7470 }
7471
7472 // Brute-force fallback: O(n) scan (only reached when no HNSW index
7473 // covers the request).
7474 let view = self.view();
7475 let candidate_ids: Vec<u32> = match label {
7476 Some(lbl) => view.nodes_with_label(lbl),
7477 None => view.nodes_all(),
7478 };
7479 let mut scored: Vec<(String, f64)> = candidate_ids
7480 .into_iter()
7481 .filter_map(|id| {
7482 let v = view.prop(id, field)?;
7483 let v_owned = v.into_value();
7484 let xs = value_as_float_list(&v_owned)?;
7485 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7486 if v_norm == 0.0 {
7487 return None;
7488 }
7489 let dot: f64 = q_unit
7490 .iter()
7491 .zip(xs.iter())
7492 .map(|(a, b)| a * (b / v_norm))
7493 .sum();
7494 if dot < min {
7495 return None;
7496 }
7497 let key = self.ids.key_of(id)?.to_string();
7498 Some((key, dot))
7499 })
7500 .collect();
7501 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7502 scored.truncate(k);
7503 scored
7504 }
7505
7506 /// Like [`find_similar_vector`] but restricts results to nodes visible in
7507 /// `mask`. Hidden nodes never appear in results; the mask is applied
7508 /// **before** k-truncation so a caller still receives up to `k` visible
7509 /// hits.
7510 ///
7511 /// # HNSW path (over-fetch policy)
7512 ///
7513 /// When an HNSW index covers the request, this function fetches `4 * k`
7514 /// candidates from the index and discards hidden nodes in the post-filter
7515 /// step. If fewer than `k` visible nodes remain after filtering the caller
7516 /// receives whatever is available — we do not re-query the index. The 4×
7517 /// multiplier is a heuristic suited for sparsely masked graphs; callers
7518 /// operating under a very selective mask should register a VectorSimilar
7519 /// rule with a non-approximate index, or use the brute-force path (no HNSW
7520 /// rule) which exhaustively filters through the masked [`GraphView`].
7521 ///
7522 /// # Brute-force path
7523 ///
7524 /// When no HNSW index covers the request the function builds a masked
7525 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7526 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7527 /// fewer than `k` exist).
7528 pub fn find_similar_vector_masked(
7529 &self,
7530 field: &str,
7531 label: Option<&str>,
7532 q: &[f64],
7533 k: usize,
7534 min: f64,
7535 mask: &crate::mask::NodeMask,
7536 ) -> Vec<(String, f64)> {
7537 self.engine.ensure_hnsw_loaded();
7538 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7539 if norm == 0.0 {
7540 return vec![];
7541 }
7542 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7543
7544 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7545 // visible hits. See doc comment above for the policy rationale.
7546 let over_k = k.saturating_mul(4).max(k + 1);
7547 let hnsw_hits = match label {
7548 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7549 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7550 };
7551 if let Some(hits) = hnsw_hits {
7552 let mut out: Vec<(String, f64)> = hits
7553 .into_iter()
7554 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7555 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7556 .collect();
7557 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7558 out.truncate(k);
7559 return out;
7560 }
7561
7562 // Brute-force fallback — masked view ensures only visible nodes are
7563 // enumerated by nodes_all(); nodes_with_label() does not filter by
7564 // mask so we apply view.visible() explicitly for the labeled case.
7565 let view = self.view_masked(mask);
7566 let candidate_ids: Vec<u32> = match label {
7567 Some(lbl) => view
7568 .nodes_with_label(lbl)
7569 .into_iter()
7570 .filter(|&id| view.visible(id))
7571 .collect(),
7572 None => view.nodes_all(),
7573 };
7574 let mut scored: Vec<(String, f64)> = candidate_ids
7575 .into_iter()
7576 .filter_map(|id| {
7577 let v = view.prop(id, field)?;
7578 let v_owned = v.into_value();
7579 let xs = value_as_float_list(&v_owned)?;
7580 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7581 if v_norm == 0.0 {
7582 return None;
7583 }
7584 let dot: f64 = q_unit
7585 .iter()
7586 .zip(xs.iter())
7587 .map(|(a, b)| a * (b / v_norm))
7588 .sum();
7589 if dot < min {
7590 return None;
7591 }
7592 let key = self.ids.key_of(id)?.to_string();
7593 Some((key, dot))
7594 })
7595 .collect();
7596 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7597 scored.truncate(k);
7598 scored
7599 }
7600
7601 /// Read a single property from an edge.
7602 ///
7603 /// Returns `None` when the edge does not exist, the field is absent, or any
7604 /// of the string keys cannot be resolved to interned ids. Only edge props
7605 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7606 /// binding; topology-only edges (no props set) return `None` for every field.
7607 pub fn get_edge_prop(
7608 &self,
7609 edge_type: &str,
7610 src_key: &str,
7611 dst_key: &str,
7612 field: &str,
7613 ) -> Option<Value> {
7614 let etype = self.syms.get(edge_type)?;
7615 let src = self.ids.get(src_key)?;
7616 let dst = self.ids.get(dst_key)?;
7617 self.edge_props_view().get(etype, src, dst, field)
7618 }
7619
7620 /// Lex → parse → plan → execute `cypher` over a read-only view.
7621 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7622 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7623 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7624 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7625 detail: format!("lex: {e}"),
7626 })?;
7627 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7628 detail: format!("parse: {e}"),
7629 })?;
7630 let t0 = std::time::Instant::now();
7631 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7632 GraphError::QueryError {
7633 detail: format!("execute: {e}"),
7634 }
7635 });
7636 let elapsed_ms = t0.elapsed().as_millis() as u64;
7637 let threshold = self.slow_query_threshold_ms;
7638 if threshold > 0 && elapsed_ms >= threshold {
7639 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7640 let entry = SlowQueryEntry {
7641 ms: elapsed_ms,
7642 query: cypher.to_string(),
7643 at_commit: self.commit_seq,
7644 };
7645 if let Ok(mut log) = self.slow_queries.lock() {
7646 if log.entries.len() == SLOW_QUERY_RING_CAP {
7647 log.entries.pop_front();
7648 }
7649 log.entries.push_back(entry);
7650 log.total += 1;
7651 }
7652 }
7653 result
7654 }
7655
7656 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7657 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7658 /// calling [`GraphDb::query`].
7659 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7660 let map: BTreeMap<String, Value> = params
7661 .iter()
7662 .map(|(k, v)| (k.to_string(), v.clone()))
7663 .collect();
7664 self.query(cypher, &map)
7665 }
7666
7667 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7668 ///
7669 /// All mutations flow through the same `insert_node` / `set_prop` /
7670 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7671 /// fires and the WAL captures everything with one fsync per statement.
7672 ///
7673 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7674 /// and `deleted` matching the write-result contract.
7675 ///
7676 /// **Mutation routing**: mutations are collected into a single
7677 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7678 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7679 /// over `self.view()` — the borrow is dropped before the batch is opened.
7680 ///
7681 /// **Limitations (v1)**:
7682 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7683 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7684 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7685 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7686 /// - Deleting a derived edge → named error "cannot delete derived edge".
7687 pub fn query_write(
7688 &mut self,
7689 cypher: &str,
7690 params: &BTreeMap<String, Value>,
7691 ) -> Result<ResultSet> {
7692 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7693 detail: format!("lex: {e}"),
7694 })?;
7695 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7696 detail: format!("parse: {e}"),
7697 })?;
7698 self.exec_write_stmt(stmt, params)
7699 }
7700
7701 fn exec_write_stmt(
7702 &mut self,
7703 stmt: WriteStatement,
7704 params: &BTreeMap<String, Value>,
7705 ) -> Result<ResultSet> {
7706 match stmt {
7707 WriteStatement::Create(s) => self.exec_create(s, params),
7708 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7709 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7710 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7711 WriteStatement::Merge(s) => self.exec_merge(s, params),
7712 }
7713 }
7714
7715 fn exec_create(
7716 &mut self,
7717 stmt: core_query::cypher::CreateStmt,
7718 params: &BTreeMap<String, Value>,
7719 ) -> Result<ResultSet> {
7720 // Extract the node key from props: require a string-valued `id` field.
7721 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7722 for node in &stmt.nodes {
7723 let var = node.var.as_deref().unwrap_or("_cn0");
7724 let key = node
7725 .props
7726 .iter()
7727 .find(|(f, _)| f == "id")
7728 .and_then(|(_, v)| {
7729 if let Value::Str(s) = v {
7730 Some(s.clone())
7731 } else {
7732 None
7733 }
7734 })
7735 .ok_or_else(|| GraphError::QueryError {
7736 detail: format!(
7737 "CREATE node ({}:{}) requires a string 'id' property",
7738 var, node.label
7739 ),
7740 })?;
7741 var_to_key.insert(var.to_string(), key);
7742 }
7743
7744 let mut batch = self.batch();
7745 let mut created: usize = 0;
7746 for node in &stmt.nodes {
7747 let var = node.var.as_deref().unwrap_or("_cn0");
7748 let key = &var_to_key[var];
7749 batch.insert_node(&node.label, key, node.props.clone());
7750 created += 1;
7751 }
7752 for edge in &stmt.edges {
7753 let src_key = var_to_key
7754 .get(&edge.src_var)
7755 .ok_or_else(|| GraphError::QueryError {
7756 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7757 })?;
7758 let dst_key = var_to_key
7759 .get(&edge.dst_var)
7760 .ok_or_else(|| GraphError::QueryError {
7761 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7762 })?;
7763 batch.insert_edge(&edge.etype, src_key, dst_key);
7764 }
7765 batch.commit()?;
7766
7767 // Optional RETURN clause: project created bindings as a read result.
7768 if let Some(returns) = stmt.returns {
7769 // Each created node is looked up by its key via a separate MATCH pattern.
7770 // Multiple single-node patterns cross-join to produce 1 output row with
7771 // all variables bound (each pattern returns exactly 1 row).
7772 let patterns: Vec<Pattern> = stmt
7773 .nodes
7774 .iter()
7775 .map(|node| {
7776 let var = node.var.as_deref().unwrap_or("_cn0");
7777 let key = var_to_key[var].clone();
7778 Pattern {
7779 start: NodePat {
7780 var: Some(var.to_string()),
7781 label: Some(node.label.clone()),
7782 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7783 },
7784 chain: vec![],
7785 shortest: false,
7786 }
7787 })
7788 .collect();
7789 let q = Query {
7790 matches: patterns,
7791 optional_clauses: vec![],
7792 where_expr: None,
7793 unwinds: vec![],
7794 post_unwind_where: None,
7795 stages: vec![],
7796 returns,
7797 distinct: false,
7798 order_by: vec![],
7799 skip: None,
7800 limit: None,
7801 };
7802 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7803 detail: format!("plan: {e}"),
7804 })?;
7805 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7806 GraphError::QueryError {
7807 detail: format!("execute: {e}"),
7808 }
7809 });
7810 }
7811
7812 let mut rs = write_result_set();
7813 rs.push_row(vec![
7814 Some(Value::Int(created as i64)),
7815 Some(Value::Int(0)),
7816 Some(Value::Int(0)),
7817 ]);
7818 Ok(rs)
7819 }
7820
7821 fn exec_match_set(
7822 &mut self,
7823 stmt: core_query::cypher::MatchSetStmt,
7824 params: &BTreeMap<String, Value>,
7825 ) -> Result<ResultSet> {
7826 let project_returns = stmt.returns.clone();
7827 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7828 // so the post-write projection can look them up by key.
7829 let mut set_vars: Vec<String> = Vec::new();
7830 for s in &stmt.sets {
7831 if !set_vars.contains(&s.var) {
7832 set_vars.push(s.var.clone());
7833 }
7834 }
7835 let rel_vars = pattern_rel_vars(&stmt.matches);
7836 let mut lookup_vars = set_vars.clone();
7837 for v in pattern_node_vars(&stmt.matches) {
7838 add_var(&mut lookup_vars, &v);
7839 }
7840 if let Some(ref returns) = project_returns {
7841 for v in ret_node_vars(returns) {
7842 if !rel_vars.iter().any(|r| r == &v) {
7843 add_var(&mut lookup_vars, &v);
7844 }
7845 }
7846 }
7847
7848 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7849 // SET values are projected as ScalarExpr items so that arithmetic expressions
7850 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7851 let mut set_returns: Vec<RetItem> = lookup_vars
7852 .iter()
7853 .map(|v| RetItem {
7854 value: RetVal::Var(v.clone()),
7855 alias: None,
7856 })
7857 .collect();
7858 // One computed column per SET clause; alias is `__sv_<i>`.
7859 let set_val_cols: Vec<String> = stmt
7860 .sets
7861 .iter()
7862 .enumerate()
7863 .map(|(i, _)| format!("__sv_{i}"))
7864 .collect();
7865 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7866 set_returns.push(RetItem {
7867 value: RetVal::ScalarExpr(sc.value.clone()),
7868 alias: Some(col.clone()),
7869 });
7870 }
7871 // Capture relationship types while r is bound; SET does not change them.
7872 for r in &rel_vars {
7873 set_returns.push(RetItem {
7874 value: RetVal::FuncCall {
7875 name: "type".into(),
7876 args: vec![Operand::Var(r.clone())],
7877 },
7878 alias: Some(rel_type_alias(r)),
7879 });
7880 }
7881
7882 let read_q = Query {
7883 matches: stmt.matches.clone(),
7884 optional_clauses: vec![],
7885 where_expr: stmt.where_expr.clone(),
7886 unwinds: vec![],
7887 post_unwind_where: None,
7888 stages: vec![],
7889 returns: set_returns,
7890 distinct: false,
7891 order_by: vec![],
7892 skip: None,
7893 limit: None,
7894 };
7895 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7896 detail: format!("plan: {e}"),
7897 })?;
7898 // MATCH phase is read-only; borrow ends before batch opens.
7899 //
7900 // When a role-scoped write is in flight, run the MATCH read through
7901 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7902 // zero-rows (no SetProp ops generated, no existence-oracle 403).
7903 // Full-authority writes (pending_write_authz=None) keep view().
7904 let match_rs = {
7905 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7906 if let Some(ref mask) = mask_opt {
7907 execute(&self.view_masked(mask), &ops, &Params(params))
7908 } else {
7909 execute(&self.view(), &ops, &Params(params))
7910 }
7911 }
7912 .map_err(|e| GraphError::QueryError {
7913 detail: format!("execute: {e}"),
7914 })?;
7915
7916 // Collect (key, field, value) for each matched row × each SET clause.
7917 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7918 for row_i in 0..match_rs.len() {
7919 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7920 let key = match match_rs.get(row_i, &sc.var) {
7921 Some(Value::Str(k)) => k.clone(),
7922 _ => {
7923 return Err(GraphError::QueryError {
7924 detail: format!(
7925 "SET variable '{}' did not resolve to a node key",
7926 sc.var
7927 ),
7928 })
7929 }
7930 };
7931 // The SET value was already evaluated by the executor.
7932 let value = match match_rs.get(row_i, col) {
7933 Some(v) => v.clone(),
7934 None => {
7935 return Err(GraphError::QueryError {
7936 detail: format!(
7937 "SET value for {}.{} evaluated to null",
7938 sc.var, sc.field
7939 ),
7940 })
7941 }
7942 };
7943 set_ops.push((key, sc.field.clone(), value));
7944 }
7945 }
7946
7947 // Apply as one atomic batch.
7948 let props_set = set_ops.len();
7949 let mut batch = self.batch();
7950 for (key, field, value) in set_ops {
7951 batch.set_prop(&key, &field, value);
7952 }
7953 batch.commit()?;
7954
7955 if let Some(returns) = project_returns {
7956 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7957 }
7958
7959 let mut rs = write_result_set();
7960 rs.push_row(vec![
7961 Some(Value::Int(0)),
7962 Some(Value::Int(props_set as i64)),
7963 Some(Value::Int(0)),
7964 ]);
7965 Ok(rs)
7966 }
7967
7968 fn exec_match_delete(
7969 &mut self,
7970 stmt: core_query::cypher::MatchDeleteStmt,
7971 params: &BTreeMap<String, Value>,
7972 ) -> Result<ResultSet> {
7973 // Collect unique node vars needed to identify edge endpoints.
7974 let mut node_vars: Vec<String> = Vec::new();
7975 for ed in &stmt.deletes {
7976 if !node_vars.contains(&ed.src_var) {
7977 node_vars.push(ed.src_var.clone());
7978 }
7979 if !node_vars.contains(&ed.dst_var) {
7980 node_vars.push(ed.dst_var.clone());
7981 }
7982 }
7983
7984 // Synthesize read query.
7985 let returns: Vec<RetItem> = node_vars
7986 .iter()
7987 .map(|v| RetItem {
7988 value: RetVal::Var(v.clone()),
7989 alias: None,
7990 })
7991 .collect();
7992 let read_q = Query {
7993 matches: stmt.matches,
7994 optional_clauses: vec![],
7995 where_expr: stmt.where_expr,
7996 unwinds: vec![],
7997 post_unwind_where: None,
7998 stages: vec![],
7999 returns,
8000 distinct: false,
8001 order_by: vec![],
8002 skip: None,
8003 limit: None,
8004 };
8005 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8006 detail: format!("plan: {e}"),
8007 })?;
8008 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8009 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8010 let match_rs = {
8011 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8012 if let Some(ref mask) = mask_opt {
8013 execute(&self.view_masked(mask), &ops, &Params(params))
8014 } else {
8015 execute(&self.view(), &ops, &Params(params))
8016 }
8017 }
8018 .map_err(|e| GraphError::QueryError {
8019 detail: format!("execute: {e}"),
8020 })?;
8021
8022 // Collect (etype, src_key, dst_key) for each row × each delete target.
8023 let mut del_ops: Vec<(String, String, String)> = Vec::new();
8024 for row_i in 0..match_rs.len() {
8025 for ed in &stmt.deletes {
8026 let src_key = match match_rs.get(row_i, &ed.src_var) {
8027 Some(Value::Str(k)) => k.clone(),
8028 _ => {
8029 return Err(GraphError::QueryError {
8030 detail: format!(
8031 "DELETE src variable '{}' did not resolve to a node key",
8032 ed.src_var
8033 ),
8034 })
8035 }
8036 };
8037 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8038 Some(Value::Str(k)) => k.clone(),
8039 _ => {
8040 return Err(GraphError::QueryError {
8041 detail: format!(
8042 "DELETE dst variable '{}' did not resolve to a node key",
8043 ed.dst_var
8044 ),
8045 })
8046 }
8047 };
8048 del_ops.push((ed.etype.clone(), src_key, dst_key));
8049 }
8050 }
8051
8052 // Apply as one atomic batch.
8053 let deleted = del_ops.len();
8054 let mut batch = self.batch();
8055 for (etype, src_key, dst_key) in del_ops {
8056 batch.delete_edge(&etype, &src_key, &dst_key);
8057 }
8058 batch.commit().map_err(|e| match e {
8059 GraphError::RuleOwned { .. } => GraphError::QueryError {
8060 detail: "cannot delete derived edge; retract via the rule or change the property"
8061 .to_string(),
8062 },
8063 other => other,
8064 })?;
8065
8066 let mut rs = write_result_set();
8067 rs.push_row(vec![
8068 Some(Value::Int(0)),
8069 Some(Value::Int(0)),
8070 Some(Value::Int(deleted as i64)),
8071 ]);
8072 Ok(rs)
8073 }
8074
8075 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8076 ///
8077 /// Collects the matching node keys via an ephemeral read query, then calls
8078 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
8079 /// the executor first checks that the node has no incident edges; if any
8080 /// remain it returns a named error matching openCypher semantics.
8081 fn exec_match_delete_node(
8082 &mut self,
8083 stmt: MatchDeleteNodeStmt,
8084 params: &BTreeMap<String, Value>,
8085 ) -> Result<ResultSet> {
8086 // Build a read query returning only the node keys we need.
8087 let returns: Vec<RetItem> = stmt
8088 .node_vars
8089 .iter()
8090 .map(|v| RetItem {
8091 value: RetVal::Var(v.clone()),
8092 alias: None,
8093 })
8094 .collect();
8095 let read_q = Query {
8096 matches: stmt.matches,
8097 optional_clauses: vec![],
8098 where_expr: stmt.where_expr,
8099 unwinds: vec![],
8100 post_unwind_where: None,
8101 stages: vec![],
8102 returns,
8103 distinct: false,
8104 order_by: vec![],
8105 skip: None,
8106 limit: None,
8107 };
8108 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8109 detail: format!("plan: {e}"),
8110 })?;
8111 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8112 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8113 let match_rs = {
8114 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8115 if let Some(ref mask) = mask_opt {
8116 execute(&self.view_masked(mask), &ops, &Params(params))
8117 } else {
8118 execute(&self.view(), &ops, &Params(params))
8119 }
8120 }
8121 .map_err(|e| GraphError::QueryError {
8122 detail: format!("execute: {e}"),
8123 })?;
8124
8125 // Collect unique node keys to delete (deduplicate across rows × vars).
8126 let mut keys: Vec<String> = Vec::new();
8127 for row_i in 0..match_rs.len() {
8128 for var in &stmt.node_vars {
8129 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8130 if !keys.contains(k) {
8131 keys.push(k.clone());
8132 }
8133 }
8134 }
8135 }
8136
8137 if !stmt.detach {
8138 // openCypher bare DELETE: error if any matched node has incident edges.
8139 for key in &keys {
8140 if let Some(id) = self.ids.get(key) {
8141 let tv = self.topo_view();
8142 let has_edges = tv.etypes().any(|et| {
8143 !tv.neighbors(et, Direction::Out, id).is_empty()
8144 || !tv.neighbors(et, Direction::In, id).is_empty()
8145 });
8146 if has_edges {
8147 return Err(GraphError::QueryError {
8148 detail: format!(
8149 "Cannot delete node `{key}` because it still has incident edges. \
8150 Use DETACH DELETE to remove the node and all its edges."
8151 ),
8152 });
8153 }
8154 }
8155 }
8156 }
8157
8158 let mut nodes_deleted = 0i64;
8159 let mut edges_deleted = 0i64;
8160 for key in keys {
8161 match self.delete_node(&key) {
8162 Ok(report) => {
8163 nodes_deleted += 1;
8164 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8165 }
8166 Err(GraphError::KeyNotFound { .. }) => {
8167 // Node may have been deleted by an earlier iteration (e.g., via
8168 // multiple MATCH rows for the same node). Safe to skip.
8169 }
8170 Err(e) => return Err(e),
8171 }
8172 }
8173
8174 let mut rs = write_result_set();
8175 rs.push_row(vec![
8176 Some(Value::Int(0)),
8177 Some(Value::Int(0)),
8178 Some(Value::Int(nodes_deleted + edges_deleted)),
8179 ]);
8180 Ok(rs)
8181 }
8182
8183 fn exec_merge(
8184 &mut self,
8185 stmt: core_query::cypher::MergeStmt,
8186 params: &BTreeMap<String, Value>,
8187 ) -> Result<ResultSet> {
8188 // MERGE: check if a node with the given key already exists.
8189 let key = match &stmt.key_value {
8190 Value::Str(s) => s.clone(),
8191 _ => {
8192 return Err(GraphError::QueryError {
8193 detail: format!(
8194 "MERGE key value must be a string (got {:?})",
8195 stmt.key_value
8196 ),
8197 })
8198 }
8199 };
8200
8201 if let Some(var) = stmt.var.as_deref() {
8202 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8203 if sc.var != var {
8204 return Err(GraphError::QueryError {
8205 detail: format!(
8206 "SET variable '{}' does not match MERGE variable '{var}'",
8207 sc.var
8208 ),
8209 });
8210 }
8211 }
8212 }
8213
8214 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8215 //
8216 // MERGE scope precondition: check create OR update scope for the
8217 // declared label BEFORE calling `has_node` (timing-oracle closure,
8218 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8219 // unscoped roles — the scope denial fires without touching the key store).
8220 //
8221 // Clone to avoid holding a borrow on `self.pending_write_authz` while
8222 // also calling `self.ids.get(key)`.
8223 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8224 let has_create = authz.scope.create_labels.contains(&stmt.label);
8225 let has_update = authz.scope.update_labels.contains(&stmt.label);
8226 if !has_create && !has_update {
8227 // Scope-before-lookup: 403 without has_node call (timing oracle
8228 // closure — see test_merge_unscoped_no_key_lookup).
8229 return Err(GraphError::RoleWriteDenied {
8230 reason: format!(
8231 "role-bound token: label '{}' not in write scope (create_labels)",
8232 stmt.label
8233 ),
8234 });
8235 }
8236 // Key lookup under mask.
8237 match self.ids.get(key.as_str()) {
8238 Some(id) if authz.mask.contains_id(id) => {
8239 // Visible: must have update scope to proceed to match arm.
8240 if !has_update {
8241 return Err(GraphError::RoleWriteDenied {
8242 reason: format!(
8243 "role-bound token: label '{}' not in write scope (update_labels)",
8244 stmt.label
8245 ),
8246 });
8247 }
8248 true // existed = true → match arm
8249 }
8250 Some(_) => {
8251 // Hidden: same error as absent to the role (spec §3.1/§3.3).
8252 return Err(GraphError::RoleWriteDenied {
8253 reason: "role-bound token: target node not visible".into(),
8254 });
8255 }
8256 None => {
8257 // Absent: must have create scope to proceed to the create arm.
8258 //
8259 // Update-only roles (create_labels empty, update_labels set):
8260 // return the SAME "not visible" error as the hidden-key branch
8261 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8262 // "confirm existence of hidden nodes: No").
8263 //
8264 // Create-scoped roles (has_create=true): absent → create arm
8265 // as before. The accepted structural key-existence disclosure
8266 // (§THREAT-MODEL) applies only when the role holds create scope.
8267 if !has_create {
8268 return Err(GraphError::RoleWriteDenied {
8269 reason: "role-bound token: target node not visible".into(),
8270 });
8271 }
8272 false // existed = false → create arm
8273 }
8274 }
8275 } else {
8276 // Full authority: use the existing non-masked has_node check.
8277 self.has_node(&key)
8278 };
8279
8280 let existed = merge_existed;
8281 let mut created = 0i64;
8282 if !existed || !stmt.on_match.is_empty() {
8283 let mut batch = self.batch();
8284 if !existed {
8285 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8286 batch.insert_node(&stmt.label, &key, props);
8287 for sc in &stmt.on_create {
8288 let value = resolve_merge_set_value(&sc.value, params)?;
8289 batch.set_prop(&key, &sc.field, value);
8290 }
8291 created = 1;
8292 } else {
8293 for sc in &stmt.on_match {
8294 let value = resolve_merge_set_value(&sc.value, params)?;
8295 batch.set_prop(&key, &sc.field, value);
8296 }
8297 }
8298 batch.commit()?;
8299 }
8300
8301 // Refresh the role mask so the just-created node is visible to this
8302 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8303 // (apply_schema subset rule), so the new node's label is already in the
8304 // role's read scope — this never widens beyond the role's declared labels.
8305 if !existed {
8306 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8307 let new_mask = self.mask_for_role(&role)?;
8308 if let Some(a) = self.pending_write_authz.as_mut() {
8309 a.mask = new_mask;
8310 }
8311 }
8312 }
8313
8314 // Optional RETURN clause: project the node (created or matched) as a read result.
8315 if let Some(returns) = stmt.returns {
8316 let var = stmt.var.as_deref().unwrap_or("_mn0");
8317 let q = Query {
8318 matches: vec![Pattern {
8319 start: NodePat {
8320 var: Some(var.to_string()),
8321 label: Some(stmt.label.clone()),
8322 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8323 },
8324 chain: vec![],
8325 shortest: false,
8326 }],
8327 optional_clauses: vec![],
8328 where_expr: None,
8329 unwinds: vec![],
8330 post_unwind_where: None,
8331 stages: vec![],
8332 returns,
8333 distinct: false,
8334 order_by: vec![],
8335 skip: None,
8336 limit: None,
8337 };
8338 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8339 detail: format!("plan: {e}"),
8340 })?;
8341 // Use view_masked when a role-scoped write is in flight so the
8342 // post-merge projection is consistent with the masked read phase.
8343 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8344 return (if let Some(ref mask) = mask_opt {
8345 execute(&self.view_masked(mask), &ops, &Params(params))
8346 } else {
8347 execute(&self.view(), &ops, &Params(params))
8348 })
8349 .map_err(|e| GraphError::QueryError {
8350 detail: format!("execute: {e}"),
8351 });
8352 }
8353
8354 let mut rs = write_result_set();
8355 rs.push_row(vec![
8356 Some(Value::Int(created)),
8357 Some(Value::Int(0)),
8358 Some(Value::Int(0)),
8359 ]);
8360 Ok(rs)
8361 }
8362
8363 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8364 /// annotated with rule name, edge type, direction, and weight.
8365 /// Results are sorted by (rule, edge_type).
8366 /// Returns `Err(KeyNotFound)` if either key is unknown.
8367 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8368 self.ensure_v8_base_sections_loaded();
8369 let id_a = self
8370 .ids
8371 .get(key_a)
8372 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8373 let id_b = self
8374 .ids
8375 .get(key_b)
8376 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8377
8378 let mut results = Vec::new();
8379
8380 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8381 // rather than O(total provenance).
8382 let scan = if self.engine.provenance_touching_len(id_a)
8383 <= self.engine.provenance_touching_len(id_b)
8384 {
8385 id_a
8386 } else {
8387 id_b
8388 };
8389 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8390 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8391 continue;
8392 }
8393 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8394 continue;
8395 };
8396 let edge_type = match self.syms.resolve(etype) {
8397 Some(s) => s.to_string(),
8398 None => continue,
8399 };
8400 // Provenance (src, dst) ids come from the archived PROVENANCE section
8401 // (large, no eager CRC). A corrupt section can produce ids that are
8402 // out of range; return Corrupt rather than panic.
8403 let src_key = self
8404 .ids
8405 .key_of(src)
8406 .ok_or_else(|| GraphError::Corrupt {
8407 detail: format!("v8: provenance src id {src} not in id table"),
8408 })?
8409 .to_string();
8410 let dst_key = self
8411 .ids
8412 .key_of(dst)
8413 .ok_or_else(|| GraphError::Corrupt {
8414 detail: format!("v8: provenance dst id {dst} not in id table"),
8415 })?
8416 .to_string();
8417 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8418 self.edge_props_view()
8419 .get(etype, src, dst, prop)
8420 .and_then(|v| {
8421 if let Value::Float(f) = v {
8422 Some(f)
8423 } else {
8424 None
8425 }
8426 })
8427 });
8428 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8429 // still have a score: recompute it from the predicate so explain
8430 // never reports "no score" for an edge the engine scored. Via-hop
8431 // rules score over their via set, not over (src, dst), so leave
8432 // those None rather than report a number the rule did not produce.
8433 let weight = stored.or_else(|| {
8434 if rule_def.via_edge.is_some() {
8435 return None;
8436 }
8437 let props_view = build_props_view(&self.props, &self.base);
8438 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8439 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8440 let src_view = NodeView {
8441 key: &src_key,
8442 props: &src_get,
8443 };
8444 let dst_view = NodeView {
8445 key: &dst_key,
8446 props: &dst_get,
8447 };
8448 evaluate(&rule_def.predicate, &src_view, &dst_view)
8449 });
8450 results.push(Explanation {
8451 rule: rule_name.to_string(),
8452 edge_type,
8453 src_key,
8454 dst_key,
8455 weight,
8456 predicate: PredicateSummary {
8457 approximate: rule_def.approximate,
8458 ..PredicateSummary::from(&rule_def.predicate)
8459 },
8460 via_edge: rule_def.via_edge.clone(),
8461 });
8462 }
8463
8464 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8465 Ok(results)
8466 }
8467
8468 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8469 let id = self
8470 .ids
8471 .get(key)
8472 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8473 let Some(sym) = self.syms.get(edge_type) else {
8474 return Ok(Vec::new());
8475 };
8476 self.topo_view()
8477 .neighbors(sym, dir, id)
8478 .iter()
8479 .map(|&n| {
8480 self.ids
8481 .key_of(n)
8482 .map(|k| k.to_string())
8483 .ok_or_else(|| GraphError::Corrupt {
8484 detail: format!("topology id {n} has no key"),
8485 })
8486 })
8487 .collect::<Result<Vec<_>>>()
8488 }
8489
8490 /// Return the last-change commit sequence for `key`, or `None` if the node
8491 /// does not exist or has never been mutated since the last V5-V7 snapshot
8492 /// (horizon-bounded for legacy stores).
8493 ///
8494 /// The returned sequence is a monotonically increasing counter that starts
8495 /// at 1 for the first commit after `open` and increments with every
8496 /// successful write. WAL replay at open also assigns sequences (1..N for N
8497 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8498 ///
8499 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8500 /// in the snapshot but not touched by any WAL frame will return `None`
8501 /// (horizon-bounded: CAS against such nodes is only safe after the first
8502 /// V8 snapshot or after the node is next mutated).
8503 pub fn last_changed(&self, key: &str) -> Option<u64> {
8504 let id = self.ids.get(key)?;
8505 self.last_change.get(&id).copied()
8506 }
8507
8508 /// The current commit sequence (number of successful commits since open,
8509 /// including WAL replay frames). Useful for recording a baseline before
8510 /// a read-modify-write cycle.
8511 pub fn commit_seq(&self) -> u64 {
8512 self.commit_seq
8513 }
8514
8515 /// Check that all `preconds` are satisfied against the current db state.
8516 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8517 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8518 for precond in preconds {
8519 match precond {
8520 Precondition::NodeUnchangedSince { key, expected } => {
8521 // Missing entry means the node predates the WAL window or
8522 // does not exist; treat as 0 (before any commit).
8523 let actual = self.last_changed(key).unwrap_or_default();
8524 if actual != *expected {
8525 return Err(GraphError::CasConflict {
8526 key: key.clone(),
8527 expected: *expected,
8528 actual,
8529 });
8530 }
8531 }
8532 Precondition::NodeAbsent { key } => {
8533 // Node must not exist (not live).
8534 if self.ids.get(key).is_some() {
8535 let actual = self.last_changed(key).unwrap_or(0);
8536 return Err(GraphError::CasConflict {
8537 key: key.clone(),
8538 expected: u64::MAX,
8539 actual,
8540 });
8541 }
8542 }
8543 }
8544 }
8545 Ok(())
8546 }
8547
8548 /// Apply a batch of mutations with compare-and-set preconditions.
8549 ///
8550 /// All preconditions are checked atomically before any operation is applied.
8551 /// If any precondition fails, the entire batch is rejected with
8552 /// [`GraphError::CasConflict`] and no WAL frame is written.
8553 ///
8554 /// # Returns
8555 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8556 ///
8557 /// # Errors
8558 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8559 /// - Any error that [`write_batch`] would return for the ops themselves.
8560 pub fn write_batch_cas(
8561 &mut self,
8562 preconds: Vec<Precondition>,
8563 ops: Vec<BatchOp>,
8564 ) -> Result<(usize, usize)> {
8565 self.check_preconditions(&preconds)?;
8566 self.commit_logged_batch(ops, None, None)
8567 }
8568
8569 /// Update the per-node last-change map for a WAL record at commit `seq`.
8570 ///
8571 /// Called after a successful apply to record which nodes were touched.
8572 /// For replay, called with the WAL-frame's replayed seq.
8573 ///
8574 /// Touch definition (see [`Precondition`] doc):
8575 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8576 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8577 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8578 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8579 /// - Batch → recurse into inner records.
8580 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8581 match rec {
8582 WalRecord::InsertNode { key, .. }
8583 | WalRecord::SetProp { key, .. }
8584 | WalRecord::RemoveProp { key, .. } => {
8585 if let Some(id) = self.ids.get(key) {
8586 self.last_change.insert(id, seq);
8587 }
8588 }
8589 WalRecord::InsertNodeId { key, .. } => {
8590 if let Some(id) = self.ids.get(key) {
8591 self.last_change.insert(id, seq);
8592 }
8593 }
8594 WalRecord::SetPropId { id, .. } => {
8595 self.last_change.insert(*id, seq);
8596 }
8597 WalRecord::InsertEdge {
8598 src_key, dst_key, ..
8599 }
8600 | WalRecord::DeleteEdge {
8601 src_key, dst_key, ..
8602 } => {
8603 if let Some(src_id) = self.ids.get(src_key) {
8604 self.last_change.insert(src_id, seq);
8605 }
8606 if let Some(dst_id) = self.ids.get(dst_key) {
8607 self.last_change.insert(dst_id, seq);
8608 }
8609 }
8610 WalRecord::InsertEdgeId { src, dst, .. } => {
8611 self.last_change.insert(*src, seq);
8612 self.last_change.insert(*dst, seq);
8613 }
8614 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8615 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8616 // History markers: state no-ops; the underlying mutation already
8617 // touched the relevant nodes' last_change entries.
8618 WalRecord::DeleteNode { .. }
8619 | WalRecord::DerivedEdgeAdded { .. }
8620 | WalRecord::DerivedEdgeRetracted { .. }
8621 | WalRecord::Intern { .. }
8622 | WalRecord::CreateRule { .. }
8623 | WalRecord::DeleteRule { .. }
8624 | WalRecord::RebuildRule { .. }
8625 | WalRecord::CreateView { .. }
8626 | WalRecord::DeleteView { .. }
8627 | WalRecord::EnableFulltext { .. }
8628 | WalRecord::DisableFulltext { .. }
8629 | WalRecord::EnableIndex { .. }
8630 | WalRecord::DisableIndex { .. } => {}
8631 // RenameNode: node id is stable; update last_change via the new key.
8632 // Called after apply(), so ids already reflects new_key.
8633 WalRecord::RenameNode { new_key, .. } => {
8634 if let Some(id) = self.ids.get(new_key) {
8635 self.last_change.insert(id, seq);
8636 }
8637 }
8638 WalRecord::Batch(inner) => {
8639 for inner_rec in inner {
8640 self.update_last_change_from_rec(inner_rec, seq);
8641 }
8642 }
8643 }
8644 }
8645
8646 pub fn node_count(&self) -> usize {
8647 self.ids.len()
8648 }
8649
8650 /// Configure archive retention: keep the `N` newest WAL archives at each
8651 /// [`snapshot_with`] call when `archive_wal: true`.
8652 ///
8653 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8654 /// `Some(0)` or `None` → unlimited (no pruning).
8655 ///
8656 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8657 /// stores the policy. Archives below the retention limit are deleted
8658 /// oldest-first. The horizon floor is updated so that
8659 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8660 /// in pruned archives rather than silently returning wrong data.
8661 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8662 self.wal_archive_retention = keep;
8663 }
8664
8665 /// Delete any WAL archives that are fully below the current horizon floor.
8666 ///
8667 /// Orphaned archives arise when the floor is written first during retention
8668 /// pruning and then a crash interrupts the archive-delete sequence. The
8669 /// opening cleanup ensures no subsequent read path sees stale data.
8670 ///
8671 /// Under the monotonic naming scheme, the archive name N equals the
8672 /// cumulative end-frame index of the archive in global commit space (i.e.
8673 /// the archive covers global frames `[prev_n, N)`). An archive is
8674 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8675 /// below the floor and have already been counted in it.
8676 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8677 if self.wal_horizon_floor == 0 {
8678 // Floor at 0 means no pruning has ever occurred; nothing to clean.
8679 return Ok(());
8680 }
8681 let archive_ns = self.fs.list_archives()?;
8682 for n in archive_ns {
8683 if n <= self.wal_horizon_floor {
8684 // Archive N ends at global frame N; all its frames are below
8685 // the floor (floor already accounts for them) → orphaned.
8686 self.fs.delete_archive(n).map_err(GraphError::Io)?;
8687 } else {
8688 // Archives are sorted ascending; first one above floor stops scan.
8689 break;
8690 }
8691 }
8692 Ok(())
8693 }
8694
8695 /// Collect all WAL frames from surviving archives (oldest-first) then the
8696 /// live WAL into one flat list, and return the total along with the number
8697 /// of archive frames at the front of the list.
8698 ///
8699 /// Commit indices into the returned list are LOCAL (0 = first frame of
8700 /// oldest surviving archive). To obtain the GLOBAL index add
8701 /// `self.wal_horizon_floor`.
8702 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8703 let archive_ns = self.fs.list_archives()?;
8704 let mut all: Vec<WalRecord> = Vec::new();
8705 for n in archive_ns {
8706 let bytes = self.fs.read_archive(n)?;
8707 let (frames, _) = decode_all(&bytes);
8708 all.extend(frames);
8709 }
8710 let archive_count = all.len() as u64;
8711 let live_bytes = self.fs.read(FileId::Wal)?;
8712 let (live_frames, _) = decode_all(&live_bytes);
8713 all.extend(live_frames);
8714 Ok((all, archive_count))
8715 }
8716
8717 /// Return the total number of committed WAL frames visible in the current
8718 /// horizon window, including frames in surviving WAL archives.
8719 ///
8720 /// This is the exclusive upper bound for valid `at_commit` indices in
8721 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8722 ///
8723 /// Returns the horizon floor when all surviving history is empty.
8724 pub fn wal_total_commits(&self) -> Result<u64> {
8725 let (frames, _) = self.all_frames()?;
8726 Ok(self.wal_horizon_floor + frames.len() as u64)
8727 }
8728
8729 /// The global frame index of the first commit reachable through surviving
8730 /// archives (0 when no archives have been pruned).
8731 pub fn wal_horizon_floor(&self) -> u64 {
8732 self.wal_horizon_floor
8733 }
8734
8735 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8736 ///
8737 /// ## Horizon
8738 ///
8739 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8740 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8741 /// zero-cost contract; a durable history log is out of scope.
8742 ///
8743 /// ## Derived edges
8744 ///
8745 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8746 /// history. Only edges written directly by the application are recorded.
8747 ///
8748 /// ## Deleted nodes
8749 ///
8750 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8751 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8752 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8753 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8754 ///
8755 /// ## Dense-id edge entries and tombstoned partners
8756 ///
8757 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8758 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8759 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8760 /// Build commit-bounded alias intervals for `queried_key`.
8761 ///
8762 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8763 /// A record written under `key` at commit `c` matches the queried identity iff
8764 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8765 ///
8766 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8767 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8768 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8769 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8770 /// only identity-2's events (commits 7–9 under "a") are in scope.
8771 ///
8772 /// Only **forward aliasing**: querying the *new* key surfaces events written
8773 /// under the *old* key. The reverse direction is not supported.
8774 fn build_key_alias_intervals(
8775 &self,
8776 frames: &[core_storage::wal::WalRecord],
8777 queried_key: &str,
8778 ) -> Vec<(String, u64, Option<u64>)> {
8779 use core_storage::wal::WalRecord;
8780
8781 // Pre-pass: build reverse_rename and key_starts maps.
8782 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8783 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8784
8785 for (local_i, frame) in frames.iter().enumerate() {
8786 let commit = self.wal_horizon_floor + local_i as u64;
8787 let records: &[WalRecord] = match frame {
8788 WalRecord::Batch(inner) => inner.as_slice(),
8789 single => std::slice::from_ref(single),
8790 };
8791 for rec in records {
8792 match rec {
8793 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8794 key_starts.entry(key.clone()).or_default().push(commit);
8795 }
8796 WalRecord::RenameNode { old_key, new_key } => {
8797 // new_key came into existence at this commit.
8798 key_starts.entry(new_key.clone()).or_default().push(commit);
8799 // Record the reverse rename: new_key was introduced by renaming old_key.
8800 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8801 }
8802 _ => {}
8803 }
8804 }
8805 }
8806
8807 // Build alias intervals by following the reverse rename chain.
8808 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8809 let mut current_key = queried_key.to_string();
8810 let mut current_valid_until: Option<u64> = None;
8811
8812 loop {
8813 // valid_from: the most recent commit where current_key was assigned to this
8814 // identity. For aliases (valid_until = Some(vu)), find the last start event
8815 // for the key strictly before vu — this is where the alias's occupancy by
8816 // this identity began, correctly excluding prior identities that reused the key.
8817 let valid_from = if let Some(vu) = current_valid_until {
8818 key_starts
8819 .get(¤t_key)
8820 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8821 .unwrap_or(self.wal_horizon_floor)
8822 } else {
8823 // Queried key — no upper bound; may have been introduced at any commit.
8824 self.wal_horizon_floor
8825 };
8826
8827 result.push((current_key.clone(), valid_from, current_valid_until));
8828
8829 match reverse_rename.get(¤t_key) {
8830 Some((old_key, rename_commit)) => {
8831 current_valid_until = Some(*rename_commit);
8832 current_key = old_key.clone();
8833 }
8834 None => break,
8835 }
8836 }
8837
8838 result
8839 }
8840
8841 /// Returns true if `record_key` matches any alias interval that covers `commit`.
8842 fn aliases_match(
8843 intervals: &[(String, u64, Option<u64>)],
8844 record_key: &str,
8845 commit: u64,
8846 ) -> bool {
8847 intervals
8848 .iter()
8849 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8850 }
8851
8852 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8853 use crate::history::{HistoryChange, HistoryEntry};
8854 use core_storage::wal::WalRecord;
8855
8856 let (frames, _) = self.all_frames()?;
8857
8858 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8859 let alias_intervals = self.build_key_alias_intervals(&frames, key);
8860
8861 let mut out: Vec<HistoryEntry> = Vec::new();
8862
8863 for (local_i, frame) in frames.iter().enumerate() {
8864 let commit = self.wal_horizon_floor + local_i as u64;
8865 // Collect the inner records to process — Batch is one commit, single records are one commit.
8866 let records: &[WalRecord] = match frame {
8867 WalRecord::Batch(inner) => inner.as_slice(),
8868 single => std::slice::from_ref(single),
8869 };
8870
8871 for rec in records {
8872 let change = match rec {
8873 WalRecord::InsertNode { label, key: k, .. }
8874 if Self::aliases_match(&alias_intervals, k, commit) =>
8875 {
8876 Some(HistoryChange::NodeInserted {
8877 label: label.clone(),
8878 })
8879 }
8880 WalRecord::InsertNodeId { label, key: k, .. }
8881 if Self::aliases_match(&alias_intervals, k, commit) =>
8882 {
8883 let label_str = match self.syms.resolve(*label) {
8884 Some(s) => s.to_string(),
8885 None => continue,
8886 };
8887 Some(HistoryChange::NodeInserted { label: label_str })
8888 }
8889 WalRecord::SetProp {
8890 key: k,
8891 field,
8892 value,
8893 } if Self::aliases_match(&alias_intervals, k, commit) => {
8894 Some(HistoryChange::PropSet {
8895 field: field.clone(),
8896 value: value.clone(),
8897 })
8898 }
8899 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8900 // key_of returns the current (post-rename) key; compare to queried key.
8901 Some(resolved) if resolved == key => {
8902 let field_str = match self.syms.resolve(*field) {
8903 Some(s) => s.to_string(),
8904 None => continue,
8905 };
8906 Some(HistoryChange::PropSet {
8907 field: field_str,
8908 value: value.clone(),
8909 })
8910 }
8911 _ => None,
8912 },
8913 WalRecord::RemoveProp { key: k, field }
8914 if Self::aliases_match(&alias_intervals, k, commit) =>
8915 {
8916 Some(HistoryChange::PropRemoved {
8917 field: field.clone(),
8918 })
8919 }
8920 WalRecord::InsertEdge {
8921 edge_type,
8922 src_key,
8923 dst_key,
8924 } => {
8925 if Self::aliases_match(&alias_intervals, src_key, commit) {
8926 Some(HistoryChange::EdgeAdded {
8927 edge_type: edge_type.clone(),
8928 other: dst_key.clone(),
8929 outgoing: true,
8930 })
8931 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8932 Some(HistoryChange::EdgeAdded {
8933 edge_type: edge_type.clone(),
8934 other: src_key.clone(),
8935 outgoing: false,
8936 })
8937 } else {
8938 None
8939 }
8940 }
8941 WalRecord::InsertEdgeId { etype, src, dst } => {
8942 let etype_str = match self.syms.resolve(*etype) {
8943 Some(s) => s.to_string(),
8944 None => continue,
8945 };
8946 let src_key = self.ids.key_of(*src);
8947 let dst_key = self.ids.key_of(*dst);
8948 if src_key == Some(key) {
8949 let other = match dst_key {
8950 Some(s) => s.to_string(),
8951 None => continue,
8952 };
8953 Some(HistoryChange::EdgeAdded {
8954 edge_type: etype_str,
8955 other,
8956 outgoing: true,
8957 })
8958 } else if dst_key == Some(key) {
8959 let other = match src_key {
8960 Some(s) => s.to_string(),
8961 None => continue,
8962 };
8963 Some(HistoryChange::EdgeAdded {
8964 edge_type: etype_str,
8965 other,
8966 outgoing: false,
8967 })
8968 } else {
8969 None
8970 }
8971 }
8972 WalRecord::DeleteEdge {
8973 edge_type,
8974 src_key,
8975 dst_key,
8976 } => {
8977 if Self::aliases_match(&alias_intervals, src_key, commit) {
8978 Some(HistoryChange::EdgeRemoved {
8979 edge_type: edge_type.clone(),
8980 other: dst_key.clone(),
8981 outgoing: true,
8982 })
8983 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8984 Some(HistoryChange::EdgeRemoved {
8985 edge_type: edge_type.clone(),
8986 other: src_key.clone(),
8987 outgoing: false,
8988 })
8989 } else {
8990 None
8991 }
8992 }
8993 WalRecord::DeleteNode { key: k }
8994 if Self::aliases_match(&alias_intervals, k, commit) =>
8995 {
8996 Some(HistoryChange::NodeDeleted)
8997 }
8998 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8999 _ => None,
9000 };
9001
9002 if let Some(change) = change {
9003 out.push(HistoryEntry { commit, change });
9004 }
9005 }
9006 }
9007
9008 Ok(out)
9009 }
9010
9011 /// Return the per-edge change history between nodes `a` and `b` by scanning
9012 /// the on-disk WAL.
9013 ///
9014 /// ## Horizon
9015 ///
9016 /// History reaches back only to the last WAL-truncating snapshot, exactly
9017 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9018 /// `total_commits` (= number of WAL frames), which is the exclusive upper
9019 /// bound for valid commit indices.
9020 ///
9021 /// ## Derived edges
9022 ///
9023 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9024 /// WAL markers written by `log_then_apply_with` after each rule-firing
9025 /// mutation. The `rule` field of those events carries the rule name.
9026 ///
9027 /// ## DeleteNode
9028 ///
9029 /// When a node is deleted, its manual incident edges are swept inline without
9030 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9031 /// events for either endpoint and synthesises `Retracted(rule:None)` events
9032 /// for each manual edge that was active at that point. Derived edges active at
9033 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9034 /// the engine appends immediately after the `DeleteNode` record; those events
9035 /// carry correct rule attribution and are emitted by the marker arm, not the
9036 /// synthetic sweep.
9037 ///
9038 /// ## Masks
9039 ///
9040 /// Like `node_history`, this method has no mask parameter and returns WAL
9041 /// history regardless of any role mask. For masked history semantics, apply
9042 /// the mask at the caller level.
9043 pub fn edge_history(
9044 &self,
9045 a: &str,
9046 b: &str,
9047 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9048 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9049 use core_storage::wal::WalRecord;
9050
9051 let (frames, _) = self.all_frames()?;
9052 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9053
9054 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9055 // Intervals are commit-bounded so recycled keys don't contaminate histories.
9056 let alias_a = self.build_key_alias_intervals(&frames, a);
9057 let alias_b = self.build_key_alias_intervals(&frames, b);
9058
9059 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9060 // The is_derived flag is used by the DeleteNode sweep: manual edges are
9061 // swept with a synthetic Retracted(rule:None); derived edges are skipped
9062 // because the engine writes a DerivedEdgeRetracted marker immediately after
9063 // the DeleteNode record, which carries the correct rule attribution.
9064 let mut active: Vec<(String, String, String, bool)> = Vec::new();
9065 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9066
9067 for (local_i, frame) in frames.iter().enumerate() {
9068 let commit = self.wal_horizon_floor + local_i as u64;
9069 let records: &[WalRecord] = match frame {
9070 WalRecord::Batch(inner) => inner.as_slice(),
9071 single => std::slice::from_ref(single),
9072 };
9073
9074 for rec in records {
9075 match rec {
9076 WalRecord::InsertEdge {
9077 edge_type,
9078 src_key,
9079 dst_key,
9080 } => {
9081 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9082 && Self::aliases_match(&alias_b, dst_key, commit);
9083 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9084 && Self::aliases_match(&alias_a, dst_key, commit);
9085 if is_ab || is_ba {
9086 active.push((
9087 edge_type.clone(),
9088 src_key.clone(),
9089 dst_key.clone(),
9090 false,
9091 ));
9092 out.push(EdgeHistoryEvent {
9093 edge_type: edge_type.clone(),
9094 commit,
9095 event: EdgeEvent::Added,
9096 rule: None,
9097 });
9098 }
9099 }
9100 WalRecord::InsertEdgeId { etype, src, dst } => {
9101 let etype_str = match self.syms.resolve(*etype) {
9102 Some(s) => s.to_string(),
9103 None => continue,
9104 };
9105 // Use key_of_historical so tombstoned nodes (deleted
9106 // later in the WAL) still resolve during the scan.
9107 let src_key = self.ids.key_of_historical(*src);
9108 let dst_key = self.ids.key_of_historical(*dst);
9109 let is_ab = src_key == Some(a) && dst_key == Some(b);
9110 let is_ba = src_key == Some(b) && dst_key == Some(a);
9111 if is_ab || is_ba {
9112 let src_str = src_key.unwrap().to_string();
9113 let dst_str = dst_key.unwrap().to_string();
9114 active.push((etype_str.clone(), src_str, dst_str, false));
9115 out.push(EdgeHistoryEvent {
9116 edge_type: etype_str,
9117 commit,
9118 event: EdgeEvent::Added,
9119 rule: None,
9120 });
9121 }
9122 }
9123 WalRecord::DeleteEdge {
9124 edge_type,
9125 src_key,
9126 dst_key,
9127 } => {
9128 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9129 && Self::aliases_match(&alias_b, dst_key, commit);
9130 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9131 && Self::aliases_match(&alias_a, dst_key, commit);
9132 if is_ab || is_ba {
9133 // Remove the first matching active entry (flag ignored).
9134 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9135 et == edge_type && s == src_key && d == dst_key
9136 }) {
9137 active.remove(pos);
9138 }
9139 out.push(EdgeHistoryEvent {
9140 edge_type: edge_type.clone(),
9141 commit,
9142 event: EdgeEvent::Retracted,
9143 rule: None,
9144 });
9145 }
9146 }
9147 WalRecord::DeleteNode { key: k }
9148 if Self::aliases_match(&alias_a, k, commit)
9149 || Self::aliases_match(&alias_b, k, commit) =>
9150 {
9151 // Sweep: implicitly retract only MANUAL active edges.
9152 // Derived active edges are skipped here because the rule
9153 // engine appends a DerivedEdgeRetracted marker immediately
9154 // after this DeleteNode record; that marker produces the
9155 // single correctly-attributed Retracted event. Derived
9156 // entries are dropped from `active` (the marker arm's
9157 // idempotent retain finds nothing to remove).
9158 for (et, _, _, is_derived) in active.drain(..) {
9159 if !is_derived {
9160 out.push(EdgeHistoryEvent {
9161 edge_type: et,
9162 commit,
9163 event: EdgeEvent::Retracted,
9164 rule: None,
9165 });
9166 }
9167 // Derived: drop silently; marker carries the Retracted event.
9168 }
9169 }
9170 WalRecord::DerivedEdgeAdded {
9171 rule,
9172 edge_type: et,
9173 src_key,
9174 dst_key,
9175 } => {
9176 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9177 && Self::aliases_match(&alias_b, dst_key, commit);
9178 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9179 && Self::aliases_match(&alias_a, dst_key, commit);
9180 if is_ab || is_ba {
9181 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9182 out.push(EdgeHistoryEvent {
9183 edge_type: et.clone(),
9184 commit,
9185 event: EdgeEvent::Added,
9186 rule: Some(rule.clone()),
9187 });
9188 }
9189 }
9190 WalRecord::DerivedEdgeRetracted {
9191 rule,
9192 edge_type: et,
9193 src_key,
9194 dst_key,
9195 } => {
9196 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9197 && Self::aliases_match(&alias_b, dst_key, commit);
9198 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9199 && Self::aliases_match(&alias_a, dst_key, commit);
9200 if is_ab || is_ba {
9201 // Push unconditionally: a derived edge whose Added marker
9202 // predates the history horizon has no `active` entry, but
9203 // the retraction is still a real in-window event.
9204 // Remove from active idempotently if present.
9205 active.retain(|(aet, s, d, _)| {
9206 !(aet == et && s == src_key && d == dst_key)
9207 });
9208 out.push(EdgeHistoryEvent {
9209 edge_type: et.clone(),
9210 commit,
9211 event: EdgeEvent::Retracted,
9212 rule: Some(rule.clone()),
9213 });
9214 }
9215 }
9216 // All other records (InsertNode, SetProp, CreateRule, etc.)
9217 // do not affect edges between a and b.
9218 _ => {}
9219 }
9220 }
9221 }
9222
9223 Ok(HistoryResult {
9224 items: out,
9225 total_commits,
9226 })
9227 }
9228
9229 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9230 /// (in either direction) at the WAL commit `at_commit`.
9231 ///
9232 /// ## Horizon
9233 ///
9234 /// Valid commit indices are `0..total_commits` where `total_commits` is the
9235 /// number of WAL frames. An `at_commit >= total_commits` is outside the
9236 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9237 ///
9238 /// ## Derived edges
9239 ///
9240 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9241 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9242 /// and therefore includes derived edges in its point-in-time evaluation,
9243 /// matching `edge_history`'s fidelity.
9244 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9245 use core_storage::wal::WalRecord;
9246
9247 let (frames, _) = self.all_frames()?;
9248 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9249
9250 // Horizon floor: commits in pruned archives are unreachable.
9251 if at_commit < self.wal_horizon_floor {
9252 return Err(GraphError::CommitOutOfRange {
9253 commit: at_commit,
9254 total: total_commits,
9255 });
9256 }
9257 if at_commit >= total_commits {
9258 return Err(GraphError::CommitOutOfRange {
9259 commit: at_commit,
9260 total: total_commits,
9261 });
9262 }
9263
9264 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9265 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9266 let alias_a = self.build_key_alias_intervals(&frames, a);
9267 let alias_b = self.build_key_alias_intervals(&frames, b);
9268
9269 // Local index into surviving frames (0 = first frame of oldest archive).
9270 let local_commit = at_commit - self.wal_horizon_floor;
9271
9272 // Replay local frames 0..=local_commit, tracking active edges.
9273 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9274
9275 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9276 let commit = self.wal_horizon_floor + local_i as u64;
9277 let records: &[WalRecord] = match frame {
9278 WalRecord::Batch(inner) => inner.as_slice(),
9279 single => std::slice::from_ref(single),
9280 };
9281
9282 for rec in records {
9283 match rec {
9284 WalRecord::InsertEdge {
9285 edge_type: et,
9286 src_key,
9287 dst_key,
9288 } => {
9289 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9290 && Self::aliases_match(&alias_b, dst_key, commit);
9291 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9292 && Self::aliases_match(&alias_a, dst_key, commit);
9293 if is_ab || is_ba {
9294 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9295 }
9296 }
9297 WalRecord::InsertEdgeId { etype, src, dst } => {
9298 let etype_str = match self.syms.resolve(*etype) {
9299 Some(s) => s.to_string(),
9300 None => continue,
9301 };
9302 // Use key_of_historical so tombstoned nodes resolve.
9303 let src_key = self.ids.key_of_historical(*src);
9304 let dst_key = self.ids.key_of_historical(*dst);
9305 let is_ab = src_key == Some(a) && dst_key == Some(b);
9306 let is_ba = src_key == Some(b) && dst_key == Some(a);
9307 if is_ab || is_ba {
9308 active.insert((
9309 etype_str,
9310 src_key.unwrap().to_string(),
9311 dst_key.unwrap().to_string(),
9312 ));
9313 }
9314 }
9315 WalRecord::DeleteEdge {
9316 edge_type: et,
9317 src_key,
9318 dst_key,
9319 } => {
9320 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9321 && Self::aliases_match(&alias_b, dst_key, commit);
9322 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9323 && Self::aliases_match(&alias_a, dst_key, commit);
9324 if is_ab || is_ba {
9325 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9326 }
9327 }
9328 WalRecord::DeleteNode { key: k }
9329 if Self::aliases_match(&alias_a, k, commit)
9330 || Self::aliases_match(&alias_b, k, commit) =>
9331 {
9332 // All edges touching the deleted node are gone.
9333 active.retain(|(_, s, d)| s != k && d != k);
9334 }
9335 WalRecord::DerivedEdgeAdded {
9336 edge_type: et,
9337 src_key,
9338 dst_key,
9339 ..
9340 } => {
9341 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9342 && Self::aliases_match(&alias_b, dst_key, commit);
9343 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9344 && Self::aliases_match(&alias_a, dst_key, commit);
9345 if is_ab || is_ba {
9346 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9347 }
9348 }
9349 WalRecord::DerivedEdgeRetracted {
9350 edge_type: et,
9351 src_key,
9352 dst_key,
9353 ..
9354 } => {
9355 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9356 && Self::aliases_match(&alias_b, dst_key, commit);
9357 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9358 && Self::aliases_match(&alias_a, dst_key, commit);
9359 if is_ab || is_ba {
9360 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9361 }
9362 }
9363 _ => {}
9364 }
9365 }
9366 }
9367
9368 Ok(active.iter().any(|(et, _, _)| et == edge_type))
9369 }
9370
9371 pub fn edge_count(&self) -> u64 {
9372 self.topo_view().edge_count()
9373 }
9374
9375 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
9376 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
9377 pub fn stats(&self) -> Stats {
9378 self.ensure_v8_base_sections_loaded();
9379 let rules: Vec<RuleStats> = self
9380 .engine
9381 .rules()
9382 .map(|r| RuleStats {
9383 name: r.name.clone(),
9384 edges: self
9385 .engine
9386 .provenance()
9387 .get(&r.name)
9388 .map(|s| s.len() as u64)
9389 .unwrap_or(0),
9390 tripped: self.engine.is_tripped(&r.name),
9391 fires: self.engine.fire_count(&r.name),
9392 approximate: r.approximate,
9393 })
9394 .collect();
9395 Stats {
9396 nodes_live: self.ids.live_len(),
9397 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
9398 edges: self.topo_view().edge_count(),
9399 rules,
9400 chain_truncations: self.engine.chain_truncations(),
9401 }
9402 }
9403
9404 /// On-disk size of the WAL file in bytes.
9405 ///
9406 /// Reads file metadata without loading WAL contents. Returns `Err` for
9407 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
9408 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
9409 let path = self.fs.wal_path().ok_or_else(|| {
9410 std::io::Error::new(
9411 std::io::ErrorKind::Unsupported,
9412 "wal_path not available for this Fs implementation",
9413 )
9414 })?;
9415 Ok(std::fs::metadata(path)?.len())
9416 }
9417
9418 /// Set the slow-query threshold. Queries whose execution time equals or
9419 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
9420 ///
9421 /// Use this setter in tests — the environment variable
9422 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
9423 /// threads.
9424 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
9425 self.slow_query_threshold_ms = ms;
9426 }
9427
9428 /// Snapshot of the slow-query ring buffer and lifetime counter.
9429 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
9430 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
9431 SlowQuerySnapshot {
9432 threshold_ms: self.slow_query_threshold_ms,
9433 count: log.total,
9434 last: log.entries.iter().cloned().collect(),
9435 }
9436 }
9437
9438 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
9439 /// to compute uptime.
9440 pub fn started_at(&self) -> std::time::Instant {
9441 self.started_at
9442 }
9443
9444 /// On-disk snapshot format version this binary writes and reads.
9445 pub fn format_version() -> u16 {
9446 core_storage::snapshot::VERSION
9447 }
9448
9449 /// Test-support: total bytes appended (SimFs only usage).
9450 pub fn fs_total_appended(&self) -> usize
9451 where
9452 F: FsIntrospect,
9453 {
9454 self.fs.total_appended()
9455 }
9456
9457 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
9458 pub fn fs_sync_count(&self) -> usize
9459 where
9460 F: FsIntrospect,
9461 {
9462 self.fs.sync_count()
9463 }
9464
9465 /// Consume the db, returning its fs (for crash simulation).
9466 pub fn into_fs(self) -> F {
9467 self.fs
9468 }
9469
9470 pub fn snapshot(&mut self) -> Result<()> {
9471 self.snapshot_with(SnapshotOptions::default())
9472 }
9473
9474 /// Snapshot with explicit options.
9475 ///
9476 /// # `keep_wal`
9477 ///
9478 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
9479 /// - The WAL is replaced with a minimal baseline containing one
9480 /// `EnableFulltext` record per active declaration. All pre-snapshot
9481 /// history is discarded; `open_at` can only reach post-snapshot commits.
9482 ///
9483 /// When `keep_wal` is `true`:
9484 /// - The WAL is left intact. All pre-snapshot commits remain reachable
9485 /// via `open_at`. The existing WAL already contains the original
9486 /// `EnableFulltext` records, so no baseline re-write is needed; the
9487 /// recovery guards in `apply()` silently skip any duplicate records on
9488 /// replay.
9489 /// - Crash window: a crash after the snapshot write but before the next
9490 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
9491 /// snapshot is loaded and the WAL replayed idempotently over it — safe
9492 /// because every `apply()` arm is idempotent when replayed over an
9493 /// already-current snapshot.
9494 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
9495 if self.read_only {
9496 return Err(GraphError::ReadOnly);
9497 }
9498 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
9499 // appending ends up holding a descriptor on an unlinked inode and loses
9500 // commits it believes durable. Snapshotting therefore requires the
9501 // cross-process write lock, exactly as appending does. Unlike the WAL
9502 // append path this does not go through `log_then_apply_with`, so both
9503 // guards are repeated here.
9504 if self.degraded {
9505 return Err(GraphError::Io(std::io::Error::other(
9506 "database degraded after group-commit fsync failure; reopen required",
9507 )));
9508 }
9509 if self.lock_denied {
9510 return Err(GraphError::Busy { holder: None });
9511 }
9512 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
9513 // Used by the archive path's conservative genesis-chain check: if a prior
9514 // snapshot exists but wal.truncated does not, we cannot distinguish a
9515 // legacy store (may have been truncated in an older code version) from a
9516 // new store that only used keep_wal=true. Conservative: refuse genesis in
9517 // both cases. Must be sampled here, before the snapshot write below.
9518 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
9519 self.ensure_v8_base_sections_loaded();
9520 // Ensure provenance is decoded before to_persist() clones it.
9521 self.engine.ensure_provenance_loaded_mut();
9522 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
9523 let rule_defs = rule_defs_typed
9524 .iter()
9525 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
9526 .collect();
9527 // Collect HNSW state and IVF state. When indexes are not yet
9528 // populated (clean open, no mutation since open), pass the retained
9529 // raw bytes through directly so that migrate/snapshot does not
9530 // silently discard fitted approximate-rule indexes.
9531 let hnsw_state = self.engine.export_hnsw_state_passthrough();
9532 let ivf_bytes = if !self.engine.indexes_populated() {
9533 // Pass retained IVF bytes through unchanged (no re-encode).
9534 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
9535 } else {
9536 // Indexes live: encode from current state.
9537 let raw_ivf = self.engine.export_ivf_state();
9538 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
9539 .into_iter()
9540 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
9541 (
9542 name,
9543 core_storage::snapshot::PerRuleIvfState {
9544 src: core_storage::snapshot::SideIvfState {
9545 centroids: sc,
9546 clusters: sa,
9547 drift: sd,
9548 },
9549 dst: core_storage::snapshot::SideIvfState {
9550 centroids: dc,
9551 clusters: da,
9552 drift: dd,
9553 },
9554 },
9555 )
9556 })
9557 .collect();
9558 if ivf_state_map.is_empty() {
9559 Vec::new()
9560 } else {
9561 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
9562 }
9563 };
9564 let view_defs: Vec<Vec<u8>> = self
9565 .view_store
9566 .views()
9567 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
9568 .collect();
9569 if self.base.is_some() {
9570 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
9571 // write it atomically, remap it as the new base, then clear the overlay.
9572 let meta = V8Meta {
9573 labels: self.labels.clone(),
9574 edge_props: self.edge_props.clone(),
9575 rule_defs,
9576 provenance,
9577 rule_tripped,
9578 rule_fires,
9579 ivf_bytes,
9580 view_defs,
9581 wal_truncated: !opts.keep_wal,
9582 hnsw: hnsw_state,
9583 last_change: self.last_change.clone(),
9584 };
9585 let mut buf: Vec<u8> = Vec::new();
9586 {
9587 // Clone the Arc so the old base stays alive while we encode.
9588 // The borrow of archived_csr (into old_base's mmap) is released
9589 // at the end of this block, before we replace self.base.
9590 let old_base = self.base.clone().expect("is_some checked above");
9591 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9592 detail: format!("v8 snapshot: topology section: {e:?}"),
9593 })?;
9594 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9595 detail: format!("v8 snapshot: columns section: {e:?}"),
9596 })?;
9597 let archived_edge_props =
9598 old_base
9599 .edge_props_section()
9600 .map_err(|e| GraphError::Corrupt {
9601 detail: format!("v8 snapshot: edge_props section: {e:?}"),
9602 })?;
9603 let edge_props_raw =
9604 old_base
9605 .edge_props_raw_bytes()
9606 .map_err(|e| GraphError::Corrupt {
9607 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9608 })?;
9609 let prov_raw =
9610 old_base
9611 .provenance_raw_bytes()
9612 .map_err(|e| GraphError::Corrupt {
9613 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9614 })?;
9615 encode_v8(
9616 Some(archived_csr),
9617 Some(archived_cols),
9618 Some((archived_edge_props, edge_props_raw)),
9619 Some(prov_raw),
9620 &self.topo,
9621 &self.props,
9622 &self.ids,
9623 &self.syms,
9624 &meta,
9625 &mut buf,
9626 )?;
9627 }
9628 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9629 // Remap the freshly-written snapshot as the new base.
9630 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9631 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9632 core_storage::v8::MappedBase::map(&snap_path)
9633 } else {
9634 core_storage::v8::MappedBase::from_bytes(buf)
9635 }
9636 .map_err(|e| GraphError::Corrupt {
9637 detail: format!("v8 snapshot: remap new base: {e:?}"),
9638 })?;
9639 self.base = Some(Arc::new(new_base));
9640 // Clear the overlay and prop tombstones — all data is now in the new base.
9641 self.topo = Topology::new();
9642 self.props = core_storage::columns::ColumnStore::new();
9643 } else {
9644 // Legacy path (V5–V7 stores without a V8 base).
9645 //
9646 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9647 // clone and no encode_v8_from_state intermediate clones. The big
9648 // structures (self.topo, self.props) are borrowed, not cloned.
9649 // self.edge_props is moved (not cloned) because we immediately clear it
9650 // when we remap the new V8 snapshot as self.base (see below).
9651 //
9652 // Eliminates from peak RSS vs. the old SnapshotState path:
9653 // • self.topo.clone() (~topology HashMap footprint)
9654 // • self.props.clone() (~column-store footprint)
9655 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9656 let meta = V8Meta {
9657 labels: self.labels.clone(),
9658 wal_truncated: !opts.keep_wal,
9659 // Move edge_props out so the large overlay is freed when meta
9660 // drops at end of this block (self.edge_props is now empty; reads
9661 // after base assignment go through the mmap'd base section).
9662 edge_props: std::mem::take(&mut self.edge_props),
9663 rule_defs,
9664 provenance,
9665 rule_tripped,
9666 rule_fires,
9667 ivf_bytes,
9668 view_defs,
9669 hnsw: hnsw_state,
9670 last_change: self.last_change.clone(),
9671 };
9672 let mut buf = Vec::new();
9673 encode_v8(
9674 None,
9675 None,
9676 None,
9677 None,
9678 &self.topo,
9679 &self.props,
9680 &self.ids,
9681 &self.syms,
9682 &meta,
9683 &mut buf,
9684 )?;
9685 // meta (and the moved edge_props inside it) is no longer needed;
9686 // drop it before the write to keep the peak window narrow.
9687 drop(meta);
9688 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9689 // Remap the freshly-written V8 snapshot as self.base.
9690 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9691 // On SimFs (tests): pass buf to from_bytes.
9692 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9693 drop(buf);
9694 core_storage::v8::MappedBase::map(&snap_path)
9695 } else {
9696 core_storage::v8::MappedBase::from_bytes(buf)
9697 }
9698 .map_err(|e| GraphError::Corrupt {
9699 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9700 })?;
9701 self.base = Some(Arc::new(new_base));
9702 // Free the large heap-allocated decoded state — all data is now in the
9703 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
9704 // self.edge_props was already moved into meta and is effectively empty.
9705 self.topo = Topology::new();
9706 self.props = core_storage::columns::ColumnStore::new();
9707 }
9708
9709 if opts.archive_wal {
9710 // History-preserving snapshot (Task 4):
9711 // 1. Snapshot already written above (write_atomic → fsynced).
9712 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
9713 // Crash window B: crash here leaves archive present, WAL
9714 // absent. Reopen: snapshot loaded (full state), no WAL
9715 // replay. Archive is NOT replayed into live state — it is
9716 // pre-snapshot by construction. Safe.
9717 // 3. Optionally write genesis marker (first archive only, no
9718 // prior WAL truncation).
9719 // 4. Prune old archives (retention), update horizon floor.
9720 // Pruning invalidates the genesis chain; delete marker.
9721 // 5. Write new minimal baseline WAL (write_atomic).
9722 // Crash window C: crash here leaves new archive plus no live
9723 // WAL. Same as window B — handled above.
9724 //
9725 // Sample existing archives BEFORE the rename so we can detect
9726 // whether this is the first archive.
9727 let existing_archives = self.fs.list_archives()?;
9728 let is_first_archive = existing_archives.is_empty();
9729
9730 // Compute a globally-monotonic archive name: the name equals the
9731 // cumulative end-frame index of the archive in global commit space.
9732 //
9733 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9734 // commit_seq is seeded from max(last_change), which underestimates
9735 // the WAL depth when trailing commits (e.g. insert_edge) do not
9736 // update last_change. A session-2 archive could then receive a name
9737 // ≤ the session-1 archive, causing incorrect sort order or collision.
9738 //
9739 // Instead: read and decode the live WAL here (before the rename) to
9740 // get its exact frame count, then add it to the last known global
9741 // end-frame index (the name of the most recent existing archive, or
9742 // wal_horizon_floor if no archives exist). This is O(WAL size) but
9743 // snapshot is already serialising the full graph state, so the cost
9744 // is dominated.
9745 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9746 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9747 let archive_n = existing_archives
9748 .last()
9749 .copied()
9750 .unwrap_or(self.wal_horizon_floor)
9751 + live_frames_for_name.len() as u64;
9752 self.fs.archive_wal(archive_n)?;
9753
9754 // Genesis marker: written once when the first archive is taken
9755 // from a store that has never undergone a WAL-truncating snapshot.
9756 // When present, `open_at` may replay archive-resident commits from
9757 // empty state (the archive chain covers from global index 0).
9758 //
9759 // Two conditions must ALL hold:
9760 // 1. This is the first archive (existing_archives was empty).
9761 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9762 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9763 // before truncating the WAL, so if any prior truncating snapshot was taken
9764 // — even in a previous session — snapshot.bin is present and this condition
9765 // is false. This subsumes the cross-session truncation case without
9766 // requiring a separate wal.truncated sidecar file.
9767 // For legacy stores (snapshot.bin written by an older code version that
9768 // may have truncated the WAL), the same conservative refusal applies:
9769 // we cannot prove the chain is complete, so we refuse genesis (cost =
9770 // no as-of-through-archives; never silent wrong data).
9771 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9772 // so SimFs always passes this check.
9773 if is_first_archive && !had_prior_snapshot {
9774 self.fs.write_genesis_marker()?;
9775 self.archive_genesis_chain = true;
9776 }
9777
9778 // Retention pruning: keep newest `keep` archives; delete oldest.
9779 // Pruning is the ONLY deletion site for archives.
9780 //
9781 // Crash-safety ordering (C1 fix):
9782 // 1. Count frames in surplus archives (reads only — no mutation).
9783 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
9784 // rename (atomic). A crash after this point leaves orphaned
9785 // archives on disk, but the floor is correct. The opening
9786 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
9787 // the next open, so the store is always safe to reopen.
9788 // 3. Delete the genesis marker (floor > 0 already blocks open_at
9789 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
9790 // 4. Delete surplus archives. A crash between any two deletes
9791 // leaves the floor committed and orphaned archives cleaned at
9792 // next open — never a stale floor with a missing archive prefix.
9793 if let Some(keep) = self.wal_archive_retention {
9794 if keep > 0 {
9795 let archives = self.fs.list_archives()?;
9796 // archives is sorted ascending (oldest first)
9797 if archives.len() as u32 > keep {
9798 let surplus = archives.len() - keep as usize;
9799 // Step 1: count pruned frames (reads, no mutation).
9800 let mut pruned_frames = 0u64;
9801 for &n in &archives[..surplus] {
9802 let bytes = self.fs.read_archive(n)?;
9803 let (frames, _) = decode_all(&bytes);
9804 pruned_frames += frames.len() as u64;
9805 }
9806 // Step 2: advance and persist floor FIRST.
9807 self.wal_horizon_floor += pruned_frames;
9808 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9809 // Step 3: delete genesis marker (floor > 0 already
9810 // blocks open_at; this is belt-and-suspenders cleanup).
9811 if pruned_frames > 0 && self.archive_genesis_chain {
9812 self.fs.delete_genesis_marker()?;
9813 self.archive_genesis_chain = false;
9814 }
9815 // Step 4: delete surplus archives. Crash here →
9816 // orphaned archives; cleaned at next open.
9817 for &n in &archives[..surplus] {
9818 self.fs.delete_archive(n)?;
9819 }
9820 }
9821 }
9822 }
9823
9824 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9825 let mut baseline_wal: Vec<u8> = Vec::new();
9826 for (label, field) in self.fulltext.enabled_pairs() {
9827 let rec = WalRecord::EnableFulltext {
9828 label: label.clone(),
9829 field: field.clone(),
9830 };
9831 baseline_wal.extend_from_slice(&encode_record(&rec));
9832 }
9833 for (label, field) in self.prop_index.enabled_pairs() {
9834 let rec = WalRecord::EnableIndex {
9835 label: label.clone(),
9836 field: field.clone(),
9837 };
9838 baseline_wal.extend_from_slice(&encode_record(&rec));
9839 }
9840 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9841 } else if opts.keep_wal {
9842 // keep_wal=true: WAL is left untouched. The existing WAL already
9843 // contains the EnableFulltext records from the original enable calls;
9844 // replay is idempotent (guards in apply() skip already-live entries).
9845 // No baseline re-write is needed or safe here — the full WAL history
9846 // must remain intact for open_at to reach pre-snapshot commits.
9847 } else {
9848 // keep_wal=false (default): truncate by replacing the WAL with a
9849 // minimal baseline of one EnableFulltext record per active pair.
9850 //
9851 // Crash-ordering: write_atomic is atomic.
9852 // • Crash before snapshot write → WAL unchanged. Safe.
9853 // • Crash after snapshot write but before this WAL write → full
9854 // pre-snapshot WAL still present; open_with replays idempotently.
9855 // • Crash after both writes → normal post-snapshot state.
9856 //
9857 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9858 // for any archives taken AFTER this point (their WAL slices would
9859 // not start at genesis). Delete any existing genesis marker so that
9860 // open_at refuses archive-resident commits. Future sessions are
9861 // covered by had_prior_snapshot: snapshot.bin written here persists
9862 // across sessions and prevents a later archiving session from
9863 // incorrectly claiming a complete genesis chain.
9864 if self.archive_genesis_chain {
9865 self.fs.delete_genesis_marker()?;
9866 self.archive_genesis_chain = false;
9867 }
9868 let mut baseline_wal: Vec<u8> = Vec::new();
9869 for (label, field) in self.fulltext.enabled_pairs() {
9870 let rec = WalRecord::EnableFulltext {
9871 label: label.clone(),
9872 field: field.clone(),
9873 };
9874 baseline_wal.extend_from_slice(&encode_record(&rec));
9875 }
9876 for (label, field) in self.prop_index.enabled_pairs() {
9877 let rec = WalRecord::EnableIndex {
9878 label: label.clone(),
9879 field: field.clone(),
9880 };
9881 baseline_wal.extend_from_slice(&encode_record(&rec));
9882 }
9883 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9884 }
9885 // After snapshot the overlay may have changed (V8 merge path clears
9886 // self.topo and self.props). Refresh the MVCC fold so future readers
9887 // see the post-snapshot state rather than stale overlay data.
9888 self.fold_now();
9889 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
9890 // markers this handle uses to detect other processes' work must be
9891 // re-taken from disk. Skipping this would make our own snapshot look
9892 // like a peer's on the next staleness check and force a needless
9893 // reload.
9894 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
9895 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
9896 Ok(())
9897 }
9898}
9899
9900/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9901///
9902/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9903/// callers can build a set of mutations without holding `&mut GraphDb` and
9904/// hand them off to the group-committing writer for durable, batched I/O.
9905pub enum BatchOp {
9906 InsertNode {
9907 label: String,
9908 key: String,
9909 props: Vec<(String, Value)>,
9910 },
9911 InsertEdge {
9912 edge_type: String,
9913 src_key: String,
9914 dst_key: String,
9915 },
9916 SetProp {
9917 key: String,
9918 field: String,
9919 value: Value,
9920 },
9921 RemoveProp {
9922 key: String,
9923 field: String,
9924 },
9925 DeleteEdge {
9926 edge_type: String,
9927 src_key: String,
9928 dst_key: String,
9929 },
9930 DeleteNode {
9931 key: String,
9932 },
9933 CreateRule(RuleDef),
9934 DeleteRule {
9935 name: String,
9936 },
9937 /// Rename a node's key. Validated: old must exist, new must not.
9938 RenameNode {
9939 old_key: String,
9940 new_key: String,
9941 },
9942 /// Insert an edge, auto-creating any missing endpoint as a plain node with
9943 /// `placeholder_label` and no props. Rules fire and last-change is updated
9944 /// for each created endpoint (normal InsertNode semantics in the batch frame).
9945 InsertEdgeUpsert {
9946 edge_type: String,
9947 src_key: String,
9948 dst_key: String,
9949 placeholder_label: String,
9950 },
9951}
9952
9953/// Three-way node visibility status used by `check_single_op_authz`.
9954enum NodeAuthzStatus {
9955 /// Node exists in the store and is in the role's read mask.
9956 Visible(String), // carries the node's label
9957 /// Node exists in the store but is NOT in the role's read mask.
9958 Hidden,
9959 /// Node does not exist in the store.
9960 Absent,
9961}
9962
9963/// Overlay of ops already accepted earlier in the same batch. Never written
9964/// back to the database — validation only.
9965#[derive(Default)]
9966struct Overlay {
9967 extra_keys: BTreeSet<String>,
9968 deleted_keys: BTreeSet<String>,
9969 extra_props: BTreeMap<(String, String), Value>,
9970 removed_props: BTreeSet<(String, String)>,
9971 extra_edges: BTreeSet<(String, String, String)>,
9972 deleted_edges: BTreeSet<(String, String, String)>,
9973 extra_rules: BTreeSet<String>,
9974 deleted_rules: BTreeSet<String>,
9975 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
9976 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
9977 /// sees only the rules already committed to the engine. Keyed by name so a
9978 /// later `DeleteRule` in the same batch drops the arc with the rule.
9979 extra_rule_arcs: BTreeMap<String, (String, String)>,
9980}
9981
9982/// Read-only view of live db state plus a batch overlay. Shared by single-op
9983/// public methods (empty overlay) and `commit_batch`.
9984struct MutPreview<'a, F: Fs> {
9985 db: &'a GraphDb<F>,
9986 overlay: Overlay,
9987}
9988
9989/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
9990/// `None` if `target` is unreachable.
9991///
9992/// Used for rule-chain cycle detection, where an arc is "a rule hops over
9993/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
9994/// reported path is stable for a given rule set, and iterative so a pathological
9995/// rule graph cannot overflow the stack.
9996fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
9997 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
9998 for (from, to) in arcs {
9999 adj.entry(from.as_str()).or_default().insert(to.as_str());
10000 }
10001 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10002 let mut visited: BTreeSet<&str> = BTreeSet::new();
10003 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10004 visited.insert(start);
10005 queue.push_back(start);
10006 while let Some(node) = queue.pop_front() {
10007 if node == target {
10008 let mut path = vec![node.to_string()];
10009 let mut cur = node;
10010 while let Some(&p) = parent.get(cur) {
10011 path.push(p.to_string());
10012 cur = p;
10013 }
10014 path.reverse();
10015 return Some(path);
10016 }
10017 for &next in adj.get(node).into_iter().flatten() {
10018 if visited.insert(next) {
10019 parent.insert(next, node);
10020 queue.push_back(next);
10021 }
10022 }
10023 }
10024 None
10025}
10026
10027impl<'a, F: Fs> MutPreview<'a, F> {
10028 fn new(db: &'a GraphDb<F>) -> Self {
10029 Self {
10030 db,
10031 overlay: Overlay::default(),
10032 }
10033 }
10034
10035 fn has_key(&self, key: &str) -> bool {
10036 if self.overlay.extra_keys.contains(key) {
10037 return true;
10038 }
10039 if self.overlay.deleted_keys.contains(key) {
10040 return false;
10041 }
10042 self.db.ids.get(key).is_some()
10043 }
10044
10045 fn has_prop(&self, key: &str, field: &str) -> bool {
10046 if !self.has_key(key) {
10047 return false;
10048 }
10049 let k = (key.to_string(), field.to_string());
10050 if self.overlay.removed_props.contains(&k) {
10051 return false;
10052 }
10053 if self.overlay.extra_props.contains_key(&k) {
10054 return true;
10055 }
10056 // Fresh identity (first insert in this batch, or delete+reinsert):
10057 // ignore props still sitting on the soon-to-be-tombstoned slot.
10058 if self.overlay.extra_keys.contains(key) {
10059 return false;
10060 }
10061 self.db.get_prop(key, field).is_some()
10062 }
10063
10064 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10065 let k = (
10066 edge_type.to_string(),
10067 src_key.to_string(),
10068 dst_key.to_string(),
10069 );
10070 if self.overlay.deleted_edges.contains(&k) {
10071 return false;
10072 }
10073 if self.overlay.extra_edges.contains(&k) {
10074 return true;
10075 }
10076 // A key created in this batch (including reinsert) has no db edges.
10077 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10078 return false;
10079 }
10080 if self.overlay.deleted_keys.contains(src_key)
10081 || self.overlay.deleted_keys.contains(dst_key)
10082 {
10083 return false;
10084 }
10085 let Some(src) = self.db.ids.get(src_key) else {
10086 return false;
10087 };
10088 let Some(dst) = self.db.ids.get(dst_key) else {
10089 return false;
10090 };
10091 let Some(sym) = self.db.syms.get(edge_type) else {
10092 return false;
10093 };
10094 self.db
10095 .topo_view()
10096 .neighbors(sym, Direction::Out, src)
10097 .binary_search(&dst)
10098 .is_ok()
10099 }
10100
10101 fn has_rule(&self, name: &str) -> bool {
10102 if self.overlay.extra_rules.contains(name) {
10103 return true;
10104 }
10105 if self.overlay.deleted_rules.contains(name) {
10106 return false;
10107 }
10108 self.db.engine.rules().any(|r| r.name == name)
10109 }
10110
10111 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10112 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10113 return false;
10114 }
10115 if self.overlay.deleted_keys.contains(src_key)
10116 || self.overlay.deleted_keys.contains(dst_key)
10117 {
10118 return false;
10119 }
10120 let Some(src) = self.db.ids.get(src_key) else {
10121 return false;
10122 };
10123 let Some(dst) = self.db.ids.get(dst_key) else {
10124 return false;
10125 };
10126 let Some(et) = self.db.syms.get(edge_type) else {
10127 return false;
10128 };
10129 // extra_rules is deliberately not consulted: a CreateRule earlier in
10130 // this batch has not fired, so it contributes no provenance. That is
10131 // the documented rule-window gap (see GraphDb::batch).
10132 if self.overlay.deleted_rules.is_empty() {
10133 return self.db.engine.is_owned(et, src, dst);
10134 }
10135 for (rule, triples) in self.db.engine.provenance() {
10136 if self.overlay.deleted_rules.contains(rule) {
10137 continue;
10138 }
10139 if triples.contains(&(et, src, dst)) {
10140 return true;
10141 }
10142 }
10143 false
10144 }
10145
10146 fn check_insert_node(&self, key: &str) -> Result<()> {
10147 if self.has_key(key) {
10148 Err(GraphError::DuplicateKey { key: key.into() })
10149 } else {
10150 Ok(())
10151 }
10152 }
10153
10154 fn check_live_key(&self, key: &str) -> Result<()> {
10155 if self.has_key(key) {
10156 Ok(())
10157 } else {
10158 Err(GraphError::KeyNotFound { key: key.into() })
10159 }
10160 }
10161
10162 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10163 for k in [src_key, dst_key] {
10164 if !self.has_key(k) {
10165 return Err(GraphError::KeyNotFound { key: k.into() });
10166 }
10167 }
10168 if self.is_rule_owned(edge_type, src_key, dst_key) {
10169 return Err(GraphError::RuleOwned {
10170 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10171 });
10172 }
10173 Ok(!self.has_edge(edge_type, src_key, dst_key))
10174 }
10175
10176 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10177 self.check_live_key(key)?;
10178 Ok(self.has_prop(key, field))
10179 }
10180
10181 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10182 for k in [src_key, dst_key] {
10183 if !self.has_key(k) {
10184 return Err(GraphError::KeyNotFound { key: k.into() });
10185 }
10186 }
10187 // Provenance-owned OR a live rule would derive this pair. User-first
10188 // edges that a later rule matches are not in `owned`, but deleting
10189 // them would leave a hole `rebuild_rule` immediately fills.
10190 if self.is_rule_owned(edge_type, src_key, dst_key) {
10191 return Err(GraphError::RuleOwned {
10192 detail: format!(
10193 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10194 delete or change the owning rule"
10195 ),
10196 });
10197 }
10198 if self.would_derive(edge_type, src_key, dst_key) {
10199 return Err(GraphError::RuleOwned {
10200 detail: format!(
10201 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10202 delete or change the owning rule, or a live rule would re-derive it"
10203 ),
10204 });
10205 }
10206 Ok(self.has_edge(edge_type, src_key, dst_key))
10207 }
10208
10209 /// True if any live rule (minus overlay-deleted names) would derive
10210 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10211 /// CreateRule names in `extra_rules` are ignored — same documented
10212 /// same-batch rule-window as [`Self::is_rule_owned`].
10213 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10214 if src_key == dst_key {
10215 return false;
10216 }
10217 let Some(src_label) = self.label_of(src_key) else {
10218 return false;
10219 };
10220 let Some(dst_label) = self.label_of(dst_key) else {
10221 return false;
10222 };
10223 for rule in self.db.engine.rules() {
10224 if self.overlay.deleted_rules.contains(&rule.name) {
10225 continue;
10226 }
10227 if rule.edge_type != edge_type {
10228 continue;
10229 }
10230 if rule.src_label != src_label || rule.dst_label != dst_label {
10231 continue;
10232 }
10233 let src_props = |f: &str| self.prop_value(src_key, f);
10234 let dst_props = |f: &str| self.prop_value(dst_key, f);
10235 let src_view = NodeView {
10236 key: src_key,
10237 props: &src_props,
10238 };
10239 let dst_view = NodeView {
10240 key: dst_key,
10241 props: &dst_props,
10242 };
10243 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
10244 return true;
10245 }
10246 }
10247 false
10248 }
10249
10250 fn label_of(&self, key: &str) -> Option<String> {
10251 if self.overlay.deleted_keys.contains(key) {
10252 return None;
10253 }
10254 // Fresh identities created in this batch have no stored label in the
10255 // overlay; they cannot be provenance-owned yet either.
10256 let id = self.db.ids.get(key)?;
10257 let sym = self.db.labels.get(id as usize).copied()?;
10258 if sym == u32::MAX {
10259 return None;
10260 }
10261 self.db.syms.resolve(sym).map(str::to_string)
10262 }
10263
10264 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
10265 if !self.has_key(key) {
10266 return None;
10267 }
10268 let k = (key.to_string(), field.to_string());
10269 if self.overlay.removed_props.contains(&k) {
10270 return None;
10271 }
10272 if let Some(v) = self.overlay.extra_props.get(&k) {
10273 return Some(v.clone());
10274 }
10275 if self.overlay.extra_keys.contains(key) {
10276 return None;
10277 }
10278 self.db.get_prop(key, field)
10279 }
10280
10281 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
10282 def.validate()
10283 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
10284 if self.has_rule(&def.name) {
10285 return Err(GraphError::RuleInvalid {
10286 detail: format!("rule {:?} already exists", def.name),
10287 });
10288 }
10289 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
10290 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
10291 // `edge_type`". A cycle in that graph is a rule set that would re-fire
10292 // itself forever; the engine's depth cap would silently truncate it
10293 // instead, leaving an arbitrary partial result. Reject it here, the one
10294 // place that sees the whole rule set.
10295 //
10296 // Rules accepted earlier in the same batch count too: the overlay
10297 // carries their arcs, so a cycle cannot be assembled one op at a time.
10298 if let Some(via) = def.via_edge.as_deref() {
10299 if via == def.edge_type {
10300 return Err(GraphError::RuleInvalid {
10301 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
10302 });
10303 }
10304 let mut arcs: Vec<(String, String)> = self
10305 .db
10306 .engine
10307 .rules()
10308 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
10309 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
10310 .collect();
10311 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
10312 arcs.push((via.to_string(), def.edge_type.clone()));
10313 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
10314 return Err(GraphError::RuleInvalid {
10315 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
10316 });
10317 }
10318 }
10319 Ok(())
10320 }
10321
10322 fn check_delete_rule(&self, name: &str) -> Result<()> {
10323 if self.has_rule(name) {
10324 Ok(())
10325 } else {
10326 Err(GraphError::RuleNotFound { name: name.into() })
10327 }
10328 }
10329
10330 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
10331 self.overlay.deleted_keys.remove(key);
10332 self.overlay.extra_keys.insert(key.to_string());
10333 self.overlay.extra_props.retain(|(k, _), _| k != key);
10334 self.overlay.removed_props.retain(|(k, _)| k != key);
10335 for (field, value) in props {
10336 self.overlay
10337 .extra_props
10338 .insert((key.to_string(), field.clone()), value.clone());
10339 }
10340 }
10341
10342 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10343 let k = (
10344 edge_type.to_string(),
10345 src_key.to_string(),
10346 dst_key.to_string(),
10347 );
10348 self.overlay.deleted_edges.remove(&k);
10349 self.overlay.extra_edges.insert(k);
10350 }
10351
10352 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
10353 let k = (key.to_string(), field.to_string());
10354 self.overlay.removed_props.remove(&k);
10355 self.overlay.extra_props.insert(k, value.clone());
10356 }
10357
10358 fn note_remove_prop(&mut self, key: &str, field: &str) {
10359 let k = (key.to_string(), field.to_string());
10360 self.overlay.extra_props.remove(&k);
10361 self.overlay.removed_props.insert(k);
10362 }
10363
10364 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10365 let k = (
10366 edge_type.to_string(),
10367 src_key.to_string(),
10368 dst_key.to_string(),
10369 );
10370 self.overlay.extra_edges.remove(&k);
10371 self.overlay.deleted_edges.insert(k);
10372 }
10373
10374 fn note_delete_node(&mut self, key: &str) {
10375 self.overlay.extra_keys.remove(key);
10376 self.overlay.deleted_keys.insert(key.to_string());
10377 self.overlay.extra_props.retain(|(k, _), _| k != key);
10378 self.overlay.removed_props.retain(|(k, _)| k != key);
10379 self.overlay
10380 .extra_edges
10381 .retain(|(_, s, d)| s != key && d != key);
10382 self.overlay
10383 .deleted_edges
10384 .retain(|(_, s, d)| s != key && d != key);
10385 }
10386
10387 fn note_create_rule(&mut self, def: &RuleDef) {
10388 self.overlay.deleted_rules.remove(&def.name);
10389 self.overlay.extra_rules.insert(def.name.clone());
10390 // Rules accepted earlier in this batch are not in the engine yet, so
10391 // the cycle check would not see their arcs. Keep the arc, not just the
10392 // name, so a batch cannot smuggle in a cycle one op at a time.
10393 if let Some(via) = def.via_edge.clone() {
10394 self.overlay
10395 .extra_rule_arcs
10396 .insert(def.name.clone(), (via, def.edge_type.clone()));
10397 }
10398 }
10399
10400 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
10401 if !self.has_key(old) {
10402 return Err(GraphError::KeyNotFound { key: old.into() });
10403 }
10404 if self.has_key(new) {
10405 return Err(GraphError::DuplicateKey { key: new.into() });
10406 }
10407 Ok(())
10408 }
10409
10410 fn note_rename_node(&mut self, old: &str, new: &str) {
10411 // Mark old as deleted so subsequent batch ops cannot reference it.
10412 self.overlay.extra_keys.remove(old);
10413 self.overlay.deleted_keys.insert(old.to_string());
10414 // Mark new as extra so subsequent batch ops can reference it.
10415 self.overlay.deleted_keys.remove(new);
10416 self.overlay.extra_keys.insert(new.to_string());
10417 // Migrate any overlay props from old key to new key.
10418 let new_str = new.to_string();
10419 let transferred: Vec<((String, String), Value)> = self
10420 .overlay
10421 .extra_props
10422 .iter()
10423 .filter(|((k, _), _)| k.as_str() == old)
10424 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
10425 .collect();
10426 self.overlay
10427 .extra_props
10428 .retain(|(k, _), _| k.as_str() != old);
10429 for (k, v) in transferred {
10430 self.overlay.extra_props.insert(k, v);
10431 }
10432 // Migrate removed_props.
10433 let transferred_removed: Vec<(String, String)> = self
10434 .overlay
10435 .removed_props
10436 .iter()
10437 .filter(|(k, _)| k.as_str() == old)
10438 .map(|(_, f)| (new_str.clone(), f.clone()))
10439 .collect();
10440 self.overlay
10441 .removed_props
10442 .retain(|(k, _)| k.as_str() != old);
10443 for k in transferred_removed {
10444 self.overlay.removed_props.insert(k);
10445 }
10446 }
10447
10448 fn note_delete_rule(&mut self, name: &str) {
10449 self.overlay.extra_rules.remove(name);
10450 // Drop its chain arc too: a rule created and then deleted in the same
10451 // batch must not make a later, legal rule look like a cycle.
10452 self.overlay.extra_rule_arcs.remove(name);
10453 self.overlay.deleted_rules.insert(name.to_string());
10454 // Treat the deleted rule's current provenance as gone so a later
10455 // delete_edge of those triples is a no-op (matches sequential).
10456 if let Some(triples) = self.db.engine.provenance().get(name) {
10457 for &(et, s, d) in triples {
10458 let Some(etype) = self.db.syms.resolve(et) else {
10459 continue;
10460 };
10461 let Some(src) = self.db.ids.key_of(s) else {
10462 continue;
10463 };
10464 let Some(dst) = self.db.ids.key_of(d) else {
10465 continue;
10466 };
10467 let k = (etype.to_string(), src.to_string(), dst.to_string());
10468 self.overlay.extra_edges.remove(&k);
10469 self.overlay.deleted_edges.insert(k);
10470 }
10471 }
10472 }
10473}
10474
10475/// Collects mutations and commits them as one WAL `Batch` frame.
10476///
10477/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
10478/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
10479/// See [`GraphDb::batch`] for validation and atomicity rules.
10480pub struct BatchBuilder<'a, F: Fs> {
10481 db: &'a mut GraphDb<F>,
10482 ops: Vec<BatchOp>,
10483}
10484
10485impl<'a, F: Fs> BatchBuilder<'a, F> {
10486 pub fn insert_node(
10487 &mut self,
10488 label: &str,
10489 key: &str,
10490 props: Vec<(String, Value)>,
10491 ) -> &mut Self {
10492 self.ops.push(BatchOp::InsertNode {
10493 label: label.into(),
10494 key: key.into(),
10495 props,
10496 });
10497 self
10498 }
10499
10500 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10501 self.ops.push(BatchOp::InsertEdge {
10502 edge_type: edge_type.into(),
10503 src_key: src_key.into(),
10504 dst_key: dst_key.into(),
10505 });
10506 self
10507 }
10508
10509 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
10510 self.ops.push(BatchOp::SetProp {
10511 key: key.into(),
10512 field: field.into(),
10513 value,
10514 });
10515 self
10516 }
10517
10518 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
10519 self.ops.push(BatchOp::RemoveProp {
10520 key: key.into(),
10521 field: field.into(),
10522 });
10523 self
10524 }
10525
10526 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10527 self.ops.push(BatchOp::DeleteEdge {
10528 edge_type: edge_type.into(),
10529 src_key: src_key.into(),
10530 dst_key: dst_key.into(),
10531 });
10532 self
10533 }
10534
10535 pub fn delete_node(&mut self, key: &str) -> &mut Self {
10536 self.ops.push(BatchOp::DeleteNode { key: key.into() });
10537 self
10538 }
10539
10540 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
10541 self.ops.push(BatchOp::CreateRule(def));
10542 self
10543 }
10544
10545 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
10546 self.ops.push(BatchOp::DeleteRule { name: name.into() });
10547 self
10548 }
10549
10550 /// Queue a node-rename in this batch.
10551 ///
10552 /// Validation (old exists, new not taken) runs at commit time.
10553 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
10554 self.ops.push(BatchOp::RenameNode {
10555 old_key: old_key.into(),
10556 new_key: new_key.into(),
10557 });
10558 self
10559 }
10560
10561 /// Queue an edge insert with endpoint auto-creation.
10562 ///
10563 /// Any missing endpoint is created as a plain node `{key, label:
10564 /// placeholder_label, no props}` inside this batch frame. Rules fire and
10565 /// last-change is updated for each auto-created node.
10566 pub fn insert_edge_upsert(
10567 &mut self,
10568 edge_type: &str,
10569 src_key: &str,
10570 dst_key: &str,
10571 placeholder_label: &str,
10572 ) -> &mut Self {
10573 self.ops.push(BatchOp::InsertEdgeUpsert {
10574 edge_type: edge_type.into(),
10575 src_key: src_key.into(),
10576 dst_key: dst_key.into(),
10577 placeholder_label: placeholder_label.into(),
10578 });
10579 self
10580 }
10581
10582 /// Validate every queued op, then log one `Batch` frame and apply.
10583 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10584 /// A second `commit()` after a successful one is an empty-batch no-op
10585 /// (queued ops were taken).
10586 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10587 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10588 ///
10589 /// **Rule-window limitation:** batch validation cannot see edges that a
10590 /// rule created earlier in the *same* batch will derive at apply time, so
10591 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10592 /// where sequential calls would return `Err(RuleOwned)`. State integrity
10593 /// is unaffected (idempotent apply, provenance intact). Create rules in
10594 /// their own batch, or sequentially, when later ops may touch derived
10595 /// edges.
10596 /// Validate every queued op and commit atomically.
10597 ///
10598 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10599 /// WAL records actually written (duplicate edges are silent no-ops and are
10600 /// NOT counted). Both are 0 when the batch is empty or all-noop.
10601 pub fn commit(&mut self) -> Result<(usize, usize)> {
10602 let ops = std::mem::take(&mut self.ops);
10603 self.db.commit_batch(ops)
10604 }
10605
10606 /// Same as [`commit`](Self::commit) but tail the inner events with
10607 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10608 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10609 let ops = std::mem::take(&mut self.ops);
10610 self.db
10611 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10612 }
10613}
10614
10615pub struct NodeRef<'a, F: Fs> {
10616 db: &'a GraphDb<F>,
10617 id: u32,
10618}
10619
10620impl<'a, F: Fs> NodeRef<'a, F> {
10621 pub fn key(&self) -> &str {
10622 self.db.ids.key_of(self.id).expect("dense ids")
10623 }
10624
10625 pub fn label(&self) -> &str {
10626 let sym = self
10627 .db
10628 .labels
10629 .get(self.id as usize)
10630 .copied()
10631 .filter(|&s| s != u32::MAX)
10632 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10633 self.db.syms.resolve(sym).expect("interned label symbol")
10634 }
10635
10636 pub fn prop(&self, field: &str) -> Option<Value> {
10637 self.db
10638 .props_view()
10639 .get(self.id, field)
10640 .map(|vr| vr.into_value())
10641 }
10642
10643 /// All stored fields for this node, sorted by field name.
10644 ///
10645 /// Reads from the full base+overlay view so that props stored only in the
10646 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10647 pub fn props(&self) -> BTreeMap<String, Value> {
10648 let mut out = BTreeMap::new();
10649 let pv = self.db.props_view();
10650 for field in pv.field_names() {
10651 if let Some(vr) = pv.get(self.id, &field) {
10652 out.insert(field, vr.into_value());
10653 }
10654 }
10655 out
10656 }
10657
10658 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10659 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10660 let view = self.db.view();
10661 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10662 names
10663 .iter()
10664 .filter_map(|name| view.syms.get(name))
10665 .collect()
10666 });
10667 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10668 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10669 for (nid, d) in nb.nodes {
10670 let key = view.key_of(nid);
10671 let label = view
10672 .label_of(nid)
10673 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10674 rs.push_row(vec![
10675 Some(Value::Str(key.to_string())),
10676 Some(Value::Str(label.to_string())),
10677 Some(Value::Int(d as i64)),
10678 ]);
10679 }
10680 rs
10681 }
10682
10683 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10684 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10685 let view = self.db.view();
10686 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10687 for e in expand(&view, self.id, None, Dir::Both) {
10688 // Skip edges with unknown etypes (only possible from corrupt large
10689 // TOPOLOGY section; function returns BTreeMap not Result).
10690 let Some(etype) = view.syms.resolve(e.etype) else {
10691 continue;
10692 };
10693 let etype = etype.to_string();
10694 let nbr = if e.src == self.id { e.dst } else { e.src };
10695 groups
10696 .entry(etype)
10697 .or_default()
10698 .insert(view.key_of(nbr).to_string());
10699 }
10700 groups
10701 .into_iter()
10702 .map(|(k, v)| (k, v.into_iter().collect()))
10703 .collect()
10704 }
10705}
10706
10707#[cfg(test)]
10708mod tests {
10709 use super::*;
10710 use core_rules::Predicate;
10711
10712 fn tmp_dir(name: &str) -> std::path::PathBuf {
10713 let d =
10714 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10715 let _ = std::fs::remove_dir_all(&d);
10716 d
10717 }
10718
10719 fn fk_rule() -> RuleDef {
10720 RuleDef {
10721 name: "works_at".into(),
10722 src_label: "Person".into(),
10723 dst_label: "Org".into(),
10724 predicate: Predicate::KeyMatch {
10725 field: "org_id".into(),
10726 },
10727 edge_type: "WORKS_AT".into(),
10728 weight_prop: None,
10729 max_edges: None,
10730 approximate: false,
10731 via_label: None,
10732 via_edge: None,
10733 via_dir: None,
10734 }
10735 }
10736
10737 /// Regression guard for the no-views delta-copy fast path.
10738 ///
10739 /// When no views are defined, `pending_deltas_since().to_vec()` must never
10740 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10741 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10742 /// a count of 0 after the entire sequence proves the guard fires correctly.
10743 #[test]
10744 fn no_delta_copy_when_no_views() {
10745 DELTA_COPY_COUNT.with(|c| c.set(0));
10746 let dir = tmp_dir("no-delta-copy");
10747 {
10748 let mut db = GraphDb::open(&dir).unwrap();
10749 // Insert 50 Org + 50 Person nodes with FK links.
10750 for i in 0..50u32 {
10751 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10752 }
10753 for i in 0..50u32 {
10754 db.insert_node(
10755 "Person",
10756 &format!("p{i}"),
10757 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10758 )
10759 .unwrap();
10760 }
10761 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10762 db.create_rule(fk_rule()).unwrap();
10763
10764 // Counter must stay 0 — no views, no copies.
10765 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10766 assert_eq!(
10767 copies, 0,
10768 "pending_deltas_since().to_vec() called despite no views"
10769 );
10770
10771 // Derived edges must still be correct (the guard skips only the
10772 // empty delta propagation loop, not the rule application itself).
10773 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10774 assert_eq!(
10775 nbrs,
10776 vec!["o0"],
10777 "rule must derive edges even with no views"
10778 );
10779 }
10780 let _ = std::fs::remove_dir_all(&dir);
10781 }
10782
10783 /// Gating regression: subscribe AFTER a backfill must see no stale events.
10784 /// subscribe BEFORE a backfill must see every edge-fire event.
10785 #[test]
10786 fn subscribe_after_backfill_no_stale_events() {
10787 let dir = tmp_dir("sub-after-backfill");
10788 {
10789 let mut db = GraphDb::open(&dir).unwrap();
10790 for i in 0..10u32 {
10791 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10792 db.insert_node(
10793 "Person",
10794 &format!("p{i}"),
10795 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10796 )
10797 .unwrap();
10798 }
10799 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10800 db.create_rule(fk_rule()).unwrap();
10801
10802 // Subscribe AFTER the backfill — queue must be empty (no stale events).
10803 let sub = db.subscribe_all_rules().unwrap();
10804 // No events should have queued for the prior backfill.
10805 assert!(
10806 sub.try_recv().is_none(),
10807 "subscribe after backfill must see no stale events"
10808 );
10809
10810 // Inserting a new node now should fire an event (emit_deltas is now true).
10811 db.insert_node("Org", "o_new", vec![]).unwrap();
10812 db.insert_node(
10813 "Person",
10814 "p_new",
10815 vec![("org_id".into(), Value::Str("o_new".into()))],
10816 )
10817 .unwrap();
10818 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10819 assert!(
10820 ev.is_some(),
10821 "edge-fire event must arrive after subscribe (emit_deltas=true)"
10822 );
10823 }
10824 let _ = std::fs::remove_dir_all(&dir);
10825 }
10826
10827 /// Gating regression: subscribe BEFORE a backfill → events flow.
10828 #[test]
10829 fn subscribe_before_backfill_events_flow() {
10830 let dir = tmp_dir("sub-before-backfill");
10831 {
10832 let mut db = GraphDb::open(&dir).unwrap();
10833 // Subscribe FIRST — emit_deltas becomes true.
10834 let sub = db.subscribe_all_rules().unwrap();
10835
10836 for i in 0..5u32 {
10837 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10838 db.insert_node(
10839 "Person",
10840 &format!("p{i}"),
10841 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10842 )
10843 .unwrap();
10844 }
10845 // Backfill fires with emit_deltas=true → events queued.
10846 db.create_rule(fk_rule()).unwrap();
10847
10848 // Should receive at least one edge-fired event from the backfill.
10849 let mut received = 0usize;
10850 while sub.try_recv().is_some() {
10851 received += 1;
10852 }
10853 assert!(
10854 received > 0,
10855 "subscribe before backfill must receive edge-fire events (got 0)"
10856 );
10857 }
10858 let _ = std::fs::remove_dir_all(&dir);
10859 }
10860
10861 /// Companion: when a view IS defined, the delta path fires and view values update.
10862 #[test]
10863 fn delta_copy_fires_when_view_exists() {
10864 use core_rules::ViewSource;
10865 DELTA_COPY_COUNT.with(|c| c.set(0));
10866 let dir = tmp_dir("delta-copy-with-view");
10867 {
10868 let mut db = GraphDb::open(&dir).unwrap();
10869 db.insert_node("Org", "o1", vec![]).unwrap();
10870 db.insert_node(
10871 "Person",
10872 "p1",
10873 vec![("org_id".into(), Value::Str("o1".into()))],
10874 )
10875 .unwrap();
10876 // Declare a Degree view so is_empty() returns false.
10877 db.create_view(ViewDef {
10878 name: "degree_out".into(),
10879 label: "Person".into(),
10880 view_prop: "degree_out".into(),
10881 source: ViewSource::Degree {
10882 edge_type: "WORKS_AT".into(),
10883 direction: Direction::Out,
10884 },
10885 })
10886 .unwrap();
10887 db.create_rule(fk_rule()).unwrap();
10888
10889 // At least one delta copy should have happened (CreateRule backfill).
10890 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10891 assert!(
10892 copies > 0,
10893 "expected delta copy to fire when a view is defined"
10894 );
10895
10896 // View value should be computed: p1 has one WORKS_AT out-edge.
10897 let info = db.node_info("p1").unwrap();
10898 let degree = info.props.get("degree_out");
10899 assert!(
10900 degree.is_some(),
10901 "view prop should be written to node props"
10902 );
10903 }
10904 let _ = std::fs::remove_dir_all(&dir);
10905 }
10906
10907 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10908 /// derived-edge-driven view values reflect the as-of state rather than just
10909 /// the initial backfill written at `CreateView` time.
10910 ///
10911 /// Base WAL frames (indices 0..=5 before history markers):
10912 /// 0: insert Org "o1"
10913 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10914 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10915 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
10916 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10917 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
10918 ///
10919 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10920 /// no-op), so the total commit count is higher than the base frame count.
10921 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10922 ///
10923 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10924 /// initial backfill value (0) instead of reflecting the replayed derived edges.
10925 #[test]
10926 fn open_at_derived_edge_view_values_correct() {
10927 use core_rules::ViewSource;
10928 let dir = tmp_dir("open-at-view-rebuild");
10929 {
10930 let mut db = GraphDb::open(&dir).unwrap();
10931 // frame 0
10932 db.insert_node("Org", "o1", vec![]).unwrap();
10933 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10934 db.create_view(ViewDef {
10935 name: "employee_count".into(),
10936 label: "Org".into(),
10937 view_prop: "emp".into(),
10938 source: ViewSource::Degree {
10939 edge_type: "WORKS_AT".into(),
10940 direction: Direction::In,
10941 },
10942 })
10943 .unwrap();
10944 // frame 2: create rule — no Persons yet; backfill is a no-op
10945 db.create_rule(fk_rule()).unwrap();
10946 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10947 db.insert_node(
10948 "Person",
10949 "p1",
10950 vec![("org_id".into(), Value::Str("o1".into()))],
10951 )
10952 .unwrap();
10953 // frame 4: p2 — degree = 2
10954 db.insert_node(
10955 "Person",
10956 "p2",
10957 vec![("org_id".into(), Value::Str("o1".into()))],
10958 )
10959 .unwrap();
10960 // frame 5: p3 — degree = 3
10961 db.insert_node(
10962 "Person",
10963 "p3",
10964 vec![("org_id".into(), Value::Str("o1".into()))],
10965 )
10966 .unwrap();
10967 // Sanity: normal open sees degree = 3.
10968 assert_eq!(
10969 db.get_view_prop("o1", "emp"),
10970 Some(Value::Int(3)),
10971 "normal db must show degree 3 after 3 derived edges"
10972 );
10973 } // WAL flushed
10974
10975 // Re-open normally to get the authoritative reference value.
10976 let normal_db = GraphDb::open(&dir).unwrap();
10977 let normal_emp = normal_db.get_view_prop("o1", "emp");
10978 assert_eq!(
10979 normal_emp,
10980 Some(Value::Int(3)),
10981 "re-opened normal db must show degree 3"
10982 );
10983
10984 // Latest as-of (last WAL commit): must match the normal open.
10985 // History-marker frames are appended after each rule-fire, so the total
10986 // commit count is computed dynamically rather than hardcoded.
10987 let total = crate::wal_commit_count_at(&dir).unwrap();
10988 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10989 assert_eq!(
10990 aof_latest.get_view_prop("o1", "emp"),
10991 normal_emp,
10992 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10993 );
10994
10995 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10996 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10997 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10998 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10999 assert_eq!(
11000 aof_mid.get_view_prop("o1", "emp"),
11001 Some(Value::Int(1)),
11002 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11003 );
11004
11005 let _ = std::fs::remove_dir_all(&dir);
11006 }
11007
11008 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11009 /// as-of instances never commit, so distribute_events never runs and any
11010 /// subscription would wait forever.
11011 #[test]
11012 fn subscribe_on_as_of_returns_read_only_error() {
11013 let dir = tmp_dir("sub-as-of-read-only");
11014 {
11015 let mut db = GraphDb::open(&dir).unwrap();
11016 db.insert_node("Org", "o1", vec![]).unwrap();
11017 db.create_rule(fk_rule()).unwrap();
11018 }
11019 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11020
11021 assert!(
11022 matches!(
11023 aof.subscribe_all_rules(),
11024 Err(core_storage::GraphError::ReadOnly)
11025 ),
11026 "subscribe_all_rules on as-of must return ReadOnly"
11027 );
11028 assert!(
11029 matches!(
11030 aof.subscribe_writes(),
11031 Err(core_storage::GraphError::ReadOnly)
11032 ),
11033 "subscribe_writes on as-of must return ReadOnly"
11034 );
11035 assert!(
11036 matches!(
11037 aof.subscribe_rule("works_at"),
11038 Err(core_storage::GraphError::ReadOnly)
11039 ),
11040 "subscribe_rule on as-of must return ReadOnly"
11041 );
11042 let _ = std::fs::remove_dir_all(&dir);
11043 }
11044
11045 /// Regression: a failed dense WAL rewrite must not leave speculative
11046 /// interns in `syms`. If it does, the next successful mutation logs an
11047 /// `Intern` record with an inflated id; replay (which never saw the
11048 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11049 #[test]
11050 fn dense_rewrite_error_rolls_back_speculative_interns() {
11051 let dir = tmp_dir("dense-rewrite-rollback");
11052 {
11053 let mut db = GraphDb::open(&dir).unwrap();
11054 db.insert_node("Person", "a", vec![]).unwrap();
11055
11056 // Bypass MutPreview validation to hit the rewrite's own error path
11057 // (same shape as an id-exhaustion failure mid-rewrite). The
11058 // InsertEdge arm interns the edge type before it resolves keys.
11059 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11060 edge_type: "ORPHAN_TYPE".into(),
11061 src_key: "missing".into(),
11062 dst_key: "a".into(),
11063 }]);
11064 assert!(err.is_err(), "rewrite of a missing src key must fail");
11065 assert_eq!(
11066 db.syms.get("ORPHAN_TYPE"),
11067 None,
11068 "failed rewrite must roll back speculative interns"
11069 );
11070
11071 // A later successful mutation must produce a replayable WAL.
11072 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11073 }
11074 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11075 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11076 let _ = std::fs::remove_dir_all(&dir);
11077 }
11078}