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/// Internal state for a single `subscribe_query` subscription.
71///
72/// On every commit, `distribute_events` re-executes `ops` against the current
73/// graph state, diffs the result against `prev_rows`, and pushes
74/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
75///
76/// **Full re-run per commit; use LIMIT to bound execution cost.**
77/// (Differential evaluation is roadmap / Phase 5.)
78pub(crate) struct QuerySubEntry {
79 /// Compiled plan for the subscribed Cypher query.
80 ops: Vec<PlanOp>,
81 /// Column names from the first execution (fixed for the subscription lifetime).
82 columns: Vec<String>,
83 /// Serialized (JSON) row key → row data, representing the result set at
84 /// the end of the last commit. Used to diff against the new result.
85 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
86 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
87 inner: std::sync::Weak<SubInner>,
88}
89
90/// A post-commit mutation notification.
91///
92/// Emitted from `log_then_apply` after the WAL append, fsync, and
93/// in-memory `apply` all succeed. Never emitted for rejected operations
94/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
95/// deletes/removes). Event payloads carry user keys and rule names, never
96/// internal ids.
97///
98/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
99/// `apply` only. Emission lives exclusively in `log_then_apply`, so
100/// recovery is silent even if a sink were installed (it cannot be: the
101/// sink is in-memory and set after open).
102///
103/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
104/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
105/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
106/// or all-noop batch writes no WAL and emits nothing (including no summary).
107///
108/// **Derived edges:** rule-created or retracted edges are not individually
109/// evented — they are recoverable from the triggering mutation plus the live
110/// rule set. Only the triggering record is emitted.
111///
112/// **Wire form:** externally tagged snake_case JSON
113/// (`{"node_inserted":{"label":"A","key":"k"}}`).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum MutationEvent {
117 NodeInserted {
118 label: String,
119 key: String,
120 },
121 PropSet {
122 key: String,
123 field: String,
124 },
125 PropRemoved {
126 key: String,
127 field: String,
128 },
129 EdgeInserted {
130 edge_type: String,
131 src: String,
132 dst: String,
133 },
134 EdgeDeleted {
135 edge_type: String,
136 src: String,
137 dst: String,
138 },
139 NodeDeleted {
140 key: String,
141 },
142 RuleCreated {
143 name: String,
144 },
145 RuleDeleted {
146 name: String,
147 },
148 RuleRebuilt {
149 name: String,
150 },
151 BatchApplied {
152 ops: usize,
153 },
154 Ingested {
155 label: String,
156 inserted: usize,
157 },
158}
159
160fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
161 match rec {
162 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
163 label: label.clone(),
164 key: key.clone(),
165 }),
166 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
167 label: intern.resolve(*label)?.to_string(),
168 key: key.clone(),
169 }),
170 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
171 key: key.clone(),
172 field: field.clone(),
173 }),
174 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
175 key: ids.key_of(*id)?.to_string(),
176 field: intern.resolve(*field)?.to_string(),
177 }),
178 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
179 key: key.clone(),
180 field: field.clone(),
181 }),
182 WalRecord::InsertEdge {
183 edge_type,
184 src_key,
185 dst_key,
186 } => Some(MutationEvent::EdgeInserted {
187 edge_type: edge_type.clone(),
188 src: src_key.clone(),
189 dst: dst_key.clone(),
190 }),
191 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
192 edge_type: intern.resolve(*etype)?.to_string(),
193 src: ids.key_of(*src)?.to_string(),
194 dst: ids.key_of(*dst)?.to_string(),
195 }),
196 WalRecord::DeleteEdge {
197 edge_type,
198 src_key,
199 dst_key,
200 } => Some(MutationEvent::EdgeDeleted {
201 edge_type: edge_type.clone(),
202 src: src_key.clone(),
203 dst: dst_key.clone(),
204 }),
205 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
206 WalRecord::CreateRule { def_bytes } => {
207 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
208 Some(MutationEvent::RuleCreated { name: def.name })
209 }
210 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
211 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
212 WalRecord::Batch(_)
213 | WalRecord::CreateView { .. }
214 | WalRecord::DeleteView { .. }
215 | WalRecord::EnableFulltext { .. }
216 | WalRecord::DisableFulltext { .. }
217 | WalRecord::EnableIndex { .. }
218 | WalRecord::DisableIndex { .. }
219 | WalRecord::Intern { .. }
220 // History markers are no-ops for mutation events — they carry no new
221 // state and rules re-derive deterministically on replay.
222 | WalRecord::DerivedEdgeAdded { .. }
223 | WalRecord::DerivedEdgeRetracted { .. }
224 // RenameNode carries no node/edge count change; no special event.
225 | WalRecord::RenameNode { .. } => None,
226 }
227}
228
229/// Database-wide counters plus per-rule budget/fire stats.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
231pub struct Stats {
232 pub nodes_live: usize,
233 pub nodes_tombstoned: usize,
234 pub edges: u64,
235 pub rules: Vec<RuleStats>,
236}
237
238/// One rule's provenance size, trip latch, and fire counter.
239///
240/// `tripped` is a one-way latch: once set, the engine adds no new edges for
241/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
242/// set then fits). `fires` counts `on_node_changed` evaluations plus
243/// backfill/rebuild participant ticks (rebuild counts even when it is a
244/// provenance no-op).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct RuleStats {
247 pub name: String,
248 pub edges: u64,
249 pub tripped: bool,
250 pub fires: u64,
251 /// Whether this rule uses the approximate IVF-Flat candidate path.
252 pub approximate: bool,
253}
254
255/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
256/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct PredicateSummary {
259 pub kind: String,
260 pub fields: Vec<String>,
261 pub min: Option<f64>,
262 pub tolerance: Option<f64>,
263 pub km: Option<f64>,
264 pub parts: Option<Vec<PredicateSummary>>,
265 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
266 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
267 #[serde(default)]
268 pub approximate: bool,
269}
270
271impl From<&Predicate> for PredicateSummary {
272 fn from(p: &Predicate) -> Self {
273 match p {
274 Predicate::KeyMatch { field } => PredicateSummary {
275 kind: "key_match".into(),
276 fields: vec![field.clone()],
277 min: None,
278 tolerance: None,
279 km: None,
280 parts: None,
281 approximate: false,
282 },
283 Predicate::FieldEqual { field } => PredicateSummary {
284 kind: "field_equal".into(),
285 fields: vec![field.clone()],
286 min: None,
287 tolerance: None,
288 km: None,
289 parts: None,
290 approximate: false,
291 },
292 Predicate::Overlap { field, min } => PredicateSummary {
293 kind: "overlap".into(),
294 fields: vec![field.clone()],
295 min: Some(*min),
296 tolerance: None,
297 km: None,
298 parts: None,
299 approximate: false,
300 },
301 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
302 kind: "numeric_within".into(),
303 fields: vec![field.clone()],
304 min: None,
305 tolerance: Some(*tolerance),
306 km: None,
307 parts: None,
308 approximate: false,
309 },
310 Predicate::GeoRadius { field, km } => PredicateSummary {
311 kind: "geo_radius".into(),
312 fields: vec![field.clone()],
313 min: None,
314 tolerance: None,
315 km: Some(*km),
316 parts: None,
317 approximate: false,
318 },
319 Predicate::VectorSimilar { field, min } => PredicateSummary {
320 kind: "vector_similar".into(),
321 fields: vec![field.clone()],
322 min: Some(*min),
323 tolerance: None,
324 km: None,
325 parts: None,
326 approximate: false,
327 },
328 Predicate::All(inner) => {
329 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
330 let mut fields = Vec::new();
331 for part in &parts {
332 for f in &part.fields {
333 if !fields.contains(f) {
334 fields.push(f.clone());
335 }
336 }
337 }
338 PredicateSummary {
339 kind: "all".into(),
340 fields,
341 min: None,
342 tolerance: None,
343 km: None,
344 parts: Some(parts),
345 approximate: false,
346 }
347 }
348 Predicate::Any(inner) => {
349 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
350 let mut fields = Vec::new();
351 for part in &parts {
352 for f in &part.fields {
353 if !fields.contains(f) {
354 fields.push(f.clone());
355 }
356 }
357 }
358 PredicateSummary {
359 kind: "any".into(),
360 fields,
361 min: None,
362 tolerance: None,
363 km: None,
364 parts: Some(parts),
365 approximate: false,
366 }
367 }
368 }
369 }
370}
371
372/// Snapshot of a live node's key, label, and columnar properties.
373///
374/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
375/// regardless of insert order or the columnar store's `HashMap` iteration.
376///
377/// Deliberately does not derive `Serialize`: `Value`'s serde form is
378/// internally tagged. Wire JSON is built by `value_to_json` in the server.
379#[derive(Debug, Clone, PartialEq)]
380pub struct NodeInfo {
381 pub key: String,
382 pub label: String,
383 pub props: BTreeMap<String, Value>,
384}
385
386/// Counts returned by [`GraphDb::delete_node`].
387#[derive(Debug, Clone, PartialEq, Eq, Default)]
388pub struct DeleteReport {
389 /// Number of manual (user-inserted) edges removed.
390 pub manual_edges: u64,
391 /// Number of derived (rule-owned) edges retracted.
392 pub derived_edges: u64,
393}
394
395/// One directed edge incident on a node, with provenance membership.
396///
397/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
398/// Plan-8 `by_node` provenance index.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
400pub struct EdgeInfo {
401 pub edge_type: String,
402 pub src_key: String,
403 pub dst_key: String,
404 pub derived: bool,
405}
406
407/// An edge with mask-aware endpoint visibility.
408///
409/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
410/// mode — hidden endpoints carry `*_restricted: true`.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct MaskedEdge {
413 pub edge_type: String,
414 pub src_key: String,
415 /// `true` when `src_key` is in the DB but hidden from the mask.
416 pub src_restricted: bool,
417 pub dst_key: String,
418 /// `true` when `dst_key` is in the DB but hidden from the mask.
419 pub dst_restricted: bool,
420 pub derived: bool,
421}
422
423/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
424///
425/// `None` from that method means the key does not exist (→ 404).
426/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
427#[derive(Debug, PartialEq)]
428pub enum MaskedNodeResult {
429 Visible(NodeInfo),
430 /// Node exists in the DB but is hidden from this mask.
431 Restricted,
432}
433
434/// One rule-owned edge between two nodes, with the rule name, edge type,
435/// direction (src_key → dst_key), and weight if the rule stores one.
436#[derive(Debug, Clone, PartialEq, Serialize)]
437pub struct Explanation {
438 pub rule: String,
439 pub edge_type: String,
440 pub src_key: String,
441 pub dst_key: String,
442 pub weight: Option<f64>,
443 pub predicate: PredicateSummary,
444}
445
446/// Report returned by [`GraphDb::backup_to`].
447#[derive(Debug, Clone)]
448pub struct BackupReport {
449 /// Filenames copied into the destination directory (sorted ascending).
450 pub files: Vec<String>,
451 /// Total bytes written across all copied files.
452 pub bytes: u64,
453 /// `true` when the destination opened cleanly and passed post-copy checks.
454 ///
455 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
456 /// matched **and** the destination opened without error.
457 ///
458 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
459 /// CRC-check; `verified` is `true` when the destination opened and
460 /// replayed the WAL without error (record-level checksums in the WAL
461 /// provide the integrity signal, not section CRCs).
462 pub verified: bool,
463}
464
465/// One directed edge in export form, with optional rule attribution for derived edges.
466///
467/// Returned by [`GraphDb::all_edges_for_export`].
468#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
469pub struct ExportEdge {
470 pub edge_type: String,
471 pub src: String,
472 pub dst: String,
473 pub derived: bool,
474 /// Rule name that created this edge, if derived. `None` for manual edges.
475 pub rule: Option<String>,
476}
477
478/// Construct the standard write-query result set (columns: created, properties_set, deleted).
479fn write_result_set() -> ResultSet {
480 ResultSet::new(vec![
481 "created".into(),
482 "properties_set".into(),
483 "deleted".into(),
484 ])
485}
486
487fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
488 match op {
489 Operand::Lit(v) => Ok(v.clone()),
490 Operand::Param(name) => params
491 .get(name)
492 .cloned()
493 .ok_or_else(|| GraphError::QueryError {
494 detail: format!("missing parameter `{name}`"),
495 }),
496 _ => Err(GraphError::QueryError {
497 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
498 }),
499 }
500}
501
502fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
503 match op {
504 Operand::Prop { var, .. } | Operand::Var(var) => {
505 if !out.contains(var) {
506 out.push(var.clone());
507 }
508 }
509 Operand::FuncCall { args, .. } => {
510 for arg in args {
511 operand_node_vars(arg, out);
512 }
513 }
514 Operand::BinArith { left, right, .. } => {
515 operand_node_vars(left, out);
516 operand_node_vars(right, out);
517 }
518 Operand::Case { branches, default } => {
519 // Branch conditions reference vars already bound (and mask-filtered)
520 // by the MATCH phase, so collecting from the value operands + ELSE
521 // is sufficient for RETURN-projection var discovery.
522 for (_, value) in branches {
523 operand_node_vars(value, out);
524 }
525 if let Some(d) = default {
526 operand_node_vars(d, out);
527 }
528 }
529 Operand::Lit(_) | Operand::Param(_) => {}
530 }
531}
532
533fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
534 let mut out = Vec::new();
535 for item in items {
536 match &item.value {
537 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
538 if !out.contains(v) {
539 out.push(v.clone());
540 }
541 }
542 RetVal::FuncCall { args, .. } => {
543 for arg in args {
544 operand_node_vars(arg, &mut out);
545 }
546 }
547 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
548 RetVal::Agg { .. } => {}
549 }
550 }
551 out
552}
553
554fn add_var(out: &mut Vec<String>, v: &str) {
555 if !out.iter().any(|x| x == v) {
556 out.push(v.to_string());
557 }
558}
559
560fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
561 let mut out = Vec::new();
562 for p in pats {
563 if let Some(v) = &p.start.var {
564 add_var(&mut out, v);
565 }
566 for (_, dest) in &p.chain {
567 if let Some(v) = &dest.var {
568 add_var(&mut out, v);
569 }
570 }
571 }
572 out
573}
574
575fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
576 let mut out = Vec::new();
577 for p in pats {
578 for (rel, _) in &p.chain {
579 if rel.hops.is_none() {
580 if let Some(v) = &rel.var {
581 add_var(&mut out, v);
582 }
583 }
584 }
585 }
586 out
587}
588
589fn rel_type_alias(var: &str) -> String {
590 format!("__rt_{var}")
591}
592
593fn ret_column_name(item: &RetItem) -> String {
594 if let Some(alias) = &item.alias {
595 return alias.clone();
596 }
597 match &item.value {
598 RetVal::Var(v) => v.clone(),
599 RetVal::Prop { var, field } => format!("{var}.{field}"),
600 RetVal::FuncCall { name, args } => {
601 let arg_strs: Vec<String> = args
602 .iter()
603 .map(|a| match a {
604 Operand::Var(v) => v.clone(),
605 Operand::Prop { var, field } => format!("{var}.{field}"),
606 Operand::Lit(_) => "<lit>".to_string(),
607 Operand::Param(p) => format!("${p}"),
608 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
609 Operand::BinArith { .. } => "<arith>".to_string(),
610 Operand::Case { .. } => "<case>".to_string(),
611 })
612 .collect();
613 format!("{name}({})", arg_strs.join(", "))
614 }
615 RetVal::ScalarExpr(_) => "<expr>".to_string(),
616 RetVal::Agg { .. } => "<agg>".to_string(),
617 }
618}
619
620fn eval_set_return_operand<F: Fs>(
621 db: &GraphDb<F>,
622 match_rs: &ResultSet,
623 row: usize,
624 rel_vars: &[String],
625 op: &Operand,
626 params: &BTreeMap<String, Value>,
627) -> Result<Option<Value>> {
628 match op {
629 Operand::Lit(v) => Ok(Some(v.clone())),
630 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
631 detail: format!("missing parameter `{name}`"),
632 }).map(Some),
633 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
634 detail: format!(
635 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
636 ),
637 }),
638 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
639 Operand::Prop { var, field } => {
640 if rel_vars.iter().any(|r| r == var) {
641 return Ok(None);
642 }
643 let Some(Value::Str(key)) = match_rs.get(row, var) else {
644 return Ok(None);
645 };
646 Ok(db.get_prop(key, field))
647 }
648 Operand::FuncCall { name, args } => {
649 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
650 }
651 Operand::BinArith { op, left, right } => {
652 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
653 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
654 eval_set_return_arith(op, lv, rv)
655 }
656 // CASE is supported in read-query RETURN; in a write-statement RETURN
657 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
658 Operand::Case { .. } => Err(GraphError::QueryError {
659 detail: "CASE is not supported in a write-statement RETURN projection; \
660 use a read query"
661 .into(),
662 }),
663 }
664}
665
666fn eval_set_return_arith(
667 op: &ArithOp,
668 lv: Option<Value>,
669 rv: Option<Value>,
670) -> Result<Option<Value>> {
671 match (lv, rv) {
672 (None, _) | (_, None) => Ok(None),
673 (Some(Value::Int(a)), Some(Value::Int(b))) => {
674 let result = match op {
675 ArithOp::Sub => a.saturating_sub(b),
676 ArithOp::Mul => a.saturating_mul(b),
677 ArithOp::Add => a.saturating_add(b),
678 ArithOp::Div => {
679 if b == 0 {
680 return Err(GraphError::QueryError {
681 detail: "division by zero".into(),
682 });
683 }
684 a.checked_div(b).unwrap_or(i64::MAX)
685 }
686 };
687 Ok(Some(Value::Int(result)))
688 }
689 (Some(lv), Some(rv)) => {
690 let a = match &lv {
691 Value::Float(f) => *f,
692 Value::Int(i) => *i as f64,
693 _ => {
694 return Err(GraphError::QueryError {
695 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
696 })
697 }
698 };
699 let b = match &rv {
700 Value::Float(f) => *f,
701 Value::Int(i) => *i as f64,
702 _ => {
703 return Err(GraphError::QueryError {
704 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
705 })
706 }
707 };
708 let result = match op {
709 ArithOp::Sub => a - b,
710 ArithOp::Mul => a * b,
711 ArithOp::Add => a + b,
712 ArithOp::Div => {
713 if b == 0.0 {
714 return Err(GraphError::QueryError {
715 detail: "division by zero".into(),
716 });
717 }
718 a / b
719 }
720 };
721 Ok(Some(Value::Float(result)))
722 }
723 }
724}
725
726fn eval_set_return_func<F: Fs>(
727 db: &GraphDb<F>,
728 match_rs: &ResultSet,
729 row: usize,
730 rel_vars: &[String],
731 name: &str,
732 args: &[Operand],
733 params: &BTreeMap<String, Value>,
734) -> Result<Option<Value>> {
735 let norm = name.to_ascii_lowercase();
736 if norm == "type" {
737 if args.len() != 1 {
738 return Err(GraphError::QueryError {
739 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
740 });
741 }
742 let Operand::Var(rel) = &args[0] else {
743 return Err(GraphError::QueryError {
744 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
745 });
746 };
747 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
748 }
749 let mut vals = Vec::with_capacity(args.len());
750 for arg in args {
751 vals.push(eval_set_return_operand(
752 db, match_rs, row, rel_vars, arg, params,
753 )?);
754 }
755 match norm.as_str() {
756 "tolower" => {
757 if vals.len() != 1 {
758 return Err(GraphError::QueryError {
759 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
760 });
761 }
762 Ok(vals[0].clone().map(|val| match val {
763 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
764 other => other,
765 }))
766 }
767 "toupper" => {
768 if vals.len() != 1 {
769 return Err(GraphError::QueryError {
770 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
771 });
772 }
773 Ok(vals[0].clone().map(|val| match val {
774 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
775 other => other,
776 }))
777 }
778 "size" => match vals.first().cloned().flatten() {
779 None => Ok(None),
780 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
781 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
782 Some(_) => Ok(None),
783 },
784 "coalesce" => Ok(vals.into_iter().flatten().next()),
785 "abs" => match vals.first().cloned().flatten() {
786 None => Ok(None),
787 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
788 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
789 Some(_) => Ok(None),
790 },
791 "round" => match vals.first().cloned().flatten() {
792 None => Ok(None),
793 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
794 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
795 Some(_) => Ok(None),
796 },
797 _ => Err(GraphError::QueryError {
798 detail: format!(
799 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
800 ),
801 }),
802 }
803}
804
805fn eval_set_return_item<F: Fs>(
806 db: &GraphDb<F>,
807 match_rs: &ResultSet,
808 row: usize,
809 rel_vars: &[String],
810 item: &RetItem,
811 params: &BTreeMap<String, Value>,
812) -> Result<Option<Value>> {
813 match &item.value {
814 RetVal::Var(v) => eval_set_return_operand(
815 db,
816 match_rs,
817 row,
818 rel_vars,
819 &Operand::Var(v.clone()),
820 params,
821 ),
822 RetVal::Prop { var, field } => eval_set_return_operand(
823 db,
824 match_rs,
825 row,
826 rel_vars,
827 &Operand::Prop {
828 var: var.clone(),
829 field: field.clone(),
830 },
831 params,
832 ),
833 RetVal::FuncCall { name, args } => {
834 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
835 }
836 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
837 RetVal::Agg { .. } => Err(GraphError::QueryError {
838 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
839 }),
840 }
841}
842
843/// Project user RETURN from original MATCH rows after SET. No rematch.
844fn project_set_return_rows<F: Fs>(
845 db: &GraphDb<F>,
846 rel_vars: &[String],
847 match_rs: &ResultSet,
848 returns: &[RetItem],
849 params: &BTreeMap<String, Value>,
850) -> Result<ResultSet> {
851 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
852 let mut out = ResultSet::new(columns);
853 for row in 0..match_rs.len() {
854 let mut cells = Vec::with_capacity(returns.len());
855 for item in returns {
856 cells.push(eval_set_return_item(
857 db, match_rs, row, rel_vars, item, params,
858 )?);
859 }
860 out.push_row(cells);
861 }
862 Ok(out)
863}
864
865/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
866/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
867/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
868/// Returns `None` for non-list values or lists with non-numeric elements.
869fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
870 match v {
871 Value::List(items) => items
872 .iter()
873 .map(|item| match item {
874 Value::Float(f) => Some(*f),
875 Value::Int(i) => Some(*i as f64),
876 _ => None,
877 })
878 .collect(),
879 _ => None,
880 }
881}
882
883fn make_graph_mut<'a>(
884 ids: &'a IdMap,
885 syms: &'a mut Interner,
886 labels: &'a [u32],
887 props: core_storage::v8::seam::ColumnsView<'a>,
888 topo: &'a mut Topology,
889 edge_props: &'a mut EdgeProps,
890) -> GraphMut<'a> {
891 GraphMut {
892 ids,
893 syms,
894 labels,
895 props,
896 topo,
897 edge_props,
898 }
899}
900
901/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
902///
903/// Takes explicit field references rather than `&self` so the caller can hold
904/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
905fn build_props_view<'a>(
906 props: &'a ColumnStore,
907 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
908) -> core_storage::v8::seam::ColumnsView<'a> {
909 match base {
910 None => core_storage::v8::seam::ColumnsView::owned(props),
911 Some(b) => {
912 let archived = b
913 .columns()
914 .expect("base columns section bounds validated at open");
915 core_storage::v8::seam::ColumnsView::with_base(props, archived)
916 }
917 }
918}
919
920fn build_topo_view<'a>(
921 overlay: &'a Topology,
922 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
923) -> core_storage::v8::seam::TopologyView<'a> {
924 match base {
925 None => core_storage::v8::seam::TopologyView::owned(overlay),
926 Some(b) => {
927 let archived_csr = b
928 .topology()
929 .expect("base topology section bounds validated at open");
930 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
931 }
932 }
933}
934
935/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
936///
937/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
938/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
939/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
940/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
941/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
942#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
943pub enum FsyncPolicy {
944 /// Every WAL commit calls `fs.sync` (today's behavior).
945 #[default]
946 Strict,
947 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
948 /// this policy is set on the database.
949 Batched,
950 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
951 Relaxed,
952}
953
954/// A precondition for a compare-and-set batch write.
955///
956/// All preconditions in a [`GraphDb::write_batch_cas`] or
957/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
958/// any operation in the batch is applied. If any precondition fails, the
959/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
960/// is written.
961///
962/// # Touch definition
963///
964/// A node's last-change commit (`last_changed`) is updated when any of the
965/// following state-changing WAL records touch it:
966///
967/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
968/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
969/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
970/// endpoints (an edge change touches both sides).
971/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
972/// for deleted keys so the pre-deletion entry is never observed.
973///
974/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
975/// state no-ops. The underlying mutation that triggered rule firing already
976/// updated the relevant nodes' last-change entries. Rule-management records
977/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
978/// do not touch any node's last-change.
979#[derive(Debug, Clone, PartialEq, Eq)]
980pub enum Precondition {
981 /// The node's last-change commit must equal `expected`.
982 ///
983 /// Fails with [`GraphError::CasConflict`] when:
984 /// - The node does not exist (`last_changed` returns `None`), or
985 /// - The recorded commit seq does not match `expected`.
986 NodeUnchangedSince { key: String, expected: u64 },
987 /// The node must not exist (not inserted, or already deleted).
988 ///
989 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
990 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
991 NodeAbsent { key: String },
992}
993
994pub struct GraphDb<F: Fs> {
995 fs: F,
996 ids: IdMap,
997 syms: Interner,
998 topo: Topology,
999 props: ColumnStore,
1000 labels: Vec<u32>, // node id -> label symbol
1001 edge_props: EdgeProps,
1002 engine: RuleEngine,
1003 view_store: ViewStore,
1004 /// Incremental inverted index for full-text-lite search.
1005 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1006 fulltext: FulltextIndex,
1007 /// Opt-in equality index over scalar node properties.
1008 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1009 /// open end (mirrors `fulltext`).
1010 prop_index: PropertyIndex,
1011 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1012 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1013 fsync: FsyncPolicy,
1014 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1015 /// call increments this once; all events emitted from that call share the same
1016 /// `commit_seq` value.
1017 commit_seq: u64,
1018 /// RBAC role definitions loaded from `roles.json` at open.
1019 ///
1020 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1021 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1022 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1023 roles: Option<Vec<RoleDef>>,
1024 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1025 /// distribute_events call.
1026 subscriptions: Vec<SubEntry>,
1027 /// Live query subscriptions. Re-executed on every commit when non-empty.
1028 /// Dead `Weak` entries are pruned inside `distribute_events`.
1029 query_subscriptions: Vec<QuerySubEntry>,
1030 /// Queue capacity for new subscriptions created by this db. Default is
1031 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1032 /// to test Lagged behaviour with small queues.
1033 sub_capacity: usize,
1034 /// True for as-of instances opened via [`GraphDb::open_at`].
1035 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1036 /// when this flag is set.
1037 read_only: bool,
1038 /// Total WAL commit count at the time [`open_at`] was called.
1039 /// 0 for normal (non-as-of) instances.
1040 total_wal_commits: u64,
1041 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1042 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1043 /// reads go through a merged `TopologyView`. `self.props` is always
1044 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1045 base: Option<Arc<core_storage::v8::MappedBase>>,
1046 // ── MVCC epoch reader state ───────────────────────────────────────────────
1047 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1048 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1049 /// `None` only between struct creation and the first fold.
1050 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1051 /// Per-commit deltas accumulated since the last fold.
1052 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1053 /// How many commits have occurred since the last fold.
1054 commits_since_fold: usize,
1055 /// When true, `log_then_apply_with` buffers event notifications instead of
1056 /// firing them immediately. Used by the group-commit drain thread to defer
1057 /// events until after the group fsync (R2: durability before notification).
1058 /// Cleared to false once the drain thread flushes or discards the buffer.
1059 defer_events: bool,
1060 /// Buffered events accumulated while `defer_events` is true.
1061 deferred_events: Vec<DeferredEvent>,
1062 /// Set to true by the group-commit drain thread when a group fsync fails
1063 /// after WAL truncation. All subsequent mutation attempts return an IO
1064 /// error until the database is reopened.
1065 degraded: bool,
1066 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1067 /// HNSW, and IVF sections from the mmap base into the engine's retained
1068 /// fields. `false` on all opens until first use; always `true` for non-V8
1069 /// opens (base is None, fast-path sets flag immediately).
1070 v8_sections_loaded: std::sync::atomic::AtomicBool,
1071 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1072 v8_sections_mutex: std::sync::Mutex<()>,
1073 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1074 /// the node was last modified by commit `seq`.
1075 ///
1076 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1077 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1078 /// nodes return `None` from `last_changed` until they are next mutated.
1079 ///
1080 /// See [`Precondition`] for the full touch definition.
1081 last_change: HashMap<u32, u64>,
1082 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1083 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1084 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1085 wal_archive_retention: Option<u32>,
1086 /// Global frame index of the first commit that is still reachable through
1087 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1088 /// Default 0 = all history reachable.
1089 wal_horizon_floor: u64,
1090 /// True when the surviving archive chain forms a continuous WAL history
1091 /// starting from the store's first commit (the genesis chain).
1092 ///
1093 /// `open_at` may replay archive-resident commits from empty state only when
1094 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1095 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1096 /// already exist (breaks the chain for subsequent archives), or
1097 /// - any archive is pruned (floor advances past zero).
1098 ///
1099 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1100 archive_genesis_chain: bool,
1101 /// Transient write-authz context set by `write_batch_authz` /
1102 /// `query_write_authz` for the duration of ONE mutation call.
1103 /// Always `None` at rest. Never serialized, never WAL-replayed.
1104 pending_write_authz: Option<WriteAuthz>,
1105}
1106
1107/// One group of deferred event notifications, held until the group fsync
1108/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1109struct DeferredEvent {
1110 rec: core_storage::WalRecord,
1111 engine_deltas: Vec<EngineEdgeDelta>,
1112 seq: u64,
1113 ingest: Option<(String, usize)>,
1114}
1115
1116/// Options for [`GraphDb::open_with_options`].
1117#[derive(Clone, Copy, Debug)]
1118pub struct OpenOptions {
1119 /// Rewrite an old-format snapshot to the current VERSION after a
1120 /// successful load (default `true`). The old snapshot is kept as
1121 /// `snapshot.bin.bak` until the next clean open at the current version,
1122 /// at which point the `.bak` is deleted.
1123 ///
1124 /// Set to `false` to open a store without touching any on-disk files
1125 /// (useful for read-only inspection of a store at an older format).
1126 pub auto_migrate: bool,
1127}
1128
1129impl Default for OpenOptions {
1130 fn default() -> Self {
1131 Self { auto_migrate: true }
1132 }
1133}
1134
1135/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1136///
1137/// `None` at the call site = full authority (today's zero-cost behavior).
1138/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1139/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1140/// record is built. A denial returns an error with no WAL frame written.
1141///
1142/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1143/// hidden-node existence to callers.
1144#[derive(Clone, Debug)]
1145pub struct WriteAuthz {
1146 pub role: String,
1147 pub scope: WriteScope,
1148 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1149 /// Always `Omit`-mode — never `Stub`.
1150 pub mask: crate::mask::NodeMask,
1151}
1152
1153/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1154///
1155/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1156/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1157/// syncs the directory entry. This is the only correct path for writing the
1158/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1159/// the directory sync.
1160pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1161 use core_storage::fs::{FileId, Fs as _};
1162 RealFs::new(dir)
1163 .map_err(core_storage::GraphError::Io)?
1164 .write_atomic(FileId::SnapshotBak, bytes)
1165 .map_err(core_storage::GraphError::Io)
1166}
1167
1168/// Return the on-disk snapshot format version without decoding the full snapshot.
1169///
1170/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1171/// snapshot file exists (WAL-only store). Returns an error if the header is
1172/// malformed.
1173pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1174 use std::io::Read as _;
1175 let path = dir.join("snapshot.bin");
1176 let mut header = [0u8; 6];
1177 let n = match std::fs::File::open(&path) {
1178 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1179 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1180 Err(e) => return Err(core_storage::GraphError::Io(e)),
1181 };
1182 core_storage::snapshot::peek_version(&header[..n])
1183}
1184
1185/// Options for [`GraphDb::snapshot_with`].
1186#[derive(Debug, Clone, Default)]
1187pub struct SnapshotOptions {
1188 /// When `true`, the WAL is preserved after the snapshot write.
1189 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1190 /// When `false` (the default), the WAL is truncated to a minimal
1191 /// baseline so cold-start replay stays fast.
1192 pub keep_wal: bool,
1193 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1194 /// before a fresh WAL baseline is written (history-preserving snapshot).
1195 ///
1196 /// This is the feature opt-in: `false` (the default) leaves the existing
1197 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1198 /// precedence over `keep_wal` when both are set.
1199 ///
1200 /// Archives can be scanned by [`GraphDb::node_history`],
1201 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1202 /// [`GraphDb::open_at`], extending the reachable history horizon across
1203 /// snapshot boundaries.
1204 pub archive_wal: bool,
1205}
1206
1207impl GraphDb<RealFs> {
1208 /// Open the database at `dir` with default options.
1209 ///
1210 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1211 /// Old-format snapshots (V5, V6) are automatically migrated to the
1212 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1213 pub fn open(dir: &std::path::Path) -> Result<Self> {
1214 Self::open_with_options(dir, OpenOptions::default())
1215 }
1216
1217 /// Open the database at `dir` with explicit options.
1218 ///
1219 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1220 /// snapshot is an older format version, this function:
1221 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1222 /// + fsynced) before any modification.
1223 /// 2. Rewrites `snapshot.bin` at the current format version via
1224 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1225 ///
1226 /// If migration fails the error is returned and the original files are
1227 /// intact (the `.bak` was written before the new snapshot was attempted).
1228 ///
1229 /// A clean open that finds the snapshot already at the current version
1230 /// deletes any leftover `.bak` file.
1231 ///
1232 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1233 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1234 // Header-only peek — 6 bytes, no full decode.
1235 let snap_version = snapshot_version_at(dir)?;
1236
1237 // Full load: decode snapshot + replay WAL + rebuild indexes.
1238 let mut db = Self::open_with(RealFs::new(dir)?)?;
1239
1240 if opts.auto_migrate {
1241 match snap_version {
1242 Some(ver) if ver < core_storage::snapshot::VERSION => {
1243 let _tm = std::time::Instant::now();
1244 // Copy the original snapshot to .bak at OS level — no in-memory
1245 // buffer required for a 2+ GiB file.
1246 //
1247 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1248 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1249 // A torn .bak on crash is acceptable because the original
1250 // snapshot.bin is the authoritative source until after the rename.
1251 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1252 .map_err(core_storage::GraphError::Io)?;
1253 trace_migrate!("bak copy done", _tm);
1254 // Rewrite snapshot at current version; keep WAL intact.
1255 db.snapshot_with(SnapshotOptions {
1256 keep_wal: true,
1257 ..SnapshotOptions::default()
1258 })?;
1259 trace_migrate!("snapshot_with done", _tm);
1260 }
1261 Some(_) => {
1262 // Already current version: remove any leftover .bak.
1263 let bak = dir.join("snapshot.bin.bak");
1264 if bak.exists() {
1265 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1266 }
1267 }
1268 None => {
1269 // WAL-only store — nothing to migrate on open.
1270 }
1271 }
1272 }
1273
1274 Ok(db)
1275 }
1276
1277 /// Open a read-only view of the database as it existed after `commit`.
1278 ///
1279 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1280 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1281 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1282 ///
1283 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1284 /// so as-of can only reach commits recorded in the current WAL (those
1285 /// written after the most recent snapshot, or all commits if no snapshot
1286 /// was ever taken). Commit 0 in `open_at` always refers to the first
1287 /// frame in the WAL that exists on disk, not the first ever write to the
1288 /// database. When the on-disk snapshot recorded that it truncated the
1289 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1290 /// before frame replay, so the as-of view includes all pre-snapshot data.
1291 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1292 /// are ignored and replay is WAL-only, as before.
1293 ///
1294 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1295 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1296 /// `stats()` work normally.
1297 ///
1298 /// # Errors
1299 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1300 /// when the WAL is empty after a snapshot).
1301 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1302 Self::open_at_with(RealFs::new(dir)?, commit)
1303 }
1304
1305 /// Run a **read-only** Cypher query against the graph as it existed at
1306 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1307 /// of this store's directory at that commit and executes the read there.
1308 ///
1309 /// The current instance is unaffected. Write statements are rejected (the
1310 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1311 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1312 /// state. Prefer this over holding many historical instances open.
1313 ///
1314 /// # Errors
1315 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1316 /// - A query error for a malformed or write query.
1317 pub fn query_at(
1318 &self,
1319 commit: u64,
1320 cypher: &str,
1321 params: &std::collections::BTreeMap<String, Value>,
1322 ) -> Result<ResultSet> {
1323 let dir = self.fs.dir().to_path_buf();
1324 let temporal = Self::open_at(&dir, commit)?;
1325 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1326 detail: format!("lex: {e}"),
1327 })?) {
1328 return Err(GraphError::QueryError {
1329 detail: "query_at is read-only: write statements are not permitted in a \
1330 time-travel query"
1331 .into(),
1332 });
1333 }
1334 temporal.query(cypher, params)
1335 }
1336}
1337
1338impl<F: Fs> GraphDb<F> {
1339 pub fn open_with(fs: F) -> Result<Self> {
1340 let mut db = Self {
1341 fs,
1342 ids: IdMap::new(),
1343 syms: Interner::new(),
1344 topo: Topology::new(),
1345 props: ColumnStore::new(),
1346 labels: Vec::new(),
1347 edge_props: EdgeProps::new(),
1348 engine: RuleEngine::new(),
1349 view_store: ViewStore::new(),
1350 fulltext: FulltextIndex::new(),
1351 prop_index: PropertyIndex::new(),
1352 event_sink: None,
1353 fsync: FsyncPolicy::Strict,
1354 commit_seq: 0,
1355 roles: Some(vec![]),
1356 subscriptions: Vec::new(),
1357 query_subscriptions: Vec::new(),
1358 sub_capacity: DEFAULT_SUB_CAPACITY,
1359 read_only: false,
1360 total_wal_commits: 0,
1361 base: None,
1362 fold_overlay: None,
1363 delta_tail: Vec::new(),
1364 commits_since_fold: 0,
1365 defer_events: false,
1366 deferred_events: Vec::new(),
1367 degraded: false,
1368 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1369 v8_sections_mutex: std::sync::Mutex::new(()),
1370 last_change: HashMap::new(),
1371 wal_archive_retention: None,
1372 wal_horizon_floor: 0,
1373 archive_genesis_chain: false,
1374 pending_write_authz: None,
1375 };
1376 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1377 db.archive_genesis_chain = db.fs.has_genesis_marker();
1378 // Opening cleanup: remove orphaned archives — archives whose frames all
1379 // fall below the horizon floor. Orphans arise when a crash interrupted
1380 // the retention-prune sequence after the floor was written but before
1381 // all surplus archives were deleted. Safe to delete: floor already
1382 // accounts for their frames.
1383 db.cleanup_orphaned_archives()?;
1384 let _t0 = std::time::Instant::now();
1385 // Peek 6 bytes to determine snapshot version without reading the full
1386 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1387 // default impl reads all bytes and truncates (still correct).
1388 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1389 let is_v8 = snap_header.len() >= 6
1390 && &snap_header[0..4] == b"GDB1"
1391 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1392 == core_storage::snapshot::VERSION_8;
1393 if is_v8 {
1394 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1395 // No 2.4GB heap Vec is allocated on RealFs.
1396 let mapped = Arc::new(
1397 if let Some(snap_path) = db.fs.snapshot_path() {
1398 core_storage::v8::MappedBase::map(&snap_path)
1399 } else {
1400 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1401 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1402 }
1403 .map_err(|e| GraphError::Corrupt {
1404 detail: format!("v8: mmap open: {e:?}"),
1405 })?,
1406 );
1407 db.restore_v8_base(Arc::clone(&mapped))?;
1408 trace_open!("restore_v8_base", _t0);
1409 db.base = Some(mapped);
1410 trace_open!("base assigned", _t0);
1411 } else if !snap_header.is_empty() {
1412 // Legacy V5-V7: full read required for decode.
1413 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1414 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1415 db.restore_snapshot_state(state)?;
1416 }
1417 }
1418 // else: snap_header is empty = no snapshot file, fresh store.
1419 //
1420 // Seed commit_seq from the highest seq persisted in last_change so that
1421 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1422 // already stored in the snapshot. Without this, a db with one snapshot
1423 // commit would save last_change["a"]=1, then on reopen the first WAL
1424 // frame would replay at seq=1 again — colliding and making WAL-tail
1425 // mutations indistinguishable from the snapshot baseline.
1426 //
1427 // Safety invariant (seq-recycling):
1428 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1429 // last_change because they belonged to a previous db lifetime — a new
1430 // db starts at commit_seq=0 with an empty last_change. Therefore no
1431 // CAS precondition can carry a recycled seq as its `expected` value
1432 // and accidentally match a live node's last_change entry.
1433 //
1434 // `expected:0` on a deleted-then-reinserted node:
1435 // After deletion, last_changed() returns None; callers that call
1436 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1437 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1438 // expected=0 correctly conflicts. The only way to observe actual=0 in
1439 // a CasConflict would be a caller that invented expected=0 without ever
1440 // calling last_changed() — unreachable via the documented API contract.
1441 if let Some(&max_seq) = db.last_change.values().max() {
1442 db.commit_seq = db.commit_seq.max(max_seq);
1443 }
1444 let bytes = db.fs.read(FileId::Wal)?;
1445 let (records, valid_len) = decode_all(&bytes);
1446 if valid_len < bytes.len() {
1447 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1448 }
1449 // WAL-present path: build indexes eagerly BEFORE replay so that the
1450 // first replayed record does not trigger the lazy-init guard (which
1451 // would call reindex_all_load_ivf on an empty graph, defeating the
1452 // point of restoring IVF/HNSW blobs from the snapshot).
1453 if !records.is_empty() {
1454 db.ensure_v8_base_sections_loaded();
1455 trace_open!("lazy sections loaded (WAL path)", _t0);
1456 db.engine.consume_retained_state_eager(
1457 &db.ids,
1458 &db.syms,
1459 &db.labels,
1460 build_props_view(&db.props, &db.base),
1461 );
1462 }
1463 for rec in records {
1464 db.apply(&rec)?;
1465 // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1466 // No subscriber exists yet; discard is correct.
1467 let _ = db.engine.drain_deltas();
1468 // Track commit_seq during replay so last_change entries are
1469 // consistent with the seqs assigned by log_then_apply_with on
1470 // subsequent live commits. After N replayed frames, commit_seq=N;
1471 // live commits begin at N+1.
1472 db.commit_seq += 1;
1473 let replay_seq = db.commit_seq;
1474 db.update_last_change_from_rec(&rec, replay_seq);
1475 }
1476 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1477 // this assert catches the regression in debug builds immediately.
1478 debug_assert_eq!(
1479 db.engine.pending_delta_count(),
1480 0,
1481 "pending_deltas non-empty after replay — \
1482 per-frame drain must run inside the loop to keep memory O(1)"
1483 );
1484 // T2 note: the per-frame drain IS the suppression seam for replay.
1485 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1486 // replaying subscribers; the mechanism is already in place.
1487 let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1488 trace_open!("wal replay done", _t0);
1489 // Rebuild view values after WAL replay only when there is no V8 base.
1490 // With a V8 base, view values are correct in the snapshot and are updated
1491 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1492 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1493 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1494 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1495 // base value).
1496 if db.base.is_none() {
1497 let topo_view = TopologyView::owned(&db.topo);
1498 db.view_store
1499 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1500 }
1501 // Rebuild full-text index after WAL replay. Corrects drift from
1502 // per-record incremental apply during replay.
1503 db.fulltext.rebuild_all(
1504 &db.ids,
1505 &db.labels,
1506 &db.syms,
1507 build_props_view(&db.props, &db.base),
1508 );
1509 db.prop_index.rebuild_all(
1510 &db.ids,
1511 &db.labels,
1512 &db.syms,
1513 build_props_view(&db.props, &db.base),
1514 );
1515 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1516 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1517 db.roles = Self::load_roles_from_fs(&db.fs)?;
1518 // Capture the initial MVCC fold so reader() is ready immediately.
1519 db.fold_now();
1520 trace_open!("open_with complete", _t0);
1521 Ok(db)
1522 }
1523
1524 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1525 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1526 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
1527 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1528 /// Restore all persisted state from a decoded snapshot. Shared by
1529 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1530 fn restore_snapshot_state(
1531 &mut self,
1532 state: core_storage::snapshot::SnapshotState,
1533 ) -> Result<()> {
1534 self.ids = state.ids;
1535 self.syms = state.syms;
1536 self.topo = state.topo;
1537 self.props = state.props;
1538 self.labels = state.labels;
1539 self.edge_props = state.edge_props;
1540 // Cross-section label integrity for V5/V7 snapshots: same invariants as
1541 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
1542 // out-of-range sym ids, or a sentinel label on a live node would otherwise
1543 // open successfully and panic later in `NodeRef::label()` or
1544 // `neighborhood_masked()`. Catching it here turns those into typed
1545 // `GraphError::Corrupt` at open time.
1546 {
1547 let ids_len = self.ids.len();
1548 if self.labels.len() != ids_len {
1549 return Err(GraphError::Corrupt {
1550 detail: format!(
1551 "snapshot: labels vec has {} entries but id table has {} total slots",
1552 self.labels.len(),
1553 ids_len,
1554 ),
1555 });
1556 }
1557 let syms_len = self.syms.len() as u32;
1558 for (i, &sym) in self.labels.iter().enumerate() {
1559 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1560 if sym == u32::MAX {
1561 if !is_tombstoned {
1562 return Err(GraphError::Corrupt {
1563 detail: format!(
1564 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1565 ),
1566 });
1567 }
1568 } else if sym >= syms_len {
1569 return Err(GraphError::Corrupt {
1570 detail: format!(
1571 "snapshot: label at id slot {i} references sym {sym} \
1572 which is out of interner range ({syms_len})"
1573 ),
1574 });
1575 }
1576 }
1577 }
1578 let defs: Vec<RuleDef> = state
1579 .rule_defs
1580 .iter()
1581 .map(|b| {
1582 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1583 detail: format!("snapshot rule_def deserialize: {e}"),
1584 })
1585 })
1586 .collect::<Result<Vec<_>>>()?;
1587 self.engine =
1588 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1589 // Candidate indexes are rebuilt lazily on the first mutation (see
1590 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
1591 // snapshot are retained without deserializing so that:
1592 // - clean-open (empty WAL): indexes stay empty; blobs load on first
1593 // ANN query via ensure_hnsw_loaded, or on first mutation via the
1594 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1595 // - WAL-present: open_with calls consume_retained_state_eager before
1596 // replay so HNSW/IVF are live before any record fires the hooks.
1597 let ivf_bytes = if state.ivf_state.is_empty() {
1598 Vec::new()
1599 } else {
1600 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1601 };
1602 // Store blobs without eagerly deserializing them.
1603 self.engine
1604 .store_snapshot_state(state.hnsw_state, ivf_bytes);
1605 // Restore view defs from snapshot (V5).
1606 // The ColumnStore already contains view values from the snapshot;
1607 // use restore_view (no collision check, no backfill) so the store
1608 // is aware of the definitions. rebuild_all runs after WAL replay.
1609 for def_bytes in &state.view_defs {
1610 let def: ViewDef =
1611 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1612 detail: format!("snapshot view_def deserialize: {e}"),
1613 })?;
1614 self.view_store
1615 .restore_view(def)
1616 .map_err(|e| GraphError::Corrupt {
1617 detail: format!("snapshot view restore: {e}"),
1618 })?;
1619 }
1620 Ok(())
1621 }
1622
1623 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1624 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1625 ///
1626 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1627 /// deserialization and view rebuild have access to all column data.
1628 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1629 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1630 detail: format!("v8: ids section: {e:?}"),
1631 })?);
1632 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1633 detail: format!("v8: syms section: {e:?}"),
1634 })?);
1635
1636 // C1: self.props is left as an empty overlay. Column reads go through
1637 // props_view() (ColumnsView::with_base), which consults the archived base
1638 // section zero-copy. This avoids the O(columns) heap copy at every open.
1639
1640 // self.topo deliberately left as Topology::new() — overlay path.
1641
1642 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1643 detail: format!("v8: meta section: {e:?}"),
1644 })?)
1645 .map_err(|e| GraphError::Corrupt {
1646 detail: format!("v8: meta decode: {e:?}"),
1647 })?;
1648 self.labels = meta.labels;
1649 // Cross-section label integrity: labels must cover every id slot (live
1650 // and tombstoned), every non-sentinel sym must be within the interner's
1651 // bound, and no live (non-tombstoned) node may carry the u32::MAX
1652 // sentinel label. Without this check, a crafted snapshot where the META
1653 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1654 // sym ids, or a sentinel label on a live node, would open successfully
1655 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1656 // related read paths. Catching the inconsistency here converts those
1657 // panics into typed `GraphError::Corrupt` at open time.
1658 {
1659 let ids_len = self.ids.len();
1660 if self.labels.len() != ids_len {
1661 return Err(GraphError::Corrupt {
1662 detail: format!(
1663 "v8: labels section has {} entries but id table has {} total slots",
1664 self.labels.len(),
1665 ids_len,
1666 ),
1667 });
1668 }
1669 let syms_len = self.syms.len() as u32;
1670 for (i, &sym) in self.labels.iter().enumerate() {
1671 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1672 if sym == u32::MAX {
1673 // Sentinel is only valid for tombstoned slots.
1674 if !is_tombstoned {
1675 return Err(GraphError::Corrupt {
1676 detail: format!(
1677 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1678 ),
1679 });
1680 }
1681 } else if sym >= syms_len {
1682 return Err(GraphError::Corrupt {
1683 detail: format!(
1684 "v8: label at id slot {i} references sym {sym} \
1685 which is out of interner range ({syms_len})"
1686 ),
1687 });
1688 }
1689 }
1690 }
1691 // C3: self.edge_props stays as an empty overlay. Reads go through
1692 // edge_props_view() which consults the mmap'd base section zero-copy
1693 // via EdgePropsView::with_base. No heap decode at open time.
1694
1695 // Restore rule engine.
1696 let (rule_def_bytes, rule_tripped, rule_fires) =
1697 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1698 GraphError::Corrupt {
1699 detail: format!("v8: rules_meta section: {e:?}"),
1700 }
1701 })?);
1702 let defs: Vec<RuleDef> = rule_def_bytes
1703 .iter()
1704 .map(|b| {
1705 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1706 detail: format!("v8: rule_def deserialize: {e}"),
1707 })
1708 })
1709 .collect::<Result<Vec<_>>>()?;
1710 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1711 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1712 // `ensure_v8_base_sections_loaded` reads them on first use from
1713 // `self.base` (set by the caller immediately after this returns).
1714 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1715
1716 // Restore view definitions.
1717 let view_defs =
1718 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1719 detail: format!("v8: views section: {e:?}"),
1720 })?);
1721 for def_bytes in &view_defs {
1722 let def: ViewDef =
1723 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1724 detail: format!("v8: view_def deserialize: {e}"),
1725 })?;
1726 self.view_store
1727 .restore_view(def)
1728 .map_err(|e| GraphError::Corrupt {
1729 detail: format!("v8: view restore: {e}"),
1730 })?;
1731 }
1732 // Load the last-change map from section 11 (small section; load eagerly).
1733 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1734 // in that case and `decode_last_change_bytes` returns an empty map.
1735 let last_change_raw = mapped
1736 .last_change_bytes()
1737 .map_err(|e| GraphError::Corrupt {
1738 detail: format!("v8: last_change section: {e:?}"),
1739 })?;
1740 self.last_change = decode_last_change_bytes(last_change_raw);
1741
1742 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1743 // the file. Pure bounds check — no bytes read, no page faults triggered.
1744 // Catches truncated snapshots at open time before the lazy deferred reads.
1745 mapped.validate_section_bounds().map_err(|e| match e {
1746 GraphError::Corrupt { detail } => GraphError::Corrupt {
1747 detail: format!("v8: section bounds: {detail}"),
1748 },
1749 other => other,
1750 })?;
1751 Ok(())
1752 }
1753
1754 /// Read provenance, HNSW, and IVF sections from the mmap base into the
1755 /// engine's retained fields on first call. Subsequent calls are a no-op
1756 /// (AtomicBool fast-path).
1757 ///
1758 /// Must be called before any code path that reads or mutates engine
1759 /// provenance, HNSW, or IVF state:
1760 /// - WAL replay (before `consume_retained_state_eager`)
1761 /// - First mutation (`log_then_apply_with`)
1762 /// - Read-only paths (`stats`, `explain`, `node_edges`)
1763 /// - Snapshot (`snapshot_with`)
1764 ///
1765 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1766 fn ensure_v8_base_sections_loaded(&self) {
1767 use std::sync::atomic::Ordering;
1768 if self.v8_sections_loaded.load(Ordering::Acquire) {
1769 return;
1770 }
1771 let _guard = self
1772 .v8_sections_mutex
1773 .lock()
1774 .expect("v8 sections mutex poisoned");
1775 if self.v8_sections_loaded.load(Ordering::Acquire) {
1776 return; // another caller populated while we waited
1777 }
1778 let _t = std::time::Instant::now();
1779 if let Some(base) = &self.base {
1780 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1781 // Bounds are already validated at open time (restore_v8_base →
1782 // validate_section_bounds) — unreachable post-validate_section_bounds;
1783 // unwrap_or_default is a safety belt against impossible errors.
1784 let prov_bytes = base
1785 .provenance_raw_bytes()
1786 .map(|b| b.to_vec())
1787 .unwrap_or_default();
1788 self.engine.store_provenance_bytes(prov_bytes);
1789 // HNSW: decode rkyv blobs into owned map.
1790 let hnsw_state = base
1791 .hnsw_section()
1792 .map(archived_hnsw_to_owned)
1793 .unwrap_or_default();
1794 // IVF: raw bincode bytes; deserialized on first mutation/query.
1795 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1796 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1797 }
1798 self.v8_sections_loaded.store(true, Ordering::Release);
1799 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1800 eprintln!(
1801 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1802 _t.elapsed()
1803 );
1804 }
1805 }
1806
1807 /// Return a `TopologyView` that merges the mmap'd base (when present) with
1808 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
1809 /// the full merged topology without going through `self.view()`.
1810 fn topo_view(&self) -> TopologyView<'_> {
1811 match self.base {
1812 None => TopologyView::owned(&self.topo),
1813 Some(ref base) => {
1814 // SAFETY: base lives as long as self; section bounds validated at open.
1815 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1816 let archived = base
1817 .topology()
1818 .expect("base topology section bounds validated at open");
1819 TopologyView::with_base(&self.topo, archived)
1820 }
1821 }
1822 }
1823
1824 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1825 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
1826 /// overlay first, then fall through to the archived base section zero-copy.
1827 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1828 match self.base {
1829 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1830 Some(ref base) => {
1831 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1832 let archived = base
1833 .columns()
1834 .expect("base columns section bounds validated at open");
1835 core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1836 }
1837 }
1838 }
1839
1840 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1841 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1842 ///
1843 /// Reads consult the overlay first (for post-snapshot mutations), then fall
1844 /// through to the archived base section zero-copy. Tombstones in the
1845 /// overlay mask deleted-from-base entries.
1846 fn edge_props_view(&self) -> EdgePropsView<'_> {
1847 match self.base {
1848 None => EdgePropsView::owned(&self.edge_props),
1849 Some(ref base) => {
1850 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1851 let archived = base
1852 .edge_props_section()
1853 .expect("base edge_props section bounds validated at open");
1854 EdgePropsView::with_base(&self.edge_props, archived)
1855 }
1856 }
1857 }
1858
1859 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1860 let mut db = Self {
1861 fs,
1862 ids: IdMap::new(),
1863 syms: Interner::new(),
1864 topo: Topology::new(),
1865 props: ColumnStore::new(),
1866 labels: Vec::new(),
1867 edge_props: EdgeProps::new(),
1868 engine: RuleEngine::new(),
1869 view_store: ViewStore::new(),
1870 fulltext: FulltextIndex::new(),
1871 prop_index: PropertyIndex::new(),
1872 event_sink: None,
1873 fsync: FsyncPolicy::Strict,
1874 commit_seq: 0,
1875 roles: Some(vec![]),
1876 subscriptions: Vec::new(),
1877 query_subscriptions: Vec::new(),
1878 sub_capacity: DEFAULT_SUB_CAPACITY,
1879 read_only: false, // set to true after replay
1880 total_wal_commits: 0,
1881 base: None,
1882 fold_overlay: None,
1883 delta_tail: Vec::new(),
1884 commits_since_fold: 0,
1885 defer_events: false,
1886 deferred_events: Vec::new(),
1887 degraded: false,
1888 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1889 v8_sections_mutex: std::sync::Mutex::new(()),
1890 last_change: HashMap::new(),
1891 wal_archive_retention: None,
1892 wal_horizon_floor: 0,
1893 archive_genesis_chain: false,
1894 pending_write_authz: None,
1895 };
1896 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1897 db.archive_genesis_chain = db.fs.has_genesis_marker();
1898 // Same orphaned-archive cleanup as open_with: floor was written first
1899 // during pruning, so a crash may have left stale archives below floor.
1900 db.cleanup_orphaned_archives()?;
1901 // Collect archive frames (oldest-first) and live WAL frames.
1902 // Archives represent pre-snapshot history; the snapshot captures the
1903 // cumulative state at the time of archiving. Crash-window guarantee:
1904 // A: crash before rename → WAL intact, no archive. Reopen: normal.
1905 // B: crash after rename, before new WAL → archive present, WAL
1906 // absent. Reopen: snapshot loaded (full state), no WAL replay.
1907 // C: crash after new baseline WAL written → normal post-archive.
1908 let archive_ns = db.fs.list_archives()?;
1909 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
1910 for n in &archive_ns {
1911 let arc_bytes = db.fs.read_archive(*n)?;
1912 let (arc_frames, _) = decode_all(&arc_bytes);
1913 archive_frames_all.extend(arc_frames);
1914 }
1915 let total_archive_frames = archive_frames_all.len() as u64;
1916
1917 let live_bytes = db.fs.read(FileId::Wal)?;
1918 let (live_records, _valid_len) = decode_all(&live_bytes);
1919 let total_surviving = total_archive_frames + live_records.len() as u64;
1920 // Global total including any pruned history below the horizon floor.
1921 let total = db.wal_horizon_floor + total_surviving;
1922
1923 // Horizon and range check.
1924 if commit < db.wal_horizon_floor {
1925 return Err(GraphError::CommitOutOfRange { commit, total });
1926 }
1927 if commit >= total {
1928 return Err(GraphError::CommitOutOfRange { commit, total });
1929 }
1930
1931 // Local index into surviving frames (0 = first frame of oldest archive).
1932 let local = commit - db.wal_horizon_floor;
1933
1934 if local < total_archive_frames {
1935 // Target commit is in an archive. Correct replay from empty state
1936 // is only possible when the archive chain is an uninterrupted
1937 // genesis chain (first archive taken from a fresh store, no prior
1938 // WAL truncation) and no archives have been pruned (floor == 0).
1939 //
1940 // If either condition is violated the prefix needed to reconstruct
1941 // the requested state is gone; refuse rather than return wrong data.
1942 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
1943 return Err(GraphError::CommitOutOfRange { commit, total });
1944 }
1945 // Replay all archive frames up to and including the target commit
1946 // from an empty database state. Archives must be replayed in order
1947 // so that dense-id intern tables are built up correctly.
1948 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
1949 db.apply(&rec)?;
1950 let _ = db.engine.drain_deltas();
1951 }
1952 } else {
1953 // Target commit is in the live WAL: load snapshot as base, then
1954 // replay the needed live WAL prefix.
1955 //
1956 // Base state: a truncating snapshot (wal_truncated=true) compacts
1957 // all pre-truncation / pre-archive commits. Dense-id records in
1958 // the live WAL reference ids/interns that the snapshot provides.
1959 // Peek 6 bytes (same pattern as open_with).
1960 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1961 let is_v8 = snap_header.len() >= 6
1962 && &snap_header[0..4] == b"GDB1"
1963 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1964 == core_storage::snapshot::VERSION_8;
1965 if is_v8 {
1966 let state = if let Some(snap_path) = db.fs.snapshot_path() {
1967 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
1968 GraphError::Corrupt {
1969 detail: format!("v8: open_at mmap: {e:?}"),
1970 }
1971 })?;
1972 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
1973 } else {
1974 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1975 core_storage::snapshot::decode(&snap_bytes)?
1976 };
1977 if let Some(state) = state {
1978 if state.wal_truncated {
1979 db.restore_snapshot_state(state)?;
1980 }
1981 }
1982 } else if !snap_header.is_empty() {
1983 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1984 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1985 if state.wal_truncated {
1986 db.restore_snapshot_state(state)?;
1987 }
1988 }
1989 }
1990 // else: snap_header empty = no snapshot file.
1991 let live_local = local - total_archive_frames;
1992 for rec in live_records.into_iter().take((live_local + 1) as usize) {
1993 db.apply(&rec)?;
1994 let _ = db.engine.drain_deltas();
1995 }
1996 }
1997 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1998 // post-loop assert in open_with.
1999 debug_assert_eq!(
2000 db.engine.pending_delta_count(),
2001 0,
2002 "pending_deltas non-empty after open_at replay — \
2003 per-frame drain must run inside the loop to keep memory O(1)"
2004 );
2005 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2006 // Rebuild view values after WAL replay so derived-edge-driven views
2007 // reflect the as-of state. open_at always uses the legacy path (no V8
2008 // base), so topo_view is always owned.
2009 {
2010 let topo_view = TopologyView::owned(&db.topo);
2011 db.view_store
2012 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2013 }
2014 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2015 db.fulltext.rebuild_all(
2016 &db.ids,
2017 &db.labels,
2018 &db.syms,
2019 build_props_view(&db.props, &db.base),
2020 );
2021 db.prop_index.rebuild_all(
2022 &db.ids,
2023 &db.labels,
2024 &db.syms,
2025 build_props_view(&db.props, &db.base),
2026 );
2027 // Load roles sidecar (current roles, not point-in-time).
2028 db.roles = Self::load_roles_from_fs(&db.fs)?;
2029 db.read_only = true;
2030 db.total_wal_commits = total;
2031 // Capture initial fold so reader() is immediately usable.
2032 db.fold_now();
2033 Ok(db)
2034 }
2035
2036 /// Whether this instance is a read-only as-of view.
2037 pub fn is_read_only(&self) -> bool {
2038 self.read_only
2039 }
2040
2041 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2042
2043 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2044 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2045 /// the end of `open_with` / `open_at_with` to prime the reader.
2046 fn fold_now(&mut self) {
2047 let frozen = crate::reader::FrozenOverlay {
2048 ids: self.ids.clone(),
2049 syms: self.syms.clone(),
2050 topo: self.topo.clone(),
2051 props: self.props.clone(),
2052 labels: self.labels.clone(),
2053 edge_props: self.edge_props.clone(),
2054 roles: self.roles.clone(),
2055 fulltext: self.fulltext.clone(),
2056 };
2057 self.fold_overlay = Some(Arc::new(frozen));
2058 self.delta_tail.clear();
2059 self.commits_since_fold = 0;
2060 }
2061
2062 /// Capture a lock-free reader snapshot of the current db state.
2063 ///
2064 /// The read lock is held only for the duration of this call (to clone a
2065 /// handful of `Arc` handles). Subsequent query operations run without any
2066 /// lock.
2067 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2068 crate::reader::ReaderSnapshot::new(
2069 self.fold_overlay
2070 .clone()
2071 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2072 self.base.clone(),
2073 self.delta_tail.clone(),
2074 )
2075 }
2076
2077 /// Total number of WAL commits at the time [`open_at`] was called.
2078 /// Returns 0 for normal (non-as-of) instances.
2079 pub fn total_wal_commits(&self) -> u64 {
2080 self.total_wal_commits
2081 }
2082
2083 /// Apply a record to in-memory state. Used by both live writes and replay,
2084 /// so replay is definitionally identical to the original execution.
2085 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2086 match rec {
2087 WalRecord::InsertNode { label, key, props } => {
2088 let id = self.ids.try_insert(key)?;
2089 let sym = self.syms.intern(label);
2090 if self.labels.len() <= id as usize {
2091 // gap slots are sentinels, never valid label symbols
2092 self.labels.resize(id as usize + 1, u32::MAX);
2093 }
2094 self.labels[id as usize] = sym;
2095 for (field, value) in props {
2096 self.props.set(id, field, value.clone());
2097 }
2098 // Initialize view values for the new node before the engine runs so
2099 // delta-based increments start from a known zero baseline.
2100 self.view_store
2101 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2102 // Fire rules for the newly inserted node.
2103 let cursor = self.engine.pending_delta_count();
2104 let mut eng = std::mem::take(&mut self.engine);
2105 {
2106 let mut gm = make_graph_mut(
2107 &self.ids,
2108 &mut self.syms,
2109 &self.labels,
2110 build_props_view(&self.props, &self.base),
2111 &mut self.topo,
2112 &mut self.edge_props,
2113 );
2114 eng.on_node_changed(id, None, &mut gm);
2115 }
2116 self.engine = eng;
2117 // Process derived-edge deltas for view maintenance.
2118 // Fast path: skip the O(delta_count) allocation when no views exist.
2119 if !self.view_store.is_empty() {
2120 #[cfg(test)]
2121 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2122 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2123 for d in &new_deltas {
2124 self.view_store.on_edge_changed(
2125 d.etype_sym,
2126 d.src_id,
2127 d.dst_id,
2128 d.fired,
2129 &mut self.props,
2130 &build_topo_view(&self.topo, &self.base),
2131 &self.ids,
2132 &self.syms,
2133 &self.labels,
2134 self.base.as_ref().map(|b| {
2135 b.columns()
2136 .expect("base columns section bounds validated at open")
2137 }),
2138 );
2139 }
2140 }
2141 // Full-text index maintenance: index enabled fields for this label.
2142 if self.fulltext.has_label(label) {
2143 for (field, value) in props {
2144 if self.fulltext.is_enabled(label, field) {
2145 self.fulltext.add_tokens(id, field, value);
2146 }
2147 }
2148 }
2149 // Property (equality) index maintenance.
2150 if self.prop_index.has_label(label) {
2151 for (field, value) in props {
2152 self.prop_index.set(label, field, id, value);
2153 }
2154 }
2155 }
2156 WalRecord::InsertEdge {
2157 edge_type,
2158 src_key,
2159 dst_key,
2160 } => {
2161 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2162 detail: format!("wal replay references unknown key {src_key}"),
2163 })?;
2164 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2165 detail: format!("wal replay references unknown key {dst_key}"),
2166 })?;
2167 let etype = self.syms.intern(edge_type);
2168 // Skip if the edge is already visible in the merged base+overlay
2169 // view. This keeps WAL replay idempotent when the WAL contains
2170 // pre-snapshot records that are already encoded in a V8 base
2171 // (keep_wal=true opens and crash-before-truncation scenarios).
2172 if self.base.is_some()
2173 && self
2174 .topo_view()
2175 .neighbors(etype, Direction::Out, src)
2176 .contains(&dst)
2177 {
2178 return Ok(());
2179 }
2180 self.topo.add_edge(etype, src, dst);
2181 // View maintenance for manual edge insert.
2182 self.view_store.on_edge_changed(
2183 etype,
2184 src,
2185 dst,
2186 true,
2187 &mut self.props,
2188 &build_topo_view(&self.topo, &self.base),
2189 &self.ids,
2190 &self.syms,
2191 &self.labels,
2192 self.base.as_ref().map(|b| {
2193 b.columns()
2194 .expect("base columns section bounds validated at open")
2195 }),
2196 );
2197 // Rule engine: via-hop rules must update when user edges change.
2198 let cursor = self.engine.pending_delta_count();
2199 let mut eng = std::mem::take(&mut self.engine);
2200 {
2201 let mut gm = make_graph_mut(
2202 &self.ids,
2203 &mut self.syms,
2204 &self.labels,
2205 build_props_view(&self.props, &self.base),
2206 &mut self.topo,
2207 &mut self.edge_props,
2208 );
2209 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2210 }
2211 self.engine = eng;
2212 if !self.view_store.is_empty() {
2213 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2214 for d in &new_deltas {
2215 self.view_store.on_edge_changed(
2216 d.etype_sym,
2217 d.src_id,
2218 d.dst_id,
2219 d.fired,
2220 &mut self.props,
2221 &build_topo_view(&self.topo, &self.base),
2222 &self.ids,
2223 &self.syms,
2224 &self.labels,
2225 self.base.as_ref().map(|b| {
2226 b.columns()
2227 .expect("base columns section bounds validated at open")
2228 }),
2229 );
2230 }
2231 }
2232 }
2233 WalRecord::SetProp { key, field, value } => {
2234 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2235 detail: format!("wal replay references unknown key {key}"),
2236 })?;
2237 let old_value = build_props_view(&self.props, &self.base)
2238 .get(id, field)
2239 .map(|vr| vr.into_value());
2240 self.props.set(id, field, value.clone());
2241 // Fire rules for the changed field.
2242 let cursor = self.engine.pending_delta_count();
2243 let mut eng = std::mem::take(&mut self.engine);
2244 {
2245 let mut gm = make_graph_mut(
2246 &self.ids,
2247 &mut self.syms,
2248 &self.labels,
2249 build_props_view(&self.props, &self.base),
2250 &mut self.topo,
2251 &mut self.edge_props,
2252 );
2253 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2254 }
2255 self.engine = eng;
2256 // Derived-edge deltas → view updates.
2257 if !self.view_store.is_empty() {
2258 #[cfg(test)]
2259 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2260 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2261 for d in &new_deltas {
2262 self.view_store.on_edge_changed(
2263 d.etype_sym,
2264 d.src_id,
2265 d.dst_id,
2266 d.fired,
2267 &mut self.props,
2268 &build_topo_view(&self.topo, &self.base),
2269 &self.ids,
2270 &self.syms,
2271 &self.labels,
2272 self.base.as_ref().map(|b| {
2273 b.columns()
2274 .expect("base columns section bounds validated at open")
2275 }),
2276 );
2277 }
2278 }
2279 // Neighbor-aggregate views that read `field` must also update.
2280 self.view_store.on_prop_changed(
2281 id,
2282 field,
2283 &mut self.props,
2284 &build_topo_view(&self.topo, &self.base),
2285 &self.ids,
2286 &self.syms,
2287 &self.labels,
2288 self.base.as_ref().map(|b| {
2289 b.columns()
2290 .expect("base columns section bounds validated at open")
2291 }),
2292 );
2293 // Full-text index maintenance: update tokens for this field if indexed.
2294 if self.fulltext.field_indexed(field) {
2295 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2296 if sym == u32::MAX {
2297 None
2298 } else {
2299 self.syms.resolve(sym)
2300 }
2301 });
2302 if let Some(label) = label_opt {
2303 if self.fulltext.is_enabled(label, field) {
2304 self.fulltext.remove_node_field(id, field);
2305 self.fulltext.add_tokens(id, field, value);
2306 }
2307 }
2308 }
2309 // Property (equality) index maintenance: re-key this node's value.
2310 if self.prop_index.field_indexed(field) {
2311 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2312 if sym == u32::MAX {
2313 None
2314 } else {
2315 self.syms.resolve(sym)
2316 }
2317 });
2318 if let Some(label) = label_opt {
2319 self.prop_index.set(label, field, id, value);
2320 }
2321 }
2322 }
2323 WalRecord::Intern { id, text } => {
2324 if let Some(existing) = self.syms.get(text) {
2325 if existing != *id {
2326 return Err(GraphError::Corrupt {
2327 detail: format!(
2328 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2329 ),
2330 });
2331 }
2332 } else {
2333 let got = self.syms.intern(text);
2334 if got != *id {
2335 return Err(GraphError::Corrupt {
2336 detail: format!(
2337 "wal intern assigned {got} for {text:?}, record wanted {id}"
2338 ),
2339 });
2340 }
2341 }
2342 }
2343 WalRecord::InsertNodeId { label, key, props } => {
2344 let id = self.ids.try_insert(key)?;
2345 if self.labels.len() <= id as usize {
2346 self.labels.resize(id as usize + 1, u32::MAX);
2347 }
2348 self.labels[id as usize] = *label;
2349 let label_str = self
2350 .syms
2351 .resolve(*label)
2352 .ok_or_else(|| GraphError::Corrupt {
2353 detail: format!("wal InsertNodeId unknown label intern {label}"),
2354 })?
2355 .to_string();
2356 for (field_sym, value) in props {
2357 let field =
2358 self.syms
2359 .resolve(*field_sym)
2360 .ok_or_else(|| GraphError::Corrupt {
2361 detail: format!(
2362 "wal InsertNodeId unknown field intern {field_sym}"
2363 ),
2364 })?;
2365 self.props.set(id, field, value.clone());
2366 }
2367 self.view_store
2368 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2369 let cursor = self.engine.pending_delta_count();
2370 let mut eng = std::mem::take(&mut self.engine);
2371 {
2372 let mut gm = make_graph_mut(
2373 &self.ids,
2374 &mut self.syms,
2375 &self.labels,
2376 build_props_view(&self.props, &self.base),
2377 &mut self.topo,
2378 &mut self.edge_props,
2379 );
2380 eng.on_node_changed(id, None, &mut gm);
2381 }
2382 self.engine = eng;
2383 if !self.view_store.is_empty() {
2384 #[cfg(test)]
2385 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2386 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2387 for d in &new_deltas {
2388 self.view_store.on_edge_changed(
2389 d.etype_sym,
2390 d.src_id,
2391 d.dst_id,
2392 d.fired,
2393 &mut self.props,
2394 &build_topo_view(&self.topo, &self.base),
2395 &self.ids,
2396 &self.syms,
2397 &self.labels,
2398 self.base.as_ref().map(|b| {
2399 b.columns()
2400 .expect("base columns section bounds validated at open")
2401 }),
2402 );
2403 }
2404 }
2405 if self.fulltext.has_label(&label_str) {
2406 for (field_sym, value) in props {
2407 let Some(field) = self.syms.resolve(*field_sym) else {
2408 continue;
2409 };
2410 if self.fulltext.is_enabled(&label_str, field) {
2411 self.fulltext.add_tokens(id, field, value);
2412 }
2413 }
2414 }
2415 if self.prop_index.has_label(&label_str) {
2416 for (field_sym, value) in props {
2417 let Some(field) = self.syms.resolve(*field_sym) else {
2418 continue;
2419 };
2420 self.prop_index.set(&label_str, field, id, value);
2421 }
2422 }
2423 }
2424 WalRecord::InsertEdgeId { etype, src, dst } => {
2425 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2426 // already be tombstoned. Skip rather than attaching edges to
2427 // dead ids (DeleteNode keys the live re-insert, not the old id).
2428 if self.ids.is_tombstoned(*src)
2429 || self.ids.is_tombstoned(*dst)
2430 || self.ids.key_of(*src).is_none()
2431 || self.ids.key_of(*dst).is_none()
2432 {
2433 return Ok(());
2434 }
2435 // Skip if already visible in the merged view (same idempotency
2436 // guard as InsertEdge above: prevents double-counting when
2437 // pre-snapshot WAL records are replayed over a V8 base).
2438 if self.base.is_some()
2439 && self
2440 .topo_view()
2441 .neighbors(*etype, Direction::Out, *src)
2442 .contains(dst)
2443 {
2444 return Ok(());
2445 }
2446 self.topo.add_edge(*etype, *src, *dst);
2447 self.view_store.on_edge_changed(
2448 *etype,
2449 *src,
2450 *dst,
2451 true,
2452 &mut self.props,
2453 &build_topo_view(&self.topo, &self.base),
2454 &self.ids,
2455 &self.syms,
2456 &self.labels,
2457 self.base.as_ref().map(|b| {
2458 b.columns()
2459 .expect("base columns section bounds validated at open")
2460 }),
2461 );
2462 // Rule engine: via-hop rules fire when user via-edges are inserted.
2463 // Resolve etype back to string so on_edge_changed can match rules by name.
2464 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2465 let cursor = self.engine.pending_delta_count();
2466 let mut eng = std::mem::take(&mut self.engine);
2467 {
2468 let mut gm = make_graph_mut(
2469 &self.ids,
2470 &mut self.syms,
2471 &self.labels,
2472 build_props_view(&self.props, &self.base),
2473 &mut self.topo,
2474 &mut self.edge_props,
2475 );
2476 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2477 }
2478 self.engine = eng;
2479 if !self.view_store.is_empty() {
2480 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2481 for d in &new_deltas {
2482 self.view_store.on_edge_changed(
2483 d.etype_sym,
2484 d.src_id,
2485 d.dst_id,
2486 d.fired,
2487 &mut self.props,
2488 &build_topo_view(&self.topo, &self.base),
2489 &self.ids,
2490 &self.syms,
2491 &self.labels,
2492 self.base.as_ref().map(|b| {
2493 b.columns()
2494 .expect("base columns section bounds validated at open")
2495 }),
2496 );
2497 }
2498 }
2499 }
2500 }
2501 WalRecord::SetPropId { id, field, value } => {
2502 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2503 return Ok(());
2504 }
2505 let field_str = self
2506 .syms
2507 .resolve(*field)
2508 .ok_or_else(|| GraphError::Corrupt {
2509 detail: format!("wal SetPropId unknown field intern {field}"),
2510 })?
2511 .to_string();
2512 let old_value = build_props_view(&self.props, &self.base)
2513 .get(*id, &field_str)
2514 .map(|vr| vr.into_value());
2515 self.props.set(*id, &field_str, value.clone());
2516 let cursor = self.engine.pending_delta_count();
2517 let mut eng = std::mem::take(&mut self.engine);
2518 {
2519 let mut gm = make_graph_mut(
2520 &self.ids,
2521 &mut self.syms,
2522 &self.labels,
2523 build_props_view(&self.props, &self.base),
2524 &mut self.topo,
2525 &mut self.edge_props,
2526 );
2527 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2528 }
2529 self.engine = eng;
2530 if !self.view_store.is_empty() {
2531 #[cfg(test)]
2532 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2533 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2534 for d in &new_deltas {
2535 self.view_store.on_edge_changed(
2536 d.etype_sym,
2537 d.src_id,
2538 d.dst_id,
2539 d.fired,
2540 &mut self.props,
2541 &build_topo_view(&self.topo, &self.base),
2542 &self.ids,
2543 &self.syms,
2544 &self.labels,
2545 self.base.as_ref().map(|b| {
2546 b.columns()
2547 .expect("base columns section bounds validated at open")
2548 }),
2549 );
2550 }
2551 }
2552 self.view_store.on_prop_changed(
2553 *id,
2554 &field_str,
2555 &mut self.props,
2556 &build_topo_view(&self.topo, &self.base),
2557 &self.ids,
2558 &self.syms,
2559 &self.labels,
2560 self.base.as_ref().map(|b| {
2561 b.columns()
2562 .expect("base columns section bounds validated at open")
2563 }),
2564 );
2565 if self.fulltext.field_indexed(&field_str) {
2566 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2567 if sym == u32::MAX {
2568 None
2569 } else {
2570 self.syms.resolve(sym)
2571 }
2572 });
2573 if let Some(label) = label_opt {
2574 if self.fulltext.is_enabled(label, &field_str) {
2575 self.fulltext.remove_node_field(*id, &field_str);
2576 self.fulltext.add_tokens(*id, &field_str, value);
2577 }
2578 }
2579 }
2580 if self.prop_index.field_indexed(&field_str) {
2581 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2582 if sym == u32::MAX {
2583 None
2584 } else {
2585 self.syms.resolve(sym)
2586 }
2587 });
2588 if let Some(label) = label_opt {
2589 self.prop_index.set(label, &field_str, *id, value);
2590 }
2591 }
2592 }
2593 WalRecord::CreateRule { def_bytes } => {
2594 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2595 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2596 })?;
2597 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2598 // so the engine already has it; silently skip to avoid a spurious
2599 // RuleInvalid error in the crash window between snapshot write and WAL
2600 // truncation.
2601 if self.engine.rules().any(|r| r.name == def.name) {
2602 return Ok(());
2603 }
2604 let cursor = self.engine.pending_delta_count();
2605 let mut eng = std::mem::take(&mut self.engine);
2606 let result = {
2607 let mut gm = make_graph_mut(
2608 &self.ids,
2609 &mut self.syms,
2610 &self.labels,
2611 build_props_view(&self.props, &self.base),
2612 &mut self.topo,
2613 &mut self.edge_props,
2614 );
2615 eng.create_rule(def, &mut gm)
2616 };
2617 self.engine = eng;
2618 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2619 // Derived-edge fires from backfill → view updates.
2620 // Fast path: skip O(edge_count) allocation when no views exist.
2621 if !self.view_store.is_empty() {
2622 #[cfg(test)]
2623 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2624 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2625 for d in &new_deltas {
2626 self.view_store.on_edge_changed(
2627 d.etype_sym,
2628 d.src_id,
2629 d.dst_id,
2630 d.fired,
2631 &mut self.props,
2632 &build_topo_view(&self.topo, &self.base),
2633 &self.ids,
2634 &self.syms,
2635 &self.labels,
2636 self.base.as_ref().map(|b| {
2637 b.columns()
2638 .expect("base columns section bounds validated at open")
2639 }),
2640 );
2641 }
2642 }
2643 }
2644 WalRecord::DeleteRule { name } => {
2645 // Replay-over-snapshot idempotency: the snapshot already captured the
2646 // post-delete state so the rule is absent; silently skip to avoid a
2647 // spurious RuleNotFound error in the crash window between snapshot write
2648 // and WAL truncation.
2649 if !self.engine.rules().any(|r| r.name == *name) {
2650 return Ok(());
2651 }
2652 let cursor = self.engine.pending_delta_count();
2653 let mut eng = std::mem::take(&mut self.engine);
2654 let result = {
2655 let mut gm = make_graph_mut(
2656 &self.ids,
2657 &mut self.syms,
2658 &self.labels,
2659 build_props_view(&self.props, &self.base),
2660 &mut self.topo,
2661 &mut self.edge_props,
2662 );
2663 eng.delete_rule(name, &mut gm)
2664 };
2665 self.engine = eng;
2666 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2667 // Derived-edge retractions → view updates.
2668 if !self.view_store.is_empty() {
2669 #[cfg(test)]
2670 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2671 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2672 for d in &new_deltas {
2673 self.view_store.on_edge_changed(
2674 d.etype_sym,
2675 d.src_id,
2676 d.dst_id,
2677 d.fired,
2678 &mut self.props,
2679 &build_topo_view(&self.topo, &self.base),
2680 &self.ids,
2681 &self.syms,
2682 &self.labels,
2683 self.base.as_ref().map(|b| {
2684 b.columns()
2685 .expect("base columns section bounds validated at open")
2686 }),
2687 );
2688 }
2689 }
2690 }
2691 WalRecord::RemoveProp { key, field } => {
2692 // Recovery-safe: unknown key or already-absent field is a
2693 // clean no-op. Crash-window replay over a snapshot that
2694 // already applied this record must not Err.
2695 let Some(id) = self.ids.get(key) else {
2696 return Ok(());
2697 };
2698 // Read old value through the seam for rule retraction.
2699 let old = build_props_view(&self.props, &self.base)
2700 .get(id, field)
2701 .map(|vr| vr.into_value());
2702 self.props.remove(id, field);
2703 // If the base still supplies the value after the overlay removal,
2704 // record a tombstone so ColumnsView::get does not resurrect it.
2705 // This covers both the base-only case AND the both-resident case:
2706 // base-only (in_overlay=false): old prop was only in base, remove
2707 // is a no-op on overlay, base still visible → tombstone needed.
2708 // both-resident (in_overlay=true): overlay had v2, base has v1;
2709 // removing overlay uncovers v1 → tombstone needed.
2710 // Idempotent on double-replay: second pass sees the tombstone →
2711 // get() returns None → condition is false → no duplicate tombstone.
2712 if build_props_view(&self.props, &self.base)
2713 .get(id, field)
2714 .is_some()
2715 {
2716 self.props.record_prop_tombstone(id, field);
2717 }
2718 let cursor = self.engine.pending_delta_count();
2719 let mut eng = std::mem::take(&mut self.engine);
2720 {
2721 let mut gm = make_graph_mut(
2722 &self.ids,
2723 &mut self.syms,
2724 &self.labels,
2725 build_props_view(&self.props, &self.base),
2726 &mut self.topo,
2727 &mut self.edge_props,
2728 );
2729 eng.on_node_changed(id, Some((field, old)), &mut gm);
2730 }
2731 self.engine = eng;
2732 // Derived-edge deltas → view updates.
2733 if !self.view_store.is_empty() {
2734 #[cfg(test)]
2735 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2736 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2737 for d in &new_deltas {
2738 self.view_store.on_edge_changed(
2739 d.etype_sym,
2740 d.src_id,
2741 d.dst_id,
2742 d.fired,
2743 &mut self.props,
2744 &build_topo_view(&self.topo, &self.base),
2745 &self.ids,
2746 &self.syms,
2747 &self.labels,
2748 self.base.as_ref().map(|b| {
2749 b.columns()
2750 .expect("base columns section bounds validated at open")
2751 }),
2752 );
2753 }
2754 }
2755 // Neighbor-aggregate views that read `field` must also update.
2756 self.view_store.on_prop_changed(
2757 id,
2758 field,
2759 &mut self.props,
2760 &build_topo_view(&self.topo, &self.base),
2761 &self.ids,
2762 &self.syms,
2763 &self.labels,
2764 self.base.as_ref().map(|b| {
2765 b.columns()
2766 .expect("base columns section bounds validated at open")
2767 }),
2768 );
2769 // Full-text index maintenance: remove tokens for this field.
2770 if self.fulltext.field_indexed(field) {
2771 self.fulltext.remove_node_field(id, field);
2772 }
2773 // Property (equality) index maintenance: drop this node's entry.
2774 if self.prop_index.field_indexed(field) {
2775 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2776 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2777 }) {
2778 self.prop_index.remove_node(label, field, id);
2779 }
2780 }
2781 }
2782 WalRecord::DeleteEdge {
2783 edge_type,
2784 src_key,
2785 dst_key,
2786 } => {
2787 // Recovery-safe: unknown keys, unknown etype, or already-
2788 // absent edge is a clean no-op (remove_edge returns false).
2789 let Some(src) = self.ids.get(src_key) else {
2790 return Ok(());
2791 };
2792 let Some(dst) = self.ids.get(dst_key) else {
2793 return Ok(());
2794 };
2795 let Some(etype) = self.syms.get(edge_type) else {
2796 return Ok(());
2797 };
2798 // I3: phantom-tombstone guard. When a V8 base is present, a
2799 // DeleteEdge WAL record for an edge that was already absorbed into
2800 // the new base (i.e. neither in overlay nor in base) must be skipped.
2801 // Without this guard, remove_edge records a tombstone for an edge
2802 // that no longer exists, incorrectly understating edge_count.
2803 if self.base.is_some()
2804 && !self
2805 .topo_view()
2806 .neighbors(etype, core_storage::topology::Direction::Out, src)
2807 .contains(&dst)
2808 {
2809 return Ok(());
2810 }
2811 self.topo.remove_edge(etype, src, dst);
2812 self.edge_props.remove_edge(etype, src, dst);
2813 // View maintenance for manual edge delete (topo already updated above).
2814 self.view_store.on_edge_changed(
2815 etype,
2816 src,
2817 dst,
2818 false,
2819 &mut self.props,
2820 &build_topo_view(&self.topo, &self.base),
2821 &self.ids,
2822 &self.syms,
2823 &self.labels,
2824 self.base.as_ref().map(|b| {
2825 b.columns()
2826 .expect("base columns section bounds validated at open")
2827 }),
2828 );
2829 // Rule engine: via-hop rules must retract when user via-edges are deleted.
2830 let cursor = self.engine.pending_delta_count();
2831 let mut eng = std::mem::take(&mut self.engine);
2832 {
2833 let mut gm = make_graph_mut(
2834 &self.ids,
2835 &mut self.syms,
2836 &self.labels,
2837 build_props_view(&self.props, &self.base),
2838 &mut self.topo,
2839 &mut self.edge_props,
2840 );
2841 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2842 }
2843 self.engine = eng;
2844 if !self.view_store.is_empty() {
2845 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2846 for d in &new_deltas {
2847 self.view_store.on_edge_changed(
2848 d.etype_sym,
2849 d.src_id,
2850 d.dst_id,
2851 d.fired,
2852 &mut self.props,
2853 &build_topo_view(&self.topo, &self.base),
2854 &self.ids,
2855 &self.syms,
2856 &self.labels,
2857 self.base.as_ref().map(|b| {
2858 b.columns()
2859 .expect("base columns section bounds validated at open")
2860 }),
2861 );
2862 }
2863 }
2864 }
2865 WalRecord::DeleteNode { key } => {
2866 // Recovery-safe: already-tombstoned / unknown key is a clean
2867 // no-op. Crash-window replay over a snapshot that already
2868 // applied this record cannot recover the retired id from the
2869 // key (`IdMap::get` is None), so every subsequent step is
2870 // skipped. Each step is independently idempotent if invoked
2871 // twice on a still-live id: retraction is a no-op on empty
2872 // provenance, `remove_edge` returns false, `remove_all` is a
2873 // no-op, `ids.delete` returns None, label sentinel is sticky.
2874 let Some(n) = self.ids.get(key) else {
2875 return Ok(());
2876 };
2877
2878 // (1) Retract derived edges + de-index while props/labels live.
2879 let cursor = self.engine.pending_delta_count();
2880 let mut eng = std::mem::take(&mut self.engine);
2881 {
2882 let mut gm = make_graph_mut(
2883 &self.ids,
2884 &mut self.syms,
2885 &self.labels,
2886 build_props_view(&self.props, &self.base),
2887 &mut self.topo,
2888 &mut self.edge_props,
2889 );
2890 eng.on_node_removed(n, &mut gm);
2891 }
2892 self.engine = eng;
2893 // Derived-edge retractions → view updates for neighbors.
2894 if !self.view_store.is_empty() {
2895 #[cfg(test)]
2896 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2897 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2898 for d in &new_deltas {
2899 self.view_store.on_edge_changed(
2900 d.etype_sym,
2901 d.src_id,
2902 d.dst_id,
2903 d.fired,
2904 &mut self.props,
2905 &build_topo_view(&self.topo, &self.base),
2906 &self.ids,
2907 &self.syms,
2908 &self.labels,
2909 self.base.as_ref().map(|b| {
2910 b.columns()
2911 .expect("base columns section bounds validated at open")
2912 }),
2913 );
2914 }
2915 }
2916
2917 // (2) Sweep ALL remaining edges incident to n, both directions,
2918 // every etype. This cascade is intentionally mask-independent:
2919 // topology integrity requires removing every edge touching the
2920 // deleted node regardless of the caller's visibility scope.
2921 // (The mask limits which nodes a role's read phase can return;
2922 // the WAL delete always executes with full storage authority.)
2923 // Collect then remove so neighbor slices stay valid during
2924 // iteration. Remove from topo first, then call view maintenance
2925 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
2926 let etypes: Vec<u32> = self.topo.etypes().collect();
2927 let mut doomed = Vec::new();
2928 for et in &etypes {
2929 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
2930 doomed.push((*et, n, dst));
2931 }
2932 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
2933 doomed.push((*et, src, n));
2934 }
2935 }
2936 for (et, s, d) in doomed {
2937 self.topo.remove_edge(et, s, d);
2938 self.edge_props.remove_edge(et, s, d);
2939 // View maintenance: n's own view values will be cleared by
2940 // remove_all below; only update surviving neighbors.
2941 self.view_store.on_edge_changed(
2942 et,
2943 s,
2944 d,
2945 false,
2946 &mut self.props,
2947 &build_topo_view(&self.topo, &self.base),
2948 &self.ids,
2949 &self.syms,
2950 &self.labels,
2951 self.base.as_ref().map(|b| {
2952 b.columns()
2953 .expect("base columns section bounds validated at open")
2954 }),
2955 );
2956 }
2957
2958 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
2959 self.props.remove_all(n);
2960 // Full-text index maintenance: remove all tokens for this node.
2961 self.fulltext.remove_node(n);
2962 // Property (equality) index maintenance: drop all entries for n.
2963 self.prop_index.remove_node_all(n);
2964
2965 // (4) Retire the dense id and stamp the label sentinel.
2966 self.ids.delete(key);
2967 if let Some(slot) = self.labels.get_mut(n as usize) {
2968 *slot = u32::MAX;
2969 }
2970 }
2971 WalRecord::Batch(inner) => {
2972 // Apply each inner record in order through the same apply path.
2973 // Inner records are validated free of nested Batch by encode_record.
2974 for rec in inner {
2975 self.apply(rec)?;
2976 }
2977 }
2978 WalRecord::RebuildRule { name } => {
2979 // Replay-over-snapshot idempotency: the snapshot may already
2980 // reflect a later delete_rule, so the rule is absent; skip.
2981 if !self.engine.rules().any(|r| r.name == *name) {
2982 return Ok(());
2983 }
2984 let cursor = self.engine.pending_delta_count();
2985 let mut eng = std::mem::take(&mut self.engine);
2986 let result = {
2987 let mut gm = make_graph_mut(
2988 &self.ids,
2989 &mut self.syms,
2990 &self.labels,
2991 build_props_view(&self.props, &self.base),
2992 &mut self.topo,
2993 &mut self.edge_props,
2994 );
2995 eng.rebuild(name, &mut gm)
2996 };
2997 self.engine = eng;
2998 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2999 // Derived-edge delta changes → view updates.
3000 if !self.view_store.is_empty() {
3001 #[cfg(test)]
3002 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3003 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3004 for d in &new_deltas {
3005 self.view_store.on_edge_changed(
3006 d.etype_sym,
3007 d.src_id,
3008 d.dst_id,
3009 d.fired,
3010 &mut self.props,
3011 &build_topo_view(&self.topo, &self.base),
3012 &self.ids,
3013 &self.syms,
3014 &self.labels,
3015 self.base.as_ref().map(|b| {
3016 b.columns()
3017 .expect("base columns section bounds validated at open")
3018 }),
3019 );
3020 }
3021 }
3022 }
3023 WalRecord::CreateView { def_bytes } => {
3024 let def: ViewDef =
3025 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3026 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3027 })?;
3028 // Replay-over-snapshot idempotency: view already present → skip.
3029 if self.view_store.has_view(&def.name) {
3030 return Ok(());
3031 }
3032 self.view_store
3033 .create_view(
3034 def,
3035 &mut self.props,
3036 &build_topo_view(&self.topo, &self.base),
3037 &self.ids,
3038 &self.syms,
3039 &self.labels,
3040 )
3041 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3042 }
3043 WalRecord::DeleteView { name } => {
3044 // Replay-over-snapshot idempotency: view already absent → skip.
3045 if !self.view_store.has_view(name) {
3046 return Ok(());
3047 }
3048 self.view_store
3049 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3050 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3051 }
3052 WalRecord::EnableFulltext { label, field } => {
3053 // Replay-over-snapshot idempotency: already enabled → skip.
3054 if self.fulltext.is_enabled(label, field) {
3055 return Ok(());
3056 }
3057 self.fulltext.enable(label, field);
3058 // Backfill: index all live nodes of this label that have the field.
3059 let n = self.ids.len() as u32;
3060 for id in 0..n {
3061 let Some(&sym) = self.labels.get(id as usize) else {
3062 continue;
3063 };
3064 if sym == u32::MAX {
3065 continue; // tombstoned
3066 }
3067 let Some(lbl) = self.syms.resolve(sym) else {
3068 continue;
3069 };
3070 if lbl != label {
3071 continue;
3072 }
3073 if let Some(value) = build_props_view(&self.props, &self.base)
3074 .get(id, field)
3075 .map(|vr| vr.into_value())
3076 {
3077 self.fulltext.add_tokens(id, field, &value);
3078 }
3079 }
3080 }
3081 WalRecord::DisableFulltext { label, field } => {
3082 // Replay-over-snapshot idempotency: already disabled → skip.
3083 if !self.fulltext.is_enabled(label, field) {
3084 return Ok(());
3085 }
3086 // If another label still indexes this field, the postings column
3087 // is kept — but it must not contain node_ids from the now-disabled
3088 // label. Remove them before calling disable() so the field_indexed
3089 // guard inside disable() sees the correct post-removal state.
3090 if self.fulltext.field_indexed_by_other(label, field) {
3091 if let Some(label_sym) = self.syms.get(label) {
3092 for (node_id, &lsym) in self.labels.iter().enumerate() {
3093 if lsym == label_sym {
3094 self.fulltext.remove_node_field(node_id as u32, field);
3095 }
3096 }
3097 }
3098 }
3099 self.fulltext.disable(label, field);
3100 }
3101 WalRecord::EnableIndex { label, field } => {
3102 // Replay-over-snapshot idempotency: already enabled → skip.
3103 if self.prop_index.is_enabled(label, field) {
3104 return Ok(());
3105 }
3106 self.prop_index.enable(label, field);
3107 // Backfill: index all live nodes of this label that have the field.
3108 let n = self.ids.len() as u32;
3109 for id in 0..n {
3110 let Some(&sym) = self.labels.get(id as usize) else {
3111 continue;
3112 };
3113 if sym == u32::MAX {
3114 continue; // tombstoned
3115 }
3116 let Some(lbl) = self.syms.resolve(sym) else {
3117 continue;
3118 };
3119 if lbl != label {
3120 continue;
3121 }
3122 if let Some(value) = build_props_view(&self.props, &self.base)
3123 .get(id, field)
3124 .map(|vr| vr.into_value())
3125 {
3126 self.prop_index.set(label, field, id, &value);
3127 }
3128 }
3129 }
3130 WalRecord::DisableIndex { label, field } => {
3131 self.prop_index.disable(label, field);
3132 }
3133 // History markers carry no replay state — rules re-derive edges
3134 // deterministically on open/replay. Skip unconditionally.
3135 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3136 // ── rename_node ──────────────────────────────────────────────────
3137 WalRecord::RenameNode { old_key, new_key } => {
3138 // Recovery-safe: if old_key is already gone (key was renamed
3139 // by a snapshot or a prior replay frame), skip cleanly.
3140 if self.ids.get(old_key).is_none() {
3141 return Ok(());
3142 }
3143 // The rename only updates the key-table; the dense id, all
3144 // topo edges, props, labels, and rule state are id-indexed and
3145 // require no change.
3146 self.ids
3147 .rename(old_key, new_key)
3148 .map_err(|e| GraphError::Corrupt {
3149 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3150 })?;
3151 }
3152 }
3153 Ok(())
3154 }
3155
3156 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3157 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3158 /// idempotent when the string is already bound. Always emit: after
3159 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3160 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3161 let id = if let Some(id) = self.syms.get(s) {
3162 id
3163 } else {
3164 self.syms.intern(s)
3165 };
3166 (
3167 id,
3168 WalRecord::Intern {
3169 id,
3170 text: s.to_string(),
3171 },
3172 )
3173 }
3174
3175 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3176 /// state is left mutated: speculative interns made while building the
3177 /// output are rolled back, so a later successful mutation cannot log an
3178 /// `Intern` record whose id replay would never reproduce.
3179 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3180 let syms_checkpoint = self.syms.len();
3181 let result = self.rewrite_wal_dense_inner(recs);
3182 if result.is_err() {
3183 self.syms.truncate(syms_checkpoint);
3184 }
3185 result
3186 }
3187
3188 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3189 let mut out = Vec::with_capacity(recs.len());
3190 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3191 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3192 let mut interned = std::collections::HashSet::<u32>::new();
3193 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3194 detail: "id space exhausted".into(),
3195 })?;
3196 let lookup = |ids: &IdMap,
3197 pending: &std::collections::HashMap<String, u32>,
3198 key: &str|
3199 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3200 for rec in recs {
3201 match rec {
3202 WalRecord::InsertNode { label, key, props } => {
3203 let (label_id, intern) = self.intern_wal(&label);
3204 if interned.insert(label_id) {
3205 out.push(intern);
3206 }
3207 let mut props_id = Vec::with_capacity(props.len());
3208 for (field, value) in props {
3209 let (field_id, intern) = self.intern_wal(&field);
3210 if interned.insert(field_id) {
3211 out.push(intern);
3212 }
3213 props_id.push((field_id, value));
3214 }
3215 if lookup(&self.ids, &pending, &key).is_none() {
3216 pending.insert(key.clone(), next);
3217 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3218 detail: "id space exhausted".into(),
3219 })?;
3220 }
3221 out.push(WalRecord::InsertNodeId {
3222 label: label_id,
3223 key,
3224 props: props_id,
3225 });
3226 }
3227 WalRecord::SetProp { key, field, value } => {
3228 let id =
3229 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3230 detail: format!("dense WAL rewrite missing key {key}"),
3231 })?;
3232 let (field_id, intern) = self.intern_wal(&field);
3233 if interned.insert(field_id) {
3234 out.push(intern);
3235 }
3236 out.push(WalRecord::SetPropId {
3237 id,
3238 field: field_id,
3239 value,
3240 });
3241 }
3242 WalRecord::InsertEdge {
3243 edge_type,
3244 src_key,
3245 dst_key,
3246 } => {
3247 let (etype, intern) = self.intern_wal(&edge_type);
3248 if interned.insert(etype) {
3249 out.push(intern);
3250 }
3251 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3252 GraphError::Corrupt {
3253 detail: format!("dense WAL rewrite missing src {src_key}"),
3254 }
3255 })?;
3256 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3257 GraphError::Corrupt {
3258 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3259 }
3260 })?;
3261 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3262 }
3263 WalRecord::RenameNode {
3264 ref old_key,
3265 ref new_key,
3266 } => {
3267 // Track the rename in `pending` so subsequent InsertEdge /
3268 // SetProp records in this batch can resolve the new key.
3269 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3270 GraphError::Corrupt {
3271 detail: format!(
3272 "dense WAL rewrite: RenameNode old key {old_key} not found"
3273 ),
3274 }
3275 })?;
3276 pending.remove(old_key.as_str());
3277 pending.insert(new_key.clone(), id);
3278 out.push(rec);
3279 }
3280 other => out.push(other),
3281 }
3282 }
3283 Ok(out)
3284 }
3285
3286 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3287 let recs = self.rewrite_wal_dense(recs)?;
3288 match recs.len() {
3289 0 => Ok(()),
3290 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3291 _ => self.log_then_apply(WalRecord::Batch(recs)),
3292 }
3293 }
3294
3295 /// Durable write, then notify the event sink. Replay (`apply` during
3296 /// `open`) never enters this function, so it is the replay-silent seam.
3297 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3298 self.log_then_apply_with(rec, None, self.fsync)
3299 }
3300
3301 /// Whether this frame must fsync under `policy`.
3302 ///
3303 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3304 /// mutations do not. The dense rewrite wraps a single mutation in a
3305 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3306 /// from the count — removing that filter would make every single-op write
3307 /// fsync under Batched (or, if the threshold were raised instead, skip a
3308 /// needed fsync for real two-op batches).
3309 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3310 match policy {
3311 FsyncPolicy::Relaxed => false,
3312 FsyncPolicy::Strict => true,
3313 FsyncPolicy::Batched => match rec {
3314 // Intern + one mutation is the single-op rewrite, not a user batch.
3315 WalRecord::Batch(inner) => {
3316 inner
3317 .iter()
3318 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3319 .count()
3320 > 1
3321 }
3322 _ => false,
3323 },
3324 }
3325 }
3326
3327 /// # Apply-infallibility invariant (load-bearing)
3328 ///
3329 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3330 /// for a `Batch` frame after a successful WAL write, the WAL would contain
3331 /// the full frame while in-memory state would reflect only the ops before
3332 /// the failure. On reopen, WAL replay would then apply the entire batch —
3333 /// diverging permanently from what the pre-crash process had in memory.
3334 ///
3335 /// For `Batch` frames this situation cannot arise because:
3336 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3337 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
3338 /// use, with no concurrent mutation between validation exit and apply entry.
3339 /// - Every `apply` arm for a validated op is either infallible by construction
3340 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3341 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3342 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3343 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3344 ///
3345 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3346 /// `Err` for a `Batch` frame, making any future regression immediately visible
3347 /// in tests rather than silently diverging crash-recovery behaviour.
3348 fn log_then_apply_with(
3349 &mut self,
3350 rec: WalRecord,
3351 ingest: Option<(String, usize)>,
3352 policy: FsyncPolicy,
3353 ) -> Result<()> {
3354 // Read-only guard: as-of instances must never write the WAL.
3355 if self.read_only {
3356 return Err(GraphError::ReadOnly);
3357 }
3358 // Degraded guard: fsync failure left WAL truncated; in-memory state
3359 // is ahead of the on-disk WAL, so further mutations would deepen the
3360 // divergence. Reopen the database to recover.
3361 if self.degraded {
3362 return Err(GraphError::Io(std::io::Error::other(
3363 "database degraded after group-commit fsync failure; reopen required",
3364 )));
3365 }
3366 // Ensure retained provenance bytes are decoded into the live mutable
3367 // fields before any mutation touches self.engine.provenance. This is a
3368 // no-op if provenance was never stored (fresh store) or has already been
3369 // consumed (subsequent mutations). WAL replay calls apply() directly
3370 // and is covered by consume_retained_state_eager before replay.
3371 self.ensure_v8_base_sections_loaded();
3372 self.engine.ensure_provenance_loaded_mut();
3373 // Invariant (I-1): no stale deltas may enter from a previous apply.
3374 // If any engine method ever accumulates deltas before erroring, they would
3375 // contaminate the *next* commit's event stream. This assert fires in debug
3376 // builds, making any future regression visible at the earliest point.
3377 debug_assert_eq!(
3378 self.engine.pending_delta_count(),
3379 0,
3380 "stale engine deltas at log_then_apply_with entry — \
3381 a previous apply arm may have accumulated deltas before erroring; \
3382 the caller must drain_deltas() on any error path before returning"
3383 );
3384 self.fs.append(FileId::Wal, &encode_record(&rec))?;
3385 if Self::wal_needs_sync(policy, &rec) {
3386 self.fs.sync(FileId::Wal)?;
3387 }
3388 // Marker writing always needs the engine deltas, but the engine only
3389 // accumulates them when emit_deltas is true (normally gated on subscribers
3390 // or views being present). Enable emission for this apply if it is
3391 // currently off, then restore the original state unconditionally via an
3392 // RAII guard — this prevents a panic in apply() from leaking the flag.
3393 struct RestoreEmitDeltas(*mut RuleEngine, bool);
3394 impl Drop for RestoreEmitDeltas {
3395 fn drop(&mut self) {
3396 // SAFETY: pointer into self (GraphDb); guard is dropped within
3397 // this frame before log_then_apply_with returns.
3398 unsafe { (*self.0).set_emit_deltas(self.1) };
3399 }
3400 }
3401 let original_emit = self.engine.emit_deltas();
3402 if !original_emit {
3403 self.engine.set_emit_deltas(true);
3404 }
3405 // SAFETY: raw pointer into self; guard dropped within this frame.
3406 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3407
3408 let apply_result = self.apply(&rec);
3409 // For Batch frames, post-validation apply must be infallible (see above).
3410 // A debug_assert here catches any future change that makes apply fallible
3411 // before the caller notices via silent WAL/memory divergence.
3412 if matches!(&rec, WalRecord::Batch(_)) {
3413 debug_assert!(
3414 apply_result.is_ok(),
3415 "Batch apply returned Err after successful WAL write — \
3416 the validate-then-apply invariant has been violated; \
3417 see log_then_apply_with invariant doc"
3418 );
3419 }
3420 if apply_result.is_err() {
3421 // Discard any partial deltas accumulated by the failed apply.
3422 // They must not ride the next commit's event stream (I-1).
3423 // _emit_guard restores emit_deltas on drop automatically.
3424 let _ = self.engine.drain_deltas();
3425 let _ = self.engine.take_rebuild_needed();
3426 apply_result?;
3427 }
3428 self.commit_seq += 1;
3429 let seq = self.commit_seq;
3430 // Update per-node last-change map for the committed record.
3431 // Must happen after commit_seq is incremented so the seq is correct.
3432 self.update_last_change_from_rec(&rec, seq);
3433 // Drain engine deltas and distribute to subscribers before the existing
3434 // MutationEvent sink fires — both happen post-fsync, post-apply.
3435 // _emit_guard restores emit_deltas after this line when it drops.
3436 let engine_deltas = self.engine.drain_deltas();
3437
3438 // Append history-marker WAL records for any derived-edge changes so
3439 // that `edge_history` and `was_linked` can surface rule-attributed
3440 // events. Markers are STATE NO-OPS during replay; they are written
3441 // without an additional fsync (the triggering commit's sync already
3442 // happened; the next commit's sync covers these lazily).
3443 if !engine_deltas.is_empty() {
3444 let markers: Vec<WalRecord> = engine_deltas
3445 .iter()
3446 .map(|d| {
3447 if d.fired {
3448 WalRecord::DerivedEdgeAdded {
3449 rule: d.rule.clone(),
3450 edge_type: d.edge_type.clone(),
3451 src_key: d.src_key.clone(),
3452 dst_key: d.dst_key.clone(),
3453 }
3454 } else {
3455 WalRecord::DerivedEdgeRetracted {
3456 rule: d.rule.clone(),
3457 edge_type: d.edge_type.clone(),
3458 src_key: d.src_key.clone(),
3459 dst_key: d.dst_key.clone(),
3460 }
3461 }
3462 })
3463 .collect();
3464 let marker_frame = if markers.len() == 1 {
3465 markers.into_iter().next().unwrap()
3466 } else {
3467 WalRecord::Batch(markers)
3468 };
3469 // Ignore append errors: markers are best-effort history
3470 // annotations. Losing them does not affect state correctness.
3471 let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3472 }
3473
3474 // Record MVCC CommitDelta for the epoch reader. The WAL record is
3475 // stored as-is (including any nested Batch / Intern records); the
3476 // ReaderSnapshot's apply_one function handles all variants.
3477 {
3478 let derived_inserts = engine_deltas
3479 .iter()
3480 .filter(|d| d.fired)
3481 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3482 .collect();
3483 let derived_deletes = engine_deltas
3484 .iter()
3485 .filter(|d| !d.fired)
3486 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3487 .collect();
3488 let delta = Arc::new(crate::reader::CommitDelta {
3489 records: vec![rec.clone()],
3490 derived_inserts,
3491 derived_deletes,
3492 });
3493 self.delta_tail.push(delta);
3494 self.commits_since_fold += 1;
3495 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3496 self.fold_now();
3497 }
3498 }
3499
3500 if self.defer_events {
3501 // Group-commit drain thread: hold events until after the group
3502 // fsync so subscribers only observe durable data (R2).
3503 self.deferred_events.push(DeferredEvent {
3504 rec: rec.clone(),
3505 engine_deltas,
3506 seq,
3507 ingest,
3508 });
3509 } else {
3510 self.distribute_events(&rec, &engine_deltas, seq);
3511 self.emit_committed(&rec, ingest);
3512 }
3513 // Drift is only known after apply, so auto-rebuild cannot join the
3514 // triggering op's WAL frame. Issue RebuildRule as a second commit.
3515 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3516 // retrigger loop is impossible if the fit succeeded, but we still
3517 // drain the flag so a leftover cannot re-enter.
3518 let rebuilds = self.engine.take_rebuild_needed();
3519 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3520 let mut failed = Vec::new();
3521 for name in rebuilds {
3522 if self.engine.rules().any(|r| r.name == name) {
3523 // User op is already durable. A failed second commit must
3524 // not surface as the caller's error.
3525 if let Err(e) =
3526 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3527 {
3528 eprintln!(
3529 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3530 );
3531 failed.push(name);
3532 }
3533 }
3534 }
3535 for name in failed {
3536 self.engine.queue_rebuild_needed(name);
3537 }
3538 }
3539 Ok(())
3540 }
3541
3542 /// Install a post-commit hook. Replaces any previous sink.
3543 ///
3544 /// The sink runs inside `log_then_apply` after a successful
3545 /// durable commit, while the caller still holds `&mut self`. When this
3546 /// database is behind a [`crate::SharedDb`], that means the **write
3547 /// guard is held**. The sink must never call `read` / `write` (or any
3548 /// other method) on the same `SharedDb` — the `RwLock` is not
3549 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3550 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3551 /// Intended examples: `std::sync::mpsc::SyncSender`,
3552 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3553 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3554 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3555 self.event_sink = Some(sink);
3556 }
3557
3558 /// Whether a post-commit event sink is currently installed.
3559 pub fn has_event_sink(&self) -> bool {
3560 self.event_sink.is_some()
3561 }
3562
3563 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3564 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3565 self.fsync = p;
3566 }
3567
3568 /// Return the current WAL fsync cadence.
3569 pub fn fsync_policy(&self) -> FsyncPolicy {
3570 self.fsync
3571 }
3572
3573 // ── Group-commit event deferral ───────────────────────────────────────────
3574
3575 /// Enable or disable deferred event mode.
3576 ///
3577 /// When `true`, event notifications (subscription `DbEvent`s and legacy
3578 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3579 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3580 /// or [`discard_deferred_events`] if the fsync failed and the group must
3581 /// be treated as lost.
3582 pub fn set_deferred_events_mode(&mut self, defer: bool) {
3583 self.defer_events = defer;
3584 }
3585
3586 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3587 /// was set to true. Clears the buffer.
3588 ///
3589 /// Called by the drain thread AFTER a successful group fsync, so
3590 /// subscribers observe only data that is durably on disk.
3591 pub fn flush_deferred_events(&mut self) {
3592 let events = std::mem::take(&mut self.deferred_events);
3593 for de in events {
3594 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3595 self.emit_committed(&de.rec, de.ingest);
3596 }
3597 }
3598
3599 /// Discard all buffered events without firing them.
3600 ///
3601 /// Called by the drain thread when a group fsync fails: the WAL has been
3602 /// truncated back to the pre-group offset, so the committed-but-unsynced
3603 /// ops must not be observable to subscribers.
3604 pub fn discard_deferred_events(&mut self) {
3605 self.deferred_events.clear();
3606 }
3607
3608 // ── Degraded state ────────────────────────────────────────────────────────
3609
3610 /// Mark this database as degraded.
3611 ///
3612 /// Called by the group-commit drain thread after a group fsync failure and
3613 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3614 /// further mutations would deepen the divergence. All subsequent calls to
3615 /// [`log_then_apply_with`] return `Err` until the database is reopened.
3616 pub fn set_degraded(&mut self) {
3617 self.degraded = true;
3618 }
3619
3620 fn emit(&self, ev: MutationEvent) {
3621 if let Some(sink) = &self.event_sink {
3622 sink(ev);
3623 }
3624 }
3625
3626 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3627 match rec {
3628 WalRecord::Batch(inner) => {
3629 for r in inner {
3630 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3631 self.emit(ev);
3632 }
3633 }
3634 match ingest {
3635 Some((label, inserted)) => {
3636 self.emit(MutationEvent::Ingested { label, inserted })
3637 }
3638 None => {
3639 let ops = inner
3640 .iter()
3641 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3642 .count();
3643 if ops > 1 {
3644 self.emit(MutationEvent::BatchApplied { ops });
3645 }
3646 }
3647 }
3648 }
3649 other => {
3650 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3651 self.emit(ev);
3652 }
3653 }
3654 }
3655 }
3656
3657 // -----------------------------------------------------------------------
3658 // Subscription API
3659 // -----------------------------------------------------------------------
3660
3661 /// Distribute post-commit events to all live subscribers.
3662 ///
3663 /// Build a row-key → row-data map from a [`ResultSet`].
3664 ///
3665 /// Each row is serialized to JSON to form its key; a debug fallback is used
3666 /// if serialization fails. Used by both the initial-seed path in
3667 /// [`Self::subscribe_query`] and the per-commit diff path in
3668 /// [`Self::distribute_events`] to keep the two in sync.
3669 fn result_to_row_map(
3670 result: &core_query::ResultSet,
3671 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3672 (0..result.len())
3673 .map(|i| {
3674 let row = result.row(i).to_vec();
3675 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3676 (key, row)
3677 })
3678 .collect()
3679 }
3680
3681 /// Distribute post-commit events to all live subscribers.
3682 ///
3683 /// Called from `log_then_apply_with` after apply + fsync, before the
3684 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3685 ///
3686 /// Query subscriptions (subscribe_query) re-execute their plan on every
3687 /// call and diff the result against the previous run. Zero overhead when
3688 /// no query subscriptions are active.
3689 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3690 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3691 return;
3692 }
3693
3694 if !self.subscriptions.is_empty() {
3695 // Build write events from the WAL record.
3696 let write_events: Vec<DbEvent> =
3697 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3698
3699 // Build edge events from engine deltas. Weight is looked up from
3700 // edge_props at distribution time (after apply), so it's always fresh.
3701 let edge_events: Vec<DbEvent> = engine_deltas
3702 .iter()
3703 .map(|d| {
3704 if d.fired {
3705 let weight = self
3706 .edge_props
3707 .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3708 .and_then(|v| {
3709 if let core_storage::Value::Float(f) = v {
3710 Some(*f)
3711 } else {
3712 None
3713 }
3714 });
3715 DbEvent::EdgeFired {
3716 rule: d.rule.clone(),
3717 src_key: d.src_key.clone(),
3718 dst_key: d.dst_key.clone(),
3719 edge_type: d.edge_type.clone(),
3720 weight,
3721 commit_seq: seq,
3722 }
3723 } else {
3724 DbEvent::EdgeRetracted {
3725 rule: d.rule.clone(),
3726 src_key: d.src_key.clone(),
3727 dst_key: d.dst_key.clone(),
3728 edge_type: d.edge_type.clone(),
3729 commit_seq: seq,
3730 }
3731 }
3732 })
3733 .collect();
3734
3735 // Prune dead entries; push matching events to live ones.
3736 self.subscriptions.retain(|entry| {
3737 let Some(inner) = entry.inner.upgrade() else {
3738 return false;
3739 };
3740 for ev in &write_events {
3741 if event_matches(ev, &entry.filter) {
3742 inner.push(ev.clone());
3743 }
3744 }
3745 for ev in &edge_events {
3746 if event_matches(ev, &entry.filter) {
3747 inner.push(ev.clone());
3748 }
3749 }
3750 true
3751 });
3752
3753 // Turn off delta accumulation if all subscribers dropped and no views remain.
3754 if self.subscriptions.is_empty() && self.view_store.is_empty() {
3755 self.engine.set_emit_deltas(false);
3756 }
3757 }
3758
3759 // Query subscriptions: full re-run per commit, then diff rows.
3760 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3761 // Differential evaluation is roadmap / Phase 5.
3762 if !self.query_subscriptions.is_empty() {
3763 // Take the list out so we can call self.view() without borrow conflict.
3764 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3765 let empty_params = BTreeMap::new();
3766 query_subs.retain_mut(|entry| {
3767 let Some(inner) = entry.inner.upgrade() else {
3768 return false; // subscriber dropped — prune
3769 };
3770 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
3771 Ok(r) => r,
3772 Err(e) => {
3773 // Keep the subscription alive; skip the diff for this commit.
3774 // Re-run errors are transient (e.g., planner change) and
3775 // self-heal when the next commit succeeds.
3776 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
3777 return true;
3778 }
3779 };
3780 // Build new row map: serialized-key → row data.
3781 let new_row_map = Self::result_to_row_map(&result);
3782 // Removed rows: in prev but not in new.
3783 for (key, row) in &entry.prev_row_map {
3784 if !new_row_map.contains_key(key) {
3785 inner.push(DbEvent::QueryRowRemoved {
3786 columns: entry.columns.clone(),
3787 row: row.clone(),
3788 });
3789 }
3790 }
3791 // Added rows: in new but not in prev.
3792 for (key, row) in &new_row_map {
3793 if !entry.prev_row_map.contains_key(key) {
3794 inner.push(DbEvent::QueryRowAdded {
3795 columns: entry.columns.clone(),
3796 row: row.clone(),
3797 });
3798 }
3799 }
3800 entry.prev_row_map = new_row_map;
3801 true
3802 });
3803 self.query_subscriptions = query_subs;
3804 }
3805 }
3806
3807 /// Returns `true` if any live subscriber or view definition requires delta
3808 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
3809 fn needs_emit_deltas(&self) -> bool {
3810 !self.view_store.is_empty()
3811 || self
3812 .subscriptions
3813 .iter()
3814 .any(|e| e.inner.upgrade().is_some())
3815 }
3816
3817 /// Convert a WAL record into `DbEvent` write events with the given seq.
3818 fn write_events_from_record(
3819 rec: &WalRecord,
3820 seq: u64,
3821 intern: &Interner,
3822 ids: &IdMap,
3823 ) -> Vec<DbEvent> {
3824 match rec {
3825 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
3826 label: label.clone(),
3827 key: key.clone(),
3828 commit_seq: seq,
3829 }],
3830 // *Id arms run after a successful apply, so resolution can only
3831 // fail on a programming error. Skip the event rather than emit a
3832 // fabricated "" that clients can't tell from a real empty value
3833 // (mirrors event_from_record returning None).
3834 WalRecord::InsertNodeId { label, key, .. } => intern
3835 .resolve(*label)
3836 .map(|label| DbEvent::NodeInserted {
3837 label: label.to_string(),
3838 key: key.clone(),
3839 commit_seq: seq,
3840 })
3841 .into_iter()
3842 .collect(),
3843 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
3844 key: key.clone(),
3845 field: field.clone(),
3846 commit_seq: seq,
3847 }],
3848 WalRecord::SetPropId { id, field, .. } => ids
3849 .key_of(*id)
3850 .zip(intern.resolve(*field))
3851 .map(|(key, field)| DbEvent::PropSet {
3852 key: key.to_string(),
3853 field: field.to_string(),
3854 commit_seq: seq,
3855 })
3856 .into_iter()
3857 .collect(),
3858 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
3859 key: key.clone(),
3860 field: field.clone(),
3861 commit_seq: seq,
3862 }],
3863 WalRecord::InsertEdge {
3864 edge_type,
3865 src_key,
3866 dst_key,
3867 } => vec![DbEvent::EdgeInserted {
3868 edge_type: edge_type.clone(),
3869 src: src_key.clone(),
3870 dst: dst_key.clone(),
3871 commit_seq: seq,
3872 }],
3873 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
3874 Some(DbEvent::EdgeInserted {
3875 edge_type: intern.resolve(*etype)?.to_string(),
3876 src: ids.key_of(*src)?.to_string(),
3877 dst: ids.key_of(*dst)?.to_string(),
3878 commit_seq: seq,
3879 })
3880 })()
3881 .into_iter()
3882 .collect(),
3883 WalRecord::DeleteEdge {
3884 edge_type,
3885 src_key,
3886 dst_key,
3887 } => vec![DbEvent::EdgeDeleted {
3888 edge_type: edge_type.clone(),
3889 src: src_key.clone(),
3890 dst: dst_key.clone(),
3891 commit_seq: seq,
3892 }],
3893 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
3894 key: key.clone(),
3895 commit_seq: seq,
3896 }],
3897 WalRecord::Batch(inner) => inner
3898 .iter()
3899 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
3900 .collect(),
3901 WalRecord::CreateRule { .. }
3902 | WalRecord::DeleteRule { .. }
3903 | WalRecord::RebuildRule { .. }
3904 | WalRecord::CreateView { .. }
3905 | WalRecord::DeleteView { .. }
3906 | WalRecord::EnableFulltext { .. }
3907 | WalRecord::DisableFulltext { .. }
3908 | WalRecord::EnableIndex { .. }
3909 | WalRecord::DisableIndex { .. }
3910 | WalRecord::Intern { .. }
3911 // History markers produce no DbEvent — the engine delta already
3912 // fired the EdgeFired/EdgeRetracted subscription events.
3913 | WalRecord::DerivedEdgeAdded { .. }
3914 | WalRecord::DerivedEdgeRetracted { .. }
3915 | WalRecord::RenameNode { .. } => vec![],
3916 }
3917 }
3918
3919 /// Subscribe to edge-fire and edge-retract events for one named rule.
3920 ///
3921 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
3922 /// currently registered. Dropping the returned [`Subscription`] handle
3923 /// unregisters the subscriber — no further events are queued, no
3924 /// resources leak.
3925 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
3926 if self.read_only {
3927 return Err(core_storage::GraphError::ReadOnly);
3928 }
3929 if !self.engine.rules().any(|r| r.name == rule_name) {
3930 return Err(core_storage::GraphError::RuleNotFound {
3931 name: rule_name.to_string(),
3932 });
3933 }
3934 let inner = SubInner::new(self.sub_capacity());
3935 self.subscriptions.push(SubEntry {
3936 filter: SubFilter::Rule(rule_name.to_string()),
3937 inner: std::sync::Arc::downgrade(&inner),
3938 });
3939 self.engine.set_emit_deltas(true);
3940 Ok(Subscription(inner))
3941 }
3942
3943 /// Subscribe to edge-fire and edge-retract events for **all** rules.
3944 ///
3945 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3946 /// as-of instances never commit, so `distribute_events` never runs and the
3947 /// subscription would never deliver events.
3948 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
3949 if self.read_only {
3950 return Err(core_storage::GraphError::ReadOnly);
3951 }
3952 let inner = SubInner::new(self.sub_capacity());
3953 self.subscriptions.push(SubEntry {
3954 filter: SubFilter::AllRules,
3955 inner: std::sync::Arc::downgrade(&inner),
3956 });
3957 self.engine.set_emit_deltas(true);
3958 Ok(Subscription(inner))
3959 }
3960
3961 /// Subscribe to write events: node insert/delete, prop set/remove.
3962 ///
3963 /// Does not include edge-fire / edge-retract (rule-derived edge events).
3964 ///
3965 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3966 /// as-of instances never commit, so `distribute_events` never runs and the
3967 /// subscription would never deliver events.
3968 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
3969 if self.read_only {
3970 return Err(core_storage::GraphError::ReadOnly);
3971 }
3972 let inner = SubInner::new(self.sub_capacity());
3973 self.subscriptions.push(SubEntry {
3974 filter: SubFilter::Writes,
3975 inner: std::sync::Arc::downgrade(&inner),
3976 });
3977 self.engine.set_emit_deltas(true);
3978 Ok(Subscription(inner))
3979 }
3980
3981 /// Subscribe to incremental Cypher query results.
3982 ///
3983 /// Parses and plans `cypher`; rejects the query if the plan is not in the
3984 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
3985 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
3986 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
3987 ///
3988 /// SKIP is not supported — it shifts the result window on every commit,
3989 /// causing spurious Added/Removed churn for rows whose data never changed.
3990 /// Multi-hop Expand chains are not supported; each additional MATCH clause
3991 /// widens scope beyond the documented single-scan / single-hop subset.
3992 ///
3993 /// After each successful commit, the plan is **fully re-executed** and the
3994 /// result is diffed against the previous run. Added rows produce
3995 /// [`DbEvent::QueryRowAdded`]; removed rows produce
3996 /// [`DbEvent::QueryRowRemoved`].
3997 ///
3998 /// **Full re-run per commit; use LIMIT to bound execution cost.**
3999 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4000 /// is roadmap / Phase 5.
4001 ///
4002 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4003 /// as-of instances never commit, so `distribute_events` never runs and the
4004 /// subscription would never deliver events.
4005 ///
4006 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4007 /// or if the plan shape is not in the allowlist.
4008 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4009 if self.read_only {
4010 return Err(GraphError::ReadOnly);
4011 }
4012 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4013 detail: format!("lex: {e}"),
4014 })?;
4015 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4016 detail: format!("parse: {e}"),
4017 })?;
4018 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4019 detail: format!("plan: {e}"),
4020 })?;
4021 if !is_subscribable(&ops) {
4022 return Err(GraphError::QueryError {
4023 detail: "subscribe_query only supports allowlisted plan shapes: \
4024 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4025 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4026 Not supported: multi-hop Expand chains, SKIP (creates \
4027 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4028 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4029 Use LIMIT to bound re-execution cost."
4030 .to_string(),
4031 });
4032 }
4033 // Execute once to capture initial state (initial rows are not emitted as
4034 // events — the subscriber learns the baseline via the first query call).
4035 let empty_params = BTreeMap::new();
4036 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4037 GraphError::QueryError {
4038 detail: format!("execute: {e}"),
4039 }
4040 })?;
4041 let columns = initial.columns().to_vec();
4042 let prev_row_map = Self::result_to_row_map(&initial);
4043 let inner = SubInner::new(self.sub_capacity());
4044 self.query_subscriptions.push(QuerySubEntry {
4045 ops,
4046 columns,
4047 prev_row_map,
4048 inner: std::sync::Arc::downgrade(&inner),
4049 });
4050 Ok(Subscription(inner))
4051 }
4052
4053 /// Queue capacity used for new subscriptions.
4054 fn sub_capacity(&self) -> usize {
4055 self.sub_capacity
4056 }
4057
4058 /// Override per-subscriber queue capacity for subsequently created
4059 /// subscriptions on this db instance.
4060 ///
4061 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4062 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4063 /// generating tens of thousands of events.
4064 ///
4065 /// This is a test-support escape hatch. Calling it in production reduces
4066 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4067 /// to discourage accidental production use.
4068 #[doc(hidden)]
4069 pub fn set_sub_capacity(&mut self, capacity: usize) {
4070 self.sub_capacity = capacity;
4071 }
4072
4073 // -----------------------------------------------------------------------
4074
4075 /// Start an atomic batch.
4076 ///
4077 /// The returned [`BatchBuilder`] borrows `self` mutably until
4078 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4079 /// validation, no WAL I/O. `commit` validates every queued op against
4080 /// live state plus preceding ops in this batch (duplicate key inside
4081 /// the batch is `Err`; an edge between two nodes created earlier in
4082 /// the batch is valid; `delete_node` then insert of the same key is a
4083 /// fresh identity). Validation never mutates the database. Any failure
4084 /// leaves WAL bytes and in-memory state identical to before `commit`.
4085 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4086 /// and each inner record is applied in order so rules fire per record.
4087 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4088 ///
4089 /// **Rule-window limitation:** batch validation cannot see edges that a
4090 /// rule created earlier in the *same* batch will derive at apply time, so
4091 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4092 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4093 /// is unaffected (idempotent apply, provenance intact). Create rules in
4094 /// their own batch, or sequentially, when later ops may touch derived
4095 /// edges.
4096 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4097 BatchBuilder {
4098 db: self,
4099 ops: Vec::new(),
4100 }
4101 }
4102
4103 /// Closure-style atomic write batch.
4104 ///
4105 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4106 /// then committing. All ops queued inside `build` are validated in order and
4107 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4108 /// once per inner record, in order, after commit — semantically identical to
4109 /// sequential single-op writes.
4110 ///
4111 /// **Error semantics — validate-then-apply.** `build` queues ops without
4112 /// touching the database. [`BatchBuilder::commit`] validates every op against
4113 /// live state plus earlier ops in this batch before writing anything. If op N
4114 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4115 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4116 /// changes. The database is identical to its state before `write_batch` was
4117 /// called.
4118 ///
4119 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4120 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4121 /// either fully applied or not at all. However, while applying a committed
4122 /// batch, concurrent readers may observe intermediate states as ops are applied
4123 /// sequentially in memory. There is no interactive transaction isolation in v1.
4124 /// This is documented as "crash-atomic write batches; no interactive
4125 /// transactions or read isolation."
4126 ///
4127 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4128 /// writes zero WAL bytes and returns `(0, 0)`.
4129 ///
4130 /// # Example
4131 ///
4132 /// ```rust,ignore
4133 /// let (nodes, edges) = db.write_batch(|b| {
4134 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4135 /// b.insert_node("Person", "bob", vec![]);
4136 /// b.insert_edge("KNOWS", "alice", "bob");
4137 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4138 /// b.delete_node("old_key");
4139 /// })?;
4140 /// // One fsync; on crash replay: all five ops land or none do.
4141 /// ```
4142 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4143 where
4144 C: FnOnce(&mut BatchBuilder<'_, F>),
4145 {
4146 let mut b = self.batch();
4147 build(&mut b);
4148 b.commit()
4149 }
4150
4151 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4152 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4153 /// inserts, so incremental fire sees the new rules. Per-row key problems
4154 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4155 /// `Err` means nothing was applied.
4156 ///
4157 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4158 /// distinct source labels sharing an FK field each get their own rule.
4159 pub fn ingest(
4160 &mut self,
4161 label: &str,
4162 rows: Vec<BTreeMap<String, Value>>,
4163 opts: &IngestOptions,
4164 ) -> Result<IngestReport> {
4165 self.ingest_with_edges(label, rows, opts, &[])
4166 }
4167
4168 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4169 /// A failing edge rejects the whole request; nothing is applied.
4170 pub fn ingest_with_edges(
4171 &mut self,
4172 label: &str,
4173 rows: Vec<BTreeMap<String, Value>>,
4174 opts: &IngestOptions,
4175 edges: &[(String, String, String)],
4176 ) -> Result<IngestReport> {
4177 crate::ingest::run(self, label, rows, opts, edges)
4178 }
4179
4180 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4181 ///
4182 /// JSON `null` fields are silently omitted (not stored, not a row error).
4183 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4184 /// Parse failures and a top-level value that is not an array of objects
4185 /// return [`GraphError::IngestError`].
4186 pub fn ingest_json(
4187 &mut self,
4188 label: &str,
4189 json: &str,
4190 opts: &IngestOptions,
4191 ) -> Result<IngestReport> {
4192 crate::ingest::run_json(self, label, json, opts)
4193 }
4194
4195 fn commit_logged_batch(
4196 &mut self,
4197 ops: Vec<BatchOp>,
4198 ingest: Option<(String, usize)>,
4199 // Two-source rule: write_batch_authz threads authz here directly (never
4200 // touches pending_write_authz); query_write_authz sets the field instead
4201 // and passes None. Only one source is non-None per call.
4202 param_authz: Option<WriteAuthz>,
4203 ) -> Result<(usize, usize)> {
4204 // Read-only guard: catches empty-batch calls before the early-return
4205 // that skips log_then_apply_with, ensuring all mutation entry points fail.
4206 if self.read_only {
4207 return Err(GraphError::ReadOnly);
4208 }
4209 // Ensure provenance is decoded before MutPreview accesses it
4210 // (note_delete_rule / is_rule_owned may call engine.provenance()).
4211 self.engine.ensure_provenance_loaded_mut();
4212
4213 // ── Authz pre-check ──────────────────────────────────────────────────
4214 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4215 // produces no WAL frame (all-or-nothing at the authz boundary extends
4216 // the existing validate-then-apply contract to role-scope checks).
4217 //
4218 // `batch_created` tracks key→label for nodes created by earlier ops in
4219 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4220 // as visible without needing to call `self.ids.get` on not-yet-committed
4221 // keys (they won't be there yet).
4222 //
4223 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4224 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4225 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4226 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4227 if let Some(ref authz) = authz_opt {
4228 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4229 for op in &ops {
4230 self.check_single_op_authz(authz, op, &batch_created)?;
4231 // Update batch_created after a passing authz check so that
4232 // subsequent ops in this batch see the nodes as "about to exist".
4233 match op {
4234 BatchOp::InsertNode { label, key, .. } => {
4235 // Only track genuinely new nodes (absent from the
4236 // snapshot at authz-check time). A pre-existing visible
4237 // key would be a DuplicateKey — not a real creation —
4238 // so MutPreview handles it. Letting it into batch_created
4239 // would allow a later SetProp to bypass update_labels
4240 // via the "batch-created → always updatable" ruling
4241 // (delete+recreate exploit, fix for I1 review round 2).
4242 //
4243 // Accepted edge: for a delete+recreate-with-different-
4244 // label batch, node_status resolves the pre-delete
4245 // (store) label for any subsequent update checks. This
4246 // grants no net-new capability — a role that can delete+
4247 // create can already place arbitrary props via
4248 // InsertNode's own props field.
4249 if self.ids.get(key.as_str()).is_none() {
4250 batch_created.insert(key.clone(), label.clone());
4251 }
4252 }
4253 BatchOp::InsertEdgeUpsert {
4254 placeholder_label,
4255 src_key,
4256 dst_key,
4257 ..
4258 } => {
4259 // Both endpoints will be created if not already in store.
4260 for ep_key in [src_key, dst_key] {
4261 if self.ids.get(ep_key.as_str()).is_none()
4262 && !batch_created.contains_key(ep_key.as_str())
4263 {
4264 batch_created.insert(ep_key.clone(), placeholder_label.clone());
4265 }
4266 }
4267 }
4268 _ => {}
4269 }
4270 }
4271 }
4272
4273 let recs = {
4274 let mut preview = MutPreview::new(self);
4275 let mut recs = Vec::with_capacity(ops.len());
4276 for op in ops {
4277 match op {
4278 BatchOp::InsertNode { label, key, props } => {
4279 preview.check_insert_node(&key)?;
4280 preview.note_insert_node(&key, &props);
4281 recs.push(WalRecord::InsertNode { label, key, props });
4282 }
4283 BatchOp::InsertEdge {
4284 edge_type,
4285 src_key,
4286 dst_key,
4287 } => {
4288 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4289 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4290 recs.push(WalRecord::InsertEdge {
4291 edge_type,
4292 src_key,
4293 dst_key,
4294 });
4295 }
4296 }
4297 BatchOp::SetProp { key, field, value } => {
4298 preview.check_live_key(&key)?;
4299 preview.note_set_prop(&key, &field, &value);
4300 recs.push(WalRecord::SetProp { key, field, value });
4301 }
4302 BatchOp::RemoveProp { key, field } => {
4303 if preview.prepare_remove_prop(&key, &field)? {
4304 preview.note_remove_prop(&key, &field);
4305 recs.push(WalRecord::RemoveProp { key, field });
4306 }
4307 }
4308 BatchOp::DeleteEdge {
4309 edge_type,
4310 src_key,
4311 dst_key,
4312 } => {
4313 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4314 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4315 recs.push(WalRecord::DeleteEdge {
4316 edge_type,
4317 src_key,
4318 dst_key,
4319 });
4320 }
4321 }
4322 BatchOp::DeleteNode { key } => {
4323 preview.check_live_key(&key)?;
4324 preview.note_delete_node(&key);
4325 recs.push(WalRecord::DeleteNode { key });
4326 }
4327 BatchOp::CreateRule(def) => {
4328 preview.check_create_rule(&def)?;
4329 let def_bytes =
4330 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4331 detail: format!("serialize rule: {e}"),
4332 })?;
4333 preview.note_create_rule(&def.name);
4334 recs.push(WalRecord::CreateRule { def_bytes });
4335 }
4336 BatchOp::DeleteRule { name } => {
4337 preview.check_delete_rule(&name)?;
4338 preview.note_delete_rule(&name);
4339 recs.push(WalRecord::DeleteRule { name });
4340 }
4341 BatchOp::RenameNode { old_key, new_key } => {
4342 preview.check_rename_node(&old_key, &new_key)?;
4343 preview.note_rename_node(&old_key, &new_key);
4344 recs.push(WalRecord::RenameNode { old_key, new_key });
4345 }
4346 BatchOp::InsertEdgeUpsert {
4347 edge_type,
4348 src_key,
4349 dst_key,
4350 placeholder_label,
4351 } => {
4352 // Auto-create any missing endpoints as plain InsertNode ops.
4353 // Rules fire and last-change is updated for each created node.
4354 for key in [&src_key, &dst_key] {
4355 if !preview.has_key(key) {
4356 preview.check_insert_node(key)?;
4357 preview.note_insert_node(key, &[]);
4358 recs.push(WalRecord::InsertNode {
4359 label: placeholder_label.clone(),
4360 key: key.clone(),
4361 props: vec![],
4362 });
4363 }
4364 }
4365 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4366 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4367 recs.push(WalRecord::InsertEdge {
4368 edge_type,
4369 src_key,
4370 dst_key,
4371 });
4372 }
4373 }
4374 }
4375 }
4376 recs
4377 };
4378 if recs.is_empty() {
4379 return Ok((0, 0));
4380 }
4381 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4382 // *Id form, so only the dense variants can appear in `recs` here.
4383 let recs = self.rewrite_wal_dense(recs)?;
4384 let nodes_inserted = recs
4385 .iter()
4386 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4387 .count();
4388 let edges_inserted = recs
4389 .iter()
4390 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4391 .count();
4392 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4393 // under Strict. Pass self.fsync directly so Strict stays Strict —
4394 // wal_needs_sync(Strict, _) always returns true regardless of op count.
4395 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4396 // short-circuit on single-op batches and silently skip the fsync.
4397 // Batched fsyncs only for multi-op batches; Relaxed always skips.
4398 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4399 Ok((nodes_inserted, edges_inserted))
4400 }
4401
4402 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4403 self.commit_logged_batch(ops, None, None)
4404 }
4405
4406 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4407 /// and the group-commit drain thread, which do a single group fsync later.
4408 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4409 // Restore fsync policy even on panic via a raw-pointer drop guard.
4410 // A panic here would poison the RwLock anyway, but the correct policy
4411 // must be in place if the guard is ever unwrapped.
4412 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4413 impl Drop for RestoreFsync {
4414 fn drop(&mut self) {
4415 // SAFETY: the pointer is valid for the full duration of
4416 // commit_batch_nosync; the guard is dropped before the frame
4417 // returns, and GraphDb outlives this frame.
4418 unsafe {
4419 *self.0 = self.1;
4420 }
4421 }
4422 }
4423 let saved = self.fsync;
4424 // SAFETY: raw pointer into self; guard dropped within this frame.
4425 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4426 self.fsync = FsyncPolicy::Relaxed;
4427 self.commit_logged_batch(ops, None, None)
4428 }
4429
4430 /// Commit multiple op-batches as a **group**: each submission gets its own
4431 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4432 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4433 ///
4434 /// # Durability semantics
4435 ///
4436 /// A crash before the group fsync may lose **all** submissions in the group.
4437 /// A crash after the group fsync preserves all of them. No submission is
4438 /// ever torn: each WAL frame is either fully applied on replay or dropped
4439 /// in its entirety (CRC-protected frame boundaries).
4440 ///
4441 /// Events and subscription notifications fire per-submission immediately
4442 /// after apply, which may be before the group fsync. From a subscriber's
4443 /// perspective this is equivalent to the `Relaxed` durability window.
4444 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4445 /// fsync, so from their perspective durability is fully guaranteed.
4446 ///
4447 /// # MVCC interplay
4448 ///
4449 /// Each submission records its own `CommitDelta`; the fold-every-K counter
4450 /// increments per submission (not per group), preserving existing reader
4451 /// snapshot semantics.
4452 ///
4453 /// # Returns
4454 ///
4455 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4456 /// in order. Failures are per-submission (validation errors); the group
4457 /// fsync error (if any) is returned as the second tuple element.
4458 pub fn commit_group(
4459 &mut self,
4460 groups: Vec<Vec<BatchOp>>,
4461 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4462 let mut results = Vec::with_capacity(groups.len());
4463 for ops in groups {
4464 results.push(self.commit_batch_nosync(ops));
4465 }
4466 let any_ok = results.iter().any(|r| r.is_ok());
4467 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4468 self.fs
4469 .sync(core_storage::fs::FileId::Wal)
4470 .map_err(GraphError::Io)
4471 .err()
4472 } else {
4473 None
4474 };
4475 (results, sync_err)
4476 }
4477
4478 /// Like [`commit_group`] but skips the group fsync entirely.
4479 ///
4480 /// Used by the drain thread to apply submissions under the write lock and
4481 /// then perform the single fsync OUTSIDE the lock (via
4482 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4483 /// to concurrent readers.
4484 pub fn commit_group_nosync(
4485 &mut self,
4486 groups: Vec<Vec<BatchOp>>,
4487 ) -> Vec<Result<(usize, usize)>> {
4488 let mut results = Vec::with_capacity(groups.len());
4489 for ops in groups {
4490 results.push(self.commit_batch_nosync(ops));
4491 }
4492 results
4493 }
4494
4495 pub fn insert_node(
4496 &mut self,
4497 label: &str,
4498 key: &str,
4499 props: Vec<(String, Value)>,
4500 ) -> Result<()> {
4501 if self.read_only {
4502 return Err(GraphError::ReadOnly);
4503 }
4504 MutPreview::new(self).check_insert_node(key)?;
4505 self.log_dense(vec![WalRecord::InsertNode {
4506 label: label.into(),
4507 key: key.into(),
4508 props,
4509 }])
4510 }
4511
4512 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4513 if self.read_only {
4514 return Err(GraphError::ReadOnly);
4515 }
4516 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4517 return Ok(false);
4518 }
4519 self.log_dense(vec![WalRecord::InsertEdge {
4520 edge_type: edge_type.into(),
4521 src_key: src_key.into(),
4522 dst_key: dst_key.into(),
4523 }])?;
4524 Ok(true)
4525 }
4526
4527 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4528 if self.read_only {
4529 return Err(GraphError::ReadOnly);
4530 }
4531 if let Some(view_name) = self.view_store.view_for_prop(field) {
4532 return Err(GraphError::ViewPropReadOnly {
4533 view_name: view_name.to_string(),
4534 });
4535 }
4536 MutPreview::new(self).check_live_key(key)?;
4537 self.log_dense(vec![WalRecord::SetProp {
4538 key: key.into(),
4539 field: field.into(),
4540 value,
4541 }])
4542 }
4543
4544 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4545 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4546 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4547 if self.read_only {
4548 return Err(GraphError::ReadOnly);
4549 }
4550 if let Some(view_name) = self.view_store.view_for_prop(field) {
4551 return Err(GraphError::ViewPropReadOnly {
4552 view_name: view_name.to_string(),
4553 });
4554 }
4555 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4556 return Ok(false);
4557 }
4558 self.log_then_apply(WalRecord::RemoveProp {
4559 key: key.into(),
4560 field: field.into(),
4561 })?;
4562 Ok(true)
4563 }
4564
4565 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4566 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4567 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4568 /// (the rule would just put the edge back; delete or change the rule).
4569 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4570 if self.read_only {
4571 return Err(GraphError::ReadOnly);
4572 }
4573 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4574 return Ok(false);
4575 }
4576 self.log_then_apply(WalRecord::DeleteEdge {
4577 edge_type: edge_type.into(),
4578 src_key: src_key.into(),
4579 dst_key: dst_key.into(),
4580 })?;
4581 Ok(true)
4582 }
4583
4584 /// Delete a live node. Unknown or already-tombstoned keys are
4585 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4586 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4587 /// (crash window) is a clean no-op.
4588 ///
4589 /// Returns a [`DeleteReport`] with counts of manual and derived edges
4590 /// removed (computed from live state before the deletion is applied).
4591 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4592 if self.read_only {
4593 return Err(GraphError::ReadOnly);
4594 }
4595 // Provenance must be loaded before we query provenance_touching.
4596 self.engine.ensure_provenance_loaded_mut();
4597 let id = self
4598 .ids
4599 .get(key)
4600 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4601
4602 // Count edges before the delete is applied so we can report counts.
4603 let derived_set: BTreeSet<(u32, u32, u32)> = self
4604 .engine
4605 .provenance_touching(id)
4606 .map(|(_, etype, src, dst)| (etype, src, dst))
4607 .collect();
4608 let derived_edges = derived_set.len() as u64;
4609
4610 let mut total_topo = 0u64;
4611 let tv = self.topo_view();
4612 for et in tv.etypes() {
4613 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4614 + tv.neighbors(et, Direction::In, id).len() as u64;
4615 }
4616 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4617 // triples in both the topo scan (Out and In from id) and in provenance_touching.
4618 // The subtraction remains correct because both counts include both directions.
4619 let manual_edges = total_topo.saturating_sub(derived_edges);
4620
4621 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4622 Ok(DeleteReport {
4623 manual_edges,
4624 derived_edges,
4625 })
4626 }
4627
4628 /// Rename a live node's key. The dense id (and therefore all edges,
4629 /// props, history, and last-change tracking) is unaffected.
4630 ///
4631 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4632 /// Returns `Err(DuplicateKey)` if `new` is already live.
4633 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4634 if self.read_only {
4635 return Err(GraphError::ReadOnly);
4636 }
4637 MutPreview::new(self).check_rename_node(old, new)?;
4638 self.log_then_apply(WalRecord::RenameNode {
4639 old_key: old.into(),
4640 new_key: new.into(),
4641 })
4642 }
4643
4644 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4645 /// `None` if the rule does not exist or is not approximate.
4646 ///
4647 /// The drift counter increments on IVF insert/remove after the last fit.
4648 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4649 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4650 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4651 // SideIvfExport = (centroids, node→cluster, drift)
4652 self.engine
4653 .export_ivf_state()
4654 .remove(rule)
4655 .map(|(_src, dst)| dst.2)
4656 }
4657
4658 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4659 /// Validation and duplicate-name check run before logging so invalid rules
4660 /// never enter the WAL.
4661 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4662 if self.read_only {
4663 return Err(GraphError::ReadOnly);
4664 }
4665 MutPreview::new(self).check_create_rule(&def)?;
4666 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4667 detail: format!("serialize rule: {e}"),
4668 })?;
4669 self.log_then_apply(WalRecord::CreateRule { def_bytes })
4670 }
4671
4672 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4673 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4674 if self.read_only {
4675 return Err(GraphError::ReadOnly);
4676 }
4677 MutPreview::new(self).check_delete_rule(name)?;
4678 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4679 }
4680
4681 /// Return a snapshot of all registered rules.
4682 pub fn rules(&self) -> Vec<RuleDef> {
4683 self.engine.rules().cloned().collect()
4684 }
4685
4686 // -----------------------------------------------------------------------
4687 // Rule suggestion API
4688 // -----------------------------------------------------------------------
4689
4690 /// Profile the database and suggest linking rules with previewed edge counts.
4691 ///
4692 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4693 /// sampling. Suggestions are sorted by estimated edge count (descending).
4694 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4695 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4696 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4697 }
4698
4699 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4700 /// reproducibility. Same seed + same data = identical output.
4701 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4702 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4703 .suggestions
4704 }
4705
4706 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4707 ///
4708 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4709 /// and a `truncated` flag indicating whether the global budget fired before all
4710 /// candidates were evaluated.
4711 pub fn suggest_rules_with_config(
4712 &self,
4713 config: &core_rules::suggest::SuggestConfig,
4714 seed: u64,
4715 ) -> core_rules::SuggestReport {
4716 use std::collections::BTreeMap;
4717
4718 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4719 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4720 for id in 0..self.ids.len() as u32 {
4721 let Some(key) = self.ids.key_of(id) else {
4722 continue;
4723 };
4724 let Some(&sym) = self.labels.get(id as usize) else {
4725 continue;
4726 };
4727 if sym == u32::MAX {
4728 continue; // tombstoned
4729 }
4730 let Some(label) = self.syms.resolve(sym) else {
4731 continue;
4732 };
4733 label_nodes
4734 .entry(label.to_string())
4735 .or_default()
4736 .push((id, key.to_string()));
4737 }
4738
4739 let existing = self.rules();
4740 let pv = build_props_view(&self.props, &self.base);
4741 let all_fields: Vec<String> = pv.field_names();
4742
4743 core_rules::suggest::suggest_rules(
4744 &label_nodes,
4745 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4746 &all_fields,
4747 &existing,
4748 config,
4749 seed,
4750 )
4751 }
4752
4753 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
4754 /// plus later mutations replay identically (rebuild is a pure function
4755 /// of state).
4756 ///
4757 /// Only exit from the tripped latch: if the full desired set fits the
4758 /// budget, it is applied completely and `tripped` clears; if it still
4759 /// exceeds the budget, provenance is left untouched and `tripped` stays
4760 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
4761 /// Unknown rule → `RuleNotFound`, nothing logged.
4762 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
4763 if self.read_only {
4764 return Err(GraphError::ReadOnly);
4765 }
4766 if !self.engine.rules().any(|r| r.name == name) {
4767 return Err(GraphError::RuleNotFound { name: name.into() });
4768 }
4769 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
4770 }
4771
4772 // -----------------------------------------------------------------------
4773 // Materialized view API
4774 // -----------------------------------------------------------------------
4775
4776 /// Register a new materialized property view, backfill its values for all
4777 /// existing nodes, and WAL-log the definition.
4778 ///
4779 /// # Errors
4780 /// - `ReadOnly`: called on an as-of instance.
4781 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
4782 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
4783 if self.read_only {
4784 return Err(GraphError::ReadOnly);
4785 }
4786 // Pre-validate before WAL write.
4787 def.validate()
4788 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4789 if self.view_store.has_view(&def.name) {
4790 return Err(GraphError::RuleInvalid {
4791 detail: format!("view {:?} already exists", def.name),
4792 });
4793 }
4794 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
4795 return Err(GraphError::RuleInvalid {
4796 detail: format!(
4797 "view_prop {:?} is already used by view {:?}",
4798 def.view_prop, existing
4799 ),
4800 });
4801 }
4802 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4803 detail: format!("serialize view: {e}"),
4804 })?;
4805 // Enable delta accumulation before the view is registered so subsequent
4806 // incremental edge events reach view maintenance from this point onward.
4807 // (The backfill inside create_view reads topo directly; it does not rely
4808 // on pending deltas.)
4809 self.engine.set_emit_deltas(true);
4810 self.log_then_apply(WalRecord::CreateView { def_bytes })
4811 }
4812
4813 /// Remove a named view and delete its values from every node.
4814 ///
4815 /// # Errors
4816 /// - `ReadOnly`: called on an as-of instance.
4817 /// - `RuleNotFound`: view does not exist.
4818 pub fn delete_view(&mut self, name: &str) -> Result<()> {
4819 if self.read_only {
4820 return Err(GraphError::ReadOnly);
4821 }
4822 if !self.view_store.has_view(name) {
4823 return Err(GraphError::RuleNotFound { name: name.into() });
4824 }
4825 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
4826 // After deletion, disable accumulation if no listeners remain.
4827 if !self.needs_emit_deltas() {
4828 self.engine.set_emit_deltas(false);
4829 }
4830 result
4831 }
4832
4833 /// Snapshot of all registered view definitions.
4834 pub fn views(&self) -> Vec<ViewDef> {
4835 self.view_store.views().cloned().collect()
4836 }
4837
4838 // -----------------------------------------------------------------------
4839 // Full-text-lite API
4840 // -----------------------------------------------------------------------
4841
4842 /// Enable full-text indexing for all nodes of `label` on property `field`.
4843 ///
4844 /// After this call, every subsequent write to `(label, field)` is reflected
4845 /// in the index incrementally. Existing nodes are backfilled immediately.
4846 /// The declaration is persisted as a WAL record; the index itself is rebuilt
4847 /// from scratch on re-open (no snapshot format changes).
4848 ///
4849 /// # Errors
4850 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4851 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4852 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4853 if self.read_only {
4854 return Err(GraphError::ReadOnly);
4855 }
4856 if self.fulltext.is_enabled(label, field) {
4857 return Err(GraphError::RuleInvalid {
4858 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
4859 });
4860 }
4861 self.log_then_apply(WalRecord::EnableFulltext {
4862 label: label.into(),
4863 field: field.into(),
4864 })
4865 }
4866
4867 /// Disable full-text indexing for `(label, field)` and drop its postings.
4868 ///
4869 /// # Errors
4870 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4871 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4872 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4873 if self.read_only {
4874 return Err(GraphError::ReadOnly);
4875 }
4876 if !self.fulltext.is_enabled(label, field) {
4877 return Err(GraphError::RuleNotFound {
4878 name: format!("fulltext({label},{field})"),
4879 });
4880 }
4881 self.log_then_apply(WalRecord::DisableFulltext {
4882 label: label.into(),
4883 field: field.into(),
4884 })
4885 }
4886
4887 /// Whether `(label, field)` is currently indexed for full-text search.
4888 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
4889 self.fulltext.is_enabled(label, field)
4890 }
4891
4892 /// Enable an equality index for all nodes of `label` on scalar property
4893 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
4894 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
4895 /// declaration persists via WAL and the postings rebuild on re-open.
4896 ///
4897 /// # Errors
4898 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4899 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4900 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
4901 if self.read_only {
4902 return Err(GraphError::ReadOnly);
4903 }
4904 if self.prop_index.is_enabled(label, field) {
4905 return Err(GraphError::RuleInvalid {
4906 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
4907 });
4908 }
4909 self.log_then_apply(WalRecord::EnableIndex {
4910 label: label.into(),
4911 field: field.into(),
4912 })
4913 }
4914
4915 /// Disable the equality index for `(label, field)` and drop its postings.
4916 ///
4917 /// # Errors
4918 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4919 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4920 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
4921 if self.read_only {
4922 return Err(GraphError::ReadOnly);
4923 }
4924 if !self.prop_index.is_enabled(label, field) {
4925 return Err(GraphError::RuleNotFound {
4926 name: format!("index({label},{field})"),
4927 });
4928 }
4929 self.log_then_apply(WalRecord::DisableIndex {
4930 label: label.into(),
4931 field: field.into(),
4932 })
4933 }
4934
4935 /// Whether `(label, field)` currently has an equality index.
4936 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
4937 self.prop_index.is_enabled(label, field)
4938 }
4939
4940 /// Search a full-text-indexed field.
4941 ///
4942 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
4943 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
4944 ///
4945 /// **Query syntax:**
4946 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
4947 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
4948 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
4949 /// - `AND` keyword is accepted explicitly and is the default.
4950 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
4951 ///
4952 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
4953 /// Pin: this is the documented, tested, stable behavior for v1.
4954 ///
4955 /// **Memory / performance:** O(postings) lookup; no scan. The index is
4956 /// in-memory and proportional to total indexed text across all enabled fields.
4957 ///
4958 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
4959 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
4960 /// key ascending for deterministic tiebreaking.
4961 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4962 // Resolve node_ids to keys (excluding tombstones) then re-sort by
4963 // (score DESC, key ASC) to give a deterministic, key-lexicographic
4964 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
4965 // which diverges from key order when nodes were not inserted in key-lex order.
4966 let mut results: Vec<(String, f64)> = self
4967 .fulltext
4968 .search(field, query, 0)
4969 .into_iter()
4970 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
4971 .collect();
4972 results.sort_by(|a, b| {
4973 b.1.partial_cmp(&a.1)
4974 .unwrap_or(std::cmp::Ordering::Equal)
4975 .then(a.0.cmp(&b.0))
4976 });
4977 results
4978 }
4979
4980 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
4981 ///
4982 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
4983 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
4984 /// them with RRF using a fixed constant of 60.
4985 ///
4986 /// ```text
4987 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
4988 /// ```
4989 ///
4990 /// Returns the top `k` nodes by fused score, ties broken by node key
4991 /// ascending (deterministic).
4992 ///
4993 /// # Vector leg fallback
4994 ///
4995 /// When `query_vec` is empty the vector leg is skipped entirely and
4996 /// results are ranked by the text list alone through the same RRF path
4997 /// (each text result scores `1/(60 + rank)` from that single list).
4998 ///
4999 /// When `label` is `None`, the vector leg **always** returns empty results.
5000 /// Internally `label` is mapped to `""`, which does not match any rule-created
5001 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5002 /// the brute-force fallback finds no nodes with an empty label. The fused
5003 /// ranking is therefore text-only in this case.
5004 pub fn search_hybrid(
5005 &self,
5006 text_field: &str,
5007 query_text: &str,
5008 vector_field: &str,
5009 query_vec: &[f64],
5010 label: Option<&str>,
5011 k: usize,
5012 ) -> Vec<(String, f64)> {
5013 use std::collections::HashMap;
5014
5015 const RRF_K: f64 = 60.0;
5016 let pool = 4 * k;
5017
5018 // Accumulate per-node RRF scores.
5019 let mut scores: HashMap<String, f64> = HashMap::new();
5020
5021 // Text leg.
5022 let text_hits = self.search(text_field, query_text);
5023 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5024 let rank = (rank0 + 1) as f64;
5025 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5026 }
5027
5028 // Vector leg (skipped when query_vec is empty).
5029 if !query_vec.is_empty() {
5030 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5031 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5032 let rank = (rank0 + 1) as f64;
5033 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5034 }
5035 }
5036
5037 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5038 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5039 ranked.sort_by(|a, b| {
5040 b.1.partial_cmp(&a.1)
5041 .unwrap_or(std::cmp::Ordering::Equal)
5042 .then(a.0.cmp(&b.0))
5043 });
5044 ranked.truncate(k);
5045 ranked
5046 }
5047
5048 /// For DST/testing: scratch BM25 search over live nodes without the index.
5049 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5050 /// returns BM25-ranked results.
5051 ///
5052 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5053 /// `scratch_search(field, q)` at every quiescent state.
5054 #[doc(hidden)]
5055 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5056 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5057 use std::collections::BTreeMap;
5058
5059 let groups = parse_query(query);
5060 if groups.is_empty() {
5061 return vec![];
5062 }
5063
5064 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5065 struct NodeData {
5066 key: String,
5067 /// stemmed_token → positions (sorted)
5068 tokens: BTreeMap<String, Vec<u32>>,
5069 dl: u32,
5070 }
5071
5072 let mut nodes: Vec<NodeData> = Vec::new();
5073 for id in 0..self.ids.len() as u32 {
5074 let Some(key) = self.ids.key_of(id) else {
5075 continue;
5076 };
5077 let Some(&sym) = self.labels.get(id as usize) else {
5078 continue;
5079 };
5080 if sym == u32::MAX {
5081 continue;
5082 }
5083 let label = match self.syms.resolve(sym) {
5084 Some(l) => l,
5085 None => continue,
5086 };
5087 if !self.fulltext.is_enabled(label, field) {
5088 continue;
5089 }
5090 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5091 continue;
5092 };
5093 // Use value_tokens_stemmed_with_positions so list elements are
5094 // separated by POSITION_GAP — identical to the index path, which
5095 // prevents phrase queries from matching across element boundaries.
5096 let stemmed_with_pos = match &value {
5097 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5098 _ => continue,
5099 };
5100 let dl = stemmed_with_pos.len() as u32;
5101 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5102 for (tok, pos) in stemmed_with_pos {
5103 tok_map.entry(tok).or_default().push(pos);
5104 }
5105 nodes.push(NodeData {
5106 key: key.to_string(),
5107 tokens: tok_map,
5108 dl,
5109 });
5110 }
5111
5112 if nodes.is_empty() {
5113 return vec![];
5114 }
5115
5116 // --- BM25 corpus stats ---
5117 let n = nodes.len() as f64;
5118 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5119 // df per stemmed token across all live indexed nodes.
5120 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5121 for nd in &nodes {
5122 for tok in nd.tokens.keys() {
5123 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5124 }
5125 }
5126
5127 const K1: f64 = 1.2;
5128 const B: f64 = 0.75;
5129
5130 // --- Pass 2: score each node against each OR-group ---
5131 let mut results: Vec<(String, f64)> = Vec::new();
5132 for nd in &nodes {
5133 let dl = nd.dl as f64;
5134 let mut total_score = 0.0f64;
5135
5136 'group: for group in &groups {
5137 let mut group_score = 0.0f64;
5138
5139 for term in group {
5140 if term.negated {
5141 // Negated: if doc has this stemmed token → group fails.
5142 let present = if term.prefix {
5143 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5144 } else {
5145 nd.tokens.contains_key(term.token.as_str())
5146 };
5147 if present {
5148 continue 'group;
5149 }
5150 continue;
5151 }
5152 if term.prefix {
5153 // Prefix: sum BM25 for all matching stemmed tokens.
5154 let mut prefix_matched = false;
5155 for (tok, positions) in &nd.tokens {
5156 if tok.starts_with(term.token.as_str()) {
5157 let tf = positions.len() as f64;
5158 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5159 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5160 let tf_norm =
5161 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5162 group_score += idf * tf_norm;
5163 prefix_matched = true;
5164 }
5165 }
5166 if !prefix_matched {
5167 continue 'group;
5168 }
5169 } else {
5170 // term.token is already stemmed by parse_query; use directly.
5171 match nd.tokens.get(term.token.as_str()) {
5172 None => continue 'group,
5173 Some(positions) => {
5174 let tf = positions.len() as f64;
5175 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5176 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5177 let tf_norm =
5178 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5179 group_score += idf * tf_norm;
5180 }
5181 }
5182 }
5183 }
5184
5185 if group_score > 0.0 {
5186 total_score += group_score;
5187 }
5188 }
5189
5190 if total_score > 0.0 {
5191 results.push((nd.key.clone(), total_score));
5192 }
5193 }
5194
5195 results.sort_by(|a, b| {
5196 b.1.partial_cmp(&a.1)
5197 .unwrap_or(std::cmp::Ordering::Equal)
5198 .then(a.0.cmp(&b.0))
5199 });
5200 results
5201 }
5202
5203 /// Return the current view-maintained value of `view_prop` for node `key`.
5204 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5205 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5206 let id = self.ids.get(key)?;
5207 self.props_view()
5208 .get(id, view_prop)
5209 .map(|vr| vr.into_value())
5210 }
5211
5212 /// For testing / DST oracle: scratch recompute of a view value for one node.
5213 ///
5214 /// Returns `None` if the node does not exist, the view does not exist, or
5215 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5216 #[doc(hidden)]
5217 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5218 let node = self.ids.get(key)?;
5219 let def = self.view_store.views().find(|v| v.name == view_name)?;
5220 // Use TopologyView so that NeighborAgg sees base + overlay edges
5221 // without materialising a temporary Topology (I1).
5222 let topo_view = self.topo_view();
5223 core_rules::views::compute_view_value(
5224 def,
5225 node,
5226 self.props_view(),
5227 &topo_view,
5228 &self.ids,
5229 &self.syms,
5230 &self.labels,
5231 )
5232 }
5233
5234 // -----------------------------------------------------------------------
5235 // Graph algorithm API
5236 // -----------------------------------------------------------------------
5237
5238 /// Run PageRank over the unified topology (manual + derived edges).
5239 ///
5240 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5241 /// ascending). Set `config.edge_type` to restrict to one edge type.
5242 /// `config.converged` is `true` only when the power iteration converged
5243 /// within `config.max_iters` and within any time budget.
5244 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5245 let topo = build_topo_view(&self.topo, &self.base);
5246 crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5247 }
5248
5249 /// Weakly-connected components over the unified topology (treated as
5250 /// undirected regardless of how edges were inserted).
5251 ///
5252 /// Component IDs are the key of the smallest member in the component
5253 /// (deterministic). Result sorted by (component_id, key).
5254 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5255 let topo = build_topo_view(&self.topo, &self.base);
5256 crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5257 }
5258
5259 /// Degree centrality for every live node.
5260 ///
5261 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5262 /// `AlgoDir::Both` = out + in (total directed degree).
5263 ///
5264 /// For one-shot ranking use this; for a live property updated on every
5265 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5266 pub fn degree_centrality(
5267 &self,
5268 config: &crate::algo::DegreeConfig,
5269 ) -> crate::algo::DegreeReport {
5270 let topo = build_topo_view(&self.topo, &self.base);
5271 crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5272 }
5273
5274 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5275 /// atomically via a single write-batch (one WAL frame, one fsync).
5276 ///
5277 /// # Errors
5278 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5279 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5280 /// (collision check mirrors `create_view`).
5281 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5282 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5283 if self.read_only {
5284 return Err(GraphError::ReadOnly);
5285 }
5286 // Collision check: refuse if prop_name is view-managed.
5287 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5288 return Err(GraphError::RuleInvalid {
5289 detail: format!(
5290 "prop {:?} is managed by view {:?} and cannot be written as scores",
5291 prop_name, view_name
5292 ),
5293 });
5294 }
5295 // Refuse if prop_name is a view name itself (confusing namespace collision).
5296 if self.view_store.has_view(prop_name) {
5297 return Err(GraphError::RuleInvalid {
5298 detail: format!(
5299 "prop_name {:?} collides with an existing view name",
5300 prop_name
5301 ),
5302 });
5303 }
5304 // Write all scores in a single crash-atomic batch.
5305 self.write_batch(|b| {
5306 for (key, score) in scores {
5307 b.set_prop(key, prop_name, Value::Float(*score));
5308 }
5309 })?;
5310 Ok(())
5311 }
5312
5313 /// Return the value of `field` for the node with key `key`, or `None` if
5314 /// the node or field is absent. Reads through the overlay-over-base
5315 /// `ColumnsView`, materialising base values on demand (zero heap cost for
5316 /// overlay hits; one clone per base hit).
5317 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5318 let id = self.ids.get(key)?;
5319 self.props_view().get(id, field).map(|vr| vr.into_value())
5320 }
5321
5322 pub fn has_node(&self, key: &str) -> bool {
5323 self.ids.get(key).is_some()
5324 }
5325
5326 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5327 pub(crate) fn ids(&self) -> &IdMap {
5328 &self.ids
5329 }
5330
5331 // -----------------------------------------------------------------------
5332 // RBAC role resolution
5333 // -----------------------------------------------------------------------
5334
5335 /// Parse `roles.json` bytes from `fs`.
5336 ///
5337 /// Return values:
5338 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5339 /// and valid; in both cases `mask_for_role` uses the
5340 /// list normally (an absent file means no roles defined).
5341 /// `Ok(None)` — file present but corrupt or unrecognised version
5342 /// → poisoned state; `mask_for_role` returns `Err` for
5343 /// any role name until the file is fixed and the DB
5344 /// re-opened (or `apply_schema` is called to repair it).
5345 ///
5346 /// Note: `None` signals corruption, not absence — the opposite of what an
5347 /// optional "file missing" convention would suggest. The open path stores
5348 /// this result on `db.roles` directly.
5349 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5350 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5351 if bytes.is_empty() {
5352 // Empty bytes means either the file is absent or zero-byte — both
5353 // are treated identically as "no roles defined". A zero-byte
5354 // roles.json does NOT widen access: an absent file and a zero-byte
5355 // file both resolve to an empty role list (sees nothing by default).
5356 return Ok(Some(vec![]));
5357 }
5358 match serde_json::from_slice::<RolesFile>(&bytes) {
5359 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5360 // Corrupt or unrecognised version (>2): poison the roles state.
5361 _ => Ok(None),
5362 }
5363 }
5364
5365 /// Resolve a role to a node-visibility mask against the current graph state.
5366 ///
5367 /// Returns `Err` when:
5368 /// - `roles.json` was present but corrupt at open (poisoned state), or
5369 /// - `role` does not match any defined role name.
5370 ///
5371 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5372 /// all live nodes carrying any label in `labels`. Label resolution is live
5373 /// — new nodes of an allowed label are visible without re-applying the
5374 /// schema. An empty union = empty mask = sees nothing.
5375 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5376 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5377 detail:
5378 "roles.json was corrupt at open; fix the file and re-open to restore role access"
5379 .into(),
5380 })?;
5381 let def = roles
5382 .iter()
5383 .find(|r| r.name == role)
5384 .ok_or_else(|| GraphError::KeyNotFound {
5385 key: format!("role:{role}"),
5386 })?;
5387
5388 let mut visible = std::collections::HashSet::new();
5389
5390 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5391 for key in &def.keys {
5392 if let Some(id) = self.ids.get(key) {
5393 visible.insert(id);
5394 }
5395 }
5396
5397 // Label leg: live scan — iterate labels vec for matching symbol.
5398 for label_name in &def.labels {
5399 if let Some(sym) = self.syms.get(label_name) {
5400 for (i, &s) in self.labels.iter().enumerate() {
5401 if s == sym {
5402 visible.insert(i as u32);
5403 }
5404 }
5405 }
5406 }
5407
5408 Ok(crate::mask::NodeMask::from_ids(visible))
5409 }
5410
5411 /// Return the current list of role definitions.
5412 ///
5413 /// Returns an empty list when no roles are defined or when `roles.json`
5414 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5415 /// the fail-loud error in that case).
5416 pub fn roles(&self) -> Vec<RoleDef> {
5417 self.roles.as_deref().unwrap_or(&[]).to_vec()
5418 }
5419
5420 // ── Role-scoped write authz ───────────────────────────────────────────────
5421
5422 /// Execute `ops` with optional role-scoped write authorization.
5423 ///
5424 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5425 /// (zero-cost bypass of all authz checks).
5426 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5427 /// record is built. A denial returns an error with no WAL frame written
5428 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5429 ///
5430 /// See the plan's "authz decision table" section for the full semantics.
5431 pub fn write_batch_authz(
5432 &mut self,
5433 authz: Option<&WriteAuthz>,
5434 ops: Vec<BatchOp>,
5435 ) -> Result<(usize, usize)> {
5436 // Thread authz as a direct parameter — never touches pending_write_authz.
5437 self.commit_logged_batch(ops, None, authz.cloned())
5438 }
5439
5440 /// Execute a Cypher write statement with role-scoped write authorization.
5441 ///
5442 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5443 /// lifetime as execution, satisfying §5 lock discipline). The resolved
5444 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5445 /// call so that all inner `batch.commit()` calls are authz-checked.
5446 ///
5447 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5448 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5449 /// timing-oracle item (hidden ≡ absent for unscoped roles).
5450 ///
5451 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5452 /// "this endpoint is not permitted".
5453 pub fn query_write_authz(
5454 &mut self,
5455 role: &str,
5456 cypher: &str,
5457 params: &BTreeMap<String, Value>,
5458 ) -> Result<ResultSet> {
5459 // Resolve scope (fails fast if role has no write scope).
5460 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5461 let scope =
5462 {
5463 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5464 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5465 })?;
5466 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5467 GraphError::KeyNotFound {
5468 key: format!("role:{role}"),
5469 }
5470 })?;
5471 def.write
5472 .clone()
5473 .ok_or_else(|| GraphError::RoleWriteDenied {
5474 reason: "role-bound token: writes are not permitted".into(),
5475 })?
5476 };
5477 // Resolve mask inside the call (same guard, §5 coherence).
5478 let mask = self.mask_for_role(role)?;
5479 self.pending_write_authz = Some(WriteAuthz {
5480 role: role.into(),
5481 scope,
5482 mask,
5483 });
5484 // RAII guard: always clears pending_write_authz on scope exit, including
5485 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5486 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5487 impl Drop for ClearPendingAuthzOnDrop {
5488 fn drop(&mut self) {
5489 // SAFETY: pointer into the owning GraphDb; guard is dropped
5490 // within this function's frame before it returns.
5491 unsafe { *self.0 = None };
5492 }
5493 }
5494 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5495 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5496 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5497 detail: format!("lex: {e}"),
5498 })?;
5499 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5500 detail: format!("parse: {e}"),
5501 })?;
5502 self.exec_write_stmt(stmt, params)
5503 }
5504
5505 /// Execute `ops` with optional role-scoped write authorization, suppressing
5506 /// fsync (for use inside the group-commit drain thread, which performs one
5507 /// group fsync after releasing the write lock).
5508 ///
5509 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5510 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5511 /// contract established by [`commit_batch_nosync`].
5512 pub(crate) fn write_batch_authz_nosync(
5513 &mut self,
5514 authz: Option<&WriteAuthz>,
5515 ops: Vec<BatchOp>,
5516 ) -> Result<(usize, usize)> {
5517 let saved = self.fsync;
5518 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5519 impl Drop for RestoreFsync {
5520 fn drop(&mut self) {
5521 // SAFETY: pointer into the owning GraphDb; guard is dropped
5522 // within the enclosing function's frame before it returns.
5523 unsafe { *self.0 = self.1 };
5524 }
5525 }
5526 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5527 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5528 self.fsync = FsyncPolicy::Relaxed;
5529 self.commit_logged_batch(ops, None, authz.cloned())
5530 }
5531
5532 /// Execute a `/ingest` request with role-scoped write authorization.
5533 ///
5534 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5535 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5536 /// Sets `pending_write_authz` for the duration of the call so that the
5537 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5538 /// and evaluates the decision table per-op before any WAL write.
5539 ///
5540 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5541 /// denied by the decision table with the appropriate §4.3 scope reason;
5542 /// no special HTTP-layer check is needed.
5543 ///
5544 /// Roles with `write: None` return `RoleWriteDenied` with
5545 /// "writes are not permitted" (byte-identical to v1 blanket 403).
5546 pub fn ingest_with_edges_authz(
5547 &mut self,
5548 role: &str,
5549 label: &str,
5550 rows: Vec<std::collections::BTreeMap<String, Value>>,
5551 opts: &crate::ingest::IngestOptions,
5552 edges: &[(String, String, String)],
5553 ) -> Result<crate::ingest::IngestReport> {
5554 // Resolve scope (fails fast if role has no write scope).
5555 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5556 let scope =
5557 {
5558 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5559 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5560 })?;
5561 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5562 GraphError::KeyNotFound {
5563 key: format!("role:{role}"),
5564 }
5565 })?;
5566 def.write
5567 .clone()
5568 .ok_or_else(|| GraphError::RoleWriteDenied {
5569 reason: "role-bound token: writes are not permitted".into(),
5570 })?
5571 };
5572 let mask = self.mask_for_role(role)?;
5573 self.pending_write_authz = Some(WriteAuthz {
5574 role: role.into(),
5575 scope,
5576 mask,
5577 });
5578 // RAII guard: always clears pending_write_authz on scope exit, including
5579 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5580 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5581 impl Drop for ClearPendingAuthzOnDrop {
5582 fn drop(&mut self) {
5583 // SAFETY: pointer into the owning GraphDb; guard is dropped
5584 // within this function's frame before it returns.
5585 unsafe { *self.0 = None };
5586 }
5587 }
5588 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5589 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5590 self.ingest_with_edges(label, rows, opts, edges)
5591 }
5592
5593 /// Evaluate the write-authz decision table for one `BatchOp`.
5594 ///
5595 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5596 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
5597 /// the remaining ops are not evaluated and no WAL frame is written.
5598 ///
5599 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5600 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
5601 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5602 /// batch creates counts as visible if its label passed the create-class gate").
5603 fn check_single_op_authz(
5604 &self,
5605 authz: &WriteAuthz,
5606 op: &BatchOp,
5607 batch_created: &BTreeMap<String, String>,
5608 ) -> Result<()> {
5609 // Helper: 3-way node status under the authz mask.
5610 //
5611 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5612 // as Visible with their recorded label — their create gate already passed
5613 // and they are not yet in self.ids (not committed). This fixes the
5614 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5615 // the SetProp must not see the node as Absent.
5616 let node_status = |key: &str| -> NodeAuthzStatus {
5617 if let Some(label) = batch_created.get(key) {
5618 return NodeAuthzStatus::Visible(label.clone());
5619 }
5620 match self.ids.get(key) {
5621 None => NodeAuthzStatus::Absent,
5622 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5623 Some(id) => {
5624 let label = self
5625 .labels
5626 .get(id as usize)
5627 .and_then(|&sym| {
5628 if sym == u32::MAX {
5629 None
5630 } else {
5631 self.syms.resolve(sym).map(str::to_string)
5632 }
5633 })
5634 .unwrap_or_default();
5635 NodeAuthzStatus::Visible(label)
5636 }
5637 }
5638 };
5639
5640 // Helper: is an InsertEdgeUpsert endpoint visible?
5641 // A same-batch placeholder counts as visible if its label passed
5642 // the create-class gate (spec "upsert placeholder-counts-as-visible").
5643 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5644 // In store and visible?
5645 if let Some(id) = self.ids.get(ep_key) {
5646 return authz.mask.contains_id(id);
5647 }
5648 // Created by an earlier op in this batch?
5649 if let Some(created_label) = batch_created.get(ep_key) {
5650 return authz.scope.create_labels.contains(created_label);
5651 }
5652 // Will be created by THIS InsertEdgeUpsert: placeholder_label
5653 // must pass the create-class gate.
5654 authz
5655 .scope
5656 .create_labels
5657 .contains(&placeholder_label.to_string())
5658 };
5659
5660 match op {
5661 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5662 // These ops are never routed to role-scoped paths by the HTTP layer,
5663 // but we 403 them here to close any future bypass route.
5664 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5665 return Err(GraphError::RoleWriteDenied {
5666 reason: "role-bound token: this endpoint is not permitted".into(),
5667 });
5668 }
5669
5670 // ── CREATE-class: InsertNode ─────────────────────────────────────
5671 //
5672 // Decision table row 1 (scope-before-lookup): check label in
5673 // create_labels BEFORE any key lookup. This is the structural
5674 // closure of the §6.2 timing-oracle item — the denial fires even
5675 // when the store is EMPTY (see test_create_scope_denied_empty_store).
5676 BatchOp::InsertNode { label, key, .. } => {
5677 if !authz.scope.create_labels.contains(label) {
5678 return Err(GraphError::RoleWriteDenied {
5679 reason: format!(
5680 "role-bound token: label '{}' not in write scope (create_labels)",
5681 label
5682 ),
5683 });
5684 }
5685 // Row 2/3: key lookup.
5686 match self.ids.get(key.as_str()) {
5687 Some(id) if authz.mask.contains_id(id) => {
5688 // Visible: DuplicateKey — let MutPreview handle this.
5689 }
5690 Some(_) => {
5691 // Hidden: indistinguishable from absent to the role.
5692 return Err(GraphError::RoleWriteDenied {
5693 reason: "role-bound token: target node not visible".into(),
5694 });
5695 }
5696 None => {
5697 // Absent: proceed (create).
5698 }
5699 }
5700 }
5701
5702 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5703 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5704 if batch_created.contains_key(key.as_str()) {
5705 // Batch-created node: create gate already passed this batch.
5706 // Updating it in the same batch is always allowed, regardless
5707 // of update_labels (ruling §3.5: "writer just created it").
5708 } else {
5709 let label = match node_status(key) {
5710 NodeAuthzStatus::Visible(lbl) => lbl,
5711 _ => {
5712 return Err(GraphError::RoleWriteDenied {
5713 reason: "role-bound token: target node not visible".into(),
5714 });
5715 }
5716 };
5717 if !authz.scope.update_labels.contains(&label) {
5718 return Err(GraphError::RoleWriteDenied {
5719 reason: format!(
5720 "role-bound token: label '{}' not in write scope (update_labels)",
5721 label
5722 ),
5723 });
5724 }
5725 }
5726 }
5727
5728 // ── DELETE-class: DeleteNode ─────────────────────────────────────
5729 BatchOp::DeleteNode { key } => {
5730 let label = match node_status(key) {
5731 NodeAuthzStatus::Visible(lbl) => lbl,
5732 _ => {
5733 return Err(GraphError::RoleWriteDenied {
5734 reason: "role-bound token: target node not visible".into(),
5735 });
5736 }
5737 };
5738 if !authz.scope.delete_labels.contains(&label) {
5739 return Err(GraphError::RoleWriteDenied {
5740 reason: format!(
5741 "role-bound token: label '{}' not in write scope (delete_labels)",
5742 label
5743 ),
5744 });
5745 }
5746 }
5747
5748 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5749 //
5750 // Derived-edge rejection runs BEFORE the delete_edge_types scope
5751 // check (spec §3.5: "existing derived-edge rejection precedes
5752 // delete_edge_types check").
5753 BatchOp::DeleteEdge {
5754 edge_type,
5755 src_key,
5756 dst_key,
5757 } => {
5758 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
5759 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
5760 self.ids.get(src_key.as_str()),
5761 self.ids.get(dst_key.as_str()),
5762 self.syms.get(edge_type.as_str()),
5763 ) {
5764 if self.engine.is_owned(et_sym, src_id, dst_id) {
5765 return Err(GraphError::RuleOwned {
5766 detail: format!(
5767 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5768 delete or change the owning rule"
5769 ),
5770 });
5771 }
5772 // Also check would_derive via MutPreview (empty overlay, pre-batch).
5773 let preview = MutPreview::new(self);
5774 if preview.would_derive(edge_type, src_key, dst_key) {
5775 return Err(GraphError::RuleOwned {
5776 detail: format!(
5777 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5778 delete or change the owning rule, or a live rule would \
5779 re-derive it"
5780 ),
5781 });
5782 }
5783 }
5784 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
5785 if !authz.scope.delete_edge_types.contains(edge_type) {
5786 return Err(GraphError::RoleWriteDenied {
5787 reason: format!(
5788 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
5789 edge_type
5790 ),
5791 });
5792 }
5793 // Both endpoints must be visible.
5794 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5795 match self.ids.get(ep_key) {
5796 None => {
5797 return Err(GraphError::RoleWriteDenied {
5798 reason: "role-bound token: edge endpoint not visible".into(),
5799 });
5800 }
5801 Some(id) if !authz.mask.contains_id(id) => {
5802 return Err(GraphError::RoleWriteDenied {
5803 reason: "role-bound token: edge endpoint not visible".into(),
5804 });
5805 }
5806 _ => {}
5807 }
5808 }
5809 }
5810
5811 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
5812 //
5813 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
5814 BatchOp::InsertEdge {
5815 edge_type,
5816 src_key,
5817 dst_key,
5818 } => {
5819 if !authz.scope.create_edge_types.contains(edge_type) {
5820 return Err(GraphError::RoleWriteDenied {
5821 reason: format!(
5822 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5823 edge_type
5824 ),
5825 });
5826 }
5827 // Both endpoints must be visible. A node created by an earlier
5828 // InsertNode in the same batch (tracked in batch_created) counts
5829 // as visible if its label passed the create-class gate.
5830 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5831 if batch_created.contains_key(ep_key) {
5832 // Created earlier this batch — already scope-checked.
5833 continue;
5834 }
5835 match self.ids.get(ep_key) {
5836 None => {
5837 return Err(GraphError::RoleWriteDenied {
5838 reason: "role-bound token: edge endpoint not visible".into(),
5839 });
5840 }
5841 Some(id) if !authz.mask.contains_id(id) => {
5842 return Err(GraphError::RoleWriteDenied {
5843 reason: "role-bound token: edge endpoint not visible".into(),
5844 });
5845 }
5846 _ => {}
5847 }
5848 }
5849 }
5850
5851 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
5852 //
5853 // Scope check first; then endpoint visibility using same-batch
5854 // placeholder awareness (spec: "a placeholder endpoint the SAME
5855 // batch creates counts as visible if its label passed the
5856 // create-class gate").
5857 BatchOp::InsertEdgeUpsert {
5858 edge_type,
5859 src_key,
5860 dst_key,
5861 placeholder_label,
5862 } => {
5863 if !authz.scope.create_edge_types.contains(edge_type) {
5864 return Err(GraphError::RoleWriteDenied {
5865 reason: format!(
5866 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5867 edge_type
5868 ),
5869 });
5870 }
5871 // Check placeholder label against create_labels (create-class gate).
5872 // This ensures the auto-created endpoints are scope-allowed.
5873 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5874 if !upsert_ep_visible(ep_key, placeholder_label) {
5875 return Err(GraphError::RoleWriteDenied {
5876 reason: "role-bound token: edge endpoint not visible".into(),
5877 });
5878 }
5879 }
5880 }
5881 }
5882 Ok(())
5883 }
5884
5885 /// Write `roles` to `roles.json` atomically and update the in-memory list.
5886 ///
5887 /// Called by `apply_schema` when roles change. Never called on unchanged
5888 /// re-apply — this preserves byte-identical idempotency.
5889 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
5890 let file = RolesFile::new_versioned(roles.clone());
5891 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
5892 detail: format!("roles serialization: {e}"),
5893 })?;
5894 self.fs
5895 .write_atomic(FileId::Roles, &bytes)
5896 .map_err(GraphError::Io)?;
5897 self.roles = Some(roles);
5898 // Refresh the MVCC frozen overlay so that reader() immediately sees the
5899 // updated role definitions without waiting for the next K-commit fold.
5900 self.fold_now();
5901 Ok(())
5902 }
5903
5904 fn view(&self) -> GraphView<'_> {
5905 GraphView {
5906 ids: &self.ids,
5907 syms: &self.syms,
5908 labels: &self.labels,
5909 props: self.props_view(),
5910 topo: self.topo_view(),
5911 edge_props: self.edge_props_view(),
5912 mask: None,
5913 prop_index: Some(&self.prop_index),
5914 }
5915 }
5916
5917 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
5918 GraphView {
5919 ids: &self.ids,
5920 syms: &self.syms,
5921 labels: &self.labels,
5922 props: self.props_view(),
5923 topo: self.topo_view(),
5924 edge_props: self.edge_props_view(),
5925 mask: Some(&mask.visible),
5926 prop_index: Some(&self.prop_index),
5927 }
5928 }
5929
5930 /// Execute a read-only Cypher query with a node visibility mask.
5931 ///
5932 /// Only nodes whose key is in `mask` are accessible: label scans, key
5933 /// lookups, and neighbor expansions all respect the mask. Edges where
5934 /// either endpoint is hidden are silently dropped.
5935 ///
5936 /// Returns `Err` with a "masked queries are read-only" message when
5937 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
5938 pub fn query_masked(
5939 &self,
5940 cypher: &str,
5941 params: &std::collections::BTreeMap<String, Value>,
5942 mask: &crate::mask::NodeMask,
5943 ) -> Result<ResultSet> {
5944 // Reject write statements up front.
5945 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5946 detail: format!("lex: {e}"),
5947 })?;
5948 if is_write_tokens(&tokens) {
5949 return Err(GraphError::MaskedReadOnly);
5950 }
5951 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
5952 detail: format!("parse: {e}"),
5953 })?;
5954 // Each UNION part executes against the same masked view, so the mask
5955 // applies uniformly across the chain.
5956 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
5957 GraphError::QueryError {
5958 detail: format!("execute: {e}"),
5959 }
5960 })
5961 }
5962
5963 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
5964 let id = self.ids.get(key)?;
5965 Some(NodeRef { db: self, id })
5966 }
5967
5968 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
5969 ///
5970 /// Hidden nodes are never used as traversal intermediaries in either
5971 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
5972 /// only through a hidden node will not appear in results.
5973 ///
5974 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
5975 /// a visited visible node are appended to the result as stub rows
5976 /// (`label` column is `null`, same key+depth columns as visible rows).
5977 /// They are NOT added to the BFS frontier.
5978 ///
5979 /// Returns `None` when `key` does not exist (caller should 404).
5980 ///
5981 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
5982 /// stub rows are never produced on the role path.
5983 pub fn neighborhood_masked(
5984 &self,
5985 key: &str,
5986 depth: u32,
5987 edge_types: Option<&[&str]>,
5988 dir: Dir,
5989 mask: &crate::mask::NodeMask,
5990 ) -> Option<ResultSet> {
5991 let start_id = self.ids.get(key)?;
5992 let view = self.view_masked(mask);
5993 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5994 names
5995 .iter()
5996 .filter_map(|name| view.syms.get(name))
5997 .collect()
5998 });
5999 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6000 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6001 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6002 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6003 visited.push((start_id, 0));
6004 for (nid, d) in &nb.nodes {
6005 let k = view.key_of(*nid);
6006 let label = view
6007 .label_of(*nid)
6008 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6009 rs.push_row(vec![
6010 Some(Value::Str(k.to_string())),
6011 Some(Value::Str(label.to_string())),
6012 Some(Value::Int(*d as i64)),
6013 ]);
6014 visited.push((*nid, *d));
6015 }
6016 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6017 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6018 // frontier, so the BFS never expands through them.
6019 if mask.mode() == crate::mask::MaskMode::Stub {
6020 let raw_view = self.view();
6021 let mut seen: std::collections::HashSet<u32> =
6022 visited.iter().map(|(id, _)| *id).collect();
6023 for (node_id, node_depth) in &visited {
6024 if *node_depth >= depth {
6025 continue;
6026 }
6027 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6028 let nbr = if e.src == *node_id { e.dst } else { e.src };
6029 if !mask.contains_id(nbr) && seen.insert(nbr) {
6030 if let Some(k) = self.ids.key_of(nbr) {
6031 rs.push_row(vec![
6032 Some(Value::Str(k.to_string())),
6033 None,
6034 Some(Value::Int((*node_depth + 1) as i64)),
6035 ]);
6036 }
6037 }
6038 }
6039 }
6040 }
6041 Some(rs)
6042 }
6043
6044 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6045 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6046 let n = self.node_ref(key)?;
6047 Some(NodeInfo {
6048 key: n.key().to_string(),
6049 label: n.label().to_string(),
6050 props: n.props(),
6051 })
6052 }
6053
6054 /// Look up a node with mask awareness.
6055 ///
6056 /// | Key state | Omit mode | Stub mode |
6057 /// |-------------------|-----------------|------------------------|
6058 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6059 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6060 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6061 ///
6062 /// **SECURITY**: only call from client-mask (full-token) paths.
6063 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6064 pub fn node_info_masked(
6065 &self,
6066 key: &str,
6067 mask: &crate::mask::NodeMask,
6068 ) -> Option<MaskedNodeResult> {
6069 let id = self.ids.get(key)?;
6070 if mask.contains_id(id) {
6071 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6072 } else {
6073 match mask.mode() {
6074 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6075 crate::mask::MaskMode::Omit => None,
6076 }
6077 }
6078 }
6079
6080 /// Get edges for `key` with mask-aware hidden-endpoint handling.
6081 ///
6082 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6083 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6084 /// is `true` for each hidden endpoint.
6085 ///
6086 /// Unknown key → [`GraphError::KeyNotFound`].
6087 ///
6088 /// **SECURITY**: only call from client-mask (full-token) paths.
6089 pub fn node_edges_masked(
6090 &self,
6091 key: &str,
6092 mask: &crate::mask::NodeMask,
6093 ) -> Result<Vec<MaskedEdge>> {
6094 self.ensure_v8_base_sections_loaded();
6095 let id = self
6096 .ids
6097 .get(key)
6098 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6099 let derived: BTreeSet<(u32, u32, u32)> = self
6100 .engine
6101 .provenance_touching(id)
6102 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6103 .collect();
6104 let mut edges = Vec::new();
6105 let tv = self.topo_view();
6106 for etype in tv.etypes() {
6107 // etype comes from the archived CSR (access_unchecked, no eager CRC).
6108 // A bit-flip in the large TOPOLOGY section can produce an etype id
6109 // that is not in the interner. Return Corrupt rather than panic.
6110 let edge_type = self
6111 .syms
6112 .resolve(etype)
6113 .ok_or_else(|| GraphError::Corrupt {
6114 detail: format!("v8: topology etype {etype} not in interner"),
6115 })?
6116 .to_string();
6117 for dir in [Direction::Out, Direction::In] {
6118 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6119 let nbr_restricted = !mask.contains_id(nbr);
6120 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6121 continue;
6122 }
6123 let nbr_key = self
6124 .ids
6125 .key_of(nbr)
6126 .ok_or_else(|| GraphError::Corrupt {
6127 detail: format!("topology id {nbr} has no key"),
6128 })?
6129 .to_string();
6130 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6131 match dir {
6132 Direction::Out => {
6133 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6134 }
6135 Direction::In => {
6136 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6137 }
6138 };
6139 edges.push(MaskedEdge {
6140 edge_type: edge_type.clone(),
6141 src_key,
6142 src_restricted,
6143 dst_key,
6144 dst_restricted,
6145 derived: derived.contains(&(etype, src_id, dst_id)),
6146 });
6147 }
6148 }
6149 }
6150 edges.sort_by(|a, b| {
6151 a.edge_type
6152 .cmp(&b.edge_type)
6153 .then(a.src_key.cmp(&b.src_key))
6154 .then(a.dst_key.cmp(&b.dst_key))
6155 });
6156 edges.dedup_by(|a, b| {
6157 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6158 });
6159 Ok(edges)
6160 }
6161
6162 /// Every directed edge incident on `key`, both directions, every etype.
6163 ///
6164 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6165 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6166 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6167 /// Unknown key → [`GraphError::KeyNotFound`].
6168 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6169 self.ensure_v8_base_sections_loaded();
6170 let id = self
6171 .ids
6172 .get(key)
6173 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6174 let derived: BTreeSet<(u32, u32, u32)> = self
6175 .engine
6176 .provenance_touching(id)
6177 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6178 .collect();
6179 let mut edges = Vec::new();
6180 let tv = self.topo_view();
6181 for etype in tv.etypes() {
6182 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6183 let edge_type = self
6184 .syms
6185 .resolve(etype)
6186 .ok_or_else(|| GraphError::Corrupt {
6187 detail: format!("v8: topology etype {etype} not in interner"),
6188 })?
6189 .to_string();
6190 for dir in [Direction::Out, Direction::In] {
6191 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6192 let (src, dst, src_key, dst_key) = match dir {
6193 Direction::Out => (
6194 id,
6195 nbr,
6196 key.to_string(),
6197 self.ids
6198 .key_of(nbr)
6199 .ok_or_else(|| GraphError::Corrupt {
6200 detail: format!("topology id {nbr} has no key"),
6201 })?
6202 .to_string(),
6203 ),
6204 Direction::In => (
6205 nbr,
6206 id,
6207 self.ids
6208 .key_of(nbr)
6209 .ok_or_else(|| GraphError::Corrupt {
6210 detail: format!("topology id {nbr} has no key"),
6211 })?
6212 .to_string(),
6213 key.to_string(),
6214 ),
6215 };
6216 edges.push(EdgeInfo {
6217 edge_type: edge_type.clone(),
6218 src_key,
6219 dst_key,
6220 derived: derived.contains(&(etype, src, dst)),
6221 });
6222 }
6223 }
6224 }
6225 edges.sort_by(|a, b| {
6226 a.edge_type
6227 .cmp(&b.edge_type)
6228 .then(a.src_key.cmp(&b.src_key))
6229 .then(a.dst_key.cmp(&b.dst_key))
6230 });
6231 // Self-loops appear in both Out and In; sort makes the pair adjacent
6232 // (sort key matches PartialEq for this case) so one pass drops the dup.
6233 edges.dedup();
6234 Ok(edges)
6235 }
6236
6237 // ── Backup ────────────────────────────────────────────────────────────────
6238
6239 /// Copy this store to `dest` as a consistent, verified snapshot.
6240 ///
6241 /// Copies every durable file in the database directory — `snapshot.bin`,
6242 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6243 /// `roles.json` — into a freshly created `dest` directory using OS-level
6244 /// `copy` calls (no large in-process buffers).
6245 ///
6246 /// # Consistency guarantee
6247 ///
6248 /// The guarantee is **process-local**: the caller holds `&self`, which
6249 /// prevents any concurrent writer in the **same process** from modifying
6250 /// the files during the copy. Running `mushroomdb backup` against a
6251 /// directory that is **concurrently being written by another process** (e.g.
6252 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
6253 /// `verified: true` result reduces but does not eliminate the risk of a
6254 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6255 /// consistent mid-write snapshot).
6256 ///
6257 /// **The safe path for a live-served store is `POST /backup` on the HTTP
6258 /// server.** That handler acquires the read lock on the shared database
6259 /// before calling this method, which is the correct cross-process
6260 /// synchronisation point because the server is the single process writing
6261 /// the files.
6262 ///
6263 /// After copying, opens the destination read-only and runs the CRC section
6264 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6265 /// `BackupReport::verified` reflects whether both checks passed.
6266 ///
6267 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6268 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6269 // Derive source directory from snapshot_path (RealFs only).
6270 let src_dir = match self.fs.snapshot_path() {
6271 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6272 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6273 })?,
6274 None => {
6275 return Err(GraphError::Io(std::io::Error::other(
6276 "backup_to requires a real filesystem (RealFs)",
6277 )))
6278 }
6279 };
6280
6281 std::fs::create_dir_all(dest)?;
6282
6283 let mut files: Vec<String> = Vec::new();
6284 let mut bytes: u64 = 0;
6285
6286 // Helper: copy src_dir/name → dest/name if the file exists.
6287 let mut try_copy = |name: &str| -> std::io::Result<()> {
6288 let src_path = src_dir.join(name);
6289 if src_path.exists() {
6290 let n = std::fs::copy(&src_path, dest.join(name))?;
6291 bytes += n;
6292 files.push(name.to_string());
6293 }
6294 Ok(())
6295 };
6296
6297 try_copy("snapshot.bin")?;
6298 try_copy("snapshot.bin.bak")?;
6299 try_copy("wal.bin")?;
6300 try_copy("wal.floor")?;
6301 try_copy("wal.genesis")?;
6302 try_copy("roles.json")?;
6303
6304 // Copy WAL archives.
6305 let archives = self.fs.list_archives()?;
6306 for n in &archives {
6307 let name = format!("wal.{n}.archive");
6308 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6309 bytes += n_bytes;
6310 files.push(name);
6311 }
6312
6313 files.sort();
6314
6315 // Post-copy verification: open dest and run CRC checks.
6316 let snap_in_dest = dest.join("snapshot.bin").exists();
6317 let crc_ok = if snap_in_dest {
6318 crate::verify_snapshot(dest)
6319 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6320 .unwrap_or(false)
6321 } else {
6322 true // WAL-only store: nothing to CRC-check in snapshot
6323 };
6324 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6325 let verified = crc_ok && opens_ok;
6326
6327 Ok(BackupReport {
6328 files,
6329 bytes,
6330 verified,
6331 })
6332 }
6333
6334 // ── Export helpers ────────────────────────────────────────────────────────
6335
6336 /// All live nodes, sorted by key (deterministic).
6337 ///
6338 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6339 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6340 self.ensure_v8_base_sections_loaded();
6341 let pv = self.props_view();
6342 let mut nodes = Vec::new();
6343 for id in 0..self.ids.len() as u32 {
6344 let Some(key) = self.ids.key_of(id) else {
6345 continue;
6346 };
6347 let Some(&sym) = self.labels.get(id as usize) else {
6348 continue;
6349 };
6350 if sym == u32::MAX {
6351 continue; // tombstoned
6352 }
6353 let Some(label) = self.syms.resolve(sym) else {
6354 continue;
6355 };
6356 let mut props = BTreeMap::new();
6357 for field in pv.field_names() {
6358 if let Some(vr) = pv.get(id, &field) {
6359 props.insert(field, vr.into_value());
6360 }
6361 }
6362 nodes.push(NodeInfo {
6363 key: key.to_string(),
6364 label: label.to_string(),
6365 props,
6366 });
6367 }
6368 nodes.sort_by(|a, b| a.key.cmp(&b.key));
6369 nodes
6370 }
6371
6372 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6373 ///
6374 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6375 /// Manual edges carry `derived: false` and `rule: None`.
6376 /// Deterministic across runs on the same store state.
6377 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6378 self.ensure_v8_base_sections_loaded();
6379
6380 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6381 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6382 for (rule_name, triples) in self.engine.provenance() {
6383 for &(etype, src, dst) in triples {
6384 prov.insert((etype, src, dst), rule_name.clone());
6385 }
6386 }
6387
6388 let tv = self.topo_view();
6389 let mut edges = Vec::new();
6390
6391 for id in 0..self.ids.len() as u32 {
6392 let Some(key) = self.ids.key_of(id) else {
6393 continue;
6394 };
6395 let Some(&lsym) = self.labels.get(id as usize) else {
6396 continue;
6397 };
6398 if lsym == u32::MAX {
6399 continue; // tombstoned
6400 }
6401
6402 for etype_sym in tv.etypes() {
6403 // etype from archived CSR (access_unchecked, no eager CRC).
6404 // Skip edges whose etype is not in the interner; this can only
6405 // occur with a corrupt large TOPOLOGY section (bit-flip on an
6406 // etype field in the archived data). The function returns Vec,
6407 // not Result, so we continue rather than propagate.
6408 let Some(edge_type) = self.syms.resolve(etype_sym) else {
6409 continue;
6410 };
6411 let edge_type = edge_type.to_string();
6412 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6413 let Some(dst_key) = self.ids.key_of(nbr) else {
6414 continue; // skip corrupt entries
6415 };
6416 let prov_key = (etype_sym, id, nbr);
6417 let rule = prov.get(&prov_key).cloned();
6418 let derived = rule.is_some();
6419 edges.push(ExportEdge {
6420 edge_type: edge_type.clone(),
6421 src: key.to_string(),
6422 dst: dst_key.to_string(),
6423 derived,
6424 rule,
6425 });
6426 }
6427 }
6428 }
6429
6430 edges.sort_by(|a, b| {
6431 a.edge_type
6432 .cmp(&b.edge_type)
6433 .then(a.src.cmp(&b.src))
6434 .then(a.dst.cmp(&b.dst))
6435 });
6436 edges
6437 }
6438
6439 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6440 self.view()
6441 .nodes_with_label(label)
6442 .into_iter()
6443 .map(|id| NodeRef { db: self, id })
6444 .collect()
6445 }
6446
6447 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6448 let view = self.view();
6449 view.nodes_with_label(label)
6450 .into_iter()
6451 .filter(|&id| {
6452 eval_filter(filter, &|field| {
6453 view.prop(id, field).map(|vr| vr.into_value())
6454 })
6455 })
6456 .map(|id| NodeRef { db: self, id })
6457 .collect()
6458 }
6459
6460 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6461 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
6462 /// with `label = None` will use the native ANN path rather than the O(n)
6463 /// brute-force scan.
6464 pub fn has_vector_rule(&self, field: &str) -> bool {
6465 self.engine.hnsw_has_rule(field)
6466 }
6467
6468 /// Find nodes whose `field` vector is most similar to `q` (cosine
6469 /// similarity), returning up to `k` results with similarity ≥ `min`,
6470 /// sorted descending.
6471 ///
6472 /// When `label` is `None` the search spans all labels (via
6473 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6474 /// `Some(lbl)` it restricts to nodes with that label.
6475 ///
6476 /// Uses the HNSW index when one is available (fast path); otherwise falls
6477 /// back to an O(n) brute-force scan.
6478 pub fn find_similar_vector(
6479 &self,
6480 field: &str,
6481 label: Option<&str>,
6482 q: &[f64],
6483 k: usize,
6484 min: f64,
6485 ) -> Vec<(String, f64)> {
6486 // Ensure any HNSW blobs retained from the snapshot are deserialized
6487 // before the first ANN query on a clean-open (no-WAL) path.
6488 self.engine.ensure_hnsw_loaded();
6489 // L2-normalise query for cosine via dot product.
6490 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6491 if norm == 0.0 {
6492 return vec![];
6493 }
6494 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6495
6496 // Try HNSW fast path.
6497 // `None` label searches across all VectorSimilar rules covering `field`
6498 // (merging their results); `Some(lbl)` restricts to rules whose
6499 // dst_label matches. Returns `None` when no populated HNSW index
6500 // covers the request — the O(n) brute-force fallback handles that case.
6501 let hnsw_hits = match label {
6502 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6503 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6504 };
6505 if let Some(hits) = hnsw_hits {
6506 let mut out: Vec<(String, f64)> = hits
6507 .into_iter()
6508 .filter(|&(_, sim)| sim >= min)
6509 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6510 .collect();
6511 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6512 out.truncate(k);
6513 return out;
6514 }
6515
6516 // Brute-force fallback: O(n) scan (only reached when no HNSW index
6517 // covers the request).
6518 let view = self.view();
6519 let candidate_ids: Vec<u32> = match label {
6520 Some(lbl) => view.nodes_with_label(lbl),
6521 None => view.nodes_all(),
6522 };
6523 let mut scored: Vec<(String, f64)> = candidate_ids
6524 .into_iter()
6525 .filter_map(|id| {
6526 let v = view.prop(id, field)?;
6527 let v_owned = v.into_value();
6528 let xs = value_as_float_list(&v_owned)?;
6529 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6530 if v_norm == 0.0 {
6531 return None;
6532 }
6533 let dot: f64 = q_unit
6534 .iter()
6535 .zip(xs.iter())
6536 .map(|(a, b)| a * (b / v_norm))
6537 .sum();
6538 if dot < min {
6539 return None;
6540 }
6541 let key = self.ids.key_of(id)?.to_string();
6542 Some((key, dot))
6543 })
6544 .collect();
6545 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6546 scored.truncate(k);
6547 scored
6548 }
6549
6550 /// Like [`find_similar_vector`] but restricts results to nodes visible in
6551 /// `mask`. Hidden nodes never appear in results; the mask is applied
6552 /// **before** k-truncation so a caller still receives up to `k` visible
6553 /// hits.
6554 ///
6555 /// # HNSW path (over-fetch policy)
6556 ///
6557 /// When an HNSW index covers the request, this function fetches `4 * k`
6558 /// candidates from the index and discards hidden nodes in the post-filter
6559 /// step. If fewer than `k` visible nodes remain after filtering the caller
6560 /// receives whatever is available — we do not re-query the index. The 4×
6561 /// multiplier is a heuristic suited for sparsely masked graphs; callers
6562 /// operating under a very selective mask should register a VectorSimilar
6563 /// rule with a non-approximate index, or use the brute-force path (no HNSW
6564 /// rule) which exhaustively filters through the masked [`GraphView`].
6565 ///
6566 /// # Brute-force path
6567 ///
6568 /// When no HNSW index covers the request the function builds a masked
6569 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6570 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6571 /// fewer than `k` exist).
6572 pub fn find_similar_vector_masked(
6573 &self,
6574 field: &str,
6575 label: Option<&str>,
6576 q: &[f64],
6577 k: usize,
6578 min: f64,
6579 mask: &crate::mask::NodeMask,
6580 ) -> Vec<(String, f64)> {
6581 self.engine.ensure_hnsw_loaded();
6582 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6583 if norm == 0.0 {
6584 return vec![];
6585 }
6586 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6587
6588 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6589 // visible hits. See doc comment above for the policy rationale.
6590 let over_k = k.saturating_mul(4).max(k + 1);
6591 let hnsw_hits = match label {
6592 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6593 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6594 };
6595 if let Some(hits) = hnsw_hits {
6596 let mut out: Vec<(String, f64)> = hits
6597 .into_iter()
6598 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6599 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6600 .collect();
6601 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6602 out.truncate(k);
6603 return out;
6604 }
6605
6606 // Brute-force fallback — masked view ensures only visible nodes are
6607 // enumerated by nodes_all(); nodes_with_label() does not filter by
6608 // mask so we apply view.visible() explicitly for the labeled case.
6609 let view = self.view_masked(mask);
6610 let candidate_ids: Vec<u32> = match label {
6611 Some(lbl) => view
6612 .nodes_with_label(lbl)
6613 .into_iter()
6614 .filter(|&id| view.visible(id))
6615 .collect(),
6616 None => view.nodes_all(),
6617 };
6618 let mut scored: Vec<(String, f64)> = candidate_ids
6619 .into_iter()
6620 .filter_map(|id| {
6621 let v = view.prop(id, field)?;
6622 let v_owned = v.into_value();
6623 let xs = value_as_float_list(&v_owned)?;
6624 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6625 if v_norm == 0.0 {
6626 return None;
6627 }
6628 let dot: f64 = q_unit
6629 .iter()
6630 .zip(xs.iter())
6631 .map(|(a, b)| a * (b / v_norm))
6632 .sum();
6633 if dot < min {
6634 return None;
6635 }
6636 let key = self.ids.key_of(id)?.to_string();
6637 Some((key, dot))
6638 })
6639 .collect();
6640 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6641 scored.truncate(k);
6642 scored
6643 }
6644
6645 /// Read a single property from an edge.
6646 ///
6647 /// Returns `None` when the edge does not exist, the field is absent, or any
6648 /// of the string keys cannot be resolved to interned ids. Only edge props
6649 /// written by rules (weight fields) are accessible without a `set_edge_prop`
6650 /// binding; topology-only edges (no props set) return `None` for every field.
6651 pub fn get_edge_prop(
6652 &self,
6653 edge_type: &str,
6654 src_key: &str,
6655 dst_key: &str,
6656 field: &str,
6657 ) -> Option<Value> {
6658 let etype = self.syms.get(edge_type)?;
6659 let src = self.ids.get(src_key)?;
6660 let dst = self.ids.get(dst_key)?;
6661 self.edge_props_view().get(etype, src, dst, field)
6662 }
6663
6664 /// Lex → parse → plan → execute `cypher` over a read-only view.
6665 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6666 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6667 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6668 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6669 detail: format!("lex: {e}"),
6670 })?;
6671 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6672 detail: format!("parse: {e}"),
6673 })?;
6674 execute_union(&self.view(), &union, &Params(params)).map_err(|e| GraphError::QueryError {
6675 detail: format!("execute: {e}"),
6676 })
6677 }
6678
6679 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6680 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
6681 /// calling [`GraphDb::query`].
6682 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6683 let map: BTreeMap<String, Value> = params
6684 .iter()
6685 .map(|(k, v)| (k.to_string(), v.clone()))
6686 .collect();
6687 self.query(cypher, &map)
6688 }
6689
6690 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6691 ///
6692 /// All mutations flow through the same `insert_node` / `set_prop` /
6693 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6694 /// fires and the WAL captures everything with one fsync per statement.
6695 ///
6696 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6697 /// and `deleted` matching the write-result contract.
6698 ///
6699 /// **Mutation routing**: mutations are collected into a single
6700 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6701 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6702 /// over `self.view()` — the borrow is dropped before the batch is opened.
6703 ///
6704 /// **Limitations (v1)**:
6705 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6706 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6707 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6708 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6709 /// - Deleting a derived edge → named error "cannot delete derived edge".
6710 pub fn query_write(
6711 &mut self,
6712 cypher: &str,
6713 params: &BTreeMap<String, Value>,
6714 ) -> Result<ResultSet> {
6715 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6716 detail: format!("lex: {e}"),
6717 })?;
6718 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6719 detail: format!("parse: {e}"),
6720 })?;
6721 self.exec_write_stmt(stmt, params)
6722 }
6723
6724 fn exec_write_stmt(
6725 &mut self,
6726 stmt: WriteStatement,
6727 params: &BTreeMap<String, Value>,
6728 ) -> Result<ResultSet> {
6729 match stmt {
6730 WriteStatement::Create(s) => self.exec_create(s, params),
6731 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
6732 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
6733 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
6734 WriteStatement::Merge(s) => self.exec_merge(s, params),
6735 }
6736 }
6737
6738 fn exec_create(
6739 &mut self,
6740 stmt: core_query::cypher::CreateStmt,
6741 params: &BTreeMap<String, Value>,
6742 ) -> Result<ResultSet> {
6743 // Extract the node key from props: require a string-valued `id` field.
6744 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
6745 for node in &stmt.nodes {
6746 let var = node.var.as_deref().unwrap_or("_cn0");
6747 let key = node
6748 .props
6749 .iter()
6750 .find(|(f, _)| f == "id")
6751 .and_then(|(_, v)| {
6752 if let Value::Str(s) = v {
6753 Some(s.clone())
6754 } else {
6755 None
6756 }
6757 })
6758 .ok_or_else(|| GraphError::QueryError {
6759 detail: format!(
6760 "CREATE node ({}:{}) requires a string 'id' property",
6761 var, node.label
6762 ),
6763 })?;
6764 var_to_key.insert(var.to_string(), key);
6765 }
6766
6767 let mut batch = self.batch();
6768 let mut created: usize = 0;
6769 for node in &stmt.nodes {
6770 let var = node.var.as_deref().unwrap_or("_cn0");
6771 let key = &var_to_key[var];
6772 batch.insert_node(&node.label, key, node.props.clone());
6773 created += 1;
6774 }
6775 for edge in &stmt.edges {
6776 let src_key = var_to_key
6777 .get(&edge.src_var)
6778 .ok_or_else(|| GraphError::QueryError {
6779 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
6780 })?;
6781 let dst_key = var_to_key
6782 .get(&edge.dst_var)
6783 .ok_or_else(|| GraphError::QueryError {
6784 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
6785 })?;
6786 batch.insert_edge(&edge.etype, src_key, dst_key);
6787 }
6788 batch.commit()?;
6789
6790 // Optional RETURN clause: project created bindings as a read result.
6791 if let Some(returns) = stmt.returns {
6792 // Each created node is looked up by its key via a separate MATCH pattern.
6793 // Multiple single-node patterns cross-join to produce 1 output row with
6794 // all variables bound (each pattern returns exactly 1 row).
6795 let patterns: Vec<Pattern> = stmt
6796 .nodes
6797 .iter()
6798 .map(|node| {
6799 let var = node.var.as_deref().unwrap_or("_cn0");
6800 let key = var_to_key[var].clone();
6801 Pattern {
6802 start: NodePat {
6803 var: Some(var.to_string()),
6804 label: Some(node.label.clone()),
6805 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
6806 },
6807 chain: vec![],
6808 shortest: false,
6809 }
6810 })
6811 .collect();
6812 let q = Query {
6813 matches: patterns,
6814 optional_clauses: vec![],
6815 where_expr: None,
6816 unwinds: vec![],
6817 post_unwind_where: None,
6818 stages: vec![],
6819 returns,
6820 distinct: false,
6821 order_by: vec![],
6822 skip: None,
6823 limit: None,
6824 };
6825 let ops = plan(&q).map_err(|e| GraphError::QueryError {
6826 detail: format!("plan: {e}"),
6827 })?;
6828 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
6829 GraphError::QueryError {
6830 detail: format!("execute: {e}"),
6831 }
6832 });
6833 }
6834
6835 let mut rs = write_result_set();
6836 rs.push_row(vec![
6837 Some(Value::Int(created as i64)),
6838 Some(Value::Int(0)),
6839 Some(Value::Int(0)),
6840 ]);
6841 Ok(rs)
6842 }
6843
6844 fn exec_match_set(
6845 &mut self,
6846 stmt: core_query::cypher::MatchSetStmt,
6847 params: &BTreeMap<String, Value>,
6848 ) -> Result<ResultSet> {
6849 let project_returns = stmt.returns.clone();
6850 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
6851 // so the post-write projection can look them up by key.
6852 let mut set_vars: Vec<String> = Vec::new();
6853 for s in &stmt.sets {
6854 if !set_vars.contains(&s.var) {
6855 set_vars.push(s.var.clone());
6856 }
6857 }
6858 let rel_vars = pattern_rel_vars(&stmt.matches);
6859 let mut lookup_vars = set_vars.clone();
6860 for v in pattern_node_vars(&stmt.matches) {
6861 add_var(&mut lookup_vars, &v);
6862 }
6863 if let Some(ref returns) = project_returns {
6864 for v in ret_node_vars(returns) {
6865 if !rel_vars.iter().any(|r| r == &v) {
6866 add_var(&mut lookup_vars, &v);
6867 }
6868 }
6869 }
6870
6871 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
6872 // SET values are projected as ScalarExpr items so that arithmetic expressions
6873 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
6874 let mut set_returns: Vec<RetItem> = lookup_vars
6875 .iter()
6876 .map(|v| RetItem {
6877 value: RetVal::Var(v.clone()),
6878 alias: None,
6879 })
6880 .collect();
6881 // One computed column per SET clause; alias is `__sv_<i>`.
6882 let set_val_cols: Vec<String> = stmt
6883 .sets
6884 .iter()
6885 .enumerate()
6886 .map(|(i, _)| format!("__sv_{i}"))
6887 .collect();
6888 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6889 set_returns.push(RetItem {
6890 value: RetVal::ScalarExpr(sc.value.clone()),
6891 alias: Some(col.clone()),
6892 });
6893 }
6894 // Capture relationship types while r is bound; SET does not change them.
6895 for r in &rel_vars {
6896 set_returns.push(RetItem {
6897 value: RetVal::FuncCall {
6898 name: "type".into(),
6899 args: vec![Operand::Var(r.clone())],
6900 },
6901 alias: Some(rel_type_alias(r)),
6902 });
6903 }
6904
6905 let read_q = Query {
6906 matches: stmt.matches.clone(),
6907 optional_clauses: vec![],
6908 where_expr: stmt.where_expr.clone(),
6909 unwinds: vec![],
6910 post_unwind_where: None,
6911 stages: vec![],
6912 returns: set_returns,
6913 distinct: false,
6914 order_by: vec![],
6915 skip: None,
6916 limit: None,
6917 };
6918 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6919 detail: format!("plan: {e}"),
6920 })?;
6921 // MATCH phase is read-only; borrow ends before batch opens.
6922 //
6923 // When a role-scoped write is in flight, run the MATCH read through
6924 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
6925 // zero-rows (no SetProp ops generated, no existence-oracle 403).
6926 // Full-authority writes (pending_write_authz=None) keep view().
6927 let match_rs = {
6928 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6929 if let Some(ref mask) = mask_opt {
6930 execute(&self.view_masked(mask), &ops, &Params(params))
6931 } else {
6932 execute(&self.view(), &ops, &Params(params))
6933 }
6934 }
6935 .map_err(|e| GraphError::QueryError {
6936 detail: format!("execute: {e}"),
6937 })?;
6938
6939 // Collect (key, field, value) for each matched row × each SET clause.
6940 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
6941 for row_i in 0..match_rs.len() {
6942 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6943 let key = match match_rs.get(row_i, &sc.var) {
6944 Some(Value::Str(k)) => k.clone(),
6945 _ => {
6946 return Err(GraphError::QueryError {
6947 detail: format!(
6948 "SET variable '{}' did not resolve to a node key",
6949 sc.var
6950 ),
6951 })
6952 }
6953 };
6954 // The SET value was already evaluated by the executor.
6955 let value = match match_rs.get(row_i, col) {
6956 Some(v) => v.clone(),
6957 None => {
6958 return Err(GraphError::QueryError {
6959 detail: format!(
6960 "SET value for {}.{} evaluated to null",
6961 sc.var, sc.field
6962 ),
6963 })
6964 }
6965 };
6966 set_ops.push((key, sc.field.clone(), value));
6967 }
6968 }
6969
6970 // Apply as one atomic batch.
6971 let props_set = set_ops.len();
6972 let mut batch = self.batch();
6973 for (key, field, value) in set_ops {
6974 batch.set_prop(&key, &field, value);
6975 }
6976 batch.commit()?;
6977
6978 if let Some(returns) = project_returns {
6979 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
6980 }
6981
6982 let mut rs = write_result_set();
6983 rs.push_row(vec![
6984 Some(Value::Int(0)),
6985 Some(Value::Int(props_set as i64)),
6986 Some(Value::Int(0)),
6987 ]);
6988 Ok(rs)
6989 }
6990
6991 fn exec_match_delete(
6992 &mut self,
6993 stmt: core_query::cypher::MatchDeleteStmt,
6994 params: &BTreeMap<String, Value>,
6995 ) -> Result<ResultSet> {
6996 // Collect unique node vars needed to identify edge endpoints.
6997 let mut node_vars: Vec<String> = Vec::new();
6998 for ed in &stmt.deletes {
6999 if !node_vars.contains(&ed.src_var) {
7000 node_vars.push(ed.src_var.clone());
7001 }
7002 if !node_vars.contains(&ed.dst_var) {
7003 node_vars.push(ed.dst_var.clone());
7004 }
7005 }
7006
7007 // Synthesize read query.
7008 let returns: Vec<RetItem> = node_vars
7009 .iter()
7010 .map(|v| RetItem {
7011 value: RetVal::Var(v.clone()),
7012 alias: None,
7013 })
7014 .collect();
7015 let read_q = Query {
7016 matches: stmt.matches,
7017 optional_clauses: vec![],
7018 where_expr: stmt.where_expr,
7019 unwinds: vec![],
7020 post_unwind_where: None,
7021 stages: vec![],
7022 returns,
7023 distinct: false,
7024 order_by: vec![],
7025 skip: None,
7026 limit: None,
7027 };
7028 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7029 detail: format!("plan: {e}"),
7030 })?;
7031 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7032 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7033 let match_rs = {
7034 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7035 if let Some(ref mask) = mask_opt {
7036 execute(&self.view_masked(mask), &ops, &Params(params))
7037 } else {
7038 execute(&self.view(), &ops, &Params(params))
7039 }
7040 }
7041 .map_err(|e| GraphError::QueryError {
7042 detail: format!("execute: {e}"),
7043 })?;
7044
7045 // Collect (etype, src_key, dst_key) for each row × each delete target.
7046 let mut del_ops: Vec<(String, String, String)> = Vec::new();
7047 for row_i in 0..match_rs.len() {
7048 for ed in &stmt.deletes {
7049 let src_key = match match_rs.get(row_i, &ed.src_var) {
7050 Some(Value::Str(k)) => k.clone(),
7051 _ => {
7052 return Err(GraphError::QueryError {
7053 detail: format!(
7054 "DELETE src variable '{}' did not resolve to a node key",
7055 ed.src_var
7056 ),
7057 })
7058 }
7059 };
7060 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7061 Some(Value::Str(k)) => k.clone(),
7062 _ => {
7063 return Err(GraphError::QueryError {
7064 detail: format!(
7065 "DELETE dst variable '{}' did not resolve to a node key",
7066 ed.dst_var
7067 ),
7068 })
7069 }
7070 };
7071 del_ops.push((ed.etype.clone(), src_key, dst_key));
7072 }
7073 }
7074
7075 // Apply as one atomic batch.
7076 let deleted = del_ops.len();
7077 let mut batch = self.batch();
7078 for (etype, src_key, dst_key) in del_ops {
7079 batch.delete_edge(&etype, &src_key, &dst_key);
7080 }
7081 batch.commit().map_err(|e| match e {
7082 GraphError::RuleOwned { .. } => GraphError::QueryError {
7083 detail: "cannot delete derived edge; retract via the rule or change the property"
7084 .to_string(),
7085 },
7086 other => other,
7087 })?;
7088
7089 let mut rs = write_result_set();
7090 rs.push_row(vec![
7091 Some(Value::Int(0)),
7092 Some(Value::Int(0)),
7093 Some(Value::Int(deleted as i64)),
7094 ]);
7095 Ok(rs)
7096 }
7097
7098 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7099 ///
7100 /// Collects the matching node keys via an ephemeral read query, then calls
7101 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
7102 /// the executor first checks that the node has no incident edges; if any
7103 /// remain it returns a named error matching openCypher semantics.
7104 fn exec_match_delete_node(
7105 &mut self,
7106 stmt: MatchDeleteNodeStmt,
7107 params: &BTreeMap<String, Value>,
7108 ) -> Result<ResultSet> {
7109 // Build a read query returning only the node keys we need.
7110 let returns: Vec<RetItem> = stmt
7111 .node_vars
7112 .iter()
7113 .map(|v| RetItem {
7114 value: RetVal::Var(v.clone()),
7115 alias: None,
7116 })
7117 .collect();
7118 let read_q = Query {
7119 matches: stmt.matches,
7120 optional_clauses: vec![],
7121 where_expr: stmt.where_expr,
7122 unwinds: vec![],
7123 post_unwind_where: None,
7124 stages: vec![],
7125 returns,
7126 distinct: false,
7127 order_by: vec![],
7128 skip: None,
7129 limit: None,
7130 };
7131 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7132 detail: format!("plan: {e}"),
7133 })?;
7134 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7135 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7136 let match_rs = {
7137 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7138 if let Some(ref mask) = mask_opt {
7139 execute(&self.view_masked(mask), &ops, &Params(params))
7140 } else {
7141 execute(&self.view(), &ops, &Params(params))
7142 }
7143 }
7144 .map_err(|e| GraphError::QueryError {
7145 detail: format!("execute: {e}"),
7146 })?;
7147
7148 // Collect unique node keys to delete (deduplicate across rows × vars).
7149 let mut keys: Vec<String> = Vec::new();
7150 for row_i in 0..match_rs.len() {
7151 for var in &stmt.node_vars {
7152 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7153 if !keys.contains(k) {
7154 keys.push(k.clone());
7155 }
7156 }
7157 }
7158 }
7159
7160 if !stmt.detach {
7161 // openCypher bare DELETE: error if any matched node has incident edges.
7162 for key in &keys {
7163 if let Some(id) = self.ids.get(key) {
7164 let tv = self.topo_view();
7165 let has_edges = tv.etypes().any(|et| {
7166 !tv.neighbors(et, Direction::Out, id).is_empty()
7167 || !tv.neighbors(et, Direction::In, id).is_empty()
7168 });
7169 if has_edges {
7170 return Err(GraphError::QueryError {
7171 detail: format!(
7172 "Cannot delete node `{key}` because it still has incident edges. \
7173 Use DETACH DELETE to remove the node and all its edges."
7174 ),
7175 });
7176 }
7177 }
7178 }
7179 }
7180
7181 let mut nodes_deleted = 0i64;
7182 let mut edges_deleted = 0i64;
7183 for key in keys {
7184 match self.delete_node(&key) {
7185 Ok(report) => {
7186 nodes_deleted += 1;
7187 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7188 }
7189 Err(GraphError::KeyNotFound { .. }) => {
7190 // Node may have been deleted by an earlier iteration (e.g., via
7191 // multiple MATCH rows for the same node). Safe to skip.
7192 }
7193 Err(e) => return Err(e),
7194 }
7195 }
7196
7197 let mut rs = write_result_set();
7198 rs.push_row(vec![
7199 Some(Value::Int(0)),
7200 Some(Value::Int(0)),
7201 Some(Value::Int(nodes_deleted + edges_deleted)),
7202 ]);
7203 Ok(rs)
7204 }
7205
7206 fn exec_merge(
7207 &mut self,
7208 stmt: core_query::cypher::MergeStmt,
7209 params: &BTreeMap<String, Value>,
7210 ) -> Result<ResultSet> {
7211 // MERGE: check if a node with the given key already exists.
7212 let key = match &stmt.key_value {
7213 Value::Str(s) => s.clone(),
7214 _ => {
7215 return Err(GraphError::QueryError {
7216 detail: format!(
7217 "MERGE key value must be a string (got {:?})",
7218 stmt.key_value
7219 ),
7220 })
7221 }
7222 };
7223
7224 if let Some(var) = stmt.var.as_deref() {
7225 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7226 if sc.var != var {
7227 return Err(GraphError::QueryError {
7228 detail: format!(
7229 "SET variable '{}' does not match MERGE variable '{var}'",
7230 sc.var
7231 ),
7232 });
7233 }
7234 }
7235 }
7236
7237 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7238 //
7239 // MERGE scope precondition: check create OR update scope for the
7240 // declared label BEFORE calling `has_node` (timing-oracle closure,
7241 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7242 // unscoped roles — the scope denial fires without touching the key store).
7243 //
7244 // Clone to avoid holding a borrow on `self.pending_write_authz` while
7245 // also calling `self.ids.get(key)`.
7246 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7247 let has_create = authz.scope.create_labels.contains(&stmt.label);
7248 let has_update = authz.scope.update_labels.contains(&stmt.label);
7249 if !has_create && !has_update {
7250 // Scope-before-lookup: 403 without has_node call (timing oracle
7251 // closure — see test_merge_unscoped_no_key_lookup).
7252 return Err(GraphError::RoleWriteDenied {
7253 reason: format!(
7254 "role-bound token: label '{}' not in write scope (create_labels)",
7255 stmt.label
7256 ),
7257 });
7258 }
7259 // Key lookup under mask.
7260 match self.ids.get(key.as_str()) {
7261 Some(id) if authz.mask.contains_id(id) => {
7262 // Visible: must have update scope to proceed to match arm.
7263 if !has_update {
7264 return Err(GraphError::RoleWriteDenied {
7265 reason: format!(
7266 "role-bound token: label '{}' not in write scope (update_labels)",
7267 stmt.label
7268 ),
7269 });
7270 }
7271 true // existed = true → match arm
7272 }
7273 Some(_) => {
7274 // Hidden: same error as absent to the role (spec §3.1/§3.3).
7275 return Err(GraphError::RoleWriteDenied {
7276 reason: "role-bound token: target node not visible".into(),
7277 });
7278 }
7279 None => {
7280 // Absent: must have create scope to proceed to the create arm.
7281 //
7282 // Update-only roles (create_labels empty, update_labels set):
7283 // return the SAME "not visible" error as the hidden-key branch
7284 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7285 // "confirm existence of hidden nodes: No").
7286 //
7287 // Create-scoped roles (has_create=true): absent → create arm
7288 // as before. The accepted structural key-existence disclosure
7289 // (§THREAT-MODEL) applies only when the role holds create scope.
7290 if !has_create {
7291 return Err(GraphError::RoleWriteDenied {
7292 reason: "role-bound token: target node not visible".into(),
7293 });
7294 }
7295 false // existed = false → create arm
7296 }
7297 }
7298 } else {
7299 // Full authority: use the existing non-masked has_node check.
7300 self.has_node(&key)
7301 };
7302
7303 let existed = merge_existed;
7304 let mut created = 0i64;
7305 if !existed || !stmt.on_match.is_empty() {
7306 let mut batch = self.batch();
7307 if !existed {
7308 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7309 batch.insert_node(&stmt.label, &key, props);
7310 for sc in &stmt.on_create {
7311 let value = resolve_merge_set_value(&sc.value, params)?;
7312 batch.set_prop(&key, &sc.field, value);
7313 }
7314 created = 1;
7315 } else {
7316 for sc in &stmt.on_match {
7317 let value = resolve_merge_set_value(&sc.value, params)?;
7318 batch.set_prop(&key, &sc.field, value);
7319 }
7320 }
7321 batch.commit()?;
7322 }
7323
7324 // Refresh the role mask so the just-created node is visible to this
7325 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7326 // (apply_schema subset rule), so the new node's label is already in the
7327 // role's read scope — this never widens beyond the role's declared labels.
7328 if !existed {
7329 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7330 let new_mask = self.mask_for_role(&role)?;
7331 if let Some(a) = self.pending_write_authz.as_mut() {
7332 a.mask = new_mask;
7333 }
7334 }
7335 }
7336
7337 // Optional RETURN clause: project the node (created or matched) as a read result.
7338 if let Some(returns) = stmt.returns {
7339 let var = stmt.var.as_deref().unwrap_or("_mn0");
7340 let q = Query {
7341 matches: vec![Pattern {
7342 start: NodePat {
7343 var: Some(var.to_string()),
7344 label: Some(stmt.label.clone()),
7345 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7346 },
7347 chain: vec![],
7348 shortest: false,
7349 }],
7350 optional_clauses: vec![],
7351 where_expr: None,
7352 unwinds: vec![],
7353 post_unwind_where: None,
7354 stages: vec![],
7355 returns,
7356 distinct: false,
7357 order_by: vec![],
7358 skip: None,
7359 limit: None,
7360 };
7361 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7362 detail: format!("plan: {e}"),
7363 })?;
7364 // Use view_masked when a role-scoped write is in flight so the
7365 // post-merge projection is consistent with the masked read phase.
7366 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7367 return (if let Some(ref mask) = mask_opt {
7368 execute(&self.view_masked(mask), &ops, &Params(params))
7369 } else {
7370 execute(&self.view(), &ops, &Params(params))
7371 })
7372 .map_err(|e| GraphError::QueryError {
7373 detail: format!("execute: {e}"),
7374 });
7375 }
7376
7377 let mut rs = write_result_set();
7378 rs.push_row(vec![
7379 Some(Value::Int(created)),
7380 Some(Value::Int(0)),
7381 Some(Value::Int(0)),
7382 ]);
7383 Ok(rs)
7384 }
7385
7386 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7387 /// annotated with rule name, edge type, direction, and weight.
7388 /// Results are sorted by (rule, edge_type).
7389 /// Returns `Err(KeyNotFound)` if either key is unknown.
7390 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7391 self.ensure_v8_base_sections_loaded();
7392 let id_a = self
7393 .ids
7394 .get(key_a)
7395 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7396 let id_b = self
7397 .ids
7398 .get(key_b)
7399 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7400
7401 let mut results = Vec::new();
7402
7403 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7404 // rather than O(total provenance).
7405 let scan = if self.engine.provenance_touching_len(id_a)
7406 <= self.engine.provenance_touching_len(id_b)
7407 {
7408 id_a
7409 } else {
7410 id_b
7411 };
7412 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7413 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7414 continue;
7415 }
7416 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7417 continue;
7418 };
7419 let edge_type = match self.syms.resolve(etype) {
7420 Some(s) => s.to_string(),
7421 None => continue,
7422 };
7423 // Provenance (src, dst) ids come from the archived PROVENANCE section
7424 // (large, no eager CRC). A corrupt section can produce ids that are
7425 // out of range; return Corrupt rather than panic.
7426 let src_key = self
7427 .ids
7428 .key_of(src)
7429 .ok_or_else(|| GraphError::Corrupt {
7430 detail: format!("v8: provenance src id {src} not in id table"),
7431 })?
7432 .to_string();
7433 let dst_key = self
7434 .ids
7435 .key_of(dst)
7436 .ok_or_else(|| GraphError::Corrupt {
7437 detail: format!("v8: provenance dst id {dst} not in id table"),
7438 })?
7439 .to_string();
7440 let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7441 self.edge_props_view()
7442 .get(etype, src, dst, prop)
7443 .and_then(|v| {
7444 if let Value::Float(f) = v {
7445 Some(f)
7446 } else {
7447 None
7448 }
7449 })
7450 });
7451 results.push(Explanation {
7452 rule: rule_name.to_string(),
7453 edge_type,
7454 src_key,
7455 dst_key,
7456 weight,
7457 predicate: PredicateSummary {
7458 approximate: rule_def.approximate,
7459 ..PredicateSummary::from(&rule_def.predicate)
7460 },
7461 });
7462 }
7463
7464 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7465 Ok(results)
7466 }
7467
7468 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7469 let id = self
7470 .ids
7471 .get(key)
7472 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7473 let Some(sym) = self.syms.get(edge_type) else {
7474 return Ok(Vec::new());
7475 };
7476 self.topo_view()
7477 .neighbors(sym, dir, id)
7478 .iter()
7479 .map(|&n| {
7480 self.ids
7481 .key_of(n)
7482 .map(|k| k.to_string())
7483 .ok_or_else(|| GraphError::Corrupt {
7484 detail: format!("topology id {n} has no key"),
7485 })
7486 })
7487 .collect::<Result<Vec<_>>>()
7488 }
7489
7490 /// Return the last-change commit sequence for `key`, or `None` if the node
7491 /// does not exist or has never been mutated since the last V5-V7 snapshot
7492 /// (horizon-bounded for legacy stores).
7493 ///
7494 /// The returned sequence is a monotonically increasing counter that starts
7495 /// at 1 for the first commit after `open` and increments with every
7496 /// successful write. WAL replay at open also assigns sequences (1..N for N
7497 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7498 ///
7499 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7500 /// in the snapshot but not touched by any WAL frame will return `None`
7501 /// (horizon-bounded: CAS against such nodes is only safe after the first
7502 /// V8 snapshot or after the node is next mutated).
7503 pub fn last_changed(&self, key: &str) -> Option<u64> {
7504 let id = self.ids.get(key)?;
7505 self.last_change.get(&id).copied()
7506 }
7507
7508 /// The current commit sequence (number of successful commits since open,
7509 /// including WAL replay frames). Useful for recording a baseline before
7510 /// a read-modify-write cycle.
7511 pub fn commit_seq(&self) -> u64 {
7512 self.commit_seq
7513 }
7514
7515 /// Check that all `preconds` are satisfied against the current db state.
7516 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7517 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7518 for precond in preconds {
7519 match precond {
7520 Precondition::NodeUnchangedSince { key, expected } => {
7521 // Missing entry means the node predates the WAL window or
7522 // does not exist; treat as 0 (before any commit).
7523 let actual = self.last_changed(key).unwrap_or_default();
7524 if actual != *expected {
7525 return Err(GraphError::CasConflict {
7526 key: key.clone(),
7527 expected: *expected,
7528 actual,
7529 });
7530 }
7531 }
7532 Precondition::NodeAbsent { key } => {
7533 // Node must not exist (not live).
7534 if self.ids.get(key).is_some() {
7535 let actual = self.last_changed(key).unwrap_or(0);
7536 return Err(GraphError::CasConflict {
7537 key: key.clone(),
7538 expected: u64::MAX,
7539 actual,
7540 });
7541 }
7542 }
7543 }
7544 }
7545 Ok(())
7546 }
7547
7548 /// Apply a batch of mutations with compare-and-set preconditions.
7549 ///
7550 /// All preconditions are checked atomically before any operation is applied.
7551 /// If any precondition fails, the entire batch is rejected with
7552 /// [`GraphError::CasConflict`] and no WAL frame is written.
7553 ///
7554 /// # Returns
7555 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7556 ///
7557 /// # Errors
7558 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7559 /// - Any error that [`write_batch`] would return for the ops themselves.
7560 pub fn write_batch_cas(
7561 &mut self,
7562 preconds: Vec<Precondition>,
7563 ops: Vec<BatchOp>,
7564 ) -> Result<(usize, usize)> {
7565 self.check_preconditions(&preconds)?;
7566 self.commit_logged_batch(ops, None, None)
7567 }
7568
7569 /// Update the per-node last-change map for a WAL record at commit `seq`.
7570 ///
7571 /// Called after a successful apply to record which nodes were touched.
7572 /// For replay, called with the WAL-frame's replayed seq.
7573 ///
7574 /// Touch definition (see [`Precondition`] doc):
7575 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7576 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7577 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7578 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7579 /// - Batch → recurse into inner records.
7580 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7581 match rec {
7582 WalRecord::InsertNode { key, .. }
7583 | WalRecord::SetProp { key, .. }
7584 | WalRecord::RemoveProp { key, .. } => {
7585 if let Some(id) = self.ids.get(key) {
7586 self.last_change.insert(id, seq);
7587 }
7588 }
7589 WalRecord::InsertNodeId { key, .. } => {
7590 if let Some(id) = self.ids.get(key) {
7591 self.last_change.insert(id, seq);
7592 }
7593 }
7594 WalRecord::SetPropId { id, .. } => {
7595 self.last_change.insert(*id, seq);
7596 }
7597 WalRecord::InsertEdge {
7598 src_key, dst_key, ..
7599 }
7600 | WalRecord::DeleteEdge {
7601 src_key, dst_key, ..
7602 } => {
7603 if let Some(src_id) = self.ids.get(src_key) {
7604 self.last_change.insert(src_id, seq);
7605 }
7606 if let Some(dst_id) = self.ids.get(dst_key) {
7607 self.last_change.insert(dst_id, seq);
7608 }
7609 }
7610 WalRecord::InsertEdgeId { src, dst, .. } => {
7611 self.last_change.insert(*src, seq);
7612 self.last_change.insert(*dst, seq);
7613 }
7614 // DeleteNode: node is tombstoned; last_changed(key) returns None for
7615 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7616 // History markers: state no-ops; the underlying mutation already
7617 // touched the relevant nodes' last_change entries.
7618 WalRecord::DeleteNode { .. }
7619 | WalRecord::DerivedEdgeAdded { .. }
7620 | WalRecord::DerivedEdgeRetracted { .. }
7621 | WalRecord::Intern { .. }
7622 | WalRecord::CreateRule { .. }
7623 | WalRecord::DeleteRule { .. }
7624 | WalRecord::RebuildRule { .. }
7625 | WalRecord::CreateView { .. }
7626 | WalRecord::DeleteView { .. }
7627 | WalRecord::EnableFulltext { .. }
7628 | WalRecord::DisableFulltext { .. }
7629 | WalRecord::EnableIndex { .. }
7630 | WalRecord::DisableIndex { .. } => {}
7631 // RenameNode: node id is stable; update last_change via the new key.
7632 // Called after apply(), so ids already reflects new_key.
7633 WalRecord::RenameNode { new_key, .. } => {
7634 if let Some(id) = self.ids.get(new_key) {
7635 self.last_change.insert(id, seq);
7636 }
7637 }
7638 WalRecord::Batch(inner) => {
7639 for inner_rec in inner {
7640 self.update_last_change_from_rec(inner_rec, seq);
7641 }
7642 }
7643 }
7644 }
7645
7646 pub fn node_count(&self) -> usize {
7647 self.ids.len()
7648 }
7649
7650 /// Configure archive retention: keep the `N` newest WAL archives at each
7651 /// [`snapshot_with`] call when `archive_wal: true`.
7652 ///
7653 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7654 /// `Some(0)` or `None` → unlimited (no pruning).
7655 ///
7656 /// Pruning only ever happens inside [`snapshot_with`]; this method only
7657 /// stores the policy. Archives below the retention limit are deleted
7658 /// oldest-first. The horizon floor is updated so that
7659 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7660 /// in pruned archives rather than silently returning wrong data.
7661 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7662 self.wal_archive_retention = keep;
7663 }
7664
7665 /// Delete any WAL archives that are fully below the current horizon floor.
7666 ///
7667 /// Orphaned archives arise when the floor is written first during retention
7668 /// pruning and then a crash interrupts the archive-delete sequence. The
7669 /// opening cleanup ensures no subsequent read path sees stale data.
7670 ///
7671 /// Under the monotonic naming scheme, the archive name N equals the
7672 /// cumulative end-frame index of the archive in global commit space (i.e.
7673 /// the archive covers global frames `[prev_n, N)`). An archive is
7674 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7675 /// below the floor and have already been counted in it.
7676 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7677 if self.wal_horizon_floor == 0 {
7678 // Floor at 0 means no pruning has ever occurred; nothing to clean.
7679 return Ok(());
7680 }
7681 let archive_ns = self.fs.list_archives()?;
7682 for n in archive_ns {
7683 if n <= self.wal_horizon_floor {
7684 // Archive N ends at global frame N; all its frames are below
7685 // the floor (floor already accounts for them) → orphaned.
7686 self.fs.delete_archive(n).map_err(GraphError::Io)?;
7687 } else {
7688 // Archives are sorted ascending; first one above floor stops scan.
7689 break;
7690 }
7691 }
7692 Ok(())
7693 }
7694
7695 /// Collect all WAL frames from surviving archives (oldest-first) then the
7696 /// live WAL into one flat list, and return the total along with the number
7697 /// of archive frames at the front of the list.
7698 ///
7699 /// Commit indices into the returned list are LOCAL (0 = first frame of
7700 /// oldest surviving archive). To obtain the GLOBAL index add
7701 /// `self.wal_horizon_floor`.
7702 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7703 let archive_ns = self.fs.list_archives()?;
7704 let mut all: Vec<WalRecord> = Vec::new();
7705 for n in archive_ns {
7706 let bytes = self.fs.read_archive(n)?;
7707 let (frames, _) = decode_all(&bytes);
7708 all.extend(frames);
7709 }
7710 let archive_count = all.len() as u64;
7711 let live_bytes = self.fs.read(FileId::Wal)?;
7712 let (live_frames, _) = decode_all(&live_bytes);
7713 all.extend(live_frames);
7714 Ok((all, archive_count))
7715 }
7716
7717 /// Return the total number of committed WAL frames visible in the current
7718 /// horizon window, including frames in surviving WAL archives.
7719 ///
7720 /// This is the exclusive upper bound for valid `at_commit` indices in
7721 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7722 ///
7723 /// Returns the horizon floor when all surviving history is empty.
7724 pub fn wal_total_commits(&self) -> Result<u64> {
7725 let (frames, _) = self.all_frames()?;
7726 Ok(self.wal_horizon_floor + frames.len() as u64)
7727 }
7728
7729 /// The global frame index of the first commit reachable through surviving
7730 /// archives (0 when no archives have been pruned).
7731 pub fn wal_horizon_floor(&self) -> u64 {
7732 self.wal_horizon_floor
7733 }
7734
7735 /// Return the per-node change history for `key` by scanning the on-disk WAL.
7736 ///
7737 /// ## Horizon
7738 ///
7739 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
7740 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
7741 /// zero-cost contract; a durable history log is out of scope.
7742 ///
7743 /// ## Derived edges
7744 ///
7745 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
7746 /// history. Only edges written directly by the application are recorded.
7747 ///
7748 /// ## Deleted nodes
7749 ///
7750 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
7751 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
7752 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
7753 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
7754 ///
7755 /// ## Dense-id edge entries and tombstoned partners
7756 ///
7757 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
7758 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
7759 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
7760 /// Build commit-bounded alias intervals for `queried_key`.
7761 ///
7762 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
7763 /// A record written under `key` at commit `c` matches the queried identity iff
7764 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
7765 ///
7766 /// Each alias entry carries both a lower and an upper bound so that key-reuse
7767 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
7768 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
7769 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
7770 /// only identity-2's events (commits 7–9 under "a") are in scope.
7771 ///
7772 /// Only **forward aliasing**: querying the *new* key surfaces events written
7773 /// under the *old* key. The reverse direction is not supported.
7774 fn build_key_alias_intervals(
7775 &self,
7776 frames: &[core_storage::wal::WalRecord],
7777 queried_key: &str,
7778 ) -> Vec<(String, u64, Option<u64>)> {
7779 use core_storage::wal::WalRecord;
7780
7781 // Pre-pass: build reverse_rename and key_starts maps.
7782 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
7783 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
7784
7785 for (local_i, frame) in frames.iter().enumerate() {
7786 let commit = self.wal_horizon_floor + local_i as u64;
7787 let records: &[WalRecord] = match frame {
7788 WalRecord::Batch(inner) => inner.as_slice(),
7789 single => std::slice::from_ref(single),
7790 };
7791 for rec in records {
7792 match rec {
7793 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
7794 key_starts.entry(key.clone()).or_default().push(commit);
7795 }
7796 WalRecord::RenameNode { old_key, new_key } => {
7797 // new_key came into existence at this commit.
7798 key_starts.entry(new_key.clone()).or_default().push(commit);
7799 // Record the reverse rename: new_key was introduced by renaming old_key.
7800 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
7801 }
7802 _ => {}
7803 }
7804 }
7805 }
7806
7807 // Build alias intervals by following the reverse rename chain.
7808 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
7809 let mut current_key = queried_key.to_string();
7810 let mut current_valid_until: Option<u64> = None;
7811
7812 loop {
7813 // valid_from: the most recent commit where current_key was assigned to this
7814 // identity. For aliases (valid_until = Some(vu)), find the last start event
7815 // for the key strictly before vu — this is where the alias's occupancy by
7816 // this identity began, correctly excluding prior identities that reused the key.
7817 let valid_from = if let Some(vu) = current_valid_until {
7818 key_starts
7819 .get(¤t_key)
7820 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
7821 .unwrap_or(self.wal_horizon_floor)
7822 } else {
7823 // Queried key — no upper bound; may have been introduced at any commit.
7824 self.wal_horizon_floor
7825 };
7826
7827 result.push((current_key.clone(), valid_from, current_valid_until));
7828
7829 match reverse_rename.get(¤t_key) {
7830 Some((old_key, rename_commit)) => {
7831 current_valid_until = Some(*rename_commit);
7832 current_key = old_key.clone();
7833 }
7834 None => break,
7835 }
7836 }
7837
7838 result
7839 }
7840
7841 /// Returns true if `record_key` matches any alias interval that covers `commit`.
7842 fn aliases_match(
7843 intervals: &[(String, u64, Option<u64>)],
7844 record_key: &str,
7845 commit: u64,
7846 ) -> bool {
7847 intervals
7848 .iter()
7849 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
7850 }
7851
7852 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
7853 use crate::history::{HistoryChange, HistoryEntry};
7854 use core_storage::wal::WalRecord;
7855
7856 let (frames, _) = self.all_frames()?;
7857
7858 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
7859 let alias_intervals = self.build_key_alias_intervals(&frames, key);
7860
7861 let mut out: Vec<HistoryEntry> = Vec::new();
7862
7863 for (local_i, frame) in frames.iter().enumerate() {
7864 let commit = self.wal_horizon_floor + local_i as u64;
7865 // Collect the inner records to process — Batch is one commit, single records are one commit.
7866 let records: &[WalRecord] = match frame {
7867 WalRecord::Batch(inner) => inner.as_slice(),
7868 single => std::slice::from_ref(single),
7869 };
7870
7871 for rec in records {
7872 let change = match rec {
7873 WalRecord::InsertNode { label, key: k, .. }
7874 if Self::aliases_match(&alias_intervals, k, commit) =>
7875 {
7876 Some(HistoryChange::NodeInserted {
7877 label: label.clone(),
7878 })
7879 }
7880 WalRecord::InsertNodeId { label, key: k, .. }
7881 if Self::aliases_match(&alias_intervals, k, commit) =>
7882 {
7883 let label_str = match self.syms.resolve(*label) {
7884 Some(s) => s.to_string(),
7885 None => continue,
7886 };
7887 Some(HistoryChange::NodeInserted { label: label_str })
7888 }
7889 WalRecord::SetProp {
7890 key: k,
7891 field,
7892 value,
7893 } if Self::aliases_match(&alias_intervals, k, commit) => {
7894 Some(HistoryChange::PropSet {
7895 field: field.clone(),
7896 value: value.clone(),
7897 })
7898 }
7899 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
7900 // key_of returns the current (post-rename) key; compare to queried key.
7901 Some(resolved) if resolved == key => {
7902 let field_str = match self.syms.resolve(*field) {
7903 Some(s) => s.to_string(),
7904 None => continue,
7905 };
7906 Some(HistoryChange::PropSet {
7907 field: field_str,
7908 value: value.clone(),
7909 })
7910 }
7911 _ => None,
7912 },
7913 WalRecord::RemoveProp { key: k, field }
7914 if Self::aliases_match(&alias_intervals, k, commit) =>
7915 {
7916 Some(HistoryChange::PropRemoved {
7917 field: field.clone(),
7918 })
7919 }
7920 WalRecord::InsertEdge {
7921 edge_type,
7922 src_key,
7923 dst_key,
7924 } => {
7925 if Self::aliases_match(&alias_intervals, src_key, commit) {
7926 Some(HistoryChange::EdgeAdded {
7927 edge_type: edge_type.clone(),
7928 other: dst_key.clone(),
7929 outgoing: true,
7930 })
7931 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7932 Some(HistoryChange::EdgeAdded {
7933 edge_type: edge_type.clone(),
7934 other: src_key.clone(),
7935 outgoing: false,
7936 })
7937 } else {
7938 None
7939 }
7940 }
7941 WalRecord::InsertEdgeId { etype, src, dst } => {
7942 let etype_str = match self.syms.resolve(*etype) {
7943 Some(s) => s.to_string(),
7944 None => continue,
7945 };
7946 let src_key = self.ids.key_of(*src);
7947 let dst_key = self.ids.key_of(*dst);
7948 if src_key == Some(key) {
7949 let other = match dst_key {
7950 Some(s) => s.to_string(),
7951 None => continue,
7952 };
7953 Some(HistoryChange::EdgeAdded {
7954 edge_type: etype_str,
7955 other,
7956 outgoing: true,
7957 })
7958 } else if dst_key == Some(key) {
7959 let other = match src_key {
7960 Some(s) => s.to_string(),
7961 None => continue,
7962 };
7963 Some(HistoryChange::EdgeAdded {
7964 edge_type: etype_str,
7965 other,
7966 outgoing: false,
7967 })
7968 } else {
7969 None
7970 }
7971 }
7972 WalRecord::DeleteEdge {
7973 edge_type,
7974 src_key,
7975 dst_key,
7976 } => {
7977 if Self::aliases_match(&alias_intervals, src_key, commit) {
7978 Some(HistoryChange::EdgeRemoved {
7979 edge_type: edge_type.clone(),
7980 other: dst_key.clone(),
7981 outgoing: true,
7982 })
7983 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7984 Some(HistoryChange::EdgeRemoved {
7985 edge_type: edge_type.clone(),
7986 other: src_key.clone(),
7987 outgoing: false,
7988 })
7989 } else {
7990 None
7991 }
7992 }
7993 WalRecord::DeleteNode { key: k }
7994 if Self::aliases_match(&alias_intervals, k, commit) =>
7995 {
7996 Some(HistoryChange::NodeDeleted)
7997 }
7998 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
7999 _ => None,
8000 };
8001
8002 if let Some(change) = change {
8003 out.push(HistoryEntry { commit, change });
8004 }
8005 }
8006 }
8007
8008 Ok(out)
8009 }
8010
8011 /// Return the per-edge change history between nodes `a` and `b` by scanning
8012 /// the on-disk WAL.
8013 ///
8014 /// ## Horizon
8015 ///
8016 /// History reaches back only to the last WAL-truncating snapshot, exactly
8017 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8018 /// `total_commits` (= number of WAL frames), which is the exclusive upper
8019 /// bound for valid commit indices.
8020 ///
8021 /// ## Derived edges
8022 ///
8023 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8024 /// WAL markers written by `log_then_apply_with` after each rule-firing
8025 /// mutation. The `rule` field of those events carries the rule name.
8026 ///
8027 /// ## DeleteNode
8028 ///
8029 /// When a node is deleted, its manual incident edges are swept inline without
8030 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8031 /// events for either endpoint and synthesises `Retracted(rule:None)` events
8032 /// for each manual edge that was active at that point. Derived edges active at
8033 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8034 /// the engine appends immediately after the `DeleteNode` record; those events
8035 /// carry correct rule attribution and are emitted by the marker arm, not the
8036 /// synthetic sweep.
8037 ///
8038 /// ## Masks
8039 ///
8040 /// Like `node_history`, this method has no mask parameter and returns WAL
8041 /// history regardless of any role mask. For masked history semantics, apply
8042 /// the mask at the caller level.
8043 pub fn edge_history(
8044 &self,
8045 a: &str,
8046 b: &str,
8047 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8048 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8049 use core_storage::wal::WalRecord;
8050
8051 let (frames, _) = self.all_frames()?;
8052 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8053
8054 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8055 // Intervals are commit-bounded so recycled keys don't contaminate histories.
8056 let alias_a = self.build_key_alias_intervals(&frames, a);
8057 let alias_b = self.build_key_alias_intervals(&frames, b);
8058
8059 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8060 // The is_derived flag is used by the DeleteNode sweep: manual edges are
8061 // swept with a synthetic Retracted(rule:None); derived edges are skipped
8062 // because the engine writes a DerivedEdgeRetracted marker immediately after
8063 // the DeleteNode record, which carries the correct rule attribution.
8064 let mut active: Vec<(String, String, String, bool)> = Vec::new();
8065 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8066
8067 for (local_i, frame) in frames.iter().enumerate() {
8068 let commit = self.wal_horizon_floor + local_i as u64;
8069 let records: &[WalRecord] = match frame {
8070 WalRecord::Batch(inner) => inner.as_slice(),
8071 single => std::slice::from_ref(single),
8072 };
8073
8074 for rec in records {
8075 match rec {
8076 WalRecord::InsertEdge {
8077 edge_type,
8078 src_key,
8079 dst_key,
8080 } => {
8081 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8082 && Self::aliases_match(&alias_b, dst_key, commit);
8083 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8084 && Self::aliases_match(&alias_a, dst_key, commit);
8085 if is_ab || is_ba {
8086 active.push((
8087 edge_type.clone(),
8088 src_key.clone(),
8089 dst_key.clone(),
8090 false,
8091 ));
8092 out.push(EdgeHistoryEvent {
8093 edge_type: edge_type.clone(),
8094 commit,
8095 event: EdgeEvent::Added,
8096 rule: None,
8097 });
8098 }
8099 }
8100 WalRecord::InsertEdgeId { etype, src, dst } => {
8101 let etype_str = match self.syms.resolve(*etype) {
8102 Some(s) => s.to_string(),
8103 None => continue,
8104 };
8105 // Use key_of_historical so tombstoned nodes (deleted
8106 // later in the WAL) still resolve during the scan.
8107 let src_key = self.ids.key_of_historical(*src);
8108 let dst_key = self.ids.key_of_historical(*dst);
8109 let is_ab = src_key == Some(a) && dst_key == Some(b);
8110 let is_ba = src_key == Some(b) && dst_key == Some(a);
8111 if is_ab || is_ba {
8112 let src_str = src_key.unwrap().to_string();
8113 let dst_str = dst_key.unwrap().to_string();
8114 active.push((etype_str.clone(), src_str, dst_str, false));
8115 out.push(EdgeHistoryEvent {
8116 edge_type: etype_str,
8117 commit,
8118 event: EdgeEvent::Added,
8119 rule: None,
8120 });
8121 }
8122 }
8123 WalRecord::DeleteEdge {
8124 edge_type,
8125 src_key,
8126 dst_key,
8127 } => {
8128 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8129 && Self::aliases_match(&alias_b, dst_key, commit);
8130 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8131 && Self::aliases_match(&alias_a, dst_key, commit);
8132 if is_ab || is_ba {
8133 // Remove the first matching active entry (flag ignored).
8134 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8135 et == edge_type && s == src_key && d == dst_key
8136 }) {
8137 active.remove(pos);
8138 }
8139 out.push(EdgeHistoryEvent {
8140 edge_type: edge_type.clone(),
8141 commit,
8142 event: EdgeEvent::Retracted,
8143 rule: None,
8144 });
8145 }
8146 }
8147 WalRecord::DeleteNode { key: k }
8148 if Self::aliases_match(&alias_a, k, commit)
8149 || Self::aliases_match(&alias_b, k, commit) =>
8150 {
8151 // Sweep: implicitly retract only MANUAL active edges.
8152 // Derived active edges are skipped here because the rule
8153 // engine appends a DerivedEdgeRetracted marker immediately
8154 // after this DeleteNode record; that marker produces the
8155 // single correctly-attributed Retracted event. Derived
8156 // entries are dropped from `active` (the marker arm's
8157 // idempotent retain finds nothing to remove).
8158 for (et, _, _, is_derived) in active.drain(..) {
8159 if !is_derived {
8160 out.push(EdgeHistoryEvent {
8161 edge_type: et,
8162 commit,
8163 event: EdgeEvent::Retracted,
8164 rule: None,
8165 });
8166 }
8167 // Derived: drop silently; marker carries the Retracted event.
8168 }
8169 }
8170 WalRecord::DerivedEdgeAdded {
8171 rule,
8172 edge_type: et,
8173 src_key,
8174 dst_key,
8175 } => {
8176 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8177 && Self::aliases_match(&alias_b, dst_key, commit);
8178 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8179 && Self::aliases_match(&alias_a, dst_key, commit);
8180 if is_ab || is_ba {
8181 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8182 out.push(EdgeHistoryEvent {
8183 edge_type: et.clone(),
8184 commit,
8185 event: EdgeEvent::Added,
8186 rule: Some(rule.clone()),
8187 });
8188 }
8189 }
8190 WalRecord::DerivedEdgeRetracted {
8191 rule,
8192 edge_type: et,
8193 src_key,
8194 dst_key,
8195 } => {
8196 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8197 && Self::aliases_match(&alias_b, dst_key, commit);
8198 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8199 && Self::aliases_match(&alias_a, dst_key, commit);
8200 if is_ab || is_ba {
8201 // Push unconditionally: a derived edge whose Added marker
8202 // predates the history horizon has no `active` entry, but
8203 // the retraction is still a real in-window event.
8204 // Remove from active idempotently if present.
8205 active.retain(|(aet, s, d, _)| {
8206 !(aet == et && s == src_key && d == dst_key)
8207 });
8208 out.push(EdgeHistoryEvent {
8209 edge_type: et.clone(),
8210 commit,
8211 event: EdgeEvent::Retracted,
8212 rule: Some(rule.clone()),
8213 });
8214 }
8215 }
8216 // All other records (InsertNode, SetProp, CreateRule, etc.)
8217 // do not affect edges between a and b.
8218 _ => {}
8219 }
8220 }
8221 }
8222
8223 Ok(HistoryResult {
8224 items: out,
8225 total_commits,
8226 })
8227 }
8228
8229 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8230 /// (in either direction) at the WAL commit `at_commit`.
8231 ///
8232 /// ## Horizon
8233 ///
8234 /// Valid commit indices are `0..total_commits` where `total_commits` is the
8235 /// number of WAL frames. An `at_commit >= total_commits` is outside the
8236 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8237 ///
8238 /// ## Derived edges
8239 ///
8240 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8241 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8242 /// and therefore includes derived edges in its point-in-time evaluation,
8243 /// matching `edge_history`'s fidelity.
8244 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8245 use core_storage::wal::WalRecord;
8246
8247 let (frames, _) = self.all_frames()?;
8248 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8249
8250 // Horizon floor: commits in pruned archives are unreachable.
8251 if at_commit < self.wal_horizon_floor {
8252 return Err(GraphError::CommitOutOfRange {
8253 commit: at_commit,
8254 total: total_commits,
8255 });
8256 }
8257 if at_commit >= total_commits {
8258 return Err(GraphError::CommitOutOfRange {
8259 commit: at_commit,
8260 total: total_commits,
8261 });
8262 }
8263
8264 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8265 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8266 let alias_a = self.build_key_alias_intervals(&frames, a);
8267 let alias_b = self.build_key_alias_intervals(&frames, b);
8268
8269 // Local index into surviving frames (0 = first frame of oldest archive).
8270 let local_commit = at_commit - self.wal_horizon_floor;
8271
8272 // Replay local frames 0..=local_commit, tracking active edges.
8273 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8274
8275 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8276 let commit = self.wal_horizon_floor + local_i as u64;
8277 let records: &[WalRecord] = match frame {
8278 WalRecord::Batch(inner) => inner.as_slice(),
8279 single => std::slice::from_ref(single),
8280 };
8281
8282 for rec in records {
8283 match rec {
8284 WalRecord::InsertEdge {
8285 edge_type: et,
8286 src_key,
8287 dst_key,
8288 } => {
8289 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8290 && Self::aliases_match(&alias_b, dst_key, commit);
8291 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8292 && Self::aliases_match(&alias_a, dst_key, commit);
8293 if is_ab || is_ba {
8294 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8295 }
8296 }
8297 WalRecord::InsertEdgeId { etype, src, dst } => {
8298 let etype_str = match self.syms.resolve(*etype) {
8299 Some(s) => s.to_string(),
8300 None => continue,
8301 };
8302 // Use key_of_historical so tombstoned nodes resolve.
8303 let src_key = self.ids.key_of_historical(*src);
8304 let dst_key = self.ids.key_of_historical(*dst);
8305 let is_ab = src_key == Some(a) && dst_key == Some(b);
8306 let is_ba = src_key == Some(b) && dst_key == Some(a);
8307 if is_ab || is_ba {
8308 active.insert((
8309 etype_str,
8310 src_key.unwrap().to_string(),
8311 dst_key.unwrap().to_string(),
8312 ));
8313 }
8314 }
8315 WalRecord::DeleteEdge {
8316 edge_type: et,
8317 src_key,
8318 dst_key,
8319 } => {
8320 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8321 && Self::aliases_match(&alias_b, dst_key, commit);
8322 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8323 && Self::aliases_match(&alias_a, dst_key, commit);
8324 if is_ab || is_ba {
8325 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8326 }
8327 }
8328 WalRecord::DeleteNode { key: k }
8329 if Self::aliases_match(&alias_a, k, commit)
8330 || Self::aliases_match(&alias_b, k, commit) =>
8331 {
8332 // All edges touching the deleted node are gone.
8333 active.retain(|(_, s, d)| s != k && d != k);
8334 }
8335 WalRecord::DerivedEdgeAdded {
8336 edge_type: et,
8337 src_key,
8338 dst_key,
8339 ..
8340 } => {
8341 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8342 && Self::aliases_match(&alias_b, dst_key, commit);
8343 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8344 && Self::aliases_match(&alias_a, dst_key, commit);
8345 if is_ab || is_ba {
8346 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8347 }
8348 }
8349 WalRecord::DerivedEdgeRetracted {
8350 edge_type: et,
8351 src_key,
8352 dst_key,
8353 ..
8354 } => {
8355 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8356 && Self::aliases_match(&alias_b, dst_key, commit);
8357 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8358 && Self::aliases_match(&alias_a, dst_key, commit);
8359 if is_ab || is_ba {
8360 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8361 }
8362 }
8363 _ => {}
8364 }
8365 }
8366 }
8367
8368 Ok(active.iter().any(|(et, _, _)| et == edge_type))
8369 }
8370
8371 pub fn edge_count(&self) -> u64 {
8372 self.topo_view().edge_count()
8373 }
8374
8375 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8376 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8377 pub fn stats(&self) -> Stats {
8378 self.ensure_v8_base_sections_loaded();
8379 let rules: Vec<RuleStats> = self
8380 .engine
8381 .rules()
8382 .map(|r| RuleStats {
8383 name: r.name.clone(),
8384 edges: self
8385 .engine
8386 .provenance()
8387 .get(&r.name)
8388 .map(|s| s.len() as u64)
8389 .unwrap_or(0),
8390 tripped: self.engine.is_tripped(&r.name),
8391 fires: self.engine.fire_count(&r.name),
8392 approximate: r.approximate,
8393 })
8394 .collect();
8395 Stats {
8396 nodes_live: self.ids.live_len(),
8397 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8398 edges: self.topo_view().edge_count(),
8399 rules,
8400 }
8401 }
8402
8403 /// On-disk snapshot format version this binary writes and reads.
8404 pub fn format_version() -> u16 {
8405 core_storage::snapshot::VERSION
8406 }
8407
8408 /// Test-support: total bytes appended (SimFs only usage).
8409 pub fn fs_total_appended(&self) -> usize
8410 where
8411 F: FsIntrospect,
8412 {
8413 self.fs.total_appended()
8414 }
8415
8416 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8417 pub fn fs_sync_count(&self) -> usize
8418 where
8419 F: FsIntrospect,
8420 {
8421 self.fs.sync_count()
8422 }
8423
8424 /// Consume the db, returning its fs (for crash simulation).
8425 pub fn into_fs(self) -> F {
8426 self.fs
8427 }
8428
8429 pub fn snapshot(&mut self) -> Result<()> {
8430 self.snapshot_with(SnapshotOptions::default())
8431 }
8432
8433 /// Snapshot with explicit options.
8434 ///
8435 /// # `keep_wal`
8436 ///
8437 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8438 /// - The WAL is replaced with a minimal baseline containing one
8439 /// `EnableFulltext` record per active declaration. All pre-snapshot
8440 /// history is discarded; `open_at` can only reach post-snapshot commits.
8441 ///
8442 /// When `keep_wal` is `true`:
8443 /// - The WAL is left intact. All pre-snapshot commits remain reachable
8444 /// via `open_at`. The existing WAL already contains the original
8445 /// `EnableFulltext` records, so no baseline re-write is needed; the
8446 /// recovery guards in `apply()` silently skip any duplicate records on
8447 /// replay.
8448 /// - Crash window: a crash after the snapshot write but before the next
8449 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
8450 /// snapshot is loaded and the WAL replayed idempotently over it — safe
8451 /// because every `apply()` arm is idempotent when replayed over an
8452 /// already-current snapshot.
8453 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8454 if self.read_only {
8455 return Err(GraphError::ReadOnly);
8456 }
8457 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8458 // Used by the archive path's conservative genesis-chain check: if a prior
8459 // snapshot exists but wal.truncated does not, we cannot distinguish a
8460 // legacy store (may have been truncated in an older code version) from a
8461 // new store that only used keep_wal=true. Conservative: refuse genesis in
8462 // both cases. Must be sampled here, before the snapshot write below.
8463 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8464 self.ensure_v8_base_sections_loaded();
8465 // Ensure provenance is decoded before to_persist() clones it.
8466 self.engine.ensure_provenance_loaded_mut();
8467 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8468 let rule_defs = rule_defs_typed
8469 .iter()
8470 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8471 .collect();
8472 // Collect HNSW state and IVF state. When indexes are not yet
8473 // populated (clean open, no mutation since open), pass the retained
8474 // raw bytes through directly so that migrate/snapshot does not
8475 // silently discard fitted approximate-rule indexes.
8476 let hnsw_state = self.engine.export_hnsw_state_passthrough();
8477 let ivf_bytes = if !self.engine.indexes_populated() {
8478 // Pass retained IVF bytes through unchanged (no re-encode).
8479 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8480 } else {
8481 // Indexes live: encode from current state.
8482 let raw_ivf = self.engine.export_ivf_state();
8483 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8484 .into_iter()
8485 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8486 (
8487 name,
8488 core_storage::snapshot::PerRuleIvfState {
8489 src: core_storage::snapshot::SideIvfState {
8490 centroids: sc,
8491 clusters: sa,
8492 drift: sd,
8493 },
8494 dst: core_storage::snapshot::SideIvfState {
8495 centroids: dc,
8496 clusters: da,
8497 drift: dd,
8498 },
8499 },
8500 )
8501 })
8502 .collect();
8503 if ivf_state_map.is_empty() {
8504 Vec::new()
8505 } else {
8506 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8507 }
8508 };
8509 let view_defs: Vec<Vec<u8>> = self
8510 .view_store
8511 .views()
8512 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8513 .collect();
8514 if self.base.is_some() {
8515 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8516 // write it atomically, remap it as the new base, then clear the overlay.
8517 let meta = V8Meta {
8518 labels: self.labels.clone(),
8519 edge_props: self.edge_props.clone(),
8520 rule_defs,
8521 provenance,
8522 rule_tripped,
8523 rule_fires,
8524 ivf_bytes,
8525 view_defs,
8526 wal_truncated: !opts.keep_wal,
8527 hnsw: hnsw_state,
8528 last_change: self.last_change.clone(),
8529 };
8530 let mut buf: Vec<u8> = Vec::new();
8531 {
8532 // Clone the Arc so the old base stays alive while we encode.
8533 // The borrow of archived_csr (into old_base's mmap) is released
8534 // at the end of this block, before we replace self.base.
8535 let old_base = self.base.clone().expect("is_some checked above");
8536 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8537 detail: format!("v8 snapshot: topology section: {e:?}"),
8538 })?;
8539 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8540 detail: format!("v8 snapshot: columns section: {e:?}"),
8541 })?;
8542 let archived_edge_props =
8543 old_base
8544 .edge_props_section()
8545 .map_err(|e| GraphError::Corrupt {
8546 detail: format!("v8 snapshot: edge_props section: {e:?}"),
8547 })?;
8548 let edge_props_raw =
8549 old_base
8550 .edge_props_raw_bytes()
8551 .map_err(|e| GraphError::Corrupt {
8552 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8553 })?;
8554 let prov_raw =
8555 old_base
8556 .provenance_raw_bytes()
8557 .map_err(|e| GraphError::Corrupt {
8558 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8559 })?;
8560 encode_v8(
8561 Some(archived_csr),
8562 Some(archived_cols),
8563 Some((archived_edge_props, edge_props_raw)),
8564 Some(prov_raw),
8565 &self.topo,
8566 &self.props,
8567 &self.ids,
8568 &self.syms,
8569 &meta,
8570 &mut buf,
8571 )?;
8572 }
8573 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8574 // Remap the freshly-written snapshot as the new base.
8575 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8576 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8577 core_storage::v8::MappedBase::map(&snap_path)
8578 } else {
8579 core_storage::v8::MappedBase::from_bytes(buf)
8580 }
8581 .map_err(|e| GraphError::Corrupt {
8582 detail: format!("v8 snapshot: remap new base: {e:?}"),
8583 })?;
8584 self.base = Some(Arc::new(new_base));
8585 // Clear the overlay and prop tombstones — all data is now in the new base.
8586 self.topo = Topology::new();
8587 self.props = core_storage::columns::ColumnStore::new();
8588 } else {
8589 // Legacy path (V5–V7 stores without a V8 base).
8590 //
8591 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8592 // clone and no encode_v8_from_state intermediate clones. The big
8593 // structures (self.topo, self.props) are borrowed, not cloned.
8594 // self.edge_props is moved (not cloned) because we immediately clear it
8595 // when we remap the new V8 snapshot as self.base (see below).
8596 //
8597 // Eliminates from peak RSS vs. the old SnapshotState path:
8598 // • self.topo.clone() (~topology HashMap footprint)
8599 // • self.props.clone() (~column-store footprint)
8600 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8601 let meta = V8Meta {
8602 labels: self.labels.clone(),
8603 wal_truncated: !opts.keep_wal,
8604 // Move edge_props out so the large overlay is freed when meta
8605 // drops at end of this block (self.edge_props is now empty; reads
8606 // after base assignment go through the mmap'd base section).
8607 edge_props: std::mem::take(&mut self.edge_props),
8608 rule_defs,
8609 provenance,
8610 rule_tripped,
8611 rule_fires,
8612 ivf_bytes,
8613 view_defs,
8614 hnsw: hnsw_state,
8615 last_change: self.last_change.clone(),
8616 };
8617 let mut buf = Vec::new();
8618 encode_v8(
8619 None,
8620 None,
8621 None,
8622 None,
8623 &self.topo,
8624 &self.props,
8625 &self.ids,
8626 &self.syms,
8627 &meta,
8628 &mut buf,
8629 )?;
8630 // meta (and the moved edge_props inside it) is no longer needed;
8631 // drop it before the write to keep the peak window narrow.
8632 drop(meta);
8633 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8634 // Remap the freshly-written V8 snapshot as self.base.
8635 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8636 // On SimFs (tests): pass buf to from_bytes.
8637 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8638 drop(buf);
8639 core_storage::v8::MappedBase::map(&snap_path)
8640 } else {
8641 core_storage::v8::MappedBase::from_bytes(buf)
8642 }
8643 .map_err(|e| GraphError::Corrupt {
8644 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8645 })?;
8646 self.base = Some(Arc::new(new_base));
8647 // Free the large heap-allocated decoded state — all data is now in the
8648 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
8649 // self.edge_props was already moved into meta and is effectively empty.
8650 self.topo = Topology::new();
8651 self.props = core_storage::columns::ColumnStore::new();
8652 }
8653
8654 if opts.archive_wal {
8655 // History-preserving snapshot (Task 4):
8656 // 1. Snapshot already written above (write_atomic → fsynced).
8657 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
8658 // Crash window B: crash here leaves archive present, WAL
8659 // absent. Reopen: snapshot loaded (full state), no WAL
8660 // replay. Archive is NOT replayed into live state — it is
8661 // pre-snapshot by construction. Safe.
8662 // 3. Optionally write genesis marker (first archive only, no
8663 // prior WAL truncation).
8664 // 4. Prune old archives (retention), update horizon floor.
8665 // Pruning invalidates the genesis chain; delete marker.
8666 // 5. Write new minimal baseline WAL (write_atomic).
8667 // Crash window C: crash here leaves new archive plus no live
8668 // WAL. Same as window B — handled above.
8669 //
8670 // Sample existing archives BEFORE the rename so we can detect
8671 // whether this is the first archive.
8672 let existing_archives = self.fs.list_archives()?;
8673 let is_first_archive = existing_archives.is_empty();
8674
8675 // Compute a globally-monotonic archive name: the name equals the
8676 // cumulative end-frame index of the archive in global commit space.
8677 //
8678 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8679 // commit_seq is seeded from max(last_change), which underestimates
8680 // the WAL depth when trailing commits (e.g. insert_edge) do not
8681 // update last_change. A session-2 archive could then receive a name
8682 // ≤ the session-1 archive, causing incorrect sort order or collision.
8683 //
8684 // Instead: read and decode the live WAL here (before the rename) to
8685 // get its exact frame count, then add it to the last known global
8686 // end-frame index (the name of the most recent existing archive, or
8687 // wal_horizon_floor if no archives exist). This is O(WAL size) but
8688 // snapshot is already serialising the full graph state, so the cost
8689 // is dominated.
8690 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
8691 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
8692 let archive_n = existing_archives
8693 .last()
8694 .copied()
8695 .unwrap_or(self.wal_horizon_floor)
8696 + live_frames_for_name.len() as u64;
8697 self.fs.archive_wal(archive_n)?;
8698
8699 // Genesis marker: written once when the first archive is taken
8700 // from a store that has never undergone a WAL-truncating snapshot.
8701 // When present, `open_at` may replay archive-resident commits from
8702 // empty state (the archive chain covers from global index 0).
8703 //
8704 // Two conditions must ALL hold:
8705 // 1. This is the first archive (existing_archives was empty).
8706 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
8707 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
8708 // before truncating the WAL, so if any prior truncating snapshot was taken
8709 // — even in a previous session — snapshot.bin is present and this condition
8710 // is false. This subsumes the cross-session truncation case without
8711 // requiring a separate wal.truncated sidecar file.
8712 // For legacy stores (snapshot.bin written by an older code version that
8713 // may have truncated the WAL), the same conservative refusal applies:
8714 // we cannot prove the chain is complete, so we refuse genesis (cost =
8715 // no as-of-through-archives; never silent wrong data).
8716 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
8717 // so SimFs always passes this check.
8718 if is_first_archive && !had_prior_snapshot {
8719 self.fs.write_genesis_marker()?;
8720 self.archive_genesis_chain = true;
8721 }
8722
8723 // Retention pruning: keep newest `keep` archives; delete oldest.
8724 // Pruning is the ONLY deletion site for archives.
8725 //
8726 // Crash-safety ordering (C1 fix):
8727 // 1. Count frames in surplus archives (reads only — no mutation).
8728 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
8729 // rename (atomic). A crash after this point leaves orphaned
8730 // archives on disk, but the floor is correct. The opening
8731 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
8732 // the next open, so the store is always safe to reopen.
8733 // 3. Delete the genesis marker (floor > 0 already blocks open_at
8734 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
8735 // 4. Delete surplus archives. A crash between any two deletes
8736 // leaves the floor committed and orphaned archives cleaned at
8737 // next open — never a stale floor with a missing archive prefix.
8738 if let Some(keep) = self.wal_archive_retention {
8739 if keep > 0 {
8740 let archives = self.fs.list_archives()?;
8741 // archives is sorted ascending (oldest first)
8742 if archives.len() as u32 > keep {
8743 let surplus = archives.len() - keep as usize;
8744 // Step 1: count pruned frames (reads, no mutation).
8745 let mut pruned_frames = 0u64;
8746 for &n in &archives[..surplus] {
8747 let bytes = self.fs.read_archive(n)?;
8748 let (frames, _) = decode_all(&bytes);
8749 pruned_frames += frames.len() as u64;
8750 }
8751 // Step 2: advance and persist floor FIRST.
8752 self.wal_horizon_floor += pruned_frames;
8753 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
8754 // Step 3: delete genesis marker (floor > 0 already
8755 // blocks open_at; this is belt-and-suspenders cleanup).
8756 if pruned_frames > 0 && self.archive_genesis_chain {
8757 self.fs.delete_genesis_marker()?;
8758 self.archive_genesis_chain = false;
8759 }
8760 // Step 4: delete surplus archives. Crash here →
8761 // orphaned archives; cleaned at next open.
8762 for &n in &archives[..surplus] {
8763 self.fs.delete_archive(n)?;
8764 }
8765 }
8766 }
8767 }
8768
8769 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
8770 let mut baseline_wal: Vec<u8> = Vec::new();
8771 for (label, field) in self.fulltext.enabled_pairs() {
8772 let rec = WalRecord::EnableFulltext {
8773 label: label.clone(),
8774 field: field.clone(),
8775 };
8776 baseline_wal.extend_from_slice(&encode_record(&rec));
8777 }
8778 for (label, field) in self.prop_index.enabled_pairs() {
8779 let rec = WalRecord::EnableIndex {
8780 label: label.clone(),
8781 field: field.clone(),
8782 };
8783 baseline_wal.extend_from_slice(&encode_record(&rec));
8784 }
8785 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8786 } else if opts.keep_wal {
8787 // keep_wal=true: WAL is left untouched. The existing WAL already
8788 // contains the EnableFulltext records from the original enable calls;
8789 // replay is idempotent (guards in apply() skip already-live entries).
8790 // No baseline re-write is needed or safe here — the full WAL history
8791 // must remain intact for open_at to reach pre-snapshot commits.
8792 } else {
8793 // keep_wal=false (default): truncate by replacing the WAL with a
8794 // minimal baseline of one EnableFulltext record per active pair.
8795 //
8796 // Crash-ordering: write_atomic is atomic.
8797 // • Crash before snapshot write → WAL unchanged. Safe.
8798 // • Crash after snapshot write but before this WAL write → full
8799 // pre-snapshot WAL still present; open_with replays idempotently.
8800 // • Crash after both writes → normal post-snapshot state.
8801 //
8802 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
8803 // for any archives taken AFTER this point (their WAL slices would
8804 // not start at genesis). Delete any existing genesis marker so that
8805 // open_at refuses archive-resident commits. Future sessions are
8806 // covered by had_prior_snapshot: snapshot.bin written here persists
8807 // across sessions and prevents a later archiving session from
8808 // incorrectly claiming a complete genesis chain.
8809 if self.archive_genesis_chain {
8810 self.fs.delete_genesis_marker()?;
8811 self.archive_genesis_chain = false;
8812 }
8813 let mut baseline_wal: Vec<u8> = Vec::new();
8814 for (label, field) in self.fulltext.enabled_pairs() {
8815 let rec = WalRecord::EnableFulltext {
8816 label: label.clone(),
8817 field: field.clone(),
8818 };
8819 baseline_wal.extend_from_slice(&encode_record(&rec));
8820 }
8821 for (label, field) in self.prop_index.enabled_pairs() {
8822 let rec = WalRecord::EnableIndex {
8823 label: label.clone(),
8824 field: field.clone(),
8825 };
8826 baseline_wal.extend_from_slice(&encode_record(&rec));
8827 }
8828 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8829 }
8830 // After snapshot the overlay may have changed (V8 merge path clears
8831 // self.topo and self.props). Refresh the MVCC fold so future readers
8832 // see the post-snapshot state rather than stale overlay data.
8833 self.fold_now();
8834 Ok(())
8835 }
8836}
8837
8838/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
8839///
8840/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
8841/// callers can build a set of mutations without holding `&mut GraphDb` and
8842/// hand them off to the group-committing writer for durable, batched I/O.
8843pub enum BatchOp {
8844 InsertNode {
8845 label: String,
8846 key: String,
8847 props: Vec<(String, Value)>,
8848 },
8849 InsertEdge {
8850 edge_type: String,
8851 src_key: String,
8852 dst_key: String,
8853 },
8854 SetProp {
8855 key: String,
8856 field: String,
8857 value: Value,
8858 },
8859 RemoveProp {
8860 key: String,
8861 field: String,
8862 },
8863 DeleteEdge {
8864 edge_type: String,
8865 src_key: String,
8866 dst_key: String,
8867 },
8868 DeleteNode {
8869 key: String,
8870 },
8871 CreateRule(RuleDef),
8872 DeleteRule {
8873 name: String,
8874 },
8875 /// Rename a node's key. Validated: old must exist, new must not.
8876 RenameNode {
8877 old_key: String,
8878 new_key: String,
8879 },
8880 /// Insert an edge, auto-creating any missing endpoint as a plain node with
8881 /// `placeholder_label` and no props. Rules fire and last-change is updated
8882 /// for each created endpoint (normal InsertNode semantics in the batch frame).
8883 InsertEdgeUpsert {
8884 edge_type: String,
8885 src_key: String,
8886 dst_key: String,
8887 placeholder_label: String,
8888 },
8889}
8890
8891/// Three-way node visibility status used by `check_single_op_authz`.
8892enum NodeAuthzStatus {
8893 /// Node exists in the store and is in the role's read mask.
8894 Visible(String), // carries the node's label
8895 /// Node exists in the store but is NOT in the role's read mask.
8896 Hidden,
8897 /// Node does not exist in the store.
8898 Absent,
8899}
8900
8901/// Overlay of ops already accepted earlier in the same batch. Never written
8902/// back to the database — validation only.
8903#[derive(Default)]
8904struct Overlay {
8905 extra_keys: BTreeSet<String>,
8906 deleted_keys: BTreeSet<String>,
8907 extra_props: BTreeMap<(String, String), Value>,
8908 removed_props: BTreeSet<(String, String)>,
8909 extra_edges: BTreeSet<(String, String, String)>,
8910 deleted_edges: BTreeSet<(String, String, String)>,
8911 extra_rules: BTreeSet<String>,
8912 deleted_rules: BTreeSet<String>,
8913}
8914
8915/// Read-only view of live db state plus a batch overlay. Shared by single-op
8916/// public methods (empty overlay) and `commit_batch`.
8917struct MutPreview<'a, F: Fs> {
8918 db: &'a GraphDb<F>,
8919 overlay: Overlay,
8920}
8921
8922impl<'a, F: Fs> MutPreview<'a, F> {
8923 fn new(db: &'a GraphDb<F>) -> Self {
8924 Self {
8925 db,
8926 overlay: Overlay::default(),
8927 }
8928 }
8929
8930 fn has_key(&self, key: &str) -> bool {
8931 if self.overlay.extra_keys.contains(key) {
8932 return true;
8933 }
8934 if self.overlay.deleted_keys.contains(key) {
8935 return false;
8936 }
8937 self.db.ids.get(key).is_some()
8938 }
8939
8940 fn has_prop(&self, key: &str, field: &str) -> bool {
8941 if !self.has_key(key) {
8942 return false;
8943 }
8944 let k = (key.to_string(), field.to_string());
8945 if self.overlay.removed_props.contains(&k) {
8946 return false;
8947 }
8948 if self.overlay.extra_props.contains_key(&k) {
8949 return true;
8950 }
8951 // Fresh identity (first insert in this batch, or delete+reinsert):
8952 // ignore props still sitting on the soon-to-be-tombstoned slot.
8953 if self.overlay.extra_keys.contains(key) {
8954 return false;
8955 }
8956 self.db.get_prop(key, field).is_some()
8957 }
8958
8959 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8960 let k = (
8961 edge_type.to_string(),
8962 src_key.to_string(),
8963 dst_key.to_string(),
8964 );
8965 if self.overlay.deleted_edges.contains(&k) {
8966 return false;
8967 }
8968 if self.overlay.extra_edges.contains(&k) {
8969 return true;
8970 }
8971 // A key created in this batch (including reinsert) has no db edges.
8972 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8973 return false;
8974 }
8975 if self.overlay.deleted_keys.contains(src_key)
8976 || self.overlay.deleted_keys.contains(dst_key)
8977 {
8978 return false;
8979 }
8980 let Some(src) = self.db.ids.get(src_key) else {
8981 return false;
8982 };
8983 let Some(dst) = self.db.ids.get(dst_key) else {
8984 return false;
8985 };
8986 let Some(sym) = self.db.syms.get(edge_type) else {
8987 return false;
8988 };
8989 self.db
8990 .topo_view()
8991 .neighbors(sym, Direction::Out, src)
8992 .binary_search(&dst)
8993 .is_ok()
8994 }
8995
8996 fn has_rule(&self, name: &str) -> bool {
8997 if self.overlay.extra_rules.contains(name) {
8998 return true;
8999 }
9000 if self.overlay.deleted_rules.contains(name) {
9001 return false;
9002 }
9003 self.db.engine.rules().any(|r| r.name == name)
9004 }
9005
9006 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9007 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9008 return false;
9009 }
9010 if self.overlay.deleted_keys.contains(src_key)
9011 || self.overlay.deleted_keys.contains(dst_key)
9012 {
9013 return false;
9014 }
9015 let Some(src) = self.db.ids.get(src_key) else {
9016 return false;
9017 };
9018 let Some(dst) = self.db.ids.get(dst_key) else {
9019 return false;
9020 };
9021 let Some(et) = self.db.syms.get(edge_type) else {
9022 return false;
9023 };
9024 // extra_rules is deliberately not consulted: a CreateRule earlier in
9025 // this batch has not fired, so it contributes no provenance. That is
9026 // the documented rule-window gap (see GraphDb::batch).
9027 if self.overlay.deleted_rules.is_empty() {
9028 return self.db.engine.is_owned(et, src, dst);
9029 }
9030 for (rule, triples) in self.db.engine.provenance() {
9031 if self.overlay.deleted_rules.contains(rule) {
9032 continue;
9033 }
9034 if triples.contains(&(et, src, dst)) {
9035 return true;
9036 }
9037 }
9038 false
9039 }
9040
9041 fn check_insert_node(&self, key: &str) -> Result<()> {
9042 if self.has_key(key) {
9043 Err(GraphError::DuplicateKey { key: key.into() })
9044 } else {
9045 Ok(())
9046 }
9047 }
9048
9049 fn check_live_key(&self, key: &str) -> Result<()> {
9050 if self.has_key(key) {
9051 Ok(())
9052 } else {
9053 Err(GraphError::KeyNotFound { key: key.into() })
9054 }
9055 }
9056
9057 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9058 for k in [src_key, dst_key] {
9059 if !self.has_key(k) {
9060 return Err(GraphError::KeyNotFound { key: k.into() });
9061 }
9062 }
9063 if self.is_rule_owned(edge_type, src_key, dst_key) {
9064 return Err(GraphError::RuleOwned {
9065 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9066 });
9067 }
9068 Ok(!self.has_edge(edge_type, src_key, dst_key))
9069 }
9070
9071 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9072 self.check_live_key(key)?;
9073 Ok(self.has_prop(key, field))
9074 }
9075
9076 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9077 for k in [src_key, dst_key] {
9078 if !self.has_key(k) {
9079 return Err(GraphError::KeyNotFound { key: k.into() });
9080 }
9081 }
9082 // Provenance-owned OR a live rule would derive this pair. User-first
9083 // edges that a later rule matches are not in `owned`, but deleting
9084 // them would leave a hole `rebuild_rule` immediately fills.
9085 if self.is_rule_owned(edge_type, src_key, dst_key) {
9086 return Err(GraphError::RuleOwned {
9087 detail: format!(
9088 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9089 delete or change the owning rule"
9090 ),
9091 });
9092 }
9093 if self.would_derive(edge_type, src_key, dst_key) {
9094 return Err(GraphError::RuleOwned {
9095 detail: format!(
9096 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9097 delete or change the owning rule, or a live rule would re-derive it"
9098 ),
9099 });
9100 }
9101 Ok(self.has_edge(edge_type, src_key, dst_key))
9102 }
9103
9104 /// True if any live rule (minus overlay-deleted names) would derive
9105 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9106 /// CreateRule names in `extra_rules` are ignored — same documented
9107 /// same-batch rule-window as [`Self::is_rule_owned`].
9108 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9109 if src_key == dst_key {
9110 return false;
9111 }
9112 let Some(src_label) = self.label_of(src_key) else {
9113 return false;
9114 };
9115 let Some(dst_label) = self.label_of(dst_key) else {
9116 return false;
9117 };
9118 for rule in self.db.engine.rules() {
9119 if self.overlay.deleted_rules.contains(&rule.name) {
9120 continue;
9121 }
9122 if rule.edge_type != edge_type {
9123 continue;
9124 }
9125 if rule.src_label != src_label || rule.dst_label != dst_label {
9126 continue;
9127 }
9128 let src_props = |f: &str| self.prop_value(src_key, f);
9129 let dst_props = |f: &str| self.prop_value(dst_key, f);
9130 let src_view = NodeView {
9131 key: src_key,
9132 props: &src_props,
9133 };
9134 let dst_view = NodeView {
9135 key: dst_key,
9136 props: &dst_props,
9137 };
9138 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9139 return true;
9140 }
9141 }
9142 false
9143 }
9144
9145 fn label_of(&self, key: &str) -> Option<String> {
9146 if self.overlay.deleted_keys.contains(key) {
9147 return None;
9148 }
9149 // Fresh identities created in this batch have no stored label in the
9150 // overlay; they cannot be provenance-owned yet either.
9151 let id = self.db.ids.get(key)?;
9152 let sym = self.db.labels.get(id as usize).copied()?;
9153 if sym == u32::MAX {
9154 return None;
9155 }
9156 self.db.syms.resolve(sym).map(str::to_string)
9157 }
9158
9159 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9160 if !self.has_key(key) {
9161 return None;
9162 }
9163 let k = (key.to_string(), field.to_string());
9164 if self.overlay.removed_props.contains(&k) {
9165 return None;
9166 }
9167 if let Some(v) = self.overlay.extra_props.get(&k) {
9168 return Some(v.clone());
9169 }
9170 if self.overlay.extra_keys.contains(key) {
9171 return None;
9172 }
9173 self.db.get_prop(key, field)
9174 }
9175
9176 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9177 def.validate()
9178 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9179 if self.has_rule(&def.name) {
9180 return Err(GraphError::RuleInvalid {
9181 detail: format!("rule {:?} already exists", def.name),
9182 });
9183 }
9184 Ok(())
9185 }
9186
9187 fn check_delete_rule(&self, name: &str) -> Result<()> {
9188 if self.has_rule(name) {
9189 Ok(())
9190 } else {
9191 Err(GraphError::RuleNotFound { name: name.into() })
9192 }
9193 }
9194
9195 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9196 self.overlay.deleted_keys.remove(key);
9197 self.overlay.extra_keys.insert(key.to_string());
9198 self.overlay.extra_props.retain(|(k, _), _| k != key);
9199 self.overlay.removed_props.retain(|(k, _)| k != key);
9200 for (field, value) in props {
9201 self.overlay
9202 .extra_props
9203 .insert((key.to_string(), field.clone()), value.clone());
9204 }
9205 }
9206
9207 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9208 let k = (
9209 edge_type.to_string(),
9210 src_key.to_string(),
9211 dst_key.to_string(),
9212 );
9213 self.overlay.deleted_edges.remove(&k);
9214 self.overlay.extra_edges.insert(k);
9215 }
9216
9217 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9218 let k = (key.to_string(), field.to_string());
9219 self.overlay.removed_props.remove(&k);
9220 self.overlay.extra_props.insert(k, value.clone());
9221 }
9222
9223 fn note_remove_prop(&mut self, key: &str, field: &str) {
9224 let k = (key.to_string(), field.to_string());
9225 self.overlay.extra_props.remove(&k);
9226 self.overlay.removed_props.insert(k);
9227 }
9228
9229 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9230 let k = (
9231 edge_type.to_string(),
9232 src_key.to_string(),
9233 dst_key.to_string(),
9234 );
9235 self.overlay.extra_edges.remove(&k);
9236 self.overlay.deleted_edges.insert(k);
9237 }
9238
9239 fn note_delete_node(&mut self, key: &str) {
9240 self.overlay.extra_keys.remove(key);
9241 self.overlay.deleted_keys.insert(key.to_string());
9242 self.overlay.extra_props.retain(|(k, _), _| k != key);
9243 self.overlay.removed_props.retain(|(k, _)| k != key);
9244 self.overlay
9245 .extra_edges
9246 .retain(|(_, s, d)| s != key && d != key);
9247 self.overlay
9248 .deleted_edges
9249 .retain(|(_, s, d)| s != key && d != key);
9250 }
9251
9252 fn note_create_rule(&mut self, name: &str) {
9253 self.overlay.deleted_rules.remove(name);
9254 self.overlay.extra_rules.insert(name.to_string());
9255 }
9256
9257 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9258 if !self.has_key(old) {
9259 return Err(GraphError::KeyNotFound { key: old.into() });
9260 }
9261 if self.has_key(new) {
9262 return Err(GraphError::DuplicateKey { key: new.into() });
9263 }
9264 Ok(())
9265 }
9266
9267 fn note_rename_node(&mut self, old: &str, new: &str) {
9268 // Mark old as deleted so subsequent batch ops cannot reference it.
9269 self.overlay.extra_keys.remove(old);
9270 self.overlay.deleted_keys.insert(old.to_string());
9271 // Mark new as extra so subsequent batch ops can reference it.
9272 self.overlay.deleted_keys.remove(new);
9273 self.overlay.extra_keys.insert(new.to_string());
9274 // Migrate any overlay props from old key to new key.
9275 let new_str = new.to_string();
9276 let transferred: Vec<((String, String), Value)> = self
9277 .overlay
9278 .extra_props
9279 .iter()
9280 .filter(|((k, _), _)| k.as_str() == old)
9281 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9282 .collect();
9283 self.overlay
9284 .extra_props
9285 .retain(|(k, _), _| k.as_str() != old);
9286 for (k, v) in transferred {
9287 self.overlay.extra_props.insert(k, v);
9288 }
9289 // Migrate removed_props.
9290 let transferred_removed: Vec<(String, String)> = self
9291 .overlay
9292 .removed_props
9293 .iter()
9294 .filter(|(k, _)| k.as_str() == old)
9295 .map(|(_, f)| (new_str.clone(), f.clone()))
9296 .collect();
9297 self.overlay
9298 .removed_props
9299 .retain(|(k, _)| k.as_str() != old);
9300 for k in transferred_removed {
9301 self.overlay.removed_props.insert(k);
9302 }
9303 }
9304
9305 fn note_delete_rule(&mut self, name: &str) {
9306 self.overlay.extra_rules.remove(name);
9307 self.overlay.deleted_rules.insert(name.to_string());
9308 // Treat the deleted rule's current provenance as gone so a later
9309 // delete_edge of those triples is a no-op (matches sequential).
9310 if let Some(triples) = self.db.engine.provenance().get(name) {
9311 for &(et, s, d) in triples {
9312 let Some(etype) = self.db.syms.resolve(et) else {
9313 continue;
9314 };
9315 let Some(src) = self.db.ids.key_of(s) else {
9316 continue;
9317 };
9318 let Some(dst) = self.db.ids.key_of(d) else {
9319 continue;
9320 };
9321 let k = (etype.to_string(), src.to_string(), dst.to_string());
9322 self.overlay.extra_edges.remove(&k);
9323 self.overlay.deleted_edges.insert(k);
9324 }
9325 }
9326 }
9327}
9328
9329/// Collects mutations and commits them as one WAL `Batch` frame.
9330///
9331/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9332/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9333/// See [`GraphDb::batch`] for validation and atomicity rules.
9334pub struct BatchBuilder<'a, F: Fs> {
9335 db: &'a mut GraphDb<F>,
9336 ops: Vec<BatchOp>,
9337}
9338
9339impl<'a, F: Fs> BatchBuilder<'a, F> {
9340 pub fn insert_node(
9341 &mut self,
9342 label: &str,
9343 key: &str,
9344 props: Vec<(String, Value)>,
9345 ) -> &mut Self {
9346 self.ops.push(BatchOp::InsertNode {
9347 label: label.into(),
9348 key: key.into(),
9349 props,
9350 });
9351 self
9352 }
9353
9354 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9355 self.ops.push(BatchOp::InsertEdge {
9356 edge_type: edge_type.into(),
9357 src_key: src_key.into(),
9358 dst_key: dst_key.into(),
9359 });
9360 self
9361 }
9362
9363 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9364 self.ops.push(BatchOp::SetProp {
9365 key: key.into(),
9366 field: field.into(),
9367 value,
9368 });
9369 self
9370 }
9371
9372 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9373 self.ops.push(BatchOp::RemoveProp {
9374 key: key.into(),
9375 field: field.into(),
9376 });
9377 self
9378 }
9379
9380 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9381 self.ops.push(BatchOp::DeleteEdge {
9382 edge_type: edge_type.into(),
9383 src_key: src_key.into(),
9384 dst_key: dst_key.into(),
9385 });
9386 self
9387 }
9388
9389 pub fn delete_node(&mut self, key: &str) -> &mut Self {
9390 self.ops.push(BatchOp::DeleteNode { key: key.into() });
9391 self
9392 }
9393
9394 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9395 self.ops.push(BatchOp::CreateRule(def));
9396 self
9397 }
9398
9399 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9400 self.ops.push(BatchOp::DeleteRule { name: name.into() });
9401 self
9402 }
9403
9404 /// Queue a node-rename in this batch.
9405 ///
9406 /// Validation (old exists, new not taken) runs at commit time.
9407 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9408 self.ops.push(BatchOp::RenameNode {
9409 old_key: old_key.into(),
9410 new_key: new_key.into(),
9411 });
9412 self
9413 }
9414
9415 /// Queue an edge insert with endpoint auto-creation.
9416 ///
9417 /// Any missing endpoint is created as a plain node `{key, label:
9418 /// placeholder_label, no props}` inside this batch frame. Rules fire and
9419 /// last-change is updated for each auto-created node.
9420 pub fn insert_edge_upsert(
9421 &mut self,
9422 edge_type: &str,
9423 src_key: &str,
9424 dst_key: &str,
9425 placeholder_label: &str,
9426 ) -> &mut Self {
9427 self.ops.push(BatchOp::InsertEdgeUpsert {
9428 edge_type: edge_type.into(),
9429 src_key: src_key.into(),
9430 dst_key: dst_key.into(),
9431 placeholder_label: placeholder_label.into(),
9432 });
9433 self
9434 }
9435
9436 /// Validate every queued op, then log one `Batch` frame and apply.
9437 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9438 /// A second `commit()` after a successful one is an empty-batch no-op
9439 /// (queued ops were taken).
9440 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9441 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9442 ///
9443 /// **Rule-window limitation:** batch validation cannot see edges that a
9444 /// rule created earlier in the *same* batch will derive at apply time, so
9445 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9446 /// where sequential calls would return `Err(RuleOwned)`. State integrity
9447 /// is unaffected (idempotent apply, provenance intact). Create rules in
9448 /// their own batch, or sequentially, when later ops may touch derived
9449 /// edges.
9450 /// Validate every queued op and commit atomically.
9451 ///
9452 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9453 /// WAL records actually written (duplicate edges are silent no-ops and are
9454 /// NOT counted). Both are 0 when the batch is empty or all-noop.
9455 pub fn commit(&mut self) -> Result<(usize, usize)> {
9456 let ops = std::mem::take(&mut self.ops);
9457 self.db.commit_batch(ops)
9458 }
9459
9460 /// Same as [`commit`](Self::commit) but tail the inner events with
9461 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9462 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9463 let ops = std::mem::take(&mut self.ops);
9464 self.db
9465 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9466 }
9467}
9468
9469pub struct NodeRef<'a, F: Fs> {
9470 db: &'a GraphDb<F>,
9471 id: u32,
9472}
9473
9474impl<'a, F: Fs> NodeRef<'a, F> {
9475 pub fn key(&self) -> &str {
9476 self.db.ids.key_of(self.id).expect("dense ids")
9477 }
9478
9479 pub fn label(&self) -> &str {
9480 let sym = self
9481 .db
9482 .labels
9483 .get(self.id as usize)
9484 .copied()
9485 .filter(|&s| s != u32::MAX)
9486 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9487 self.db.syms.resolve(sym).expect("interned label symbol")
9488 }
9489
9490 pub fn prop(&self, field: &str) -> Option<Value> {
9491 self.db
9492 .props_view()
9493 .get(self.id, field)
9494 .map(|vr| vr.into_value())
9495 }
9496
9497 /// All stored fields for this node, sorted by field name.
9498 ///
9499 /// Reads from the full base+overlay view so that props stored only in the
9500 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9501 pub fn props(&self) -> BTreeMap<String, Value> {
9502 let mut out = BTreeMap::new();
9503 let pv = self.db.props_view();
9504 for field in pv.field_names() {
9505 if let Some(vr) = pv.get(self.id, &field) {
9506 out.insert(field, vr.into_value());
9507 }
9508 }
9509 out
9510 }
9511
9512 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9513 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9514 let view = self.db.view();
9515 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9516 names
9517 .iter()
9518 .filter_map(|name| view.syms.get(name))
9519 .collect()
9520 });
9521 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9522 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9523 for (nid, d) in nb.nodes {
9524 let key = view.key_of(nid);
9525 let label = view
9526 .label_of(nid)
9527 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9528 rs.push_row(vec![
9529 Some(Value::Str(key.to_string())),
9530 Some(Value::Str(label.to_string())),
9531 Some(Value::Int(d as i64)),
9532 ]);
9533 }
9534 rs
9535 }
9536
9537 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9538 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9539 let view = self.db.view();
9540 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9541 for e in expand(&view, self.id, None, Dir::Both) {
9542 // Skip edges with unknown etypes (only possible from corrupt large
9543 // TOPOLOGY section; function returns BTreeMap not Result).
9544 let Some(etype) = view.syms.resolve(e.etype) else {
9545 continue;
9546 };
9547 let etype = etype.to_string();
9548 let nbr = if e.src == self.id { e.dst } else { e.src };
9549 groups
9550 .entry(etype)
9551 .or_default()
9552 .insert(view.key_of(nbr).to_string());
9553 }
9554 groups
9555 .into_iter()
9556 .map(|(k, v)| (k, v.into_iter().collect()))
9557 .collect()
9558 }
9559}
9560
9561#[cfg(test)]
9562mod tests {
9563 use super::*;
9564 use core_rules::Predicate;
9565
9566 fn tmp_dir(name: &str) -> std::path::PathBuf {
9567 let d =
9568 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9569 let _ = std::fs::remove_dir_all(&d);
9570 d
9571 }
9572
9573 fn fk_rule() -> RuleDef {
9574 RuleDef {
9575 name: "works_at".into(),
9576 src_label: "Person".into(),
9577 dst_label: "Org".into(),
9578 predicate: Predicate::KeyMatch {
9579 field: "org_id".into(),
9580 },
9581 edge_type: "WORKS_AT".into(),
9582 weight_prop: None,
9583 max_edges: None,
9584 approximate: false,
9585 via_label: None,
9586 via_edge: None,
9587 via_dir: None,
9588 }
9589 }
9590
9591 /// Regression guard for the no-views delta-copy fast path.
9592 ///
9593 /// When no views are defined, `pending_deltas_since().to_vec()` must never
9594 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9595 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9596 /// a count of 0 after the entire sequence proves the guard fires correctly.
9597 #[test]
9598 fn no_delta_copy_when_no_views() {
9599 DELTA_COPY_COUNT.with(|c| c.set(0));
9600 let dir = tmp_dir("no-delta-copy");
9601 {
9602 let mut db = GraphDb::open(&dir).unwrap();
9603 // Insert 50 Org + 50 Person nodes with FK links.
9604 for i in 0..50u32 {
9605 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9606 }
9607 for i in 0..50u32 {
9608 db.insert_node(
9609 "Person",
9610 &format!("p{i}"),
9611 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9612 )
9613 .unwrap();
9614 }
9615 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9616 db.create_rule(fk_rule()).unwrap();
9617
9618 // Counter must stay 0 — no views, no copies.
9619 let copies = DELTA_COPY_COUNT.with(|c| c.get());
9620 assert_eq!(
9621 copies, 0,
9622 "pending_deltas_since().to_vec() called despite no views"
9623 );
9624
9625 // Derived edges must still be correct (the guard skips only the
9626 // empty delta propagation loop, not the rule application itself).
9627 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9628 assert_eq!(
9629 nbrs,
9630 vec!["o0"],
9631 "rule must derive edges even with no views"
9632 );
9633 }
9634 let _ = std::fs::remove_dir_all(&dir);
9635 }
9636
9637 /// Gating regression: subscribe AFTER a backfill must see no stale events.
9638 /// subscribe BEFORE a backfill must see every edge-fire event.
9639 #[test]
9640 fn subscribe_after_backfill_no_stale_events() {
9641 let dir = tmp_dir("sub-after-backfill");
9642 {
9643 let mut db = GraphDb::open(&dir).unwrap();
9644 for i in 0..10u32 {
9645 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9646 db.insert_node(
9647 "Person",
9648 &format!("p{i}"),
9649 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9650 )
9651 .unwrap();
9652 }
9653 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9654 db.create_rule(fk_rule()).unwrap();
9655
9656 // Subscribe AFTER the backfill — queue must be empty (no stale events).
9657 let sub = db.subscribe_all_rules().unwrap();
9658 // No events should have queued for the prior backfill.
9659 assert!(
9660 sub.try_recv().is_none(),
9661 "subscribe after backfill must see no stale events"
9662 );
9663
9664 // Inserting a new node now should fire an event (emit_deltas is now true).
9665 db.insert_node("Org", "o_new", vec![]).unwrap();
9666 db.insert_node(
9667 "Person",
9668 "p_new",
9669 vec![("org_id".into(), Value::Str("o_new".into()))],
9670 )
9671 .unwrap();
9672 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9673 assert!(
9674 ev.is_some(),
9675 "edge-fire event must arrive after subscribe (emit_deltas=true)"
9676 );
9677 }
9678 let _ = std::fs::remove_dir_all(&dir);
9679 }
9680
9681 /// Gating regression: subscribe BEFORE a backfill → events flow.
9682 #[test]
9683 fn subscribe_before_backfill_events_flow() {
9684 let dir = tmp_dir("sub-before-backfill");
9685 {
9686 let mut db = GraphDb::open(&dir).unwrap();
9687 // Subscribe FIRST — emit_deltas becomes true.
9688 let sub = db.subscribe_all_rules().unwrap();
9689
9690 for i in 0..5u32 {
9691 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9692 db.insert_node(
9693 "Person",
9694 &format!("p{i}"),
9695 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9696 )
9697 .unwrap();
9698 }
9699 // Backfill fires with emit_deltas=true → events queued.
9700 db.create_rule(fk_rule()).unwrap();
9701
9702 // Should receive at least one edge-fired event from the backfill.
9703 let mut received = 0usize;
9704 while sub.try_recv().is_some() {
9705 received += 1;
9706 }
9707 assert!(
9708 received > 0,
9709 "subscribe before backfill must receive edge-fire events (got 0)"
9710 );
9711 }
9712 let _ = std::fs::remove_dir_all(&dir);
9713 }
9714
9715 /// Companion: when a view IS defined, the delta path fires and view values update.
9716 #[test]
9717 fn delta_copy_fires_when_view_exists() {
9718 use core_rules::ViewSource;
9719 DELTA_COPY_COUNT.with(|c| c.set(0));
9720 let dir = tmp_dir("delta-copy-with-view");
9721 {
9722 let mut db = GraphDb::open(&dir).unwrap();
9723 db.insert_node("Org", "o1", vec![]).unwrap();
9724 db.insert_node(
9725 "Person",
9726 "p1",
9727 vec![("org_id".into(), Value::Str("o1".into()))],
9728 )
9729 .unwrap();
9730 // Declare a Degree view so is_empty() returns false.
9731 db.create_view(ViewDef {
9732 name: "degree_out".into(),
9733 label: "Person".into(),
9734 view_prop: "degree_out".into(),
9735 source: ViewSource::Degree {
9736 edge_type: "WORKS_AT".into(),
9737 direction: Direction::Out,
9738 },
9739 })
9740 .unwrap();
9741 db.create_rule(fk_rule()).unwrap();
9742
9743 // At least one delta copy should have happened (CreateRule backfill).
9744 let copies = DELTA_COPY_COUNT.with(|c| c.get());
9745 assert!(
9746 copies > 0,
9747 "expected delta copy to fire when a view is defined"
9748 );
9749
9750 // View value should be computed: p1 has one WORKS_AT out-edge.
9751 let info = db.node_info("p1").unwrap();
9752 let degree = info.props.get("degree_out");
9753 assert!(
9754 degree.is_some(),
9755 "view prop should be written to node props"
9756 );
9757 }
9758 let _ = std::fs::remove_dir_all(&dir);
9759 }
9760
9761 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
9762 /// derived-edge-driven view values reflect the as-of state rather than just
9763 /// the initial backfill written at `CreateView` time.
9764 ///
9765 /// Base WAL frames (indices 0..=5 before history markers):
9766 /// 0: insert Org "o1"
9767 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
9768 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
9769 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
9770 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
9771 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
9772 ///
9773 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
9774 /// no-op), so the total commit count is higher than the base frame count.
9775 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
9776 ///
9777 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
9778 /// initial backfill value (0) instead of reflecting the replayed derived edges.
9779 #[test]
9780 fn open_at_derived_edge_view_values_correct() {
9781 use core_rules::ViewSource;
9782 let dir = tmp_dir("open-at-view-rebuild");
9783 {
9784 let mut db = GraphDb::open(&dir).unwrap();
9785 // frame 0
9786 db.insert_node("Org", "o1", vec![]).unwrap();
9787 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
9788 db.create_view(ViewDef {
9789 name: "employee_count".into(),
9790 label: "Org".into(),
9791 view_prop: "emp".into(),
9792 source: ViewSource::Degree {
9793 edge_type: "WORKS_AT".into(),
9794 direction: Direction::In,
9795 },
9796 })
9797 .unwrap();
9798 // frame 2: create rule — no Persons yet; backfill is a no-op
9799 db.create_rule(fk_rule()).unwrap();
9800 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
9801 db.insert_node(
9802 "Person",
9803 "p1",
9804 vec![("org_id".into(), Value::Str("o1".into()))],
9805 )
9806 .unwrap();
9807 // frame 4: p2 — degree = 2
9808 db.insert_node(
9809 "Person",
9810 "p2",
9811 vec![("org_id".into(), Value::Str("o1".into()))],
9812 )
9813 .unwrap();
9814 // frame 5: p3 — degree = 3
9815 db.insert_node(
9816 "Person",
9817 "p3",
9818 vec![("org_id".into(), Value::Str("o1".into()))],
9819 )
9820 .unwrap();
9821 // Sanity: normal open sees degree = 3.
9822 assert_eq!(
9823 db.get_view_prop("o1", "emp"),
9824 Some(Value::Int(3)),
9825 "normal db must show degree 3 after 3 derived edges"
9826 );
9827 } // WAL flushed
9828
9829 // Re-open normally to get the authoritative reference value.
9830 let normal_db = GraphDb::open(&dir).unwrap();
9831 let normal_emp = normal_db.get_view_prop("o1", "emp");
9832 assert_eq!(
9833 normal_emp,
9834 Some(Value::Int(3)),
9835 "re-opened normal db must show degree 3"
9836 );
9837
9838 // Latest as-of (last WAL commit): must match the normal open.
9839 // History-marker frames are appended after each rule-fire, so the total
9840 // commit count is computed dynamically rather than hardcoded.
9841 let total = crate::wal_commit_count_at(&dir).unwrap();
9842 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
9843 assert_eq!(
9844 aof_latest.get_view_prop("o1", "emp"),
9845 normal_emp,
9846 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
9847 );
9848
9849 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
9850 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
9851 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
9852 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
9853 assert_eq!(
9854 aof_mid.get_view_prop("o1", "emp"),
9855 Some(Value::Int(1)),
9856 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
9857 );
9858
9859 let _ = std::fs::remove_dir_all(&dir);
9860 }
9861
9862 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
9863 /// as-of instances never commit, so distribute_events never runs and any
9864 /// subscription would wait forever.
9865 #[test]
9866 fn subscribe_on_as_of_returns_read_only_error() {
9867 let dir = tmp_dir("sub-as-of-read-only");
9868 {
9869 let mut db = GraphDb::open(&dir).unwrap();
9870 db.insert_node("Org", "o1", vec![]).unwrap();
9871 db.create_rule(fk_rule()).unwrap();
9872 }
9873 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
9874
9875 assert!(
9876 matches!(
9877 aof.subscribe_all_rules(),
9878 Err(core_storage::GraphError::ReadOnly)
9879 ),
9880 "subscribe_all_rules on as-of must return ReadOnly"
9881 );
9882 assert!(
9883 matches!(
9884 aof.subscribe_writes(),
9885 Err(core_storage::GraphError::ReadOnly)
9886 ),
9887 "subscribe_writes on as-of must return ReadOnly"
9888 );
9889 assert!(
9890 matches!(
9891 aof.subscribe_rule("works_at"),
9892 Err(core_storage::GraphError::ReadOnly)
9893 ),
9894 "subscribe_rule on as-of must return ReadOnly"
9895 );
9896 let _ = std::fs::remove_dir_all(&dir);
9897 }
9898
9899 /// Regression: a failed dense WAL rewrite must not leave speculative
9900 /// interns in `syms`. If it does, the next successful mutation logs an
9901 /// `Intern` record with an inflated id; replay (which never saw the
9902 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
9903 #[test]
9904 fn dense_rewrite_error_rolls_back_speculative_interns() {
9905 let dir = tmp_dir("dense-rewrite-rollback");
9906 {
9907 let mut db = GraphDb::open(&dir).unwrap();
9908 db.insert_node("Person", "a", vec![]).unwrap();
9909
9910 // Bypass MutPreview validation to hit the rewrite's own error path
9911 // (same shape as an id-exhaustion failure mid-rewrite). The
9912 // InsertEdge arm interns the edge type before it resolves keys.
9913 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
9914 edge_type: "ORPHAN_TYPE".into(),
9915 src_key: "missing".into(),
9916 dst_key: "a".into(),
9917 }]);
9918 assert!(err.is_err(), "rewrite of a missing src key must fail");
9919 assert_eq!(
9920 db.syms.get("ORPHAN_TYPE"),
9921 None,
9922 "failed rewrite must roll back speculative interns"
9923 );
9924
9925 // A later successful mutation must produce a replayable WAL.
9926 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
9927 }
9928 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
9929 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
9930 let _ = std::fs::remove_dir_all(&dir);
9931 }
9932}