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, is_subscribable, is_write_tokens, lex, parse, parse_write, plan, MatchDeleteNodeStmt,
9 NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal, WriteStatement,
10};
11use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
12use core_rules::{
13 decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
14 ViewDef, ViewStore,
15};
16use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
17use core_storage::fulltext::FulltextIndex;
18use core_storage::v8::encode::{
19 archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
20 archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
21};
22use core_storage::v8::seam::TopologyView;
23use core_storage::wal::{decode_all, encode_record, WalRecord};
24use core_storage::EdgePropsView;
25use core_storage::{
26 ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
27};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, BTreeSet, HashMap};
30use std::sync::Arc;
31
32/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
33/// Zero-cost when the env var is absent (the var check is O(1) after first call).
34macro_rules! trace_open {
35 ($phase:literal, $t:expr) => {
36 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
37 eprintln!(
38 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
39 $phase,
40 $t.elapsed()
41 );
42 }
43 };
44}
45
46/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
47/// Zero-cost when the env var is absent (the var check is O(1) after first call).
48macro_rules! trace_migrate {
49 ($phase:literal, $t:expr) => {
50 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
51 eprintln!(
52 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
53 $phase,
54 $t.elapsed()
55 );
56 }
57 };
58}
59
60// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
61// executes (i.e., at least one view is defined). Used to verify the fast-path
62// guard skips the allocation when `view_store.is_empty()`.
63#[cfg(test)]
64thread_local! {
65 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
66}
67
68/// Internal state for a single `subscribe_query` subscription.
69///
70/// On every commit, `distribute_events` re-executes `ops` against the current
71/// graph state, diffs the result against `prev_rows`, and pushes
72/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
73///
74/// **Full re-run per commit; use LIMIT to bound execution cost.**
75/// (Differential evaluation is roadmap / Phase 5.)
76pub(crate) struct QuerySubEntry {
77 /// Compiled plan for the subscribed Cypher query.
78 ops: Vec<PlanOp>,
79 /// Column names from the first execution (fixed for the subscription lifetime).
80 columns: Vec<String>,
81 /// Serialized (JSON) row key → row data, representing the result set at
82 /// the end of the last commit. Used to diff against the new result.
83 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
84 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
85 inner: std::sync::Weak<SubInner>,
86}
87
88/// A post-commit mutation notification.
89///
90/// Emitted from `log_then_apply` after the WAL append, fsync, and
91/// in-memory `apply` all succeed. Never emitted for rejected operations
92/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
93/// deletes/removes). Event payloads carry user keys and rule names, never
94/// internal ids.
95///
96/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
97/// `apply` only. Emission lives exclusively in `log_then_apply`, so
98/// recovery is silent even if a sink were installed (it cannot be: the
99/// sink is in-memory and set after open).
100///
101/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
102/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
103/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
104/// or all-noop batch writes no WAL and emits nothing (including no summary).
105///
106/// **Derived edges:** rule-created or retracted edges are not individually
107/// evented — they are recoverable from the triggering mutation plus the live
108/// rule set. Only the triggering record is emitted.
109///
110/// **Wire form:** externally tagged snake_case JSON
111/// (`{"node_inserted":{"label":"A","key":"k"}}`).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(rename_all = "snake_case")]
114pub enum MutationEvent {
115 NodeInserted {
116 label: String,
117 key: String,
118 },
119 PropSet {
120 key: String,
121 field: String,
122 },
123 PropRemoved {
124 key: String,
125 field: String,
126 },
127 EdgeInserted {
128 edge_type: String,
129 src: String,
130 dst: String,
131 },
132 EdgeDeleted {
133 edge_type: String,
134 src: String,
135 dst: String,
136 },
137 NodeDeleted {
138 key: String,
139 },
140 RuleCreated {
141 name: String,
142 },
143 RuleDeleted {
144 name: String,
145 },
146 RuleRebuilt {
147 name: String,
148 },
149 BatchApplied {
150 ops: usize,
151 },
152 Ingested {
153 label: String,
154 inserted: usize,
155 },
156}
157
158fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
159 match rec {
160 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
161 label: label.clone(),
162 key: key.clone(),
163 }),
164 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
165 label: intern.resolve(*label)?.to_string(),
166 key: key.clone(),
167 }),
168 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
169 key: key.clone(),
170 field: field.clone(),
171 }),
172 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
173 key: ids.key_of(*id)?.to_string(),
174 field: intern.resolve(*field)?.to_string(),
175 }),
176 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
177 key: key.clone(),
178 field: field.clone(),
179 }),
180 WalRecord::InsertEdge {
181 edge_type,
182 src_key,
183 dst_key,
184 } => Some(MutationEvent::EdgeInserted {
185 edge_type: edge_type.clone(),
186 src: src_key.clone(),
187 dst: dst_key.clone(),
188 }),
189 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
190 edge_type: intern.resolve(*etype)?.to_string(),
191 src: ids.key_of(*src)?.to_string(),
192 dst: ids.key_of(*dst)?.to_string(),
193 }),
194 WalRecord::DeleteEdge {
195 edge_type,
196 src_key,
197 dst_key,
198 } => Some(MutationEvent::EdgeDeleted {
199 edge_type: edge_type.clone(),
200 src: src_key.clone(),
201 dst: dst_key.clone(),
202 }),
203 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
204 WalRecord::CreateRule { def_bytes } => {
205 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
206 Some(MutationEvent::RuleCreated { name: def.name })
207 }
208 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
209 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
210 WalRecord::Batch(_)
211 | WalRecord::CreateView { .. }
212 | WalRecord::DeleteView { .. }
213 | WalRecord::EnableFulltext { .. }
214 | WalRecord::DisableFulltext { .. }
215 | WalRecord::Intern { .. }
216 // History markers are no-ops for mutation events — they carry no new
217 // state and rules re-derive deterministically on replay.
218 | WalRecord::DerivedEdgeAdded { .. }
219 | WalRecord::DerivedEdgeRetracted { .. }
220 // RenameNode carries no node/edge count change; no special event.
221 | WalRecord::RenameNode { .. } => None,
222 }
223}
224
225/// Database-wide counters plus per-rule budget/fire stats.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct Stats {
228 pub nodes_live: usize,
229 pub nodes_tombstoned: usize,
230 pub edges: u64,
231 pub rules: Vec<RuleStats>,
232}
233
234/// One rule's provenance size, trip latch, and fire counter.
235///
236/// `tripped` is a one-way latch: once set, the engine adds no new edges for
237/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
238/// set then fits). `fires` counts `on_node_changed` evaluations plus
239/// backfill/rebuild participant ticks (rebuild counts even when it is a
240/// provenance no-op).
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct RuleStats {
243 pub name: String,
244 pub edges: u64,
245 pub tripped: bool,
246 pub fires: u64,
247 /// Whether this rule uses the approximate IVF-Flat candidate path.
248 pub approximate: bool,
249}
250
251/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
252/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct PredicateSummary {
255 pub kind: String,
256 pub fields: Vec<String>,
257 pub min: Option<f64>,
258 pub tolerance: Option<f64>,
259 pub km: Option<f64>,
260 pub parts: Option<Vec<PredicateSummary>>,
261 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
262 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
263 #[serde(default)]
264 pub approximate: bool,
265}
266
267impl From<&Predicate> for PredicateSummary {
268 fn from(p: &Predicate) -> Self {
269 match p {
270 Predicate::KeyMatch { field } => PredicateSummary {
271 kind: "key_match".into(),
272 fields: vec![field.clone()],
273 min: None,
274 tolerance: None,
275 km: None,
276 parts: None,
277 approximate: false,
278 },
279 Predicate::FieldEqual { field } => PredicateSummary {
280 kind: "field_equal".into(),
281 fields: vec![field.clone()],
282 min: None,
283 tolerance: None,
284 km: None,
285 parts: None,
286 approximate: false,
287 },
288 Predicate::Overlap { field, min } => PredicateSummary {
289 kind: "overlap".into(),
290 fields: vec![field.clone()],
291 min: Some(*min),
292 tolerance: None,
293 km: None,
294 parts: None,
295 approximate: false,
296 },
297 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
298 kind: "numeric_within".into(),
299 fields: vec![field.clone()],
300 min: None,
301 tolerance: Some(*tolerance),
302 km: None,
303 parts: None,
304 approximate: false,
305 },
306 Predicate::GeoRadius { field, km } => PredicateSummary {
307 kind: "geo_radius".into(),
308 fields: vec![field.clone()],
309 min: None,
310 tolerance: None,
311 km: Some(*km),
312 parts: None,
313 approximate: false,
314 },
315 Predicate::VectorSimilar { field, min } => PredicateSummary {
316 kind: "vector_similar".into(),
317 fields: vec![field.clone()],
318 min: Some(*min),
319 tolerance: None,
320 km: None,
321 parts: None,
322 approximate: false,
323 },
324 Predicate::All(inner) => {
325 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
326 let mut fields = Vec::new();
327 for part in &parts {
328 for f in &part.fields {
329 if !fields.contains(f) {
330 fields.push(f.clone());
331 }
332 }
333 }
334 PredicateSummary {
335 kind: "all".into(),
336 fields,
337 min: None,
338 tolerance: None,
339 km: None,
340 parts: Some(parts),
341 approximate: false,
342 }
343 }
344 Predicate::Any(inner) => {
345 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
346 let mut fields = Vec::new();
347 for part in &parts {
348 for f in &part.fields {
349 if !fields.contains(f) {
350 fields.push(f.clone());
351 }
352 }
353 }
354 PredicateSummary {
355 kind: "any".into(),
356 fields,
357 min: None,
358 tolerance: None,
359 km: None,
360 parts: Some(parts),
361 approximate: false,
362 }
363 }
364 }
365 }
366}
367
368/// Snapshot of a live node's key, label, and columnar properties.
369///
370/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
371/// regardless of insert order or the columnar store's `HashMap` iteration.
372///
373/// Deliberately does not derive `Serialize`: `Value`'s serde form is
374/// internally tagged. Wire JSON is built by `value_to_json` in the server.
375#[derive(Debug, Clone, PartialEq)]
376pub struct NodeInfo {
377 pub key: String,
378 pub label: String,
379 pub props: BTreeMap<String, Value>,
380}
381
382/// Counts returned by [`GraphDb::delete_node`].
383#[derive(Debug, Clone, PartialEq, Eq, Default)]
384pub struct DeleteReport {
385 /// Number of manual (user-inserted) edges removed.
386 pub manual_edges: u64,
387 /// Number of derived (rule-owned) edges retracted.
388 pub derived_edges: u64,
389}
390
391/// One directed edge incident on a node, with provenance membership.
392///
393/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
394/// Plan-8 `by_node` provenance index.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
396pub struct EdgeInfo {
397 pub edge_type: String,
398 pub src_key: String,
399 pub dst_key: String,
400 pub derived: bool,
401}
402
403/// An edge with mask-aware endpoint visibility.
404///
405/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
406/// mode — hidden endpoints carry `*_restricted: true`.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct MaskedEdge {
409 pub edge_type: String,
410 pub src_key: String,
411 /// `true` when `src_key` is in the DB but hidden from the mask.
412 pub src_restricted: bool,
413 pub dst_key: String,
414 /// `true` when `dst_key` is in the DB but hidden from the mask.
415 pub dst_restricted: bool,
416 pub derived: bool,
417}
418
419/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
420///
421/// `None` from that method means the key does not exist (→ 404).
422/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
423#[derive(Debug, PartialEq)]
424pub enum MaskedNodeResult {
425 Visible(NodeInfo),
426 /// Node exists in the DB but is hidden from this mask.
427 Restricted,
428}
429
430/// One rule-owned edge between two nodes, with the rule name, edge type,
431/// direction (src_key → dst_key), and weight if the rule stores one.
432#[derive(Debug, Clone, PartialEq, Serialize)]
433pub struct Explanation {
434 pub rule: String,
435 pub edge_type: String,
436 pub src_key: String,
437 pub dst_key: String,
438 pub weight: Option<f64>,
439 pub predicate: PredicateSummary,
440}
441
442/// Report returned by [`GraphDb::backup_to`].
443#[derive(Debug, Clone)]
444pub struct BackupReport {
445 /// Filenames copied into the destination directory (sorted ascending).
446 pub files: Vec<String>,
447 /// Total bytes written across all copied files.
448 pub bytes: u64,
449 /// `true` when the destination opened cleanly and passed post-copy checks.
450 ///
451 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
452 /// matched **and** the destination opened without error.
453 ///
454 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
455 /// CRC-check; `verified` is `true` when the destination opened and
456 /// replayed the WAL without error (record-level checksums in the WAL
457 /// provide the integrity signal, not section CRCs).
458 pub verified: bool,
459}
460
461/// One directed edge in export form, with optional rule attribution for derived edges.
462///
463/// Returned by [`GraphDb::all_edges_for_export`].
464#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
465pub struct ExportEdge {
466 pub edge_type: String,
467 pub src: String,
468 pub dst: String,
469 pub derived: bool,
470 /// Rule name that created this edge, if derived. `None` for manual edges.
471 pub rule: Option<String>,
472}
473
474/// Construct the standard write-query result set (columns: created, properties_set, deleted).
475fn write_result_set() -> ResultSet {
476 ResultSet::new(vec![
477 "created".into(),
478 "properties_set".into(),
479 "deleted".into(),
480 ])
481}
482
483fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
484 match op {
485 Operand::Lit(v) => Ok(v.clone()),
486 Operand::Param(name) => params
487 .get(name)
488 .cloned()
489 .ok_or_else(|| GraphError::QueryError {
490 detail: format!("missing parameter `{name}`"),
491 }),
492 _ => Err(GraphError::QueryError {
493 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
494 }),
495 }
496}
497
498fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
499 match op {
500 Operand::Prop { var, .. } | Operand::Var(var) => {
501 if !out.contains(var) {
502 out.push(var.clone());
503 }
504 }
505 Operand::FuncCall { args, .. } => {
506 for arg in args {
507 operand_node_vars(arg, out);
508 }
509 }
510 Operand::BinArith { left, right, .. } => {
511 operand_node_vars(left, out);
512 operand_node_vars(right, out);
513 }
514 Operand::Lit(_) | Operand::Param(_) => {}
515 }
516}
517
518fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
519 let mut out = Vec::new();
520 for item in items {
521 match &item.value {
522 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
523 if !out.contains(v) {
524 out.push(v.clone());
525 }
526 }
527 RetVal::FuncCall { args, .. } => {
528 for arg in args {
529 operand_node_vars(arg, &mut out);
530 }
531 }
532 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
533 RetVal::Agg { .. } => {}
534 }
535 }
536 out
537}
538
539fn add_var(out: &mut Vec<String>, v: &str) {
540 if !out.iter().any(|x| x == v) {
541 out.push(v.to_string());
542 }
543}
544
545fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
546 let mut out = Vec::new();
547 for p in pats {
548 if let Some(v) = &p.start.var {
549 add_var(&mut out, v);
550 }
551 for (_, dest) in &p.chain {
552 if let Some(v) = &dest.var {
553 add_var(&mut out, v);
554 }
555 }
556 }
557 out
558}
559
560fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
561 let mut out = Vec::new();
562 for p in pats {
563 for (rel, _) in &p.chain {
564 if rel.hops.is_none() {
565 if let Some(v) = &rel.var {
566 add_var(&mut out, v);
567 }
568 }
569 }
570 }
571 out
572}
573
574fn rel_type_alias(var: &str) -> String {
575 format!("__rt_{var}")
576}
577
578fn ret_column_name(item: &RetItem) -> String {
579 if let Some(alias) = &item.alias {
580 return alias.clone();
581 }
582 match &item.value {
583 RetVal::Var(v) => v.clone(),
584 RetVal::Prop { var, field } => format!("{var}.{field}"),
585 RetVal::FuncCall { name, args } => {
586 let arg_strs: Vec<String> = args
587 .iter()
588 .map(|a| match a {
589 Operand::Var(v) => v.clone(),
590 Operand::Prop { var, field } => format!("{var}.{field}"),
591 Operand::Lit(_) => "<lit>".to_string(),
592 Operand::Param(p) => format!("${p}"),
593 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
594 Operand::BinArith { .. } => "<arith>".to_string(),
595 })
596 .collect();
597 format!("{name}({})", arg_strs.join(", "))
598 }
599 RetVal::ScalarExpr(_) => "<expr>".to_string(),
600 RetVal::Agg { .. } => "<agg>".to_string(),
601 }
602}
603
604fn eval_set_return_operand<F: Fs>(
605 db: &GraphDb<F>,
606 match_rs: &ResultSet,
607 row: usize,
608 rel_vars: &[String],
609 op: &Operand,
610 params: &BTreeMap<String, Value>,
611) -> Result<Option<Value>> {
612 match op {
613 Operand::Lit(v) => Ok(Some(v.clone())),
614 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
615 detail: format!("missing parameter `{name}`"),
616 }).map(Some),
617 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
618 detail: format!(
619 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
620 ),
621 }),
622 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
623 Operand::Prop { var, field } => {
624 if rel_vars.iter().any(|r| r == var) {
625 return Ok(None);
626 }
627 let Some(Value::Str(key)) = match_rs.get(row, var) else {
628 return Ok(None);
629 };
630 Ok(db.get_prop(key, field))
631 }
632 Operand::FuncCall { name, args } => {
633 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
634 }
635 Operand::BinArith { op, left, right } => {
636 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
637 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
638 eval_set_return_arith(op, lv, rv)
639 }
640 }
641}
642
643fn eval_set_return_arith(
644 op: &ArithOp,
645 lv: Option<Value>,
646 rv: Option<Value>,
647) -> Result<Option<Value>> {
648 match (lv, rv) {
649 (None, _) | (_, None) => Ok(None),
650 (Some(Value::Int(a)), Some(Value::Int(b))) => {
651 let result = match op {
652 ArithOp::Sub => a.saturating_sub(b),
653 ArithOp::Mul => a.saturating_mul(b),
654 ArithOp::Add => a.saturating_add(b),
655 ArithOp::Div => {
656 if b == 0 {
657 return Err(GraphError::QueryError {
658 detail: "division by zero".into(),
659 });
660 }
661 a.checked_div(b).unwrap_or(i64::MAX)
662 }
663 };
664 Ok(Some(Value::Int(result)))
665 }
666 (Some(lv), Some(rv)) => {
667 let a = match &lv {
668 Value::Float(f) => *f,
669 Value::Int(i) => *i as f64,
670 _ => {
671 return Err(GraphError::QueryError {
672 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
673 })
674 }
675 };
676 let b = match &rv {
677 Value::Float(f) => *f,
678 Value::Int(i) => *i as f64,
679 _ => {
680 return Err(GraphError::QueryError {
681 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
682 })
683 }
684 };
685 let result = match op {
686 ArithOp::Sub => a - b,
687 ArithOp::Mul => a * b,
688 ArithOp::Add => a + b,
689 ArithOp::Div => {
690 if b == 0.0 {
691 return Err(GraphError::QueryError {
692 detail: "division by zero".into(),
693 });
694 }
695 a / b
696 }
697 };
698 Ok(Some(Value::Float(result)))
699 }
700 }
701}
702
703fn eval_set_return_func<F: Fs>(
704 db: &GraphDb<F>,
705 match_rs: &ResultSet,
706 row: usize,
707 rel_vars: &[String],
708 name: &str,
709 args: &[Operand],
710 params: &BTreeMap<String, Value>,
711) -> Result<Option<Value>> {
712 let norm = name.to_ascii_lowercase();
713 if norm == "type" {
714 if args.len() != 1 {
715 return Err(GraphError::QueryError {
716 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
717 });
718 }
719 let Operand::Var(rel) = &args[0] else {
720 return Err(GraphError::QueryError {
721 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
722 });
723 };
724 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
725 }
726 let mut vals = Vec::with_capacity(args.len());
727 for arg in args {
728 vals.push(eval_set_return_operand(
729 db, match_rs, row, rel_vars, arg, params,
730 )?);
731 }
732 match norm.as_str() {
733 "tolower" => {
734 if vals.len() != 1 {
735 return Err(GraphError::QueryError {
736 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
737 });
738 }
739 Ok(vals[0].clone().map(|val| match val {
740 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
741 other => other,
742 }))
743 }
744 "toupper" => {
745 if vals.len() != 1 {
746 return Err(GraphError::QueryError {
747 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
748 });
749 }
750 Ok(vals[0].clone().map(|val| match val {
751 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
752 other => other,
753 }))
754 }
755 "size" => match vals.first().cloned().flatten() {
756 None => Ok(None),
757 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
758 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
759 Some(_) => Ok(None),
760 },
761 "coalesce" => Ok(vals.into_iter().flatten().next()),
762 "abs" => match vals.first().cloned().flatten() {
763 None => Ok(None),
764 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
765 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
766 Some(_) => Ok(None),
767 },
768 "round" => match vals.first().cloned().flatten() {
769 None => Ok(None),
770 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
771 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
772 Some(_) => Ok(None),
773 },
774 _ => Err(GraphError::QueryError {
775 detail: format!(
776 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
777 ),
778 }),
779 }
780}
781
782fn eval_set_return_item<F: Fs>(
783 db: &GraphDb<F>,
784 match_rs: &ResultSet,
785 row: usize,
786 rel_vars: &[String],
787 item: &RetItem,
788 params: &BTreeMap<String, Value>,
789) -> Result<Option<Value>> {
790 match &item.value {
791 RetVal::Var(v) => eval_set_return_operand(
792 db,
793 match_rs,
794 row,
795 rel_vars,
796 &Operand::Var(v.clone()),
797 params,
798 ),
799 RetVal::Prop { var, field } => eval_set_return_operand(
800 db,
801 match_rs,
802 row,
803 rel_vars,
804 &Operand::Prop {
805 var: var.clone(),
806 field: field.clone(),
807 },
808 params,
809 ),
810 RetVal::FuncCall { name, args } => {
811 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
812 }
813 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
814 RetVal::Agg { .. } => Err(GraphError::QueryError {
815 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
816 }),
817 }
818}
819
820/// Project user RETURN from original MATCH rows after SET. No rematch.
821fn project_set_return_rows<F: Fs>(
822 db: &GraphDb<F>,
823 rel_vars: &[String],
824 match_rs: &ResultSet,
825 returns: &[RetItem],
826 params: &BTreeMap<String, Value>,
827) -> Result<ResultSet> {
828 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
829 let mut out = ResultSet::new(columns);
830 for row in 0..match_rs.len() {
831 let mut cells = Vec::with_capacity(returns.len());
832 for item in returns {
833 cells.push(eval_set_return_item(
834 db, match_rs, row, rel_vars, item, params,
835 )?);
836 }
837 out.push_row(cells);
838 }
839 Ok(out)
840}
841
842/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
843/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
844/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
845/// Returns `None` for non-list values or lists with non-numeric elements.
846fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
847 match v {
848 Value::List(items) => items
849 .iter()
850 .map(|item| match item {
851 Value::Float(f) => Some(*f),
852 Value::Int(i) => Some(*i as f64),
853 _ => None,
854 })
855 .collect(),
856 _ => None,
857 }
858}
859
860fn make_graph_mut<'a>(
861 ids: &'a IdMap,
862 syms: &'a mut Interner,
863 labels: &'a [u32],
864 props: core_storage::v8::seam::ColumnsView<'a>,
865 topo: &'a mut Topology,
866 edge_props: &'a mut EdgeProps,
867) -> GraphMut<'a> {
868 GraphMut {
869 ids,
870 syms,
871 labels,
872 props,
873 topo,
874 edge_props,
875 }
876}
877
878/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
879///
880/// Takes explicit field references rather than `&self` so the caller can hold
881/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
882fn build_props_view<'a>(
883 props: &'a ColumnStore,
884 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
885) -> core_storage::v8::seam::ColumnsView<'a> {
886 match base {
887 None => core_storage::v8::seam::ColumnsView::owned(props),
888 Some(b) => {
889 let archived = b
890 .columns()
891 .expect("base columns section bounds validated at open");
892 core_storage::v8::seam::ColumnsView::with_base(props, archived)
893 }
894 }
895}
896
897fn build_topo_view<'a>(
898 overlay: &'a Topology,
899 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
900) -> core_storage::v8::seam::TopologyView<'a> {
901 match base {
902 None => core_storage::v8::seam::TopologyView::owned(overlay),
903 Some(b) => {
904 let archived_csr = b
905 .topology()
906 .expect("base topology section bounds validated at open");
907 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
908 }
909 }
910}
911
912/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
913///
914/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
915/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
916/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
917/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
918/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
919#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
920pub enum FsyncPolicy {
921 /// Every WAL commit calls `fs.sync` (today's behavior).
922 #[default]
923 Strict,
924 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
925 /// this policy is set on the database.
926 Batched,
927 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
928 Relaxed,
929}
930
931/// A precondition for a compare-and-set batch write.
932///
933/// All preconditions in a [`GraphDb::write_batch_cas`] or
934/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
935/// any operation in the batch is applied. If any precondition fails, the
936/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
937/// is written.
938///
939/// # Touch definition
940///
941/// A node's last-change commit (`last_changed`) is updated when any of the
942/// following state-changing WAL records touch it:
943///
944/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
945/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
946/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
947/// endpoints (an edge change touches both sides).
948/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
949/// for deleted keys so the pre-deletion entry is never observed.
950///
951/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
952/// state no-ops. The underlying mutation that triggered rule firing already
953/// updated the relevant nodes' last-change entries. Rule-management records
954/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
955/// do not touch any node's last-change.
956#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum Precondition {
958 /// The node's last-change commit must equal `expected`.
959 ///
960 /// Fails with [`GraphError::CasConflict`] when:
961 /// - The node does not exist (`last_changed` returns `None`), or
962 /// - The recorded commit seq does not match `expected`.
963 NodeUnchangedSince { key: String, expected: u64 },
964 /// The node must not exist (not inserted, or already deleted).
965 ///
966 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
967 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
968 NodeAbsent { key: String },
969}
970
971pub struct GraphDb<F: Fs> {
972 fs: F,
973 ids: IdMap,
974 syms: Interner,
975 topo: Topology,
976 props: ColumnStore,
977 labels: Vec<u32>, // node id -> label symbol
978 edge_props: EdgeProps,
979 engine: RuleEngine,
980 view_store: ViewStore,
981 /// Incremental inverted index for full-text-lite search.
982 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
983 fulltext: FulltextIndex,
984 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
985 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
986 fsync: FsyncPolicy,
987 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
988 /// call increments this once; all events emitted from that call share the same
989 /// `commit_seq` value.
990 commit_seq: u64,
991 /// RBAC role definitions loaded from `roles.json` at open.
992 ///
993 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
994 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
995 /// `Err` for any request (fail-loud, never silently grant empty visibility).
996 roles: Option<Vec<RoleDef>>,
997 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
998 /// distribute_events call.
999 subscriptions: Vec<SubEntry>,
1000 /// Live query subscriptions. Re-executed on every commit when non-empty.
1001 /// Dead `Weak` entries are pruned inside `distribute_events`.
1002 query_subscriptions: Vec<QuerySubEntry>,
1003 /// Queue capacity for new subscriptions created by this db. Default is
1004 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1005 /// to test Lagged behaviour with small queues.
1006 sub_capacity: usize,
1007 /// True for as-of instances opened via [`GraphDb::open_at`].
1008 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1009 /// when this flag is set.
1010 read_only: bool,
1011 /// Total WAL commit count at the time [`open_at`] was called.
1012 /// 0 for normal (non-as-of) instances.
1013 total_wal_commits: u64,
1014 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1015 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1016 /// reads go through a merged `TopologyView`. `self.props` is always
1017 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1018 base: Option<Arc<core_storage::v8::MappedBase>>,
1019 // ── MVCC epoch reader state ───────────────────────────────────────────────
1020 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1021 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1022 /// `None` only between struct creation and the first fold.
1023 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1024 /// Per-commit deltas accumulated since the last fold.
1025 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1026 /// How many commits have occurred since the last fold.
1027 commits_since_fold: usize,
1028 /// When true, `log_then_apply_with` buffers event notifications instead of
1029 /// firing them immediately. Used by the group-commit drain thread to defer
1030 /// events until after the group fsync (R2: durability before notification).
1031 /// Cleared to false once the drain thread flushes or discards the buffer.
1032 defer_events: bool,
1033 /// Buffered events accumulated while `defer_events` is true.
1034 deferred_events: Vec<DeferredEvent>,
1035 /// Set to true by the group-commit drain thread when a group fsync fails
1036 /// after WAL truncation. All subsequent mutation attempts return an IO
1037 /// error until the database is reopened.
1038 degraded: bool,
1039 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1040 /// HNSW, and IVF sections from the mmap base into the engine's retained
1041 /// fields. `false` on all opens until first use; always `true` for non-V8
1042 /// opens (base is None, fast-path sets flag immediately).
1043 v8_sections_loaded: std::sync::atomic::AtomicBool,
1044 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1045 v8_sections_mutex: std::sync::Mutex<()>,
1046 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1047 /// the node was last modified by commit `seq`.
1048 ///
1049 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1050 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1051 /// nodes return `None` from `last_changed` until they are next mutated.
1052 ///
1053 /// See [`Precondition`] for the full touch definition.
1054 last_change: HashMap<u32, u64>,
1055 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1056 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1057 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1058 wal_archive_retention: Option<u32>,
1059 /// Global frame index of the first commit that is still reachable through
1060 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1061 /// Default 0 = all history reachable.
1062 wal_horizon_floor: u64,
1063 /// True when the surviving archive chain forms a continuous WAL history
1064 /// starting from the store's first commit (the genesis chain).
1065 ///
1066 /// `open_at` may replay archive-resident commits from empty state only when
1067 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1068 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1069 /// already exist (breaks the chain for subsequent archives), or
1070 /// - any archive is pruned (floor advances past zero).
1071 ///
1072 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1073 archive_genesis_chain: bool,
1074 /// Transient write-authz context set by `write_batch_authz` /
1075 /// `query_write_authz` for the duration of ONE mutation call.
1076 /// Always `None` at rest. Never serialized, never WAL-replayed.
1077 pending_write_authz: Option<WriteAuthz>,
1078}
1079
1080/// One group of deferred event notifications, held until the group fsync
1081/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1082struct DeferredEvent {
1083 rec: core_storage::WalRecord,
1084 engine_deltas: Vec<EngineEdgeDelta>,
1085 seq: u64,
1086 ingest: Option<(String, usize)>,
1087}
1088
1089/// Options for [`GraphDb::open_with_options`].
1090#[derive(Clone, Copy, Debug)]
1091pub struct OpenOptions {
1092 /// Rewrite an old-format snapshot to the current VERSION after a
1093 /// successful load (default `true`). The old snapshot is kept as
1094 /// `snapshot.bin.bak` until the next clean open at the current version,
1095 /// at which point the `.bak` is deleted.
1096 ///
1097 /// Set to `false` to open a store without touching any on-disk files
1098 /// (useful for read-only inspection of a store at an older format).
1099 pub auto_migrate: bool,
1100}
1101
1102impl Default for OpenOptions {
1103 fn default() -> Self {
1104 Self { auto_migrate: true }
1105 }
1106}
1107
1108/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1109///
1110/// `None` at the call site = full authority (today's zero-cost behavior).
1111/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1112/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1113/// record is built. A denial returns an error with no WAL frame written.
1114///
1115/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1116/// hidden-node existence to callers.
1117#[derive(Clone, Debug)]
1118pub struct WriteAuthz {
1119 pub role: String,
1120 pub scope: WriteScope,
1121 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1122 /// Always `Omit`-mode — never `Stub`.
1123 pub mask: crate::mask::NodeMask,
1124}
1125
1126/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1127///
1128/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1129/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1130/// syncs the directory entry. This is the only correct path for writing the
1131/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1132/// the directory sync.
1133pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1134 use core_storage::fs::{FileId, Fs as _};
1135 RealFs::new(dir)
1136 .map_err(core_storage::GraphError::Io)?
1137 .write_atomic(FileId::SnapshotBak, bytes)
1138 .map_err(core_storage::GraphError::Io)
1139}
1140
1141/// Return the on-disk snapshot format version without decoding the full snapshot.
1142///
1143/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1144/// snapshot file exists (WAL-only store). Returns an error if the header is
1145/// malformed.
1146pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1147 use std::io::Read as _;
1148 let path = dir.join("snapshot.bin");
1149 let mut header = [0u8; 6];
1150 let n = match std::fs::File::open(&path) {
1151 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1152 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1153 Err(e) => return Err(core_storage::GraphError::Io(e)),
1154 };
1155 core_storage::snapshot::peek_version(&header[..n])
1156}
1157
1158/// Options for [`GraphDb::snapshot_with`].
1159#[derive(Debug, Clone, Default)]
1160pub struct SnapshotOptions {
1161 /// When `true`, the WAL is preserved after the snapshot write.
1162 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1163 /// When `false` (the default), the WAL is truncated to a minimal
1164 /// baseline so cold-start replay stays fast.
1165 pub keep_wal: bool,
1166 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1167 /// before a fresh WAL baseline is written (history-preserving snapshot).
1168 ///
1169 /// This is the feature opt-in: `false` (the default) leaves the existing
1170 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1171 /// precedence over `keep_wal` when both are set.
1172 ///
1173 /// Archives can be scanned by [`GraphDb::node_history`],
1174 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1175 /// [`GraphDb::open_at`], extending the reachable history horizon across
1176 /// snapshot boundaries.
1177 pub archive_wal: bool,
1178}
1179
1180impl GraphDb<RealFs> {
1181 /// Open the database at `dir` with default options.
1182 ///
1183 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1184 /// Old-format snapshots (V5, V6) are automatically migrated to the
1185 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1186 pub fn open(dir: &std::path::Path) -> Result<Self> {
1187 Self::open_with_options(dir, OpenOptions::default())
1188 }
1189
1190 /// Open the database at `dir` with explicit options.
1191 ///
1192 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1193 /// snapshot is an older format version, this function:
1194 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1195 /// + fsynced) before any modification.
1196 /// 2. Rewrites `snapshot.bin` at the current format version via
1197 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1198 ///
1199 /// If migration fails the error is returned and the original files are
1200 /// intact (the `.bak` was written before the new snapshot was attempted).
1201 ///
1202 /// A clean open that finds the snapshot already at the current version
1203 /// deletes any leftover `.bak` file.
1204 ///
1205 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1206 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1207 // Header-only peek — 6 bytes, no full decode.
1208 let snap_version = snapshot_version_at(dir)?;
1209
1210 // Full load: decode snapshot + replay WAL + rebuild indexes.
1211 let mut db = Self::open_with(RealFs::new(dir)?)?;
1212
1213 if opts.auto_migrate {
1214 match snap_version {
1215 Some(ver) if ver < core_storage::snapshot::VERSION => {
1216 let _tm = std::time::Instant::now();
1217 // Copy the original snapshot to .bak at OS level — no in-memory
1218 // buffer required for a 2+ GiB file.
1219 //
1220 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1221 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1222 // A torn .bak on crash is acceptable because the original
1223 // snapshot.bin is the authoritative source until after the rename.
1224 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1225 .map_err(core_storage::GraphError::Io)?;
1226 trace_migrate!("bak copy done", _tm);
1227 // Rewrite snapshot at current version; keep WAL intact.
1228 db.snapshot_with(SnapshotOptions {
1229 keep_wal: true,
1230 ..SnapshotOptions::default()
1231 })?;
1232 trace_migrate!("snapshot_with done", _tm);
1233 }
1234 Some(_) => {
1235 // Already current version: remove any leftover .bak.
1236 let bak = dir.join("snapshot.bin.bak");
1237 if bak.exists() {
1238 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1239 }
1240 }
1241 None => {
1242 // WAL-only store — nothing to migrate on open.
1243 }
1244 }
1245 }
1246
1247 Ok(db)
1248 }
1249
1250 /// Open a read-only view of the database as it existed after `commit`.
1251 ///
1252 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1253 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1254 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1255 ///
1256 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1257 /// so as-of can only reach commits recorded in the current WAL (those
1258 /// written after the most recent snapshot, or all commits if no snapshot
1259 /// was ever taken). Commit 0 in `open_at` always refers to the first
1260 /// frame in the WAL that exists on disk, not the first ever write to the
1261 /// database. When the on-disk snapshot recorded that it truncated the
1262 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1263 /// before frame replay, so the as-of view includes all pre-snapshot data.
1264 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1265 /// are ignored and replay is WAL-only, as before.
1266 ///
1267 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1268 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1269 /// `stats()` work normally.
1270 ///
1271 /// # Errors
1272 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1273 /// when the WAL is empty after a snapshot).
1274 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1275 Self::open_at_with(RealFs::new(dir)?, commit)
1276 }
1277}
1278
1279impl<F: Fs> GraphDb<F> {
1280 pub fn open_with(fs: F) -> Result<Self> {
1281 let mut db = Self {
1282 fs,
1283 ids: IdMap::new(),
1284 syms: Interner::new(),
1285 topo: Topology::new(),
1286 props: ColumnStore::new(),
1287 labels: Vec::new(),
1288 edge_props: EdgeProps::new(),
1289 engine: RuleEngine::new(),
1290 view_store: ViewStore::new(),
1291 fulltext: FulltextIndex::new(),
1292 event_sink: None,
1293 fsync: FsyncPolicy::Strict,
1294 commit_seq: 0,
1295 roles: Some(vec![]),
1296 subscriptions: Vec::new(),
1297 query_subscriptions: Vec::new(),
1298 sub_capacity: DEFAULT_SUB_CAPACITY,
1299 read_only: false,
1300 total_wal_commits: 0,
1301 base: None,
1302 fold_overlay: None,
1303 delta_tail: Vec::new(),
1304 commits_since_fold: 0,
1305 defer_events: false,
1306 deferred_events: Vec::new(),
1307 degraded: false,
1308 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1309 v8_sections_mutex: std::sync::Mutex::new(()),
1310 last_change: HashMap::new(),
1311 wal_archive_retention: None,
1312 wal_horizon_floor: 0,
1313 archive_genesis_chain: false,
1314 pending_write_authz: None,
1315 };
1316 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1317 db.archive_genesis_chain = db.fs.has_genesis_marker();
1318 // Opening cleanup: remove orphaned archives — archives whose frames all
1319 // fall below the horizon floor. Orphans arise when a crash interrupted
1320 // the retention-prune sequence after the floor was written but before
1321 // all surplus archives were deleted. Safe to delete: floor already
1322 // accounts for their frames.
1323 db.cleanup_orphaned_archives()?;
1324 let _t0 = std::time::Instant::now();
1325 // Peek 6 bytes to determine snapshot version without reading the full
1326 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1327 // default impl reads all bytes and truncates (still correct).
1328 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1329 let is_v8 = snap_header.len() >= 6
1330 && &snap_header[0..4] == b"GDB1"
1331 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1332 == core_storage::snapshot::VERSION_8;
1333 if is_v8 {
1334 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1335 // No 2.4GB heap Vec is allocated on RealFs.
1336 let mapped = Arc::new(
1337 if let Some(snap_path) = db.fs.snapshot_path() {
1338 core_storage::v8::MappedBase::map(&snap_path)
1339 } else {
1340 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1341 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1342 }
1343 .map_err(|e| GraphError::Corrupt {
1344 detail: format!("v8: mmap open: {e:?}"),
1345 })?,
1346 );
1347 db.restore_v8_base(Arc::clone(&mapped))?;
1348 trace_open!("restore_v8_base", _t0);
1349 db.base = Some(mapped);
1350 trace_open!("base assigned", _t0);
1351 } else if !snap_header.is_empty() {
1352 // Legacy V5-V7: full read required for decode.
1353 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1354 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1355 db.restore_snapshot_state(state)?;
1356 }
1357 }
1358 // else: snap_header is empty = no snapshot file, fresh store.
1359 //
1360 // Seed commit_seq from the highest seq persisted in last_change so that
1361 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1362 // already stored in the snapshot. Without this, a db with one snapshot
1363 // commit would save last_change["a"]=1, then on reopen the first WAL
1364 // frame would replay at seq=1 again — colliding and making WAL-tail
1365 // mutations indistinguishable from the snapshot baseline.
1366 //
1367 // Safety invariant (seq-recycling):
1368 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1369 // last_change because they belonged to a previous db lifetime — a new
1370 // db starts at commit_seq=0 with an empty last_change. Therefore no
1371 // CAS precondition can carry a recycled seq as its `expected` value
1372 // and accidentally match a live node's last_change entry.
1373 //
1374 // `expected:0` on a deleted-then-reinserted node:
1375 // After deletion, last_changed() returns None; callers that call
1376 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1377 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1378 // expected=0 correctly conflicts. The only way to observe actual=0 in
1379 // a CasConflict would be a caller that invented expected=0 without ever
1380 // calling last_changed() — unreachable via the documented API contract.
1381 if let Some(&max_seq) = db.last_change.values().max() {
1382 db.commit_seq = db.commit_seq.max(max_seq);
1383 }
1384 let bytes = db.fs.read(FileId::Wal)?;
1385 let (records, valid_len) = decode_all(&bytes);
1386 if valid_len < bytes.len() {
1387 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1388 }
1389 // WAL-present path: build indexes eagerly BEFORE replay so that the
1390 // first replayed record does not trigger the lazy-init guard (which
1391 // would call reindex_all_load_ivf on an empty graph, defeating the
1392 // point of restoring IVF/HNSW blobs from the snapshot).
1393 if !records.is_empty() {
1394 db.ensure_v8_base_sections_loaded();
1395 trace_open!("lazy sections loaded (WAL path)", _t0);
1396 db.engine.consume_retained_state_eager(
1397 &db.ids,
1398 &db.syms,
1399 &db.labels,
1400 build_props_view(&db.props, &db.base),
1401 );
1402 }
1403 for rec in records {
1404 db.apply(&rec)?;
1405 // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1406 // No subscriber exists yet; discard is correct.
1407 let _ = db.engine.drain_deltas();
1408 // Track commit_seq during replay so last_change entries are
1409 // consistent with the seqs assigned by log_then_apply_with on
1410 // subsequent live commits. After N replayed frames, commit_seq=N;
1411 // live commits begin at N+1.
1412 db.commit_seq += 1;
1413 let replay_seq = db.commit_seq;
1414 db.update_last_change_from_rec(&rec, replay_seq);
1415 }
1416 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1417 // this assert catches the regression in debug builds immediately.
1418 debug_assert_eq!(
1419 db.engine.pending_delta_count(),
1420 0,
1421 "pending_deltas non-empty after replay — \
1422 per-frame drain must run inside the loop to keep memory O(1)"
1423 );
1424 // T2 note: the per-frame drain IS the suppression seam for replay.
1425 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1426 // replaying subscribers; the mechanism is already in place.
1427 let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1428 trace_open!("wal replay done", _t0);
1429 // Rebuild view values after WAL replay only when there is no V8 base.
1430 // With a V8 base, view values are correct in the snapshot and are updated
1431 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1432 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1433 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1434 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1435 // base value).
1436 if db.base.is_none() {
1437 let topo_view = TopologyView::owned(&db.topo);
1438 db.view_store
1439 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1440 }
1441 // Rebuild full-text index after WAL replay. Corrects drift from
1442 // per-record incremental apply during replay.
1443 db.fulltext.rebuild_all(
1444 &db.ids,
1445 &db.labels,
1446 &db.syms,
1447 build_props_view(&db.props, &db.base),
1448 );
1449 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1450 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1451 db.roles = Self::load_roles_from_fs(&db.fs)?;
1452 // Capture the initial MVCC fold so reader() is ready immediately.
1453 db.fold_now();
1454 trace_open!("open_with complete", _t0);
1455 Ok(db)
1456 }
1457
1458 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1459 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1460 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
1461 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1462 /// Restore all persisted state from a decoded snapshot. Shared by
1463 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1464 fn restore_snapshot_state(
1465 &mut self,
1466 state: core_storage::snapshot::SnapshotState,
1467 ) -> Result<()> {
1468 self.ids = state.ids;
1469 self.syms = state.syms;
1470 self.topo = state.topo;
1471 self.props = state.props;
1472 self.labels = state.labels;
1473 self.edge_props = state.edge_props;
1474 // Cross-section label integrity for V5/V7 snapshots: same invariants as
1475 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
1476 // out-of-range sym ids, or a sentinel label on a live node would otherwise
1477 // open successfully and panic later in `NodeRef::label()` or
1478 // `neighborhood_masked()`. Catching it here turns those into typed
1479 // `GraphError::Corrupt` at open time.
1480 {
1481 let ids_len = self.ids.len();
1482 if self.labels.len() != ids_len {
1483 return Err(GraphError::Corrupt {
1484 detail: format!(
1485 "snapshot: labels vec has {} entries but id table has {} total slots",
1486 self.labels.len(),
1487 ids_len,
1488 ),
1489 });
1490 }
1491 let syms_len = self.syms.len() as u32;
1492 for (i, &sym) in self.labels.iter().enumerate() {
1493 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1494 if sym == u32::MAX {
1495 if !is_tombstoned {
1496 return Err(GraphError::Corrupt {
1497 detail: format!(
1498 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1499 ),
1500 });
1501 }
1502 } else if sym >= syms_len {
1503 return Err(GraphError::Corrupt {
1504 detail: format!(
1505 "snapshot: label at id slot {i} references sym {sym} \
1506 which is out of interner range ({syms_len})"
1507 ),
1508 });
1509 }
1510 }
1511 }
1512 let defs: Vec<RuleDef> = state
1513 .rule_defs
1514 .iter()
1515 .map(|b| {
1516 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1517 detail: format!("snapshot rule_def deserialize: {e}"),
1518 })
1519 })
1520 .collect::<Result<Vec<_>>>()?;
1521 self.engine =
1522 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1523 // Candidate indexes are rebuilt lazily on the first mutation (see
1524 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
1525 // snapshot are retained without deserializing so that:
1526 // - clean-open (empty WAL): indexes stay empty; blobs load on first
1527 // ANN query via ensure_hnsw_loaded, or on first mutation via the
1528 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1529 // - WAL-present: open_with calls consume_retained_state_eager before
1530 // replay so HNSW/IVF are live before any record fires the hooks.
1531 let ivf_bytes = if state.ivf_state.is_empty() {
1532 Vec::new()
1533 } else {
1534 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1535 };
1536 // Store blobs without eagerly deserializing them.
1537 self.engine
1538 .store_snapshot_state(state.hnsw_state, ivf_bytes);
1539 // Restore view defs from snapshot (V5).
1540 // The ColumnStore already contains view values from the snapshot;
1541 // use restore_view (no collision check, no backfill) so the store
1542 // is aware of the definitions. rebuild_all runs after WAL replay.
1543 for def_bytes in &state.view_defs {
1544 let def: ViewDef =
1545 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1546 detail: format!("snapshot view_def deserialize: {e}"),
1547 })?;
1548 self.view_store
1549 .restore_view(def)
1550 .map_err(|e| GraphError::Corrupt {
1551 detail: format!("snapshot view restore: {e}"),
1552 })?;
1553 }
1554 Ok(())
1555 }
1556
1557 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1558 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1559 ///
1560 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1561 /// deserialization and view rebuild have access to all column data.
1562 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1563 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1564 detail: format!("v8: ids section: {e:?}"),
1565 })?);
1566 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1567 detail: format!("v8: syms section: {e:?}"),
1568 })?);
1569
1570 // C1: self.props is left as an empty overlay. Column reads go through
1571 // props_view() (ColumnsView::with_base), which consults the archived base
1572 // section zero-copy. This avoids the O(columns) heap copy at every open.
1573
1574 // self.topo deliberately left as Topology::new() — overlay path.
1575
1576 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1577 detail: format!("v8: meta section: {e:?}"),
1578 })?)
1579 .map_err(|e| GraphError::Corrupt {
1580 detail: format!("v8: meta decode: {e:?}"),
1581 })?;
1582 self.labels = meta.labels;
1583 // Cross-section label integrity: labels must cover every id slot (live
1584 // and tombstoned), every non-sentinel sym must be within the interner's
1585 // bound, and no live (non-tombstoned) node may carry the u32::MAX
1586 // sentinel label. Without this check, a crafted snapshot where the META
1587 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1588 // sym ids, or a sentinel label on a live node, would open successfully
1589 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1590 // related read paths. Catching the inconsistency here converts those
1591 // panics into typed `GraphError::Corrupt` at open time.
1592 {
1593 let ids_len = self.ids.len();
1594 if self.labels.len() != ids_len {
1595 return Err(GraphError::Corrupt {
1596 detail: format!(
1597 "v8: labels section has {} entries but id table has {} total slots",
1598 self.labels.len(),
1599 ids_len,
1600 ),
1601 });
1602 }
1603 let syms_len = self.syms.len() as u32;
1604 for (i, &sym) in self.labels.iter().enumerate() {
1605 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1606 if sym == u32::MAX {
1607 // Sentinel is only valid for tombstoned slots.
1608 if !is_tombstoned {
1609 return Err(GraphError::Corrupt {
1610 detail: format!(
1611 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1612 ),
1613 });
1614 }
1615 } else if sym >= syms_len {
1616 return Err(GraphError::Corrupt {
1617 detail: format!(
1618 "v8: label at id slot {i} references sym {sym} \
1619 which is out of interner range ({syms_len})"
1620 ),
1621 });
1622 }
1623 }
1624 }
1625 // C3: self.edge_props stays as an empty overlay. Reads go through
1626 // edge_props_view() which consults the mmap'd base section zero-copy
1627 // via EdgePropsView::with_base. No heap decode at open time.
1628
1629 // Restore rule engine.
1630 let (rule_def_bytes, rule_tripped, rule_fires) =
1631 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1632 GraphError::Corrupt {
1633 detail: format!("v8: rules_meta section: {e:?}"),
1634 }
1635 })?);
1636 let defs: Vec<RuleDef> = rule_def_bytes
1637 .iter()
1638 .map(|b| {
1639 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1640 detail: format!("v8: rule_def deserialize: {e}"),
1641 })
1642 })
1643 .collect::<Result<Vec<_>>>()?;
1644 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1645 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1646 // `ensure_v8_base_sections_loaded` reads them on first use from
1647 // `self.base` (set by the caller immediately after this returns).
1648 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1649
1650 // Restore view definitions.
1651 let view_defs =
1652 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1653 detail: format!("v8: views section: {e:?}"),
1654 })?);
1655 for def_bytes in &view_defs {
1656 let def: ViewDef =
1657 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1658 detail: format!("v8: view_def deserialize: {e}"),
1659 })?;
1660 self.view_store
1661 .restore_view(def)
1662 .map_err(|e| GraphError::Corrupt {
1663 detail: format!("v8: view restore: {e}"),
1664 })?;
1665 }
1666 // Load the last-change map from section 11 (small section; load eagerly).
1667 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1668 // in that case and `decode_last_change_bytes` returns an empty map.
1669 let last_change_raw = mapped
1670 .last_change_bytes()
1671 .map_err(|e| GraphError::Corrupt {
1672 detail: format!("v8: last_change section: {e:?}"),
1673 })?;
1674 self.last_change = decode_last_change_bytes(last_change_raw);
1675
1676 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1677 // the file. Pure bounds check — no bytes read, no page faults triggered.
1678 // Catches truncated snapshots at open time before the lazy deferred reads.
1679 mapped.validate_section_bounds().map_err(|e| match e {
1680 GraphError::Corrupt { detail } => GraphError::Corrupt {
1681 detail: format!("v8: section bounds: {detail}"),
1682 },
1683 other => other,
1684 })?;
1685 Ok(())
1686 }
1687
1688 /// Read provenance, HNSW, and IVF sections from the mmap base into the
1689 /// engine's retained fields on first call. Subsequent calls are a no-op
1690 /// (AtomicBool fast-path).
1691 ///
1692 /// Must be called before any code path that reads or mutates engine
1693 /// provenance, HNSW, or IVF state:
1694 /// - WAL replay (before `consume_retained_state_eager`)
1695 /// - First mutation (`log_then_apply_with`)
1696 /// - Read-only paths (`stats`, `explain`, `node_edges`)
1697 /// - Snapshot (`snapshot_with`)
1698 ///
1699 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1700 fn ensure_v8_base_sections_loaded(&self) {
1701 use std::sync::atomic::Ordering;
1702 if self.v8_sections_loaded.load(Ordering::Acquire) {
1703 return;
1704 }
1705 let _guard = self
1706 .v8_sections_mutex
1707 .lock()
1708 .expect("v8 sections mutex poisoned");
1709 if self.v8_sections_loaded.load(Ordering::Acquire) {
1710 return; // another caller populated while we waited
1711 }
1712 let _t = std::time::Instant::now();
1713 if let Some(base) = &self.base {
1714 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1715 // Bounds are already validated at open time (restore_v8_base →
1716 // validate_section_bounds) — unreachable post-validate_section_bounds;
1717 // unwrap_or_default is a safety belt against impossible errors.
1718 let prov_bytes = base
1719 .provenance_raw_bytes()
1720 .map(|b| b.to_vec())
1721 .unwrap_or_default();
1722 self.engine.store_provenance_bytes(prov_bytes);
1723 // HNSW: decode rkyv blobs into owned map.
1724 let hnsw_state = base
1725 .hnsw_section()
1726 .map(archived_hnsw_to_owned)
1727 .unwrap_or_default();
1728 // IVF: raw bincode bytes; deserialized on first mutation/query.
1729 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1730 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1731 }
1732 self.v8_sections_loaded.store(true, Ordering::Release);
1733 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1734 eprintln!(
1735 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1736 _t.elapsed()
1737 );
1738 }
1739 }
1740
1741 /// Return a `TopologyView` that merges the mmap'd base (when present) with
1742 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
1743 /// the full merged topology without going through `self.view()`.
1744 fn topo_view(&self) -> TopologyView<'_> {
1745 match self.base {
1746 None => TopologyView::owned(&self.topo),
1747 Some(ref base) => {
1748 // SAFETY: base lives as long as self; section bounds validated at open.
1749 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1750 let archived = base
1751 .topology()
1752 .expect("base topology section bounds validated at open");
1753 TopologyView::with_base(&self.topo, archived)
1754 }
1755 }
1756 }
1757
1758 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1759 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
1760 /// overlay first, then fall through to the archived base section zero-copy.
1761 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1762 match self.base {
1763 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1764 Some(ref base) => {
1765 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1766 let archived = base
1767 .columns()
1768 .expect("base columns section bounds validated at open");
1769 core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1770 }
1771 }
1772 }
1773
1774 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1775 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1776 ///
1777 /// Reads consult the overlay first (for post-snapshot mutations), then fall
1778 /// through to the archived base section zero-copy. Tombstones in the
1779 /// overlay mask deleted-from-base entries.
1780 fn edge_props_view(&self) -> EdgePropsView<'_> {
1781 match self.base {
1782 None => EdgePropsView::owned(&self.edge_props),
1783 Some(ref base) => {
1784 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1785 let archived = base
1786 .edge_props_section()
1787 .expect("base edge_props section bounds validated at open");
1788 EdgePropsView::with_base(&self.edge_props, archived)
1789 }
1790 }
1791 }
1792
1793 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1794 let mut db = Self {
1795 fs,
1796 ids: IdMap::new(),
1797 syms: Interner::new(),
1798 topo: Topology::new(),
1799 props: ColumnStore::new(),
1800 labels: Vec::new(),
1801 edge_props: EdgeProps::new(),
1802 engine: RuleEngine::new(),
1803 view_store: ViewStore::new(),
1804 fulltext: FulltextIndex::new(),
1805 event_sink: None,
1806 fsync: FsyncPolicy::Strict,
1807 commit_seq: 0,
1808 roles: Some(vec![]),
1809 subscriptions: Vec::new(),
1810 query_subscriptions: Vec::new(),
1811 sub_capacity: DEFAULT_SUB_CAPACITY,
1812 read_only: false, // set to true after replay
1813 total_wal_commits: 0,
1814 base: None,
1815 fold_overlay: None,
1816 delta_tail: Vec::new(),
1817 commits_since_fold: 0,
1818 defer_events: false,
1819 deferred_events: Vec::new(),
1820 degraded: false,
1821 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1822 v8_sections_mutex: std::sync::Mutex::new(()),
1823 last_change: HashMap::new(),
1824 wal_archive_retention: None,
1825 wal_horizon_floor: 0,
1826 archive_genesis_chain: false,
1827 pending_write_authz: None,
1828 };
1829 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1830 db.archive_genesis_chain = db.fs.has_genesis_marker();
1831 // Same orphaned-archive cleanup as open_with: floor was written first
1832 // during pruning, so a crash may have left stale archives below floor.
1833 db.cleanup_orphaned_archives()?;
1834 // Collect archive frames (oldest-first) and live WAL frames.
1835 // Archives represent pre-snapshot history; the snapshot captures the
1836 // cumulative state at the time of archiving. Crash-window guarantee:
1837 // A: crash before rename → WAL intact, no archive. Reopen: normal.
1838 // B: crash after rename, before new WAL → archive present, WAL
1839 // absent. Reopen: snapshot loaded (full state), no WAL replay.
1840 // C: crash after new baseline WAL written → normal post-archive.
1841 let archive_ns = db.fs.list_archives()?;
1842 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
1843 for n in &archive_ns {
1844 let arc_bytes = db.fs.read_archive(*n)?;
1845 let (arc_frames, _) = decode_all(&arc_bytes);
1846 archive_frames_all.extend(arc_frames);
1847 }
1848 let total_archive_frames = archive_frames_all.len() as u64;
1849
1850 let live_bytes = db.fs.read(FileId::Wal)?;
1851 let (live_records, _valid_len) = decode_all(&live_bytes);
1852 let total_surviving = total_archive_frames + live_records.len() as u64;
1853 // Global total including any pruned history below the horizon floor.
1854 let total = db.wal_horizon_floor + total_surviving;
1855
1856 // Horizon and range check.
1857 if commit < db.wal_horizon_floor {
1858 return Err(GraphError::CommitOutOfRange { commit, total });
1859 }
1860 if commit >= total {
1861 return Err(GraphError::CommitOutOfRange { commit, total });
1862 }
1863
1864 // Local index into surviving frames (0 = first frame of oldest archive).
1865 let local = commit - db.wal_horizon_floor;
1866
1867 if local < total_archive_frames {
1868 // Target commit is in an archive. Correct replay from empty state
1869 // is only possible when the archive chain is an uninterrupted
1870 // genesis chain (first archive taken from a fresh store, no prior
1871 // WAL truncation) and no archives have been pruned (floor == 0).
1872 //
1873 // If either condition is violated the prefix needed to reconstruct
1874 // the requested state is gone; refuse rather than return wrong data.
1875 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
1876 return Err(GraphError::CommitOutOfRange { commit, total });
1877 }
1878 // Replay all archive frames up to and including the target commit
1879 // from an empty database state. Archives must be replayed in order
1880 // so that dense-id intern tables are built up correctly.
1881 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
1882 db.apply(&rec)?;
1883 let _ = db.engine.drain_deltas();
1884 }
1885 } else {
1886 // Target commit is in the live WAL: load snapshot as base, then
1887 // replay the needed live WAL prefix.
1888 //
1889 // Base state: a truncating snapshot (wal_truncated=true) compacts
1890 // all pre-truncation / pre-archive commits. Dense-id records in
1891 // the live WAL reference ids/interns that the snapshot provides.
1892 // Peek 6 bytes (same pattern as open_with).
1893 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1894 let is_v8 = snap_header.len() >= 6
1895 && &snap_header[0..4] == b"GDB1"
1896 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1897 == core_storage::snapshot::VERSION_8;
1898 if is_v8 {
1899 let state = if let Some(snap_path) = db.fs.snapshot_path() {
1900 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
1901 GraphError::Corrupt {
1902 detail: format!("v8: open_at mmap: {e:?}"),
1903 }
1904 })?;
1905 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
1906 } else {
1907 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1908 core_storage::snapshot::decode(&snap_bytes)?
1909 };
1910 if let Some(state) = state {
1911 if state.wal_truncated {
1912 db.restore_snapshot_state(state)?;
1913 }
1914 }
1915 } else if !snap_header.is_empty() {
1916 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1917 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1918 if state.wal_truncated {
1919 db.restore_snapshot_state(state)?;
1920 }
1921 }
1922 }
1923 // else: snap_header empty = no snapshot file.
1924 let live_local = local - total_archive_frames;
1925 for rec in live_records.into_iter().take((live_local + 1) as usize) {
1926 db.apply(&rec)?;
1927 let _ = db.engine.drain_deltas();
1928 }
1929 }
1930 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1931 // post-loop assert in open_with.
1932 debug_assert_eq!(
1933 db.engine.pending_delta_count(),
1934 0,
1935 "pending_deltas non-empty after open_at replay — \
1936 per-frame drain must run inside the loop to keep memory O(1)"
1937 );
1938 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
1939 // Rebuild view values after WAL replay so derived-edge-driven views
1940 // reflect the as-of state. open_at always uses the legacy path (no V8
1941 // base), so topo_view is always owned.
1942 {
1943 let topo_view = TopologyView::owned(&db.topo);
1944 db.view_store
1945 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1946 }
1947 // Rebuild full-text index for as-of view (mirrors open_with pattern).
1948 db.fulltext.rebuild_all(
1949 &db.ids,
1950 &db.labels,
1951 &db.syms,
1952 build_props_view(&db.props, &db.base),
1953 );
1954 // Load roles sidecar (current roles, not point-in-time).
1955 db.roles = Self::load_roles_from_fs(&db.fs)?;
1956 db.read_only = true;
1957 db.total_wal_commits = total;
1958 // Capture initial fold so reader() is immediately usable.
1959 db.fold_now();
1960 Ok(db)
1961 }
1962
1963 /// Whether this instance is a read-only as-of view.
1964 pub fn is_read_only(&self) -> bool {
1965 self.read_only
1966 }
1967
1968 // ── MVCC epoch reader ─────────────────────────────────────────────────────
1969
1970 /// Clone the current overlay state into a new `FrozenOverlay` and reset
1971 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
1972 /// the end of `open_with` / `open_at_with` to prime the reader.
1973 fn fold_now(&mut self) {
1974 let frozen = crate::reader::FrozenOverlay {
1975 ids: self.ids.clone(),
1976 syms: self.syms.clone(),
1977 topo: self.topo.clone(),
1978 props: self.props.clone(),
1979 labels: self.labels.clone(),
1980 edge_props: self.edge_props.clone(),
1981 roles: self.roles.clone(),
1982 fulltext: self.fulltext.clone(),
1983 };
1984 self.fold_overlay = Some(Arc::new(frozen));
1985 self.delta_tail.clear();
1986 self.commits_since_fold = 0;
1987 }
1988
1989 /// Capture a lock-free reader snapshot of the current db state.
1990 ///
1991 /// The read lock is held only for the duration of this call (to clone a
1992 /// handful of `Arc` handles). Subsequent query operations run without any
1993 /// lock.
1994 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
1995 crate::reader::ReaderSnapshot::new(
1996 self.fold_overlay
1997 .clone()
1998 .expect("fold_overlay is always Some after open_with; call reader() after open"),
1999 self.base.clone(),
2000 self.delta_tail.clone(),
2001 )
2002 }
2003
2004 /// Total number of WAL commits at the time [`open_at`] was called.
2005 /// Returns 0 for normal (non-as-of) instances.
2006 pub fn total_wal_commits(&self) -> u64 {
2007 self.total_wal_commits
2008 }
2009
2010 /// Apply a record to in-memory state. Used by both live writes and replay,
2011 /// so replay is definitionally identical to the original execution.
2012 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2013 match rec {
2014 WalRecord::InsertNode { label, key, props } => {
2015 let id = self.ids.try_insert(key)?;
2016 let sym = self.syms.intern(label);
2017 if self.labels.len() <= id as usize {
2018 // gap slots are sentinels, never valid label symbols
2019 self.labels.resize(id as usize + 1, u32::MAX);
2020 }
2021 self.labels[id as usize] = sym;
2022 for (field, value) in props {
2023 self.props.set(id, field, value.clone());
2024 }
2025 // Initialize view values for the new node before the engine runs so
2026 // delta-based increments start from a known zero baseline.
2027 self.view_store
2028 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2029 // Fire rules for the newly inserted node.
2030 let cursor = self.engine.pending_delta_count();
2031 let mut eng = std::mem::take(&mut self.engine);
2032 {
2033 let mut gm = make_graph_mut(
2034 &self.ids,
2035 &mut self.syms,
2036 &self.labels,
2037 build_props_view(&self.props, &self.base),
2038 &mut self.topo,
2039 &mut self.edge_props,
2040 );
2041 eng.on_node_changed(id, None, &mut gm);
2042 }
2043 self.engine = eng;
2044 // Process derived-edge deltas for view maintenance.
2045 // Fast path: skip the O(delta_count) allocation when no views exist.
2046 if !self.view_store.is_empty() {
2047 #[cfg(test)]
2048 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2049 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2050 for d in &new_deltas {
2051 self.view_store.on_edge_changed(
2052 d.etype_sym,
2053 d.src_id,
2054 d.dst_id,
2055 d.fired,
2056 &mut self.props,
2057 &build_topo_view(&self.topo, &self.base),
2058 &self.ids,
2059 &self.syms,
2060 &self.labels,
2061 self.base.as_ref().map(|b| {
2062 b.columns()
2063 .expect("base columns section bounds validated at open")
2064 }),
2065 );
2066 }
2067 }
2068 // Full-text index maintenance: index enabled fields for this label.
2069 if self.fulltext.has_label(label) {
2070 for (field, value) in props {
2071 if self.fulltext.is_enabled(label, field) {
2072 self.fulltext.add_tokens(id, field, value);
2073 }
2074 }
2075 }
2076 }
2077 WalRecord::InsertEdge {
2078 edge_type,
2079 src_key,
2080 dst_key,
2081 } => {
2082 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2083 detail: format!("wal replay references unknown key {src_key}"),
2084 })?;
2085 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2086 detail: format!("wal replay references unknown key {dst_key}"),
2087 })?;
2088 let etype = self.syms.intern(edge_type);
2089 // Skip if the edge is already visible in the merged base+overlay
2090 // view. This keeps WAL replay idempotent when the WAL contains
2091 // pre-snapshot records that are already encoded in a V8 base
2092 // (keep_wal=true opens and crash-before-truncation scenarios).
2093 if self.base.is_some()
2094 && self
2095 .topo_view()
2096 .neighbors(etype, Direction::Out, src)
2097 .contains(&dst)
2098 {
2099 return Ok(());
2100 }
2101 self.topo.add_edge(etype, src, dst);
2102 // View maintenance for manual edge insert.
2103 self.view_store.on_edge_changed(
2104 etype,
2105 src,
2106 dst,
2107 true,
2108 &mut self.props,
2109 &build_topo_view(&self.topo, &self.base),
2110 &self.ids,
2111 &self.syms,
2112 &self.labels,
2113 self.base.as_ref().map(|b| {
2114 b.columns()
2115 .expect("base columns section bounds validated at open")
2116 }),
2117 );
2118 // Rule engine: via-hop rules must update when user edges change.
2119 let cursor = self.engine.pending_delta_count();
2120 let mut eng = std::mem::take(&mut self.engine);
2121 {
2122 let mut gm = make_graph_mut(
2123 &self.ids,
2124 &mut self.syms,
2125 &self.labels,
2126 build_props_view(&self.props, &self.base),
2127 &mut self.topo,
2128 &mut self.edge_props,
2129 );
2130 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2131 }
2132 self.engine = eng;
2133 if !self.view_store.is_empty() {
2134 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2135 for d in &new_deltas {
2136 self.view_store.on_edge_changed(
2137 d.etype_sym,
2138 d.src_id,
2139 d.dst_id,
2140 d.fired,
2141 &mut self.props,
2142 &build_topo_view(&self.topo, &self.base),
2143 &self.ids,
2144 &self.syms,
2145 &self.labels,
2146 self.base.as_ref().map(|b| {
2147 b.columns()
2148 .expect("base columns section bounds validated at open")
2149 }),
2150 );
2151 }
2152 }
2153 }
2154 WalRecord::SetProp { key, field, value } => {
2155 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2156 detail: format!("wal replay references unknown key {key}"),
2157 })?;
2158 let old_value = build_props_view(&self.props, &self.base)
2159 .get(id, field)
2160 .map(|vr| vr.into_value());
2161 self.props.set(id, field, value.clone());
2162 // Fire rules for the changed field.
2163 let cursor = self.engine.pending_delta_count();
2164 let mut eng = std::mem::take(&mut self.engine);
2165 {
2166 let mut gm = make_graph_mut(
2167 &self.ids,
2168 &mut self.syms,
2169 &self.labels,
2170 build_props_view(&self.props, &self.base),
2171 &mut self.topo,
2172 &mut self.edge_props,
2173 );
2174 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2175 }
2176 self.engine = eng;
2177 // Derived-edge deltas → view updates.
2178 if !self.view_store.is_empty() {
2179 #[cfg(test)]
2180 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2181 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2182 for d in &new_deltas {
2183 self.view_store.on_edge_changed(
2184 d.etype_sym,
2185 d.src_id,
2186 d.dst_id,
2187 d.fired,
2188 &mut self.props,
2189 &build_topo_view(&self.topo, &self.base),
2190 &self.ids,
2191 &self.syms,
2192 &self.labels,
2193 self.base.as_ref().map(|b| {
2194 b.columns()
2195 .expect("base columns section bounds validated at open")
2196 }),
2197 );
2198 }
2199 }
2200 // Neighbor-aggregate views that read `field` must also update.
2201 self.view_store.on_prop_changed(
2202 id,
2203 field,
2204 &mut self.props,
2205 &build_topo_view(&self.topo, &self.base),
2206 &self.ids,
2207 &self.syms,
2208 &self.labels,
2209 self.base.as_ref().map(|b| {
2210 b.columns()
2211 .expect("base columns section bounds validated at open")
2212 }),
2213 );
2214 // Full-text index maintenance: update tokens for this field if indexed.
2215 if self.fulltext.field_indexed(field) {
2216 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2217 if sym == u32::MAX {
2218 None
2219 } else {
2220 self.syms.resolve(sym)
2221 }
2222 });
2223 if let Some(label) = label_opt {
2224 if self.fulltext.is_enabled(label, field) {
2225 self.fulltext.remove_node_field(id, field);
2226 self.fulltext.add_tokens(id, field, value);
2227 }
2228 }
2229 }
2230 }
2231 WalRecord::Intern { id, text } => {
2232 if let Some(existing) = self.syms.get(text) {
2233 if existing != *id {
2234 return Err(GraphError::Corrupt {
2235 detail: format!(
2236 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2237 ),
2238 });
2239 }
2240 } else {
2241 let got = self.syms.intern(text);
2242 if got != *id {
2243 return Err(GraphError::Corrupt {
2244 detail: format!(
2245 "wal intern assigned {got} for {text:?}, record wanted {id}"
2246 ),
2247 });
2248 }
2249 }
2250 }
2251 WalRecord::InsertNodeId { label, key, props } => {
2252 let id = self.ids.try_insert(key)?;
2253 if self.labels.len() <= id as usize {
2254 self.labels.resize(id as usize + 1, u32::MAX);
2255 }
2256 self.labels[id as usize] = *label;
2257 let label_str = self
2258 .syms
2259 .resolve(*label)
2260 .ok_or_else(|| GraphError::Corrupt {
2261 detail: format!("wal InsertNodeId unknown label intern {label}"),
2262 })?
2263 .to_string();
2264 for (field_sym, value) in props {
2265 let field =
2266 self.syms
2267 .resolve(*field_sym)
2268 .ok_or_else(|| GraphError::Corrupt {
2269 detail: format!(
2270 "wal InsertNodeId unknown field intern {field_sym}"
2271 ),
2272 })?;
2273 self.props.set(id, field, value.clone());
2274 }
2275 self.view_store
2276 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2277 let cursor = self.engine.pending_delta_count();
2278 let mut eng = std::mem::take(&mut self.engine);
2279 {
2280 let mut gm = make_graph_mut(
2281 &self.ids,
2282 &mut self.syms,
2283 &self.labels,
2284 build_props_view(&self.props, &self.base),
2285 &mut self.topo,
2286 &mut self.edge_props,
2287 );
2288 eng.on_node_changed(id, None, &mut gm);
2289 }
2290 self.engine = eng;
2291 if !self.view_store.is_empty() {
2292 #[cfg(test)]
2293 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2294 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2295 for d in &new_deltas {
2296 self.view_store.on_edge_changed(
2297 d.etype_sym,
2298 d.src_id,
2299 d.dst_id,
2300 d.fired,
2301 &mut self.props,
2302 &build_topo_view(&self.topo, &self.base),
2303 &self.ids,
2304 &self.syms,
2305 &self.labels,
2306 self.base.as_ref().map(|b| {
2307 b.columns()
2308 .expect("base columns section bounds validated at open")
2309 }),
2310 );
2311 }
2312 }
2313 if self.fulltext.has_label(&label_str) {
2314 for (field_sym, value) in props {
2315 let Some(field) = self.syms.resolve(*field_sym) else {
2316 continue;
2317 };
2318 if self.fulltext.is_enabled(&label_str, field) {
2319 self.fulltext.add_tokens(id, field, value);
2320 }
2321 }
2322 }
2323 }
2324 WalRecord::InsertEdgeId { etype, src, dst } => {
2325 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2326 // already be tombstoned. Skip rather than attaching edges to
2327 // dead ids (DeleteNode keys the live re-insert, not the old id).
2328 if self.ids.is_tombstoned(*src)
2329 || self.ids.is_tombstoned(*dst)
2330 || self.ids.key_of(*src).is_none()
2331 || self.ids.key_of(*dst).is_none()
2332 {
2333 return Ok(());
2334 }
2335 // Skip if already visible in the merged view (same idempotency
2336 // guard as InsertEdge above: prevents double-counting when
2337 // pre-snapshot WAL records are replayed over a V8 base).
2338 if self.base.is_some()
2339 && self
2340 .topo_view()
2341 .neighbors(*etype, Direction::Out, *src)
2342 .contains(dst)
2343 {
2344 return Ok(());
2345 }
2346 self.topo.add_edge(*etype, *src, *dst);
2347 self.view_store.on_edge_changed(
2348 *etype,
2349 *src,
2350 *dst,
2351 true,
2352 &mut self.props,
2353 &build_topo_view(&self.topo, &self.base),
2354 &self.ids,
2355 &self.syms,
2356 &self.labels,
2357 self.base.as_ref().map(|b| {
2358 b.columns()
2359 .expect("base columns section bounds validated at open")
2360 }),
2361 );
2362 // Rule engine: via-hop rules fire when user via-edges are inserted.
2363 // Resolve etype back to string so on_edge_changed can match rules by name.
2364 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2365 let cursor = self.engine.pending_delta_count();
2366 let mut eng = std::mem::take(&mut self.engine);
2367 {
2368 let mut gm = make_graph_mut(
2369 &self.ids,
2370 &mut self.syms,
2371 &self.labels,
2372 build_props_view(&self.props, &self.base),
2373 &mut self.topo,
2374 &mut self.edge_props,
2375 );
2376 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2377 }
2378 self.engine = eng;
2379 if !self.view_store.is_empty() {
2380 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2381 for d in &new_deltas {
2382 self.view_store.on_edge_changed(
2383 d.etype_sym,
2384 d.src_id,
2385 d.dst_id,
2386 d.fired,
2387 &mut self.props,
2388 &build_topo_view(&self.topo, &self.base),
2389 &self.ids,
2390 &self.syms,
2391 &self.labels,
2392 self.base.as_ref().map(|b| {
2393 b.columns()
2394 .expect("base columns section bounds validated at open")
2395 }),
2396 );
2397 }
2398 }
2399 }
2400 }
2401 WalRecord::SetPropId { id, field, value } => {
2402 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2403 return Ok(());
2404 }
2405 let field_str = self
2406 .syms
2407 .resolve(*field)
2408 .ok_or_else(|| GraphError::Corrupt {
2409 detail: format!("wal SetPropId unknown field intern {field}"),
2410 })?
2411 .to_string();
2412 let old_value = build_props_view(&self.props, &self.base)
2413 .get(*id, &field_str)
2414 .map(|vr| vr.into_value());
2415 self.props.set(*id, &field_str, value.clone());
2416 let cursor = self.engine.pending_delta_count();
2417 let mut eng = std::mem::take(&mut self.engine);
2418 {
2419 let mut gm = make_graph_mut(
2420 &self.ids,
2421 &mut self.syms,
2422 &self.labels,
2423 build_props_view(&self.props, &self.base),
2424 &mut self.topo,
2425 &mut self.edge_props,
2426 );
2427 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2428 }
2429 self.engine = eng;
2430 if !self.view_store.is_empty() {
2431 #[cfg(test)]
2432 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2433 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2434 for d in &new_deltas {
2435 self.view_store.on_edge_changed(
2436 d.etype_sym,
2437 d.src_id,
2438 d.dst_id,
2439 d.fired,
2440 &mut self.props,
2441 &build_topo_view(&self.topo, &self.base),
2442 &self.ids,
2443 &self.syms,
2444 &self.labels,
2445 self.base.as_ref().map(|b| {
2446 b.columns()
2447 .expect("base columns section bounds validated at open")
2448 }),
2449 );
2450 }
2451 }
2452 self.view_store.on_prop_changed(
2453 *id,
2454 &field_str,
2455 &mut self.props,
2456 &build_topo_view(&self.topo, &self.base),
2457 &self.ids,
2458 &self.syms,
2459 &self.labels,
2460 self.base.as_ref().map(|b| {
2461 b.columns()
2462 .expect("base columns section bounds validated at open")
2463 }),
2464 );
2465 if self.fulltext.field_indexed(&field_str) {
2466 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2467 if sym == u32::MAX {
2468 None
2469 } else {
2470 self.syms.resolve(sym)
2471 }
2472 });
2473 if let Some(label) = label_opt {
2474 if self.fulltext.is_enabled(label, &field_str) {
2475 self.fulltext.remove_node_field(*id, &field_str);
2476 self.fulltext.add_tokens(*id, &field_str, value);
2477 }
2478 }
2479 }
2480 }
2481 WalRecord::CreateRule { def_bytes } => {
2482 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2483 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2484 })?;
2485 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2486 // so the engine already has it; silently skip to avoid a spurious
2487 // RuleInvalid error in the crash window between snapshot write and WAL
2488 // truncation.
2489 if self.engine.rules().any(|r| r.name == def.name) {
2490 return Ok(());
2491 }
2492 let cursor = self.engine.pending_delta_count();
2493 let mut eng = std::mem::take(&mut self.engine);
2494 let result = {
2495 let mut gm = make_graph_mut(
2496 &self.ids,
2497 &mut self.syms,
2498 &self.labels,
2499 build_props_view(&self.props, &self.base),
2500 &mut self.topo,
2501 &mut self.edge_props,
2502 );
2503 eng.create_rule(def, &mut gm)
2504 };
2505 self.engine = eng;
2506 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2507 // Derived-edge fires from backfill → view updates.
2508 // Fast path: skip O(edge_count) allocation when no views exist.
2509 if !self.view_store.is_empty() {
2510 #[cfg(test)]
2511 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2512 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2513 for d in &new_deltas {
2514 self.view_store.on_edge_changed(
2515 d.etype_sym,
2516 d.src_id,
2517 d.dst_id,
2518 d.fired,
2519 &mut self.props,
2520 &build_topo_view(&self.topo, &self.base),
2521 &self.ids,
2522 &self.syms,
2523 &self.labels,
2524 self.base.as_ref().map(|b| {
2525 b.columns()
2526 .expect("base columns section bounds validated at open")
2527 }),
2528 );
2529 }
2530 }
2531 }
2532 WalRecord::DeleteRule { name } => {
2533 // Replay-over-snapshot idempotency: the snapshot already captured the
2534 // post-delete state so the rule is absent; silently skip to avoid a
2535 // spurious RuleNotFound error in the crash window between snapshot write
2536 // and WAL truncation.
2537 if !self.engine.rules().any(|r| r.name == *name) {
2538 return Ok(());
2539 }
2540 let cursor = self.engine.pending_delta_count();
2541 let mut eng = std::mem::take(&mut self.engine);
2542 let result = {
2543 let mut gm = make_graph_mut(
2544 &self.ids,
2545 &mut self.syms,
2546 &self.labels,
2547 build_props_view(&self.props, &self.base),
2548 &mut self.topo,
2549 &mut self.edge_props,
2550 );
2551 eng.delete_rule(name, &mut gm)
2552 };
2553 self.engine = eng;
2554 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2555 // Derived-edge retractions → view updates.
2556 if !self.view_store.is_empty() {
2557 #[cfg(test)]
2558 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2559 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2560 for d in &new_deltas {
2561 self.view_store.on_edge_changed(
2562 d.etype_sym,
2563 d.src_id,
2564 d.dst_id,
2565 d.fired,
2566 &mut self.props,
2567 &build_topo_view(&self.topo, &self.base),
2568 &self.ids,
2569 &self.syms,
2570 &self.labels,
2571 self.base.as_ref().map(|b| {
2572 b.columns()
2573 .expect("base columns section bounds validated at open")
2574 }),
2575 );
2576 }
2577 }
2578 }
2579 WalRecord::RemoveProp { key, field } => {
2580 // Recovery-safe: unknown key or already-absent field is a
2581 // clean no-op. Crash-window replay over a snapshot that
2582 // already applied this record must not Err.
2583 let Some(id) = self.ids.get(key) else {
2584 return Ok(());
2585 };
2586 // Read old value through the seam for rule retraction.
2587 let old = build_props_view(&self.props, &self.base)
2588 .get(id, field)
2589 .map(|vr| vr.into_value());
2590 self.props.remove(id, field);
2591 // If the base still supplies the value after the overlay removal,
2592 // record a tombstone so ColumnsView::get does not resurrect it.
2593 // This covers both the base-only case AND the both-resident case:
2594 // base-only (in_overlay=false): old prop was only in base, remove
2595 // is a no-op on overlay, base still visible → tombstone needed.
2596 // both-resident (in_overlay=true): overlay had v2, base has v1;
2597 // removing overlay uncovers v1 → tombstone needed.
2598 // Idempotent on double-replay: second pass sees the tombstone →
2599 // get() returns None → condition is false → no duplicate tombstone.
2600 if build_props_view(&self.props, &self.base)
2601 .get(id, field)
2602 .is_some()
2603 {
2604 self.props.record_prop_tombstone(id, field);
2605 }
2606 let cursor = self.engine.pending_delta_count();
2607 let mut eng = std::mem::take(&mut self.engine);
2608 {
2609 let mut gm = make_graph_mut(
2610 &self.ids,
2611 &mut self.syms,
2612 &self.labels,
2613 build_props_view(&self.props, &self.base),
2614 &mut self.topo,
2615 &mut self.edge_props,
2616 );
2617 eng.on_node_changed(id, Some((field, old)), &mut gm);
2618 }
2619 self.engine = eng;
2620 // Derived-edge deltas → view updates.
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 // Neighbor-aggregate views that read `field` must also update.
2644 self.view_store.on_prop_changed(
2645 id,
2646 field,
2647 &mut self.props,
2648 &build_topo_view(&self.topo, &self.base),
2649 &self.ids,
2650 &self.syms,
2651 &self.labels,
2652 self.base.as_ref().map(|b| {
2653 b.columns()
2654 .expect("base columns section bounds validated at open")
2655 }),
2656 );
2657 // Full-text index maintenance: remove tokens for this field.
2658 if self.fulltext.field_indexed(field) {
2659 self.fulltext.remove_node_field(id, field);
2660 }
2661 }
2662 WalRecord::DeleteEdge {
2663 edge_type,
2664 src_key,
2665 dst_key,
2666 } => {
2667 // Recovery-safe: unknown keys, unknown etype, or already-
2668 // absent edge is a clean no-op (remove_edge returns false).
2669 let Some(src) = self.ids.get(src_key) else {
2670 return Ok(());
2671 };
2672 let Some(dst) = self.ids.get(dst_key) else {
2673 return Ok(());
2674 };
2675 let Some(etype) = self.syms.get(edge_type) else {
2676 return Ok(());
2677 };
2678 // I3: phantom-tombstone guard. When a V8 base is present, a
2679 // DeleteEdge WAL record for an edge that was already absorbed into
2680 // the new base (i.e. neither in overlay nor in base) must be skipped.
2681 // Without this guard, remove_edge records a tombstone for an edge
2682 // that no longer exists, incorrectly understating edge_count.
2683 if self.base.is_some()
2684 && !self
2685 .topo_view()
2686 .neighbors(etype, core_storage::topology::Direction::Out, src)
2687 .contains(&dst)
2688 {
2689 return Ok(());
2690 }
2691 self.topo.remove_edge(etype, src, dst);
2692 self.edge_props.remove_edge(etype, src, dst);
2693 // View maintenance for manual edge delete (topo already updated above).
2694 self.view_store.on_edge_changed(
2695 etype,
2696 src,
2697 dst,
2698 false,
2699 &mut self.props,
2700 &build_topo_view(&self.topo, &self.base),
2701 &self.ids,
2702 &self.syms,
2703 &self.labels,
2704 self.base.as_ref().map(|b| {
2705 b.columns()
2706 .expect("base columns section bounds validated at open")
2707 }),
2708 );
2709 // Rule engine: via-hop rules must retract when user via-edges are deleted.
2710 let cursor = self.engine.pending_delta_count();
2711 let mut eng = std::mem::take(&mut self.engine);
2712 {
2713 let mut gm = make_graph_mut(
2714 &self.ids,
2715 &mut self.syms,
2716 &self.labels,
2717 build_props_view(&self.props, &self.base),
2718 &mut self.topo,
2719 &mut self.edge_props,
2720 );
2721 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2722 }
2723 self.engine = eng;
2724 if !self.view_store.is_empty() {
2725 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2726 for d in &new_deltas {
2727 self.view_store.on_edge_changed(
2728 d.etype_sym,
2729 d.src_id,
2730 d.dst_id,
2731 d.fired,
2732 &mut self.props,
2733 &build_topo_view(&self.topo, &self.base),
2734 &self.ids,
2735 &self.syms,
2736 &self.labels,
2737 self.base.as_ref().map(|b| {
2738 b.columns()
2739 .expect("base columns section bounds validated at open")
2740 }),
2741 );
2742 }
2743 }
2744 }
2745 WalRecord::DeleteNode { key } => {
2746 // Recovery-safe: already-tombstoned / unknown key is a clean
2747 // no-op. Crash-window replay over a snapshot that already
2748 // applied this record cannot recover the retired id from the
2749 // key (`IdMap::get` is None), so every subsequent step is
2750 // skipped. Each step is independently idempotent if invoked
2751 // twice on a still-live id: retraction is a no-op on empty
2752 // provenance, `remove_edge` returns false, `remove_all` is a
2753 // no-op, `ids.delete` returns None, label sentinel is sticky.
2754 let Some(n) = self.ids.get(key) else {
2755 return Ok(());
2756 };
2757
2758 // (1) Retract derived edges + de-index while props/labels live.
2759 let cursor = self.engine.pending_delta_count();
2760 let mut eng = std::mem::take(&mut self.engine);
2761 {
2762 let mut gm = make_graph_mut(
2763 &self.ids,
2764 &mut self.syms,
2765 &self.labels,
2766 build_props_view(&self.props, &self.base),
2767 &mut self.topo,
2768 &mut self.edge_props,
2769 );
2770 eng.on_node_removed(n, &mut gm);
2771 }
2772 self.engine = eng;
2773 // Derived-edge retractions → view updates for neighbors.
2774 if !self.view_store.is_empty() {
2775 #[cfg(test)]
2776 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2777 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2778 for d in &new_deltas {
2779 self.view_store.on_edge_changed(
2780 d.etype_sym,
2781 d.src_id,
2782 d.dst_id,
2783 d.fired,
2784 &mut self.props,
2785 &build_topo_view(&self.topo, &self.base),
2786 &self.ids,
2787 &self.syms,
2788 &self.labels,
2789 self.base.as_ref().map(|b| {
2790 b.columns()
2791 .expect("base columns section bounds validated at open")
2792 }),
2793 );
2794 }
2795 }
2796
2797 // (2) Sweep ALL remaining edges incident to n, both directions,
2798 // every etype. This cascade is intentionally mask-independent:
2799 // topology integrity requires removing every edge touching the
2800 // deleted node regardless of the caller's visibility scope.
2801 // (The mask limits which nodes a role's read phase can return;
2802 // the WAL delete always executes with full storage authority.)
2803 // Collect then remove so neighbor slices stay valid during
2804 // iteration. Remove from topo first, then call view maintenance
2805 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
2806 let etypes: Vec<u32> = self.topo.etypes().collect();
2807 let mut doomed = Vec::new();
2808 for et in &etypes {
2809 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
2810 doomed.push((*et, n, dst));
2811 }
2812 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
2813 doomed.push((*et, src, n));
2814 }
2815 }
2816 for (et, s, d) in doomed {
2817 self.topo.remove_edge(et, s, d);
2818 self.edge_props.remove_edge(et, s, d);
2819 // View maintenance: n's own view values will be cleared by
2820 // remove_all below; only update surviving neighbors.
2821 self.view_store.on_edge_changed(
2822 et,
2823 s,
2824 d,
2825 false,
2826 &mut self.props,
2827 &build_topo_view(&self.topo, &self.base),
2828 &self.ids,
2829 &self.syms,
2830 &self.labels,
2831 self.base.as_ref().map(|b| {
2832 b.columns()
2833 .expect("base columns section bounds validated at open")
2834 }),
2835 );
2836 }
2837
2838 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
2839 self.props.remove_all(n);
2840 // Full-text index maintenance: remove all tokens for this node.
2841 self.fulltext.remove_node(n);
2842
2843 // (4) Retire the dense id and stamp the label sentinel.
2844 self.ids.delete(key);
2845 if let Some(slot) = self.labels.get_mut(n as usize) {
2846 *slot = u32::MAX;
2847 }
2848 }
2849 WalRecord::Batch(inner) => {
2850 // Apply each inner record in order through the same apply path.
2851 // Inner records are validated free of nested Batch by encode_record.
2852 for rec in inner {
2853 self.apply(rec)?;
2854 }
2855 }
2856 WalRecord::RebuildRule { name } => {
2857 // Replay-over-snapshot idempotency: the snapshot may already
2858 // reflect a later delete_rule, so the rule is absent; skip.
2859 if !self.engine.rules().any(|r| r.name == *name) {
2860 return Ok(());
2861 }
2862 let cursor = self.engine.pending_delta_count();
2863 let mut eng = std::mem::take(&mut self.engine);
2864 let result = {
2865 let mut gm = make_graph_mut(
2866 &self.ids,
2867 &mut self.syms,
2868 &self.labels,
2869 build_props_view(&self.props, &self.base),
2870 &mut self.topo,
2871 &mut self.edge_props,
2872 );
2873 eng.rebuild(name, &mut gm)
2874 };
2875 self.engine = eng;
2876 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2877 // Derived-edge delta changes → view updates.
2878 if !self.view_store.is_empty() {
2879 #[cfg(test)]
2880 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2881 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2882 for d in &new_deltas {
2883 self.view_store.on_edge_changed(
2884 d.etype_sym,
2885 d.src_id,
2886 d.dst_id,
2887 d.fired,
2888 &mut self.props,
2889 &build_topo_view(&self.topo, &self.base),
2890 &self.ids,
2891 &self.syms,
2892 &self.labels,
2893 self.base.as_ref().map(|b| {
2894 b.columns()
2895 .expect("base columns section bounds validated at open")
2896 }),
2897 );
2898 }
2899 }
2900 }
2901 WalRecord::CreateView { def_bytes } => {
2902 let def: ViewDef =
2903 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2904 detail: format!("CreateView def_bytes deserialize failed: {e}"),
2905 })?;
2906 // Replay-over-snapshot idempotency: view already present → skip.
2907 if self.view_store.has_view(&def.name) {
2908 return Ok(());
2909 }
2910 self.view_store
2911 .create_view(
2912 def,
2913 &mut self.props,
2914 &build_topo_view(&self.topo, &self.base),
2915 &self.ids,
2916 &self.syms,
2917 &self.labels,
2918 )
2919 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
2920 }
2921 WalRecord::DeleteView { name } => {
2922 // Replay-over-snapshot idempotency: view already absent → skip.
2923 if !self.view_store.has_view(name) {
2924 return Ok(());
2925 }
2926 self.view_store
2927 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
2928 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2929 }
2930 WalRecord::EnableFulltext { label, field } => {
2931 // Replay-over-snapshot idempotency: already enabled → skip.
2932 if self.fulltext.is_enabled(label, field) {
2933 return Ok(());
2934 }
2935 self.fulltext.enable(label, field);
2936 // Backfill: index all live nodes of this label that have the field.
2937 let n = self.ids.len() as u32;
2938 for id in 0..n {
2939 let Some(&sym) = self.labels.get(id as usize) else {
2940 continue;
2941 };
2942 if sym == u32::MAX {
2943 continue; // tombstoned
2944 }
2945 let Some(lbl) = self.syms.resolve(sym) else {
2946 continue;
2947 };
2948 if lbl != label {
2949 continue;
2950 }
2951 if let Some(value) = build_props_view(&self.props, &self.base)
2952 .get(id, field)
2953 .map(|vr| vr.into_value())
2954 {
2955 self.fulltext.add_tokens(id, field, &value);
2956 }
2957 }
2958 }
2959 WalRecord::DisableFulltext { label, field } => {
2960 // Replay-over-snapshot idempotency: already disabled → skip.
2961 if !self.fulltext.is_enabled(label, field) {
2962 return Ok(());
2963 }
2964 // If another label still indexes this field, the postings column
2965 // is kept — but it must not contain node_ids from the now-disabled
2966 // label. Remove them before calling disable() so the field_indexed
2967 // guard inside disable() sees the correct post-removal state.
2968 if self.fulltext.field_indexed_by_other(label, field) {
2969 if let Some(label_sym) = self.syms.get(label) {
2970 for (node_id, &lsym) in self.labels.iter().enumerate() {
2971 if lsym == label_sym {
2972 self.fulltext.remove_node_field(node_id as u32, field);
2973 }
2974 }
2975 }
2976 }
2977 self.fulltext.disable(label, field);
2978 }
2979 // History markers carry no replay state — rules re-derive edges
2980 // deterministically on open/replay. Skip unconditionally.
2981 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
2982 // ── rename_node ──────────────────────────────────────────────────
2983 WalRecord::RenameNode { old_key, new_key } => {
2984 // Recovery-safe: if old_key is already gone (key was renamed
2985 // by a snapshot or a prior replay frame), skip cleanly.
2986 if self.ids.get(old_key).is_none() {
2987 return Ok(());
2988 }
2989 // The rename only updates the key-table; the dense id, all
2990 // topo edges, props, labels, and rule state are id-indexed and
2991 // require no change.
2992 self.ids
2993 .rename(old_key, new_key)
2994 .map_err(|e| GraphError::Corrupt {
2995 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
2996 })?;
2997 }
2998 }
2999 Ok(())
3000 }
3001
3002 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3003 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3004 /// idempotent when the string is already bound. Always emit: after
3005 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3006 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3007 let id = if let Some(id) = self.syms.get(s) {
3008 id
3009 } else {
3010 self.syms.intern(s)
3011 };
3012 (
3013 id,
3014 WalRecord::Intern {
3015 id,
3016 text: s.to_string(),
3017 },
3018 )
3019 }
3020
3021 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3022 /// state is left mutated: speculative interns made while building the
3023 /// output are rolled back, so a later successful mutation cannot log an
3024 /// `Intern` record whose id replay would never reproduce.
3025 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3026 let syms_checkpoint = self.syms.len();
3027 let result = self.rewrite_wal_dense_inner(recs);
3028 if result.is_err() {
3029 self.syms.truncate(syms_checkpoint);
3030 }
3031 result
3032 }
3033
3034 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3035 let mut out = Vec::with_capacity(recs.len());
3036 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3037 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3038 let mut interned = std::collections::HashSet::<u32>::new();
3039 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3040 detail: "id space exhausted".into(),
3041 })?;
3042 let lookup = |ids: &IdMap,
3043 pending: &std::collections::HashMap<String, u32>,
3044 key: &str|
3045 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3046 for rec in recs {
3047 match rec {
3048 WalRecord::InsertNode { label, key, props } => {
3049 let (label_id, intern) = self.intern_wal(&label);
3050 if interned.insert(label_id) {
3051 out.push(intern);
3052 }
3053 let mut props_id = Vec::with_capacity(props.len());
3054 for (field, value) in props {
3055 let (field_id, intern) = self.intern_wal(&field);
3056 if interned.insert(field_id) {
3057 out.push(intern);
3058 }
3059 props_id.push((field_id, value));
3060 }
3061 if lookup(&self.ids, &pending, &key).is_none() {
3062 pending.insert(key.clone(), next);
3063 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3064 detail: "id space exhausted".into(),
3065 })?;
3066 }
3067 out.push(WalRecord::InsertNodeId {
3068 label: label_id,
3069 key,
3070 props: props_id,
3071 });
3072 }
3073 WalRecord::SetProp { key, field, value } => {
3074 let id =
3075 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3076 detail: format!("dense WAL rewrite missing key {key}"),
3077 })?;
3078 let (field_id, intern) = self.intern_wal(&field);
3079 if interned.insert(field_id) {
3080 out.push(intern);
3081 }
3082 out.push(WalRecord::SetPropId {
3083 id,
3084 field: field_id,
3085 value,
3086 });
3087 }
3088 WalRecord::InsertEdge {
3089 edge_type,
3090 src_key,
3091 dst_key,
3092 } => {
3093 let (etype, intern) = self.intern_wal(&edge_type);
3094 if interned.insert(etype) {
3095 out.push(intern);
3096 }
3097 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3098 GraphError::Corrupt {
3099 detail: format!("dense WAL rewrite missing src {src_key}"),
3100 }
3101 })?;
3102 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3103 GraphError::Corrupt {
3104 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3105 }
3106 })?;
3107 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3108 }
3109 WalRecord::RenameNode {
3110 ref old_key,
3111 ref new_key,
3112 } => {
3113 // Track the rename in `pending` so subsequent InsertEdge /
3114 // SetProp records in this batch can resolve the new key.
3115 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3116 GraphError::Corrupt {
3117 detail: format!(
3118 "dense WAL rewrite: RenameNode old key {old_key} not found"
3119 ),
3120 }
3121 })?;
3122 pending.remove(old_key.as_str());
3123 pending.insert(new_key.clone(), id);
3124 out.push(rec);
3125 }
3126 other => out.push(other),
3127 }
3128 }
3129 Ok(out)
3130 }
3131
3132 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3133 let recs = self.rewrite_wal_dense(recs)?;
3134 match recs.len() {
3135 0 => Ok(()),
3136 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3137 _ => self.log_then_apply(WalRecord::Batch(recs)),
3138 }
3139 }
3140
3141 /// Durable write, then notify the event sink. Replay (`apply` during
3142 /// `open`) never enters this function, so it is the replay-silent seam.
3143 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3144 self.log_then_apply_with(rec, None, self.fsync)
3145 }
3146
3147 /// Whether this frame must fsync under `policy`.
3148 ///
3149 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3150 /// mutations do not. The dense rewrite wraps a single mutation in a
3151 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3152 /// from the count — removing that filter would make every single-op write
3153 /// fsync under Batched (or, if the threshold were raised instead, skip a
3154 /// needed fsync for real two-op batches).
3155 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3156 match policy {
3157 FsyncPolicy::Relaxed => false,
3158 FsyncPolicy::Strict => true,
3159 FsyncPolicy::Batched => match rec {
3160 // Intern + one mutation is the single-op rewrite, not a user batch.
3161 WalRecord::Batch(inner) => {
3162 inner
3163 .iter()
3164 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3165 .count()
3166 > 1
3167 }
3168 _ => false,
3169 },
3170 }
3171 }
3172
3173 /// # Apply-infallibility invariant (load-bearing)
3174 ///
3175 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3176 /// for a `Batch` frame after a successful WAL write, the WAL would contain
3177 /// the full frame while in-memory state would reflect only the ops before
3178 /// the failure. On reopen, WAL replay would then apply the entire batch —
3179 /// diverging permanently from what the pre-crash process had in memory.
3180 ///
3181 /// For `Batch` frames this situation cannot arise because:
3182 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3183 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
3184 /// use, with no concurrent mutation between validation exit and apply entry.
3185 /// - Every `apply` arm for a validated op is either infallible by construction
3186 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3187 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3188 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3189 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3190 ///
3191 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3192 /// `Err` for a `Batch` frame, making any future regression immediately visible
3193 /// in tests rather than silently diverging crash-recovery behaviour.
3194 fn log_then_apply_with(
3195 &mut self,
3196 rec: WalRecord,
3197 ingest: Option<(String, usize)>,
3198 policy: FsyncPolicy,
3199 ) -> Result<()> {
3200 // Read-only guard: as-of instances must never write the WAL.
3201 if self.read_only {
3202 return Err(GraphError::ReadOnly);
3203 }
3204 // Degraded guard: fsync failure left WAL truncated; in-memory state
3205 // is ahead of the on-disk WAL, so further mutations would deepen the
3206 // divergence. Reopen the database to recover.
3207 if self.degraded {
3208 return Err(GraphError::Io(std::io::Error::other(
3209 "database degraded after group-commit fsync failure; reopen required",
3210 )));
3211 }
3212 // Ensure retained provenance bytes are decoded into the live mutable
3213 // fields before any mutation touches self.engine.provenance. This is a
3214 // no-op if provenance was never stored (fresh store) or has already been
3215 // consumed (subsequent mutations). WAL replay calls apply() directly
3216 // and is covered by consume_retained_state_eager before replay.
3217 self.ensure_v8_base_sections_loaded();
3218 self.engine.ensure_provenance_loaded_mut();
3219 // Invariant (I-1): no stale deltas may enter from a previous apply.
3220 // If any engine method ever accumulates deltas before erroring, they would
3221 // contaminate the *next* commit's event stream. This assert fires in debug
3222 // builds, making any future regression visible at the earliest point.
3223 debug_assert_eq!(
3224 self.engine.pending_delta_count(),
3225 0,
3226 "stale engine deltas at log_then_apply_with entry — \
3227 a previous apply arm may have accumulated deltas before erroring; \
3228 the caller must drain_deltas() on any error path before returning"
3229 );
3230 self.fs.append(FileId::Wal, &encode_record(&rec))?;
3231 if Self::wal_needs_sync(policy, &rec) {
3232 self.fs.sync(FileId::Wal)?;
3233 }
3234 // Marker writing always needs the engine deltas, but the engine only
3235 // accumulates them when emit_deltas is true (normally gated on subscribers
3236 // or views being present). Enable emission for this apply if it is
3237 // currently off, then restore the original state unconditionally via an
3238 // RAII guard — this prevents a panic in apply() from leaking the flag.
3239 struct RestoreEmitDeltas(*mut RuleEngine, bool);
3240 impl Drop for RestoreEmitDeltas {
3241 fn drop(&mut self) {
3242 // SAFETY: pointer into self (GraphDb); guard is dropped within
3243 // this frame before log_then_apply_with returns.
3244 unsafe { (*self.0).set_emit_deltas(self.1) };
3245 }
3246 }
3247 let original_emit = self.engine.emit_deltas();
3248 if !original_emit {
3249 self.engine.set_emit_deltas(true);
3250 }
3251 // SAFETY: raw pointer into self; guard dropped within this frame.
3252 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3253
3254 let apply_result = self.apply(&rec);
3255 // For Batch frames, post-validation apply must be infallible (see above).
3256 // A debug_assert here catches any future change that makes apply fallible
3257 // before the caller notices via silent WAL/memory divergence.
3258 if matches!(&rec, WalRecord::Batch(_)) {
3259 debug_assert!(
3260 apply_result.is_ok(),
3261 "Batch apply returned Err after successful WAL write — \
3262 the validate-then-apply invariant has been violated; \
3263 see log_then_apply_with invariant doc"
3264 );
3265 }
3266 if apply_result.is_err() {
3267 // Discard any partial deltas accumulated by the failed apply.
3268 // They must not ride the next commit's event stream (I-1).
3269 // _emit_guard restores emit_deltas on drop automatically.
3270 let _ = self.engine.drain_deltas();
3271 let _ = self.engine.take_rebuild_needed();
3272 apply_result?;
3273 }
3274 self.commit_seq += 1;
3275 let seq = self.commit_seq;
3276 // Update per-node last-change map for the committed record.
3277 // Must happen after commit_seq is incremented so the seq is correct.
3278 self.update_last_change_from_rec(&rec, seq);
3279 // Drain engine deltas and distribute to subscribers before the existing
3280 // MutationEvent sink fires — both happen post-fsync, post-apply.
3281 // _emit_guard restores emit_deltas after this line when it drops.
3282 let engine_deltas = self.engine.drain_deltas();
3283
3284 // Append history-marker WAL records for any derived-edge changes so
3285 // that `edge_history` and `was_linked` can surface rule-attributed
3286 // events. Markers are STATE NO-OPS during replay; they are written
3287 // without an additional fsync (the triggering commit's sync already
3288 // happened; the next commit's sync covers these lazily).
3289 if !engine_deltas.is_empty() {
3290 let markers: Vec<WalRecord> = engine_deltas
3291 .iter()
3292 .map(|d| {
3293 if d.fired {
3294 WalRecord::DerivedEdgeAdded {
3295 rule: d.rule.clone(),
3296 edge_type: d.edge_type.clone(),
3297 src_key: d.src_key.clone(),
3298 dst_key: d.dst_key.clone(),
3299 }
3300 } else {
3301 WalRecord::DerivedEdgeRetracted {
3302 rule: d.rule.clone(),
3303 edge_type: d.edge_type.clone(),
3304 src_key: d.src_key.clone(),
3305 dst_key: d.dst_key.clone(),
3306 }
3307 }
3308 })
3309 .collect();
3310 let marker_frame = if markers.len() == 1 {
3311 markers.into_iter().next().unwrap()
3312 } else {
3313 WalRecord::Batch(markers)
3314 };
3315 // Ignore append errors: markers are best-effort history
3316 // annotations. Losing them does not affect state correctness.
3317 let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3318 }
3319
3320 // Record MVCC CommitDelta for the epoch reader. The WAL record is
3321 // stored as-is (including any nested Batch / Intern records); the
3322 // ReaderSnapshot's apply_one function handles all variants.
3323 {
3324 let derived_inserts = engine_deltas
3325 .iter()
3326 .filter(|d| d.fired)
3327 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3328 .collect();
3329 let derived_deletes = engine_deltas
3330 .iter()
3331 .filter(|d| !d.fired)
3332 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3333 .collect();
3334 let delta = Arc::new(crate::reader::CommitDelta {
3335 records: vec![rec.clone()],
3336 derived_inserts,
3337 derived_deletes,
3338 });
3339 self.delta_tail.push(delta);
3340 self.commits_since_fold += 1;
3341 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3342 self.fold_now();
3343 }
3344 }
3345
3346 if self.defer_events {
3347 // Group-commit drain thread: hold events until after the group
3348 // fsync so subscribers only observe durable data (R2).
3349 self.deferred_events.push(DeferredEvent {
3350 rec: rec.clone(),
3351 engine_deltas,
3352 seq,
3353 ingest,
3354 });
3355 } else {
3356 self.distribute_events(&rec, &engine_deltas, seq);
3357 self.emit_committed(&rec, ingest);
3358 }
3359 // Drift is only known after apply, so auto-rebuild cannot join the
3360 // triggering op's WAL frame. Issue RebuildRule as a second commit.
3361 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3362 // retrigger loop is impossible if the fit succeeded, but we still
3363 // drain the flag so a leftover cannot re-enter.
3364 let rebuilds = self.engine.take_rebuild_needed();
3365 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3366 let mut failed = Vec::new();
3367 for name in rebuilds {
3368 if self.engine.rules().any(|r| r.name == name) {
3369 // User op is already durable. A failed second commit must
3370 // not surface as the caller's error.
3371 if let Err(e) =
3372 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3373 {
3374 eprintln!(
3375 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3376 );
3377 failed.push(name);
3378 }
3379 }
3380 }
3381 for name in failed {
3382 self.engine.queue_rebuild_needed(name);
3383 }
3384 }
3385 Ok(())
3386 }
3387
3388 /// Install a post-commit hook. Replaces any previous sink.
3389 ///
3390 /// The sink runs inside `log_then_apply` after a successful
3391 /// durable commit, while the caller still holds `&mut self`. When this
3392 /// database is behind a [`crate::SharedDb`], that means the **write
3393 /// guard is held**. The sink must never call `read` / `write` (or any
3394 /// other method) on the same `SharedDb` — the `RwLock` is not
3395 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3396 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3397 /// Intended examples: `std::sync::mpsc::SyncSender`,
3398 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3399 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3400 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3401 self.event_sink = Some(sink);
3402 }
3403
3404 /// Whether a post-commit event sink is currently installed.
3405 pub fn has_event_sink(&self) -> bool {
3406 self.event_sink.is_some()
3407 }
3408
3409 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3410 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3411 self.fsync = p;
3412 }
3413
3414 /// Return the current WAL fsync cadence.
3415 pub fn fsync_policy(&self) -> FsyncPolicy {
3416 self.fsync
3417 }
3418
3419 // ── Group-commit event deferral ───────────────────────────────────────────
3420
3421 /// Enable or disable deferred event mode.
3422 ///
3423 /// When `true`, event notifications (subscription `DbEvent`s and legacy
3424 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3425 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3426 /// or [`discard_deferred_events`] if the fsync failed and the group must
3427 /// be treated as lost.
3428 pub fn set_deferred_events_mode(&mut self, defer: bool) {
3429 self.defer_events = defer;
3430 }
3431
3432 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3433 /// was set to true. Clears the buffer.
3434 ///
3435 /// Called by the drain thread AFTER a successful group fsync, so
3436 /// subscribers observe only data that is durably on disk.
3437 pub fn flush_deferred_events(&mut self) {
3438 let events = std::mem::take(&mut self.deferred_events);
3439 for de in events {
3440 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3441 self.emit_committed(&de.rec, de.ingest);
3442 }
3443 }
3444
3445 /// Discard all buffered events without firing them.
3446 ///
3447 /// Called by the drain thread when a group fsync fails: the WAL has been
3448 /// truncated back to the pre-group offset, so the committed-but-unsynced
3449 /// ops must not be observable to subscribers.
3450 pub fn discard_deferred_events(&mut self) {
3451 self.deferred_events.clear();
3452 }
3453
3454 // ── Degraded state ────────────────────────────────────────────────────────
3455
3456 /// Mark this database as degraded.
3457 ///
3458 /// Called by the group-commit drain thread after a group fsync failure and
3459 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3460 /// further mutations would deepen the divergence. All subsequent calls to
3461 /// [`log_then_apply_with`] return `Err` until the database is reopened.
3462 pub fn set_degraded(&mut self) {
3463 self.degraded = true;
3464 }
3465
3466 fn emit(&self, ev: MutationEvent) {
3467 if let Some(sink) = &self.event_sink {
3468 sink(ev);
3469 }
3470 }
3471
3472 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3473 match rec {
3474 WalRecord::Batch(inner) => {
3475 for r in inner {
3476 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3477 self.emit(ev);
3478 }
3479 }
3480 match ingest {
3481 Some((label, inserted)) => {
3482 self.emit(MutationEvent::Ingested { label, inserted })
3483 }
3484 None => {
3485 let ops = inner
3486 .iter()
3487 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3488 .count();
3489 if ops > 1 {
3490 self.emit(MutationEvent::BatchApplied { ops });
3491 }
3492 }
3493 }
3494 }
3495 other => {
3496 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3497 self.emit(ev);
3498 }
3499 }
3500 }
3501 }
3502
3503 // -----------------------------------------------------------------------
3504 // Subscription API
3505 // -----------------------------------------------------------------------
3506
3507 /// Distribute post-commit events to all live subscribers.
3508 ///
3509 /// Build a row-key → row-data map from a [`ResultSet`].
3510 ///
3511 /// Each row is serialized to JSON to form its key; a debug fallback is used
3512 /// if serialization fails. Used by both the initial-seed path in
3513 /// [`Self::subscribe_query`] and the per-commit diff path in
3514 /// [`Self::distribute_events`] to keep the two in sync.
3515 fn result_to_row_map(
3516 result: &core_query::ResultSet,
3517 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3518 (0..result.len())
3519 .map(|i| {
3520 let row = result.row(i).to_vec();
3521 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3522 (key, row)
3523 })
3524 .collect()
3525 }
3526
3527 /// Distribute post-commit events to all live subscribers.
3528 ///
3529 /// Called from `log_then_apply_with` after apply + fsync, before the
3530 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3531 ///
3532 /// Query subscriptions (subscribe_query) re-execute their plan on every
3533 /// call and diff the result against the previous run. Zero overhead when
3534 /// no query subscriptions are active.
3535 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3536 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3537 return;
3538 }
3539
3540 if !self.subscriptions.is_empty() {
3541 // Build write events from the WAL record.
3542 let write_events: Vec<DbEvent> =
3543 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3544
3545 // Build edge events from engine deltas. Weight is looked up from
3546 // edge_props at distribution time (after apply), so it's always fresh.
3547 let edge_events: Vec<DbEvent> = engine_deltas
3548 .iter()
3549 .map(|d| {
3550 if d.fired {
3551 let weight = self
3552 .edge_props
3553 .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3554 .and_then(|v| {
3555 if let core_storage::Value::Float(f) = v {
3556 Some(*f)
3557 } else {
3558 None
3559 }
3560 });
3561 DbEvent::EdgeFired {
3562 rule: d.rule.clone(),
3563 src_key: d.src_key.clone(),
3564 dst_key: d.dst_key.clone(),
3565 edge_type: d.edge_type.clone(),
3566 weight,
3567 commit_seq: seq,
3568 }
3569 } else {
3570 DbEvent::EdgeRetracted {
3571 rule: d.rule.clone(),
3572 src_key: d.src_key.clone(),
3573 dst_key: d.dst_key.clone(),
3574 edge_type: d.edge_type.clone(),
3575 commit_seq: seq,
3576 }
3577 }
3578 })
3579 .collect();
3580
3581 // Prune dead entries; push matching events to live ones.
3582 self.subscriptions.retain(|entry| {
3583 let Some(inner) = entry.inner.upgrade() else {
3584 return false;
3585 };
3586 for ev in &write_events {
3587 if event_matches(ev, &entry.filter) {
3588 inner.push(ev.clone());
3589 }
3590 }
3591 for ev in &edge_events {
3592 if event_matches(ev, &entry.filter) {
3593 inner.push(ev.clone());
3594 }
3595 }
3596 true
3597 });
3598
3599 // Turn off delta accumulation if all subscribers dropped and no views remain.
3600 if self.subscriptions.is_empty() && self.view_store.is_empty() {
3601 self.engine.set_emit_deltas(false);
3602 }
3603 }
3604
3605 // Query subscriptions: full re-run per commit, then diff rows.
3606 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3607 // Differential evaluation is roadmap / Phase 5.
3608 if !self.query_subscriptions.is_empty() {
3609 // Take the list out so we can call self.view() without borrow conflict.
3610 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3611 let empty_params = BTreeMap::new();
3612 query_subs.retain_mut(|entry| {
3613 let Some(inner) = entry.inner.upgrade() else {
3614 return false; // subscriber dropped — prune
3615 };
3616 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
3617 Ok(r) => r,
3618 Err(e) => {
3619 // Keep the subscription alive; skip the diff for this commit.
3620 // Re-run errors are transient (e.g., planner change) and
3621 // self-heal when the next commit succeeds.
3622 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
3623 return true;
3624 }
3625 };
3626 // Build new row map: serialized-key → row data.
3627 let new_row_map = Self::result_to_row_map(&result);
3628 // Removed rows: in prev but not in new.
3629 for (key, row) in &entry.prev_row_map {
3630 if !new_row_map.contains_key(key) {
3631 inner.push(DbEvent::QueryRowRemoved {
3632 columns: entry.columns.clone(),
3633 row: row.clone(),
3634 });
3635 }
3636 }
3637 // Added rows: in new but not in prev.
3638 for (key, row) in &new_row_map {
3639 if !entry.prev_row_map.contains_key(key) {
3640 inner.push(DbEvent::QueryRowAdded {
3641 columns: entry.columns.clone(),
3642 row: row.clone(),
3643 });
3644 }
3645 }
3646 entry.prev_row_map = new_row_map;
3647 true
3648 });
3649 self.query_subscriptions = query_subs;
3650 }
3651 }
3652
3653 /// Returns `true` if any live subscriber or view definition requires delta
3654 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
3655 fn needs_emit_deltas(&self) -> bool {
3656 !self.view_store.is_empty()
3657 || self
3658 .subscriptions
3659 .iter()
3660 .any(|e| e.inner.upgrade().is_some())
3661 }
3662
3663 /// Convert a WAL record into `DbEvent` write events with the given seq.
3664 fn write_events_from_record(
3665 rec: &WalRecord,
3666 seq: u64,
3667 intern: &Interner,
3668 ids: &IdMap,
3669 ) -> Vec<DbEvent> {
3670 match rec {
3671 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
3672 label: label.clone(),
3673 key: key.clone(),
3674 commit_seq: seq,
3675 }],
3676 // *Id arms run after a successful apply, so resolution can only
3677 // fail on a programming error. Skip the event rather than emit a
3678 // fabricated "" that clients can't tell from a real empty value
3679 // (mirrors event_from_record returning None).
3680 WalRecord::InsertNodeId { label, key, .. } => intern
3681 .resolve(*label)
3682 .map(|label| DbEvent::NodeInserted {
3683 label: label.to_string(),
3684 key: key.clone(),
3685 commit_seq: seq,
3686 })
3687 .into_iter()
3688 .collect(),
3689 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
3690 key: key.clone(),
3691 field: field.clone(),
3692 commit_seq: seq,
3693 }],
3694 WalRecord::SetPropId { id, field, .. } => ids
3695 .key_of(*id)
3696 .zip(intern.resolve(*field))
3697 .map(|(key, field)| DbEvent::PropSet {
3698 key: key.to_string(),
3699 field: field.to_string(),
3700 commit_seq: seq,
3701 })
3702 .into_iter()
3703 .collect(),
3704 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
3705 key: key.clone(),
3706 field: field.clone(),
3707 commit_seq: seq,
3708 }],
3709 WalRecord::InsertEdge {
3710 edge_type,
3711 src_key,
3712 dst_key,
3713 } => vec![DbEvent::EdgeInserted {
3714 edge_type: edge_type.clone(),
3715 src: src_key.clone(),
3716 dst: dst_key.clone(),
3717 commit_seq: seq,
3718 }],
3719 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
3720 Some(DbEvent::EdgeInserted {
3721 edge_type: intern.resolve(*etype)?.to_string(),
3722 src: ids.key_of(*src)?.to_string(),
3723 dst: ids.key_of(*dst)?.to_string(),
3724 commit_seq: seq,
3725 })
3726 })()
3727 .into_iter()
3728 .collect(),
3729 WalRecord::DeleteEdge {
3730 edge_type,
3731 src_key,
3732 dst_key,
3733 } => vec![DbEvent::EdgeDeleted {
3734 edge_type: edge_type.clone(),
3735 src: src_key.clone(),
3736 dst: dst_key.clone(),
3737 commit_seq: seq,
3738 }],
3739 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
3740 key: key.clone(),
3741 commit_seq: seq,
3742 }],
3743 WalRecord::Batch(inner) => inner
3744 .iter()
3745 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
3746 .collect(),
3747 WalRecord::CreateRule { .. }
3748 | WalRecord::DeleteRule { .. }
3749 | WalRecord::RebuildRule { .. }
3750 | WalRecord::CreateView { .. }
3751 | WalRecord::DeleteView { .. }
3752 | WalRecord::EnableFulltext { .. }
3753 | WalRecord::DisableFulltext { .. }
3754 | WalRecord::Intern { .. }
3755 // History markers produce no DbEvent — the engine delta already
3756 // fired the EdgeFired/EdgeRetracted subscription events.
3757 | WalRecord::DerivedEdgeAdded { .. }
3758 | WalRecord::DerivedEdgeRetracted { .. }
3759 | WalRecord::RenameNode { .. } => vec![],
3760 }
3761 }
3762
3763 /// Subscribe to edge-fire and edge-retract events for one named rule.
3764 ///
3765 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
3766 /// currently registered. Dropping the returned [`Subscription`] handle
3767 /// unregisters the subscriber — no further events are queued, no
3768 /// resources leak.
3769 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
3770 if self.read_only {
3771 return Err(core_storage::GraphError::ReadOnly);
3772 }
3773 if !self.engine.rules().any(|r| r.name == rule_name) {
3774 return Err(core_storage::GraphError::RuleNotFound {
3775 name: rule_name.to_string(),
3776 });
3777 }
3778 let inner = SubInner::new(self.sub_capacity());
3779 self.subscriptions.push(SubEntry {
3780 filter: SubFilter::Rule(rule_name.to_string()),
3781 inner: std::sync::Arc::downgrade(&inner),
3782 });
3783 self.engine.set_emit_deltas(true);
3784 Ok(Subscription(inner))
3785 }
3786
3787 /// Subscribe to edge-fire and edge-retract events for **all** rules.
3788 ///
3789 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3790 /// as-of instances never commit, so `distribute_events` never runs and the
3791 /// subscription would never deliver events.
3792 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
3793 if self.read_only {
3794 return Err(core_storage::GraphError::ReadOnly);
3795 }
3796 let inner = SubInner::new(self.sub_capacity());
3797 self.subscriptions.push(SubEntry {
3798 filter: SubFilter::AllRules,
3799 inner: std::sync::Arc::downgrade(&inner),
3800 });
3801 self.engine.set_emit_deltas(true);
3802 Ok(Subscription(inner))
3803 }
3804
3805 /// Subscribe to write events: node insert/delete, prop set/remove.
3806 ///
3807 /// Does not include edge-fire / edge-retract (rule-derived edge events).
3808 ///
3809 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3810 /// as-of instances never commit, so `distribute_events` never runs and the
3811 /// subscription would never deliver events.
3812 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
3813 if self.read_only {
3814 return Err(core_storage::GraphError::ReadOnly);
3815 }
3816 let inner = SubInner::new(self.sub_capacity());
3817 self.subscriptions.push(SubEntry {
3818 filter: SubFilter::Writes,
3819 inner: std::sync::Arc::downgrade(&inner),
3820 });
3821 self.engine.set_emit_deltas(true);
3822 Ok(Subscription(inner))
3823 }
3824
3825 /// Subscribe to incremental Cypher query results.
3826 ///
3827 /// Parses and plans `cypher`; rejects the query if the plan is not in the
3828 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
3829 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
3830 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
3831 ///
3832 /// SKIP is not supported — it shifts the result window on every commit,
3833 /// causing spurious Added/Removed churn for rows whose data never changed.
3834 /// Multi-hop Expand chains are not supported; each additional MATCH clause
3835 /// widens scope beyond the documented single-scan / single-hop subset.
3836 ///
3837 /// After each successful commit, the plan is **fully re-executed** and the
3838 /// result is diffed against the previous run. Added rows produce
3839 /// [`DbEvent::QueryRowAdded`]; removed rows produce
3840 /// [`DbEvent::QueryRowRemoved`].
3841 ///
3842 /// **Full re-run per commit; use LIMIT to bound execution cost.**
3843 /// The existing 1 M intermediate-row cap applies. Differential evaluation
3844 /// is roadmap / Phase 5.
3845 ///
3846 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3847 /// as-of instances never commit, so `distribute_events` never runs and the
3848 /// subscription would never deliver events.
3849 ///
3850 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
3851 /// or if the plan shape is not in the allowlist.
3852 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
3853 if self.read_only {
3854 return Err(GraphError::ReadOnly);
3855 }
3856 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3857 detail: format!("lex: {e}"),
3858 })?;
3859 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
3860 detail: format!("parse: {e}"),
3861 })?;
3862 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
3863 detail: format!("plan: {e}"),
3864 })?;
3865 if !is_subscribable(&ops) {
3866 return Err(GraphError::QueryError {
3867 detail: "subscribe_query only supports allowlisted plan shapes: \
3868 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
3869 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
3870 Not supported: multi-hop Expand chains, SKIP (creates \
3871 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
3872 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
3873 Use LIMIT to bound re-execution cost."
3874 .to_string(),
3875 });
3876 }
3877 // Execute once to capture initial state (initial rows are not emitted as
3878 // events — the subscriber learns the baseline via the first query call).
3879 let empty_params = BTreeMap::new();
3880 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
3881 GraphError::QueryError {
3882 detail: format!("execute: {e}"),
3883 }
3884 })?;
3885 let columns = initial.columns().to_vec();
3886 let prev_row_map = Self::result_to_row_map(&initial);
3887 let inner = SubInner::new(self.sub_capacity());
3888 self.query_subscriptions.push(QuerySubEntry {
3889 ops,
3890 columns,
3891 prev_row_map,
3892 inner: std::sync::Arc::downgrade(&inner),
3893 });
3894 Ok(Subscription(inner))
3895 }
3896
3897 /// Queue capacity used for new subscriptions.
3898 fn sub_capacity(&self) -> usize {
3899 self.sub_capacity
3900 }
3901
3902 /// Override per-subscriber queue capacity for subsequently created
3903 /// subscriptions on this db instance.
3904 ///
3905 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
3906 /// value in tests to exercise the [`DbEvent::Lagged`] path without
3907 /// generating tens of thousands of events.
3908 ///
3909 /// This is a test-support escape hatch. Calling it in production reduces
3910 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
3911 /// to discourage accidental production use.
3912 #[doc(hidden)]
3913 pub fn set_sub_capacity(&mut self, capacity: usize) {
3914 self.sub_capacity = capacity;
3915 }
3916
3917 // -----------------------------------------------------------------------
3918
3919 /// Start an atomic batch.
3920 ///
3921 /// The returned [`BatchBuilder`] borrows `self` mutably until
3922 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
3923 /// validation, no WAL I/O. `commit` validates every queued op against
3924 /// live state plus preceding ops in this batch (duplicate key inside
3925 /// the batch is `Err`; an edge between two nodes created earlier in
3926 /// the batch is valid; `delete_node` then insert of the same key is a
3927 /// fresh identity). Validation never mutates the database. Any failure
3928 /// leaves WAL bytes and in-memory state identical to before `commit`.
3929 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
3930 /// and each inner record is applied in order so rules fire per record.
3931 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
3932 ///
3933 /// **Rule-window limitation:** batch validation cannot see edges that a
3934 /// rule created earlier in the *same* batch will derive at apply time, so
3935 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
3936 /// where sequential calls would return `Err(RuleOwned)`. State integrity
3937 /// is unaffected (idempotent apply, provenance intact). Create rules in
3938 /// their own batch, or sequentially, when later ops may touch derived
3939 /// edges.
3940 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
3941 BatchBuilder {
3942 db: self,
3943 ops: Vec::new(),
3944 }
3945 }
3946
3947 /// Closure-style atomic write batch.
3948 ///
3949 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
3950 /// then committing. All ops queued inside `build` are validated in order and
3951 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
3952 /// once per inner record, in order, after commit — semantically identical to
3953 /// sequential single-op writes.
3954 ///
3955 /// **Error semantics — validate-then-apply.** `build` queues ops without
3956 /// touching the database. [`BatchBuilder::commit`] validates every op against
3957 /// live state plus earlier ops in this batch before writing anything. If op N
3958 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
3959 /// entire batch is rejected: no WAL bytes are written and no in-memory state
3960 /// changes. The database is identical to its state before `write_batch` was
3961 /// called.
3962 ///
3963 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
3964 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
3965 /// either fully applied or not at all. However, while applying a committed
3966 /// batch, concurrent readers may observe intermediate states as ops are applied
3967 /// sequentially in memory. There is no interactive transaction isolation in v1.
3968 /// This is documented as "crash-atomic write batches; no interactive
3969 /// transactions or read isolation."
3970 ///
3971 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
3972 /// writes zero WAL bytes and returns `(0, 0)`.
3973 ///
3974 /// # Example
3975 ///
3976 /// ```rust,ignore
3977 /// let (nodes, edges) = db.write_batch(|b| {
3978 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
3979 /// b.insert_node("Person", "bob", vec![]);
3980 /// b.insert_edge("KNOWS", "alice", "bob");
3981 /// b.set_prop("alice", "role", Value::Str("admin".into()));
3982 /// b.delete_node("old_key");
3983 /// })?;
3984 /// // One fsync; on crash replay: all five ops land or none do.
3985 /// ```
3986 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
3987 where
3988 C: FnOnce(&mut BatchBuilder<'_, F>),
3989 {
3990 let mut b = self.batch();
3991 build(&mut b);
3992 b.commit()
3993 }
3994
3995 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
3996 /// auto-declared KeyMatch rules (if any) first, then the accepted node
3997 /// inserts, so incremental fire sees the new rules. Per-row key problems
3998 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
3999 /// `Err` means nothing was applied.
4000 ///
4001 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4002 /// distinct source labels sharing an FK field each get their own rule.
4003 pub fn ingest(
4004 &mut self,
4005 label: &str,
4006 rows: Vec<BTreeMap<String, Value>>,
4007 opts: &IngestOptions,
4008 ) -> Result<IngestReport> {
4009 self.ingest_with_edges(label, rows, opts, &[])
4010 }
4011
4012 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4013 /// A failing edge rejects the whole request; nothing is applied.
4014 pub fn ingest_with_edges(
4015 &mut self,
4016 label: &str,
4017 rows: Vec<BTreeMap<String, Value>>,
4018 opts: &IngestOptions,
4019 edges: &[(String, String, String)],
4020 ) -> Result<IngestReport> {
4021 crate::ingest::run(self, label, rows, opts, edges)
4022 }
4023
4024 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4025 ///
4026 /// JSON `null` fields are silently omitted (not stored, not a row error).
4027 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4028 /// Parse failures and a top-level value that is not an array of objects
4029 /// return [`GraphError::IngestError`].
4030 pub fn ingest_json(
4031 &mut self,
4032 label: &str,
4033 json: &str,
4034 opts: &IngestOptions,
4035 ) -> Result<IngestReport> {
4036 crate::ingest::run_json(self, label, json, opts)
4037 }
4038
4039 fn commit_logged_batch(
4040 &mut self,
4041 ops: Vec<BatchOp>,
4042 ingest: Option<(String, usize)>,
4043 // Two-source rule: write_batch_authz threads authz here directly (never
4044 // touches pending_write_authz); query_write_authz sets the field instead
4045 // and passes None. Only one source is non-None per call.
4046 param_authz: Option<WriteAuthz>,
4047 ) -> Result<(usize, usize)> {
4048 // Read-only guard: catches empty-batch calls before the early-return
4049 // that skips log_then_apply_with, ensuring all mutation entry points fail.
4050 if self.read_only {
4051 return Err(GraphError::ReadOnly);
4052 }
4053 // Ensure provenance is decoded before MutPreview accesses it
4054 // (note_delete_rule / is_rule_owned may call engine.provenance()).
4055 self.engine.ensure_provenance_loaded_mut();
4056
4057 // ── Authz pre-check ──────────────────────────────────────────────────
4058 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4059 // produces no WAL frame (all-or-nothing at the authz boundary extends
4060 // the existing validate-then-apply contract to role-scope checks).
4061 //
4062 // `batch_created` tracks key→label for nodes created by earlier ops in
4063 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4064 // as visible without needing to call `self.ids.get` on not-yet-committed
4065 // keys (they won't be there yet).
4066 //
4067 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4068 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4069 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4070 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4071 if let Some(ref authz) = authz_opt {
4072 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4073 for op in &ops {
4074 self.check_single_op_authz(authz, op, &batch_created)?;
4075 // Update batch_created after a passing authz check so that
4076 // subsequent ops in this batch see the nodes as "about to exist".
4077 match op {
4078 BatchOp::InsertNode { label, key, .. } => {
4079 // Only track genuinely new nodes (absent from the
4080 // snapshot at authz-check time). A pre-existing visible
4081 // key would be a DuplicateKey — not a real creation —
4082 // so MutPreview handles it. Letting it into batch_created
4083 // would allow a later SetProp to bypass update_labels
4084 // via the "batch-created → always updatable" ruling
4085 // (delete+recreate exploit, fix for I1 review round 2).
4086 //
4087 // Accepted edge: for a delete+recreate-with-different-
4088 // label batch, node_status resolves the pre-delete
4089 // (store) label for any subsequent update checks. This
4090 // grants no net-new capability — a role that can delete+
4091 // create can already place arbitrary props via
4092 // InsertNode's own props field.
4093 if self.ids.get(key.as_str()).is_none() {
4094 batch_created.insert(key.clone(), label.clone());
4095 }
4096 }
4097 BatchOp::InsertEdgeUpsert {
4098 placeholder_label,
4099 src_key,
4100 dst_key,
4101 ..
4102 } => {
4103 // Both endpoints will be created if not already in store.
4104 for ep_key in [src_key, dst_key] {
4105 if self.ids.get(ep_key.as_str()).is_none()
4106 && !batch_created.contains_key(ep_key.as_str())
4107 {
4108 batch_created.insert(ep_key.clone(), placeholder_label.clone());
4109 }
4110 }
4111 }
4112 _ => {}
4113 }
4114 }
4115 }
4116
4117 let recs = {
4118 let mut preview = MutPreview::new(self);
4119 let mut recs = Vec::with_capacity(ops.len());
4120 for op in ops {
4121 match op {
4122 BatchOp::InsertNode { label, key, props } => {
4123 preview.check_insert_node(&key)?;
4124 preview.note_insert_node(&key, &props);
4125 recs.push(WalRecord::InsertNode { label, key, props });
4126 }
4127 BatchOp::InsertEdge {
4128 edge_type,
4129 src_key,
4130 dst_key,
4131 } => {
4132 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4133 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4134 recs.push(WalRecord::InsertEdge {
4135 edge_type,
4136 src_key,
4137 dst_key,
4138 });
4139 }
4140 }
4141 BatchOp::SetProp { key, field, value } => {
4142 preview.check_live_key(&key)?;
4143 preview.note_set_prop(&key, &field, &value);
4144 recs.push(WalRecord::SetProp { key, field, value });
4145 }
4146 BatchOp::RemoveProp { key, field } => {
4147 if preview.prepare_remove_prop(&key, &field)? {
4148 preview.note_remove_prop(&key, &field);
4149 recs.push(WalRecord::RemoveProp { key, field });
4150 }
4151 }
4152 BatchOp::DeleteEdge {
4153 edge_type,
4154 src_key,
4155 dst_key,
4156 } => {
4157 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4158 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4159 recs.push(WalRecord::DeleteEdge {
4160 edge_type,
4161 src_key,
4162 dst_key,
4163 });
4164 }
4165 }
4166 BatchOp::DeleteNode { key } => {
4167 preview.check_live_key(&key)?;
4168 preview.note_delete_node(&key);
4169 recs.push(WalRecord::DeleteNode { key });
4170 }
4171 BatchOp::CreateRule(def) => {
4172 preview.check_create_rule(&def)?;
4173 let def_bytes =
4174 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4175 detail: format!("serialize rule: {e}"),
4176 })?;
4177 preview.note_create_rule(&def.name);
4178 recs.push(WalRecord::CreateRule { def_bytes });
4179 }
4180 BatchOp::DeleteRule { name } => {
4181 preview.check_delete_rule(&name)?;
4182 preview.note_delete_rule(&name);
4183 recs.push(WalRecord::DeleteRule { name });
4184 }
4185 BatchOp::RenameNode { old_key, new_key } => {
4186 preview.check_rename_node(&old_key, &new_key)?;
4187 preview.note_rename_node(&old_key, &new_key);
4188 recs.push(WalRecord::RenameNode { old_key, new_key });
4189 }
4190 BatchOp::InsertEdgeUpsert {
4191 edge_type,
4192 src_key,
4193 dst_key,
4194 placeholder_label,
4195 } => {
4196 // Auto-create any missing endpoints as plain InsertNode ops.
4197 // Rules fire and last-change is updated for each created node.
4198 for key in [&src_key, &dst_key] {
4199 if !preview.has_key(key) {
4200 preview.check_insert_node(key)?;
4201 preview.note_insert_node(key, &[]);
4202 recs.push(WalRecord::InsertNode {
4203 label: placeholder_label.clone(),
4204 key: key.clone(),
4205 props: vec![],
4206 });
4207 }
4208 }
4209 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4210 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4211 recs.push(WalRecord::InsertEdge {
4212 edge_type,
4213 src_key,
4214 dst_key,
4215 });
4216 }
4217 }
4218 }
4219 }
4220 recs
4221 };
4222 if recs.is_empty() {
4223 return Ok((0, 0));
4224 }
4225 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4226 // *Id form, so only the dense variants can appear in `recs` here.
4227 let recs = self.rewrite_wal_dense(recs)?;
4228 let nodes_inserted = recs
4229 .iter()
4230 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4231 .count();
4232 let edges_inserted = recs
4233 .iter()
4234 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4235 .count();
4236 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4237 // under Strict. Pass self.fsync directly so Strict stays Strict —
4238 // wal_needs_sync(Strict, _) always returns true regardless of op count.
4239 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4240 // short-circuit on single-op batches and silently skip the fsync.
4241 // Batched fsyncs only for multi-op batches; Relaxed always skips.
4242 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4243 Ok((nodes_inserted, edges_inserted))
4244 }
4245
4246 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4247 self.commit_logged_batch(ops, None, None)
4248 }
4249
4250 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4251 /// and the group-commit drain thread, which do a single group fsync later.
4252 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4253 // Restore fsync policy even on panic via a raw-pointer drop guard.
4254 // A panic here would poison the RwLock anyway, but the correct policy
4255 // must be in place if the guard is ever unwrapped.
4256 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4257 impl Drop for RestoreFsync {
4258 fn drop(&mut self) {
4259 // SAFETY: the pointer is valid for the full duration of
4260 // commit_batch_nosync; the guard is dropped before the frame
4261 // returns, and GraphDb outlives this frame.
4262 unsafe {
4263 *self.0 = self.1;
4264 }
4265 }
4266 }
4267 let saved = self.fsync;
4268 // SAFETY: raw pointer into self; guard dropped within this frame.
4269 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4270 self.fsync = FsyncPolicy::Relaxed;
4271 self.commit_logged_batch(ops, None, None)
4272 }
4273
4274 /// Commit multiple op-batches as a **group**: each submission gets its own
4275 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4276 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4277 ///
4278 /// # Durability semantics
4279 ///
4280 /// A crash before the group fsync may lose **all** submissions in the group.
4281 /// A crash after the group fsync preserves all of them. No submission is
4282 /// ever torn: each WAL frame is either fully applied on replay or dropped
4283 /// in its entirety (CRC-protected frame boundaries).
4284 ///
4285 /// Events and subscription notifications fire per-submission immediately
4286 /// after apply, which may be before the group fsync. From a subscriber's
4287 /// perspective this is equivalent to the `Relaxed` durability window.
4288 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4289 /// fsync, so from their perspective durability is fully guaranteed.
4290 ///
4291 /// # MVCC interplay
4292 ///
4293 /// Each submission records its own `CommitDelta`; the fold-every-K counter
4294 /// increments per submission (not per group), preserving existing reader
4295 /// snapshot semantics.
4296 ///
4297 /// # Returns
4298 ///
4299 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4300 /// in order. Failures are per-submission (validation errors); the group
4301 /// fsync error (if any) is returned as the second tuple element.
4302 pub fn commit_group(
4303 &mut self,
4304 groups: Vec<Vec<BatchOp>>,
4305 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4306 let mut results = Vec::with_capacity(groups.len());
4307 for ops in groups {
4308 results.push(self.commit_batch_nosync(ops));
4309 }
4310 let any_ok = results.iter().any(|r| r.is_ok());
4311 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4312 self.fs
4313 .sync(core_storage::fs::FileId::Wal)
4314 .map_err(GraphError::Io)
4315 .err()
4316 } else {
4317 None
4318 };
4319 (results, sync_err)
4320 }
4321
4322 /// Like [`commit_group`] but skips the group fsync entirely.
4323 ///
4324 /// Used by the drain thread to apply submissions under the write lock and
4325 /// then perform the single fsync OUTSIDE the lock (via
4326 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4327 /// to concurrent readers.
4328 pub fn commit_group_nosync(
4329 &mut self,
4330 groups: Vec<Vec<BatchOp>>,
4331 ) -> Vec<Result<(usize, usize)>> {
4332 let mut results = Vec::with_capacity(groups.len());
4333 for ops in groups {
4334 results.push(self.commit_batch_nosync(ops));
4335 }
4336 results
4337 }
4338
4339 pub fn insert_node(
4340 &mut self,
4341 label: &str,
4342 key: &str,
4343 props: Vec<(String, Value)>,
4344 ) -> Result<()> {
4345 if self.read_only {
4346 return Err(GraphError::ReadOnly);
4347 }
4348 MutPreview::new(self).check_insert_node(key)?;
4349 self.log_dense(vec![WalRecord::InsertNode {
4350 label: label.into(),
4351 key: key.into(),
4352 props,
4353 }])
4354 }
4355
4356 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4357 if self.read_only {
4358 return Err(GraphError::ReadOnly);
4359 }
4360 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4361 return Ok(false);
4362 }
4363 self.log_dense(vec![WalRecord::InsertEdge {
4364 edge_type: edge_type.into(),
4365 src_key: src_key.into(),
4366 dst_key: dst_key.into(),
4367 }])?;
4368 Ok(true)
4369 }
4370
4371 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4372 if self.read_only {
4373 return Err(GraphError::ReadOnly);
4374 }
4375 if let Some(view_name) = self.view_store.view_for_prop(field) {
4376 return Err(GraphError::ViewPropReadOnly {
4377 view_name: view_name.to_string(),
4378 });
4379 }
4380 MutPreview::new(self).check_live_key(key)?;
4381 self.log_dense(vec![WalRecord::SetProp {
4382 key: key.into(),
4383 field: field.into(),
4384 value,
4385 }])
4386 }
4387
4388 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4389 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4390 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4391 if self.read_only {
4392 return Err(GraphError::ReadOnly);
4393 }
4394 if let Some(view_name) = self.view_store.view_for_prop(field) {
4395 return Err(GraphError::ViewPropReadOnly {
4396 view_name: view_name.to_string(),
4397 });
4398 }
4399 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4400 return Ok(false);
4401 }
4402 self.log_then_apply(WalRecord::RemoveProp {
4403 key: key.into(),
4404 field: field.into(),
4405 })?;
4406 Ok(true)
4407 }
4408
4409 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4410 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4411 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4412 /// (the rule would just put the edge back; delete or change the rule).
4413 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4414 if self.read_only {
4415 return Err(GraphError::ReadOnly);
4416 }
4417 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4418 return Ok(false);
4419 }
4420 self.log_then_apply(WalRecord::DeleteEdge {
4421 edge_type: edge_type.into(),
4422 src_key: src_key.into(),
4423 dst_key: dst_key.into(),
4424 })?;
4425 Ok(true)
4426 }
4427
4428 /// Delete a live node. Unknown or already-tombstoned keys are
4429 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4430 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4431 /// (crash window) is a clean no-op.
4432 ///
4433 /// Returns a [`DeleteReport`] with counts of manual and derived edges
4434 /// removed (computed from live state before the deletion is applied).
4435 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4436 if self.read_only {
4437 return Err(GraphError::ReadOnly);
4438 }
4439 // Provenance must be loaded before we query provenance_touching.
4440 self.engine.ensure_provenance_loaded_mut();
4441 let id = self
4442 .ids
4443 .get(key)
4444 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4445
4446 // Count edges before the delete is applied so we can report counts.
4447 let derived_set: BTreeSet<(u32, u32, u32)> = self
4448 .engine
4449 .provenance_touching(id)
4450 .map(|(_, etype, src, dst)| (etype, src, dst))
4451 .collect();
4452 let derived_edges = derived_set.len() as u64;
4453
4454 let mut total_topo = 0u64;
4455 let tv = self.topo_view();
4456 for et in tv.etypes() {
4457 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4458 + tv.neighbors(et, Direction::In, id).len() as u64;
4459 }
4460 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4461 // triples in both the topo scan (Out and In from id) and in provenance_touching.
4462 // The subtraction remains correct because both counts include both directions.
4463 let manual_edges = total_topo.saturating_sub(derived_edges);
4464
4465 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4466 Ok(DeleteReport {
4467 manual_edges,
4468 derived_edges,
4469 })
4470 }
4471
4472 /// Rename a live node's key. The dense id (and therefore all edges,
4473 /// props, history, and last-change tracking) is unaffected.
4474 ///
4475 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4476 /// Returns `Err(DuplicateKey)` if `new` is already live.
4477 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4478 if self.read_only {
4479 return Err(GraphError::ReadOnly);
4480 }
4481 MutPreview::new(self).check_rename_node(old, new)?;
4482 self.log_then_apply(WalRecord::RenameNode {
4483 old_key: old.into(),
4484 new_key: new.into(),
4485 })
4486 }
4487
4488 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4489 /// `None` if the rule does not exist or is not approximate.
4490 ///
4491 /// The drift counter increments on IVF insert/remove after the last fit.
4492 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4493 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4494 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4495 // SideIvfExport = (centroids, node→cluster, drift)
4496 self.engine
4497 .export_ivf_state()
4498 .remove(rule)
4499 .map(|(_src, dst)| dst.2)
4500 }
4501
4502 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4503 /// Validation and duplicate-name check run before logging so invalid rules
4504 /// never enter the WAL.
4505 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4506 if self.read_only {
4507 return Err(GraphError::ReadOnly);
4508 }
4509 MutPreview::new(self).check_create_rule(&def)?;
4510 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4511 detail: format!("serialize rule: {e}"),
4512 })?;
4513 self.log_then_apply(WalRecord::CreateRule { def_bytes })
4514 }
4515
4516 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4517 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4518 if self.read_only {
4519 return Err(GraphError::ReadOnly);
4520 }
4521 MutPreview::new(self).check_delete_rule(name)?;
4522 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4523 }
4524
4525 /// Return a snapshot of all registered rules.
4526 pub fn rules(&self) -> Vec<RuleDef> {
4527 self.engine.rules().cloned().collect()
4528 }
4529
4530 // -----------------------------------------------------------------------
4531 // Rule suggestion API
4532 // -----------------------------------------------------------------------
4533
4534 /// Profile the database and suggest linking rules with previewed edge counts.
4535 ///
4536 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4537 /// sampling. Suggestions are sorted by estimated edge count (descending).
4538 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4539 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4540 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4541 }
4542
4543 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4544 /// reproducibility. Same seed + same data = identical output.
4545 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4546 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4547 .suggestions
4548 }
4549
4550 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4551 ///
4552 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4553 /// and a `truncated` flag indicating whether the global budget fired before all
4554 /// candidates were evaluated.
4555 pub fn suggest_rules_with_config(
4556 &self,
4557 config: &core_rules::suggest::SuggestConfig,
4558 seed: u64,
4559 ) -> core_rules::SuggestReport {
4560 use std::collections::BTreeMap;
4561
4562 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4563 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4564 for id in 0..self.ids.len() as u32 {
4565 let Some(key) = self.ids.key_of(id) else {
4566 continue;
4567 };
4568 let Some(&sym) = self.labels.get(id as usize) else {
4569 continue;
4570 };
4571 if sym == u32::MAX {
4572 continue; // tombstoned
4573 }
4574 let Some(label) = self.syms.resolve(sym) else {
4575 continue;
4576 };
4577 label_nodes
4578 .entry(label.to_string())
4579 .or_default()
4580 .push((id, key.to_string()));
4581 }
4582
4583 let existing = self.rules();
4584 let pv = build_props_view(&self.props, &self.base);
4585 let all_fields: Vec<String> = pv.field_names();
4586
4587 core_rules::suggest::suggest_rules(
4588 &label_nodes,
4589 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4590 &all_fields,
4591 &existing,
4592 config,
4593 seed,
4594 )
4595 }
4596
4597 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
4598 /// plus later mutations replay identically (rebuild is a pure function
4599 /// of state).
4600 ///
4601 /// Only exit from the tripped latch: if the full desired set fits the
4602 /// budget, it is applied completely and `tripped` clears; if it still
4603 /// exceeds the budget, provenance is left untouched and `tripped` stays
4604 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
4605 /// Unknown rule → `RuleNotFound`, nothing logged.
4606 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
4607 if self.read_only {
4608 return Err(GraphError::ReadOnly);
4609 }
4610 if !self.engine.rules().any(|r| r.name == name) {
4611 return Err(GraphError::RuleNotFound { name: name.into() });
4612 }
4613 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
4614 }
4615
4616 // -----------------------------------------------------------------------
4617 // Materialized view API
4618 // -----------------------------------------------------------------------
4619
4620 /// Register a new materialized property view, backfill its values for all
4621 /// existing nodes, and WAL-log the definition.
4622 ///
4623 /// # Errors
4624 /// - `ReadOnly`: called on an as-of instance.
4625 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
4626 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
4627 if self.read_only {
4628 return Err(GraphError::ReadOnly);
4629 }
4630 // Pre-validate before WAL write.
4631 def.validate()
4632 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4633 if self.view_store.has_view(&def.name) {
4634 return Err(GraphError::RuleInvalid {
4635 detail: format!("view {:?} already exists", def.name),
4636 });
4637 }
4638 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
4639 return Err(GraphError::RuleInvalid {
4640 detail: format!(
4641 "view_prop {:?} is already used by view {:?}",
4642 def.view_prop, existing
4643 ),
4644 });
4645 }
4646 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4647 detail: format!("serialize view: {e}"),
4648 })?;
4649 // Enable delta accumulation before the view is registered so subsequent
4650 // incremental edge events reach view maintenance from this point onward.
4651 // (The backfill inside create_view reads topo directly; it does not rely
4652 // on pending deltas.)
4653 self.engine.set_emit_deltas(true);
4654 self.log_then_apply(WalRecord::CreateView { def_bytes })
4655 }
4656
4657 /// Remove a named view and delete its values from every node.
4658 ///
4659 /// # Errors
4660 /// - `ReadOnly`: called on an as-of instance.
4661 /// - `RuleNotFound`: view does not exist.
4662 pub fn delete_view(&mut self, name: &str) -> Result<()> {
4663 if self.read_only {
4664 return Err(GraphError::ReadOnly);
4665 }
4666 if !self.view_store.has_view(name) {
4667 return Err(GraphError::RuleNotFound { name: name.into() });
4668 }
4669 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
4670 // After deletion, disable accumulation if no listeners remain.
4671 if !self.needs_emit_deltas() {
4672 self.engine.set_emit_deltas(false);
4673 }
4674 result
4675 }
4676
4677 /// Snapshot of all registered view definitions.
4678 pub fn views(&self) -> Vec<ViewDef> {
4679 self.view_store.views().cloned().collect()
4680 }
4681
4682 // -----------------------------------------------------------------------
4683 // Full-text-lite API
4684 // -----------------------------------------------------------------------
4685
4686 /// Enable full-text indexing for all nodes of `label` on property `field`.
4687 ///
4688 /// After this call, every subsequent write to `(label, field)` is reflected
4689 /// in the index incrementally. Existing nodes are backfilled immediately.
4690 /// The declaration is persisted as a WAL record; the index itself is rebuilt
4691 /// from scratch on re-open (no snapshot format changes).
4692 ///
4693 /// # Errors
4694 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4695 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4696 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4697 if self.read_only {
4698 return Err(GraphError::ReadOnly);
4699 }
4700 if self.fulltext.is_enabled(label, field) {
4701 return Err(GraphError::RuleInvalid {
4702 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
4703 });
4704 }
4705 self.log_then_apply(WalRecord::EnableFulltext {
4706 label: label.into(),
4707 field: field.into(),
4708 })
4709 }
4710
4711 /// Disable full-text indexing for `(label, field)` and drop its postings.
4712 ///
4713 /// # Errors
4714 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4715 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4716 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4717 if self.read_only {
4718 return Err(GraphError::ReadOnly);
4719 }
4720 if !self.fulltext.is_enabled(label, field) {
4721 return Err(GraphError::RuleNotFound {
4722 name: format!("fulltext({label},{field})"),
4723 });
4724 }
4725 self.log_then_apply(WalRecord::DisableFulltext {
4726 label: label.into(),
4727 field: field.into(),
4728 })
4729 }
4730
4731 /// Whether `(label, field)` is currently indexed for full-text search.
4732 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
4733 self.fulltext.is_enabled(label, field)
4734 }
4735
4736 /// Search a full-text-indexed field.
4737 ///
4738 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
4739 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
4740 ///
4741 /// **Query syntax:**
4742 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
4743 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
4744 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
4745 /// - `AND` keyword is accepted explicitly and is the default.
4746 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
4747 ///
4748 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
4749 /// Pin: this is the documented, tested, stable behavior for v1.
4750 ///
4751 /// **Memory / performance:** O(postings) lookup; no scan. The index is
4752 /// in-memory and proportional to total indexed text across all enabled fields.
4753 ///
4754 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
4755 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
4756 /// key ascending for deterministic tiebreaking.
4757 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4758 // Resolve node_ids to keys (excluding tombstones) then re-sort by
4759 // (score DESC, key ASC) to give a deterministic, key-lexicographic
4760 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
4761 // which diverges from key order when nodes were not inserted in key-lex order.
4762 let mut results: Vec<(String, f64)> = self
4763 .fulltext
4764 .search(field, query, 0)
4765 .into_iter()
4766 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
4767 .collect();
4768 results.sort_by(|a, b| {
4769 b.1.partial_cmp(&a.1)
4770 .unwrap_or(std::cmp::Ordering::Equal)
4771 .then(a.0.cmp(&b.0))
4772 });
4773 results
4774 }
4775
4776 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
4777 ///
4778 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
4779 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
4780 /// them with RRF using a fixed constant of 60.
4781 ///
4782 /// ```text
4783 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
4784 /// ```
4785 ///
4786 /// Returns the top `k` nodes by fused score, ties broken by node key
4787 /// ascending (deterministic).
4788 ///
4789 /// # Vector leg fallback
4790 ///
4791 /// When `query_vec` is empty the vector leg is skipped entirely and
4792 /// results are ranked by the text list alone through the same RRF path
4793 /// (each text result scores `1/(60 + rank)` from that single list).
4794 ///
4795 /// When `label` is `None`, the vector leg **always** returns empty results.
4796 /// Internally `label` is mapped to `""`, which does not match any rule-created
4797 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
4798 /// the brute-force fallback finds no nodes with an empty label. The fused
4799 /// ranking is therefore text-only in this case.
4800 pub fn search_hybrid(
4801 &self,
4802 text_field: &str,
4803 query_text: &str,
4804 vector_field: &str,
4805 query_vec: &[f64],
4806 label: Option<&str>,
4807 k: usize,
4808 ) -> Vec<(String, f64)> {
4809 use std::collections::HashMap;
4810
4811 const RRF_K: f64 = 60.0;
4812 let pool = 4 * k;
4813
4814 // Accumulate per-node RRF scores.
4815 let mut scores: HashMap<String, f64> = HashMap::new();
4816
4817 // Text leg.
4818 let text_hits = self.search(text_field, query_text);
4819 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
4820 let rank = (rank0 + 1) as f64;
4821 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4822 }
4823
4824 // Vector leg (skipped when query_vec is empty).
4825 if !query_vec.is_empty() {
4826 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
4827 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
4828 let rank = (rank0 + 1) as f64;
4829 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4830 }
4831 }
4832
4833 // Sort: score DESC, then key ASC for deterministic tie-breaking.
4834 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
4835 ranked.sort_by(|a, b| {
4836 b.1.partial_cmp(&a.1)
4837 .unwrap_or(std::cmp::Ordering::Equal)
4838 .then(a.0.cmp(&b.0))
4839 });
4840 ranked.truncate(k);
4841 ranked
4842 }
4843
4844 /// For DST/testing: scratch BM25 search over live nodes without the index.
4845 /// Walks every live node, re-stems field tokens, computes corpus stats, and
4846 /// returns BM25-ranked results.
4847 ///
4848 /// The oracle: the ordered key list of `search(field, q)` must equal that of
4849 /// `scratch_search(field, q)` at every quiescent state.
4850 #[doc(hidden)]
4851 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4852 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
4853 use std::collections::BTreeMap;
4854
4855 let groups = parse_query(query);
4856 if groups.is_empty() {
4857 return vec![];
4858 }
4859
4860 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
4861 struct NodeData {
4862 key: String,
4863 /// stemmed_token → positions (sorted)
4864 tokens: BTreeMap<String, Vec<u32>>,
4865 dl: u32,
4866 }
4867
4868 let mut nodes: Vec<NodeData> = Vec::new();
4869 for id in 0..self.ids.len() as u32 {
4870 let Some(key) = self.ids.key_of(id) else {
4871 continue;
4872 };
4873 let Some(&sym) = self.labels.get(id as usize) else {
4874 continue;
4875 };
4876 if sym == u32::MAX {
4877 continue;
4878 }
4879 let label = match self.syms.resolve(sym) {
4880 Some(l) => l,
4881 None => continue,
4882 };
4883 if !self.fulltext.is_enabled(label, field) {
4884 continue;
4885 }
4886 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
4887 continue;
4888 };
4889 // Use value_tokens_stemmed_with_positions so list elements are
4890 // separated by POSITION_GAP — identical to the index path, which
4891 // prevents phrase queries from matching across element boundaries.
4892 let stemmed_with_pos = match &value {
4893 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
4894 _ => continue,
4895 };
4896 let dl = stemmed_with_pos.len() as u32;
4897 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
4898 for (tok, pos) in stemmed_with_pos {
4899 tok_map.entry(tok).or_default().push(pos);
4900 }
4901 nodes.push(NodeData {
4902 key: key.to_string(),
4903 tokens: tok_map,
4904 dl,
4905 });
4906 }
4907
4908 if nodes.is_empty() {
4909 return vec![];
4910 }
4911
4912 // --- BM25 corpus stats ---
4913 let n = nodes.len() as f64;
4914 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
4915 // df per stemmed token across all live indexed nodes.
4916 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
4917 for nd in &nodes {
4918 for tok in nd.tokens.keys() {
4919 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
4920 }
4921 }
4922
4923 const K1: f64 = 1.2;
4924 const B: f64 = 0.75;
4925
4926 // --- Pass 2: score each node against each OR-group ---
4927 let mut results: Vec<(String, f64)> = Vec::new();
4928 for nd in &nodes {
4929 let dl = nd.dl as f64;
4930 let mut total_score = 0.0f64;
4931
4932 'group: for group in &groups {
4933 let mut group_score = 0.0f64;
4934
4935 for term in group {
4936 if term.negated {
4937 // Negated: if doc has this stemmed token → group fails.
4938 let present = if term.prefix {
4939 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
4940 } else {
4941 nd.tokens.contains_key(term.token.as_str())
4942 };
4943 if present {
4944 continue 'group;
4945 }
4946 continue;
4947 }
4948 if term.prefix {
4949 // Prefix: sum BM25 for all matching stemmed tokens.
4950 let mut prefix_matched = false;
4951 for (tok, positions) in &nd.tokens {
4952 if tok.starts_with(term.token.as_str()) {
4953 let tf = positions.len() as f64;
4954 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
4955 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4956 let tf_norm =
4957 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4958 group_score += idf * tf_norm;
4959 prefix_matched = true;
4960 }
4961 }
4962 if !prefix_matched {
4963 continue 'group;
4964 }
4965 } else {
4966 // term.token is already stemmed by parse_query; use directly.
4967 match nd.tokens.get(term.token.as_str()) {
4968 None => continue 'group,
4969 Some(positions) => {
4970 let tf = positions.len() as f64;
4971 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
4972 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4973 let tf_norm =
4974 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4975 group_score += idf * tf_norm;
4976 }
4977 }
4978 }
4979 }
4980
4981 if group_score > 0.0 {
4982 total_score += group_score;
4983 }
4984 }
4985
4986 if total_score > 0.0 {
4987 results.push((nd.key.clone(), total_score));
4988 }
4989 }
4990
4991 results.sort_by(|a, b| {
4992 b.1.partial_cmp(&a.1)
4993 .unwrap_or(std::cmp::Ordering::Equal)
4994 .then(a.0.cmp(&b.0))
4995 });
4996 results
4997 }
4998
4999 /// Return the current view-maintained value of `view_prop` for node `key`.
5000 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5001 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5002 let id = self.ids.get(key)?;
5003 self.props_view()
5004 .get(id, view_prop)
5005 .map(|vr| vr.into_value())
5006 }
5007
5008 /// For testing / DST oracle: scratch recompute of a view value for one node.
5009 ///
5010 /// Returns `None` if the node does not exist, the view does not exist, or
5011 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5012 #[doc(hidden)]
5013 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5014 let node = self.ids.get(key)?;
5015 let def = self.view_store.views().find(|v| v.name == view_name)?;
5016 // Use TopologyView so that NeighborAgg sees base + overlay edges
5017 // without materialising a temporary Topology (I1).
5018 let topo_view = self.topo_view();
5019 core_rules::views::compute_view_value(
5020 def,
5021 node,
5022 self.props_view(),
5023 &topo_view,
5024 &self.ids,
5025 &self.syms,
5026 &self.labels,
5027 )
5028 }
5029
5030 // -----------------------------------------------------------------------
5031 // Graph algorithm API
5032 // -----------------------------------------------------------------------
5033
5034 /// Run PageRank over the unified topology (manual + derived edges).
5035 ///
5036 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5037 /// ascending). Set `config.edge_type` to restrict to one edge type.
5038 /// `config.converged` is `true` only when the power iteration converged
5039 /// within `config.max_iters` and within any time budget.
5040 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5041 crate::algo::pagerank(&self.topo, &self.ids, &self.syms, &self.labels, config)
5042 }
5043
5044 /// Weakly-connected components over the unified topology (treated as
5045 /// undirected regardless of how edges were inserted).
5046 ///
5047 /// Component IDs are the key of the smallest member in the component
5048 /// (deterministic). Result sorted by (component_id, key).
5049 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5050 crate::algo::wcc(&self.topo, &self.ids, &self.syms, &self.labels, config)
5051 }
5052
5053 /// Degree centrality for every live node.
5054 ///
5055 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5056 /// `AlgoDir::Both` = out + in (total directed degree).
5057 ///
5058 /// For one-shot ranking use this; for a live property updated on every
5059 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5060 pub fn degree_centrality(
5061 &self,
5062 config: &crate::algo::DegreeConfig,
5063 ) -> crate::algo::DegreeReport {
5064 crate::algo::degree_centrality(&self.topo, &self.ids, &self.syms, &self.labels, config)
5065 }
5066
5067 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5068 /// atomically via a single write-batch (one WAL frame, one fsync).
5069 ///
5070 /// # Errors
5071 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5072 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5073 /// (collision check mirrors `create_view`).
5074 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5075 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5076 if self.read_only {
5077 return Err(GraphError::ReadOnly);
5078 }
5079 // Collision check: refuse if prop_name is view-managed.
5080 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5081 return Err(GraphError::RuleInvalid {
5082 detail: format!(
5083 "prop {:?} is managed by view {:?} and cannot be written as scores",
5084 prop_name, view_name
5085 ),
5086 });
5087 }
5088 // Refuse if prop_name is a view name itself (confusing namespace collision).
5089 if self.view_store.has_view(prop_name) {
5090 return Err(GraphError::RuleInvalid {
5091 detail: format!(
5092 "prop_name {:?} collides with an existing view name",
5093 prop_name
5094 ),
5095 });
5096 }
5097 // Write all scores in a single crash-atomic batch.
5098 self.write_batch(|b| {
5099 for (key, score) in scores {
5100 b.set_prop(key, prop_name, Value::Float(*score));
5101 }
5102 })?;
5103 Ok(())
5104 }
5105
5106 /// Return the value of `field` for the node with key `key`, or `None` if
5107 /// the node or field is absent. Reads through the overlay-over-base
5108 /// `ColumnsView`, materialising base values on demand (zero heap cost for
5109 /// overlay hits; one clone per base hit).
5110 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5111 let id = self.ids.get(key)?;
5112 self.props_view().get(id, field).map(|vr| vr.into_value())
5113 }
5114
5115 pub fn has_node(&self, key: &str) -> bool {
5116 self.ids.get(key).is_some()
5117 }
5118
5119 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5120 pub(crate) fn ids(&self) -> &IdMap {
5121 &self.ids
5122 }
5123
5124 // -----------------------------------------------------------------------
5125 // RBAC role resolution
5126 // -----------------------------------------------------------------------
5127
5128 /// Parse `roles.json` bytes from `fs`.
5129 ///
5130 /// Return values:
5131 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5132 /// and valid; in both cases `mask_for_role` uses the
5133 /// list normally (an absent file means no roles defined).
5134 /// `Ok(None)` — file present but corrupt or unrecognised version
5135 /// → poisoned state; `mask_for_role` returns `Err` for
5136 /// any role name until the file is fixed and the DB
5137 /// re-opened (or `apply_schema` is called to repair it).
5138 ///
5139 /// Note: `None` signals corruption, not absence — the opposite of what an
5140 /// optional "file missing" convention would suggest. The open path stores
5141 /// this result on `db.roles` directly.
5142 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5143 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5144 if bytes.is_empty() {
5145 // Empty bytes means either the file is absent or zero-byte — both
5146 // are treated identically as "no roles defined". A zero-byte
5147 // roles.json does NOT widen access: an absent file and a zero-byte
5148 // file both resolve to an empty role list (sees nothing by default).
5149 return Ok(Some(vec![]));
5150 }
5151 match serde_json::from_slice::<RolesFile>(&bytes) {
5152 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5153 // Corrupt or unrecognised version (>2): poison the roles state.
5154 _ => Ok(None),
5155 }
5156 }
5157
5158 /// Resolve a role to a node-visibility mask against the current graph state.
5159 ///
5160 /// Returns `Err` when:
5161 /// - `roles.json` was present but corrupt at open (poisoned state), or
5162 /// - `role` does not match any defined role name.
5163 ///
5164 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5165 /// all live nodes carrying any label in `labels`. Label resolution is live
5166 /// — new nodes of an allowed label are visible without re-applying the
5167 /// schema. An empty union = empty mask = sees nothing.
5168 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5169 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5170 detail:
5171 "roles.json was corrupt at open; fix the file and re-open to restore role access"
5172 .into(),
5173 })?;
5174 let def = roles
5175 .iter()
5176 .find(|r| r.name == role)
5177 .ok_or_else(|| GraphError::KeyNotFound {
5178 key: format!("role:{role}"),
5179 })?;
5180
5181 let mut visible = std::collections::HashSet::new();
5182
5183 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5184 for key in &def.keys {
5185 if let Some(id) = self.ids.get(key) {
5186 visible.insert(id);
5187 }
5188 }
5189
5190 // Label leg: live scan — iterate labels vec for matching symbol.
5191 for label_name in &def.labels {
5192 if let Some(sym) = self.syms.get(label_name) {
5193 for (i, &s) in self.labels.iter().enumerate() {
5194 if s == sym {
5195 visible.insert(i as u32);
5196 }
5197 }
5198 }
5199 }
5200
5201 Ok(crate::mask::NodeMask::from_ids(visible))
5202 }
5203
5204 /// Return the current list of role definitions.
5205 ///
5206 /// Returns an empty list when no roles are defined or when `roles.json`
5207 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5208 /// the fail-loud error in that case).
5209 pub fn roles(&self) -> Vec<RoleDef> {
5210 self.roles.as_deref().unwrap_or(&[]).to_vec()
5211 }
5212
5213 // ── Role-scoped write authz ───────────────────────────────────────────────
5214
5215 /// Execute `ops` with optional role-scoped write authorization.
5216 ///
5217 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5218 /// (zero-cost bypass of all authz checks).
5219 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5220 /// record is built. A denial returns an error with no WAL frame written
5221 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5222 ///
5223 /// See the plan's "authz decision table" section for the full semantics.
5224 pub fn write_batch_authz(
5225 &mut self,
5226 authz: Option<&WriteAuthz>,
5227 ops: Vec<BatchOp>,
5228 ) -> Result<(usize, usize)> {
5229 // Thread authz as a direct parameter — never touches pending_write_authz.
5230 self.commit_logged_batch(ops, None, authz.cloned())
5231 }
5232
5233 /// Execute a Cypher write statement with role-scoped write authorization.
5234 ///
5235 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5236 /// lifetime as execution, satisfying §5 lock discipline). The resolved
5237 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5238 /// call so that all inner `batch.commit()` calls are authz-checked.
5239 ///
5240 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5241 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5242 /// timing-oracle item (hidden ≡ absent for unscoped roles).
5243 ///
5244 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5245 /// "this endpoint is not permitted".
5246 pub fn query_write_authz(
5247 &mut self,
5248 role: &str,
5249 cypher: &str,
5250 params: &BTreeMap<String, Value>,
5251 ) -> Result<ResultSet> {
5252 // Resolve scope (fails fast if role has no write scope).
5253 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5254 let scope =
5255 {
5256 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5257 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5258 })?;
5259 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5260 GraphError::KeyNotFound {
5261 key: format!("role:{role}"),
5262 }
5263 })?;
5264 def.write
5265 .clone()
5266 .ok_or_else(|| GraphError::RoleWriteDenied {
5267 reason: "role-bound token: writes are not permitted".into(),
5268 })?
5269 };
5270 // Resolve mask inside the call (same guard, §5 coherence).
5271 let mask = self.mask_for_role(role)?;
5272 self.pending_write_authz = Some(WriteAuthz {
5273 role: role.into(),
5274 scope,
5275 mask,
5276 });
5277 // RAII guard: always clears pending_write_authz on scope exit, including
5278 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5279 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5280 impl Drop for ClearPendingAuthzOnDrop {
5281 fn drop(&mut self) {
5282 // SAFETY: pointer into the owning GraphDb; guard is dropped
5283 // within this function's frame before it returns.
5284 unsafe { *self.0 = None };
5285 }
5286 }
5287 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5288 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5289 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5290 detail: format!("lex: {e}"),
5291 })?;
5292 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5293 detail: format!("parse: {e}"),
5294 })?;
5295 self.exec_write_stmt(stmt, params)
5296 }
5297
5298 /// Execute `ops` with optional role-scoped write authorization, suppressing
5299 /// fsync (for use inside the group-commit drain thread, which performs one
5300 /// group fsync after releasing the write lock).
5301 ///
5302 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5303 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5304 /// contract established by [`commit_batch_nosync`].
5305 pub(crate) fn write_batch_authz_nosync(
5306 &mut self,
5307 authz: Option<&WriteAuthz>,
5308 ops: Vec<BatchOp>,
5309 ) -> Result<(usize, usize)> {
5310 let saved = self.fsync;
5311 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5312 impl Drop for RestoreFsync {
5313 fn drop(&mut self) {
5314 // SAFETY: pointer into the owning GraphDb; guard is dropped
5315 // within the enclosing function's frame before it returns.
5316 unsafe { *self.0 = self.1 };
5317 }
5318 }
5319 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5320 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5321 self.fsync = FsyncPolicy::Relaxed;
5322 self.commit_logged_batch(ops, None, authz.cloned())
5323 }
5324
5325 /// Execute a `/ingest` request with role-scoped write authorization.
5326 ///
5327 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5328 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5329 /// Sets `pending_write_authz` for the duration of the call so that the
5330 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5331 /// and evaluates the decision table per-op before any WAL write.
5332 ///
5333 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5334 /// denied by the decision table with the appropriate §4.3 scope reason;
5335 /// no special HTTP-layer check is needed.
5336 ///
5337 /// Roles with `write: None` return `RoleWriteDenied` with
5338 /// "writes are not permitted" (byte-identical to v1 blanket 403).
5339 pub fn ingest_with_edges_authz(
5340 &mut self,
5341 role: &str,
5342 label: &str,
5343 rows: Vec<std::collections::BTreeMap<String, Value>>,
5344 opts: &crate::ingest::IngestOptions,
5345 edges: &[(String, String, String)],
5346 ) -> Result<crate::ingest::IngestReport> {
5347 // Resolve scope (fails fast if role has no write scope).
5348 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5349 let scope =
5350 {
5351 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5352 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5353 })?;
5354 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5355 GraphError::KeyNotFound {
5356 key: format!("role:{role}"),
5357 }
5358 })?;
5359 def.write
5360 .clone()
5361 .ok_or_else(|| GraphError::RoleWriteDenied {
5362 reason: "role-bound token: writes are not permitted".into(),
5363 })?
5364 };
5365 let mask = self.mask_for_role(role)?;
5366 self.pending_write_authz = Some(WriteAuthz {
5367 role: role.into(),
5368 scope,
5369 mask,
5370 });
5371 // RAII guard: always clears pending_write_authz on scope exit, including
5372 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5373 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5374 impl Drop for ClearPendingAuthzOnDrop {
5375 fn drop(&mut self) {
5376 // SAFETY: pointer into the owning GraphDb; guard is dropped
5377 // within this function's frame before it returns.
5378 unsafe { *self.0 = None };
5379 }
5380 }
5381 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5382 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5383 self.ingest_with_edges(label, rows, opts, edges)
5384 }
5385
5386 /// Evaluate the write-authz decision table for one `BatchOp`.
5387 ///
5388 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5389 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
5390 /// the remaining ops are not evaluated and no WAL frame is written.
5391 ///
5392 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5393 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
5394 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5395 /// batch creates counts as visible if its label passed the create-class gate").
5396 fn check_single_op_authz(
5397 &self,
5398 authz: &WriteAuthz,
5399 op: &BatchOp,
5400 batch_created: &BTreeMap<String, String>,
5401 ) -> Result<()> {
5402 // Helper: 3-way node status under the authz mask.
5403 //
5404 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5405 // as Visible with their recorded label — their create gate already passed
5406 // and they are not yet in self.ids (not committed). This fixes the
5407 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5408 // the SetProp must not see the node as Absent.
5409 let node_status = |key: &str| -> NodeAuthzStatus {
5410 if let Some(label) = batch_created.get(key) {
5411 return NodeAuthzStatus::Visible(label.clone());
5412 }
5413 match self.ids.get(key) {
5414 None => NodeAuthzStatus::Absent,
5415 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5416 Some(id) => {
5417 let label = self
5418 .labels
5419 .get(id as usize)
5420 .and_then(|&sym| {
5421 if sym == u32::MAX {
5422 None
5423 } else {
5424 self.syms.resolve(sym).map(str::to_string)
5425 }
5426 })
5427 .unwrap_or_default();
5428 NodeAuthzStatus::Visible(label)
5429 }
5430 }
5431 };
5432
5433 // Helper: is an InsertEdgeUpsert endpoint visible?
5434 // A same-batch placeholder counts as visible if its label passed
5435 // the create-class gate (spec "upsert placeholder-counts-as-visible").
5436 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5437 // In store and visible?
5438 if let Some(id) = self.ids.get(ep_key) {
5439 return authz.mask.contains_id(id);
5440 }
5441 // Created by an earlier op in this batch?
5442 if let Some(created_label) = batch_created.get(ep_key) {
5443 return authz.scope.create_labels.contains(created_label);
5444 }
5445 // Will be created by THIS InsertEdgeUpsert: placeholder_label
5446 // must pass the create-class gate.
5447 authz
5448 .scope
5449 .create_labels
5450 .contains(&placeholder_label.to_string())
5451 };
5452
5453 match op {
5454 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5455 // These ops are never routed to role-scoped paths by the HTTP layer,
5456 // but we 403 them here to close any future bypass route.
5457 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5458 return Err(GraphError::RoleWriteDenied {
5459 reason: "role-bound token: this endpoint is not permitted".into(),
5460 });
5461 }
5462
5463 // ── CREATE-class: InsertNode ─────────────────────────────────────
5464 //
5465 // Decision table row 1 (scope-before-lookup): check label in
5466 // create_labels BEFORE any key lookup. This is the structural
5467 // closure of the §6.2 timing-oracle item — the denial fires even
5468 // when the store is EMPTY (see test_create_scope_denied_empty_store).
5469 BatchOp::InsertNode { label, key, .. } => {
5470 if !authz.scope.create_labels.contains(label) {
5471 return Err(GraphError::RoleWriteDenied {
5472 reason: format!(
5473 "role-bound token: label '{}' not in write scope (create_labels)",
5474 label
5475 ),
5476 });
5477 }
5478 // Row 2/3: key lookup.
5479 match self.ids.get(key.as_str()) {
5480 Some(id) if authz.mask.contains_id(id) => {
5481 // Visible: DuplicateKey — let MutPreview handle this.
5482 }
5483 Some(_) => {
5484 // Hidden: indistinguishable from absent to the role.
5485 return Err(GraphError::RoleWriteDenied {
5486 reason: "role-bound token: target node not visible".into(),
5487 });
5488 }
5489 None => {
5490 // Absent: proceed (create).
5491 }
5492 }
5493 }
5494
5495 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5496 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5497 if batch_created.contains_key(key.as_str()) {
5498 // Batch-created node: create gate already passed this batch.
5499 // Updating it in the same batch is always allowed, regardless
5500 // of update_labels (ruling §3.5: "writer just created it").
5501 } else {
5502 let label = match node_status(key) {
5503 NodeAuthzStatus::Visible(lbl) => lbl,
5504 _ => {
5505 return Err(GraphError::RoleWriteDenied {
5506 reason: "role-bound token: target node not visible".into(),
5507 });
5508 }
5509 };
5510 if !authz.scope.update_labels.contains(&label) {
5511 return Err(GraphError::RoleWriteDenied {
5512 reason: format!(
5513 "role-bound token: label '{}' not in write scope (update_labels)",
5514 label
5515 ),
5516 });
5517 }
5518 }
5519 }
5520
5521 // ── DELETE-class: DeleteNode ─────────────────────────────────────
5522 BatchOp::DeleteNode { key } => {
5523 let label = match node_status(key) {
5524 NodeAuthzStatus::Visible(lbl) => lbl,
5525 _ => {
5526 return Err(GraphError::RoleWriteDenied {
5527 reason: "role-bound token: target node not visible".into(),
5528 });
5529 }
5530 };
5531 if !authz.scope.delete_labels.contains(&label) {
5532 return Err(GraphError::RoleWriteDenied {
5533 reason: format!(
5534 "role-bound token: label '{}' not in write scope (delete_labels)",
5535 label
5536 ),
5537 });
5538 }
5539 }
5540
5541 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5542 //
5543 // Derived-edge rejection runs BEFORE the delete_edge_types scope
5544 // check (spec §3.5: "existing derived-edge rejection precedes
5545 // delete_edge_types check").
5546 BatchOp::DeleteEdge {
5547 edge_type,
5548 src_key,
5549 dst_key,
5550 } => {
5551 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
5552 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
5553 self.ids.get(src_key.as_str()),
5554 self.ids.get(dst_key.as_str()),
5555 self.syms.get(edge_type.as_str()),
5556 ) {
5557 if self.engine.is_owned(et_sym, src_id, dst_id) {
5558 return Err(GraphError::RuleOwned {
5559 detail: format!(
5560 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5561 delete or change the owning rule"
5562 ),
5563 });
5564 }
5565 // Also check would_derive via MutPreview (empty overlay, pre-batch).
5566 let preview = MutPreview::new(self);
5567 if preview.would_derive(edge_type, src_key, dst_key) {
5568 return Err(GraphError::RuleOwned {
5569 detail: format!(
5570 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5571 delete or change the owning rule, or a live rule would \
5572 re-derive it"
5573 ),
5574 });
5575 }
5576 }
5577 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
5578 if !authz.scope.delete_edge_types.contains(edge_type) {
5579 return Err(GraphError::RoleWriteDenied {
5580 reason: format!(
5581 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
5582 edge_type
5583 ),
5584 });
5585 }
5586 // Both endpoints must be visible.
5587 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5588 match self.ids.get(ep_key) {
5589 None => {
5590 return Err(GraphError::RoleWriteDenied {
5591 reason: "role-bound token: edge endpoint not visible".into(),
5592 });
5593 }
5594 Some(id) if !authz.mask.contains_id(id) => {
5595 return Err(GraphError::RoleWriteDenied {
5596 reason: "role-bound token: edge endpoint not visible".into(),
5597 });
5598 }
5599 _ => {}
5600 }
5601 }
5602 }
5603
5604 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
5605 //
5606 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
5607 BatchOp::InsertEdge {
5608 edge_type,
5609 src_key,
5610 dst_key,
5611 } => {
5612 if !authz.scope.create_edge_types.contains(edge_type) {
5613 return Err(GraphError::RoleWriteDenied {
5614 reason: format!(
5615 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5616 edge_type
5617 ),
5618 });
5619 }
5620 // Both endpoints must be visible. A node created by an earlier
5621 // InsertNode in the same batch (tracked in batch_created) counts
5622 // as visible if its label passed the create-class gate.
5623 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5624 if batch_created.contains_key(ep_key) {
5625 // Created earlier this batch — already scope-checked.
5626 continue;
5627 }
5628 match self.ids.get(ep_key) {
5629 None => {
5630 return Err(GraphError::RoleWriteDenied {
5631 reason: "role-bound token: edge endpoint not visible".into(),
5632 });
5633 }
5634 Some(id) if !authz.mask.contains_id(id) => {
5635 return Err(GraphError::RoleWriteDenied {
5636 reason: "role-bound token: edge endpoint not visible".into(),
5637 });
5638 }
5639 _ => {}
5640 }
5641 }
5642 }
5643
5644 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
5645 //
5646 // Scope check first; then endpoint visibility using same-batch
5647 // placeholder awareness (spec: "a placeholder endpoint the SAME
5648 // batch creates counts as visible if its label passed the
5649 // create-class gate").
5650 BatchOp::InsertEdgeUpsert {
5651 edge_type,
5652 src_key,
5653 dst_key,
5654 placeholder_label,
5655 } => {
5656 if !authz.scope.create_edge_types.contains(edge_type) {
5657 return Err(GraphError::RoleWriteDenied {
5658 reason: format!(
5659 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5660 edge_type
5661 ),
5662 });
5663 }
5664 // Check placeholder label against create_labels (create-class gate).
5665 // This ensures the auto-created endpoints are scope-allowed.
5666 for ep_key in [src_key.as_str(), dst_key.as_str()] {
5667 if !upsert_ep_visible(ep_key, placeholder_label) {
5668 return Err(GraphError::RoleWriteDenied {
5669 reason: "role-bound token: edge endpoint not visible".into(),
5670 });
5671 }
5672 }
5673 }
5674 }
5675 Ok(())
5676 }
5677
5678 /// Write `roles` to `roles.json` atomically and update the in-memory list.
5679 ///
5680 /// Called by `apply_schema` when roles change. Never called on unchanged
5681 /// re-apply — this preserves byte-identical idempotency.
5682 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
5683 let file = RolesFile::new_versioned(roles.clone());
5684 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
5685 detail: format!("roles serialization: {e}"),
5686 })?;
5687 self.fs
5688 .write_atomic(FileId::Roles, &bytes)
5689 .map_err(GraphError::Io)?;
5690 self.roles = Some(roles);
5691 // Refresh the MVCC frozen overlay so that reader() immediately sees the
5692 // updated role definitions without waiting for the next K-commit fold.
5693 self.fold_now();
5694 Ok(())
5695 }
5696
5697 fn view(&self) -> GraphView<'_> {
5698 GraphView {
5699 ids: &self.ids,
5700 syms: &self.syms,
5701 labels: &self.labels,
5702 props: self.props_view(),
5703 topo: self.topo_view(),
5704 edge_props: self.edge_props_view(),
5705 mask: None,
5706 }
5707 }
5708
5709 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
5710 GraphView {
5711 ids: &self.ids,
5712 syms: &self.syms,
5713 labels: &self.labels,
5714 props: self.props_view(),
5715 topo: self.topo_view(),
5716 edge_props: self.edge_props_view(),
5717 mask: Some(&mask.visible),
5718 }
5719 }
5720
5721 /// Execute a read-only Cypher query with a node visibility mask.
5722 ///
5723 /// Only nodes whose key is in `mask` are accessible: label scans, key
5724 /// lookups, and neighbor expansions all respect the mask. Edges where
5725 /// either endpoint is hidden are silently dropped.
5726 ///
5727 /// Returns `Err` with a "masked queries are read-only" message when
5728 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
5729 pub fn query_masked(
5730 &self,
5731 cypher: &str,
5732 params: &std::collections::BTreeMap<String, Value>,
5733 mask: &crate::mask::NodeMask,
5734 ) -> Result<ResultSet> {
5735 // Reject write statements up front.
5736 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5737 detail: format!("lex: {e}"),
5738 })?;
5739 if is_write_tokens(&tokens) {
5740 return Err(GraphError::MaskedReadOnly);
5741 }
5742 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5743 detail: format!("parse: {e}"),
5744 })?;
5745 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5746 detail: format!("plan: {e}"),
5747 })?;
5748 execute(&self.view_masked(mask), &ops, &Params(params)).map_err(|e| {
5749 GraphError::QueryError {
5750 detail: format!("execute: {e}"),
5751 }
5752 })
5753 }
5754
5755 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
5756 let id = self.ids.get(key)?;
5757 Some(NodeRef { db: self, id })
5758 }
5759
5760 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
5761 ///
5762 /// Hidden nodes are never used as traversal intermediaries in either
5763 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
5764 /// only through a hidden node will not appear in results.
5765 ///
5766 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
5767 /// a visited visible node are appended to the result as stub rows
5768 /// (`label` column is `null`, same key+depth columns as visible rows).
5769 /// They are NOT added to the BFS frontier.
5770 ///
5771 /// Returns `None` when `key` does not exist (caller should 404).
5772 ///
5773 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
5774 /// stub rows are never produced on the role path.
5775 pub fn neighborhood_masked(
5776 &self,
5777 key: &str,
5778 depth: u32,
5779 edge_types: Option<&[&str]>,
5780 dir: Dir,
5781 mask: &crate::mask::NodeMask,
5782 ) -> Option<ResultSet> {
5783 let start_id = self.ids.get(key)?;
5784 let view = self.view_masked(mask);
5785 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5786 names
5787 .iter()
5788 .filter_map(|name| view.syms.get(name))
5789 .collect()
5790 });
5791 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
5792 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
5793 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
5794 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
5795 visited.push((start_id, 0));
5796 for (nid, d) in &nb.nodes {
5797 let k = view.key_of(*nid);
5798 let label = view
5799 .label_of(*nid)
5800 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
5801 rs.push_row(vec![
5802 Some(Value::Str(k.to_string())),
5803 Some(Value::Str(label.to_string())),
5804 Some(Value::Int(*d as i64)),
5805 ]);
5806 visited.push((*nid, *d));
5807 }
5808 // Stub mode: add hidden direct neighbours of each visited node as stubs.
5809 // Hidden nodes are edge-endpoints only — they are not added to the BFS
5810 // frontier, so the BFS never expands through them.
5811 if mask.mode() == crate::mask::MaskMode::Stub {
5812 let raw_view = self.view();
5813 let mut seen: std::collections::HashSet<u32> =
5814 visited.iter().map(|(id, _)| *id).collect();
5815 for (node_id, node_depth) in &visited {
5816 if *node_depth >= depth {
5817 continue;
5818 }
5819 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
5820 let nbr = if e.src == *node_id { e.dst } else { e.src };
5821 if !mask.contains_id(nbr) && seen.insert(nbr) {
5822 if let Some(k) = self.ids.key_of(nbr) {
5823 rs.push_row(vec![
5824 Some(Value::Str(k.to_string())),
5825 None,
5826 Some(Value::Int((*node_depth + 1) as i64)),
5827 ]);
5828 }
5829 }
5830 }
5831 }
5832 }
5833 Some(rs)
5834 }
5835
5836 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
5837 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
5838 let n = self.node_ref(key)?;
5839 Some(NodeInfo {
5840 key: n.key().to_string(),
5841 label: n.label().to_string(),
5842 props: n.props(),
5843 })
5844 }
5845
5846 /// Look up a node with mask awareness.
5847 ///
5848 /// | Key state | Omit mode | Stub mode |
5849 /// |-------------------|-----------------|------------------------|
5850 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
5851 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
5852 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
5853 ///
5854 /// **SECURITY**: only call from client-mask (full-token) paths.
5855 /// Role-token paths must use [`node_info`] after an explicit visibility check.
5856 pub fn node_info_masked(
5857 &self,
5858 key: &str,
5859 mask: &crate::mask::NodeMask,
5860 ) -> Option<MaskedNodeResult> {
5861 let id = self.ids.get(key)?;
5862 if mask.contains_id(id) {
5863 Some(MaskedNodeResult::Visible(self.node_info(key)?))
5864 } else {
5865 match mask.mode() {
5866 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
5867 crate::mask::MaskMode::Omit => None,
5868 }
5869 }
5870 }
5871
5872 /// Get edges for `key` with mask-aware hidden-endpoint handling.
5873 ///
5874 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
5875 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
5876 /// is `true` for each hidden endpoint.
5877 ///
5878 /// Unknown key → [`GraphError::KeyNotFound`].
5879 ///
5880 /// **SECURITY**: only call from client-mask (full-token) paths.
5881 pub fn node_edges_masked(
5882 &self,
5883 key: &str,
5884 mask: &crate::mask::NodeMask,
5885 ) -> Result<Vec<MaskedEdge>> {
5886 self.ensure_v8_base_sections_loaded();
5887 let id = self
5888 .ids
5889 .get(key)
5890 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5891 let derived: BTreeSet<(u32, u32, u32)> = self
5892 .engine
5893 .provenance_touching(id)
5894 .map(|(_rule, etype, src, dst)| (etype, src, dst))
5895 .collect();
5896 let mut edges = Vec::new();
5897 let tv = self.topo_view();
5898 for etype in tv.etypes() {
5899 // etype comes from the archived CSR (access_unchecked, no eager CRC).
5900 // A bit-flip in the large TOPOLOGY section can produce an etype id
5901 // that is not in the interner. Return Corrupt rather than panic.
5902 let edge_type = self
5903 .syms
5904 .resolve(etype)
5905 .ok_or_else(|| GraphError::Corrupt {
5906 detail: format!("v8: topology etype {etype} not in interner"),
5907 })?
5908 .to_string();
5909 for dir in [Direction::Out, Direction::In] {
5910 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5911 let nbr_restricted = !mask.contains_id(nbr);
5912 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
5913 continue;
5914 }
5915 let nbr_key = self
5916 .ids
5917 .key_of(nbr)
5918 .ok_or_else(|| GraphError::Corrupt {
5919 detail: format!("topology id {nbr} has no key"),
5920 })?
5921 .to_string();
5922 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
5923 match dir {
5924 Direction::Out => {
5925 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
5926 }
5927 Direction::In => {
5928 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
5929 }
5930 };
5931 edges.push(MaskedEdge {
5932 edge_type: edge_type.clone(),
5933 src_key,
5934 src_restricted,
5935 dst_key,
5936 dst_restricted,
5937 derived: derived.contains(&(etype, src_id, dst_id)),
5938 });
5939 }
5940 }
5941 }
5942 edges.sort_by(|a, b| {
5943 a.edge_type
5944 .cmp(&b.edge_type)
5945 .then(a.src_key.cmp(&b.src_key))
5946 .then(a.dst_key.cmp(&b.dst_key))
5947 });
5948 edges.dedup_by(|a, b| {
5949 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
5950 });
5951 Ok(edges)
5952 }
5953
5954 /// Every directed edge incident on `key`, both directions, every etype.
5955 ///
5956 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
5957 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
5958 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
5959 /// Unknown key → [`GraphError::KeyNotFound`].
5960 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
5961 self.ensure_v8_base_sections_loaded();
5962 let id = self
5963 .ids
5964 .get(key)
5965 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5966 let derived: BTreeSet<(u32, u32, u32)> = self
5967 .engine
5968 .provenance_touching(id)
5969 .map(|(_rule, etype, src, dst)| (etype, src, dst))
5970 .collect();
5971 let mut edges = Vec::new();
5972 let tv = self.topo_view();
5973 for etype in tv.etypes() {
5974 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
5975 let edge_type = self
5976 .syms
5977 .resolve(etype)
5978 .ok_or_else(|| GraphError::Corrupt {
5979 detail: format!("v8: topology etype {etype} not in interner"),
5980 })?
5981 .to_string();
5982 for dir in [Direction::Out, Direction::In] {
5983 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5984 let (src, dst, src_key, dst_key) = match dir {
5985 Direction::Out => (
5986 id,
5987 nbr,
5988 key.to_string(),
5989 self.ids
5990 .key_of(nbr)
5991 .ok_or_else(|| GraphError::Corrupt {
5992 detail: format!("topology id {nbr} has no key"),
5993 })?
5994 .to_string(),
5995 ),
5996 Direction::In => (
5997 nbr,
5998 id,
5999 self.ids
6000 .key_of(nbr)
6001 .ok_or_else(|| GraphError::Corrupt {
6002 detail: format!("topology id {nbr} has no key"),
6003 })?
6004 .to_string(),
6005 key.to_string(),
6006 ),
6007 };
6008 edges.push(EdgeInfo {
6009 edge_type: edge_type.clone(),
6010 src_key,
6011 dst_key,
6012 derived: derived.contains(&(etype, src, dst)),
6013 });
6014 }
6015 }
6016 }
6017 edges.sort_by(|a, b| {
6018 a.edge_type
6019 .cmp(&b.edge_type)
6020 .then(a.src_key.cmp(&b.src_key))
6021 .then(a.dst_key.cmp(&b.dst_key))
6022 });
6023 // Self-loops appear in both Out and In; sort makes the pair adjacent
6024 // (sort key matches PartialEq for this case) so one pass drops the dup.
6025 edges.dedup();
6026 Ok(edges)
6027 }
6028
6029 // ── Backup ────────────────────────────────────────────────────────────────
6030
6031 /// Copy this store to `dest` as a consistent, verified snapshot.
6032 ///
6033 /// Copies every durable file in the database directory — `snapshot.bin`,
6034 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6035 /// `roles.json` — into a freshly created `dest` directory using OS-level
6036 /// `copy` calls (no large in-process buffers).
6037 ///
6038 /// # Consistency guarantee
6039 ///
6040 /// The guarantee is **process-local**: the caller holds `&self`, which
6041 /// prevents any concurrent writer in the **same process** from modifying
6042 /// the files during the copy. Running `mushroomdb backup` against a
6043 /// directory that is **concurrently being written by another process** (e.g.
6044 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
6045 /// `verified: true` result reduces but does not eliminate the risk of a
6046 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6047 /// consistent mid-write snapshot).
6048 ///
6049 /// **The safe path for a live-served store is `POST /backup` on the HTTP
6050 /// server.** That handler acquires the read lock on the shared database
6051 /// before calling this method, which is the correct cross-process
6052 /// synchronisation point because the server is the single process writing
6053 /// the files.
6054 ///
6055 /// After copying, opens the destination read-only and runs the CRC section
6056 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6057 /// `BackupReport::verified` reflects whether both checks passed.
6058 ///
6059 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6060 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6061 // Derive source directory from snapshot_path (RealFs only).
6062 let src_dir = match self.fs.snapshot_path() {
6063 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6064 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6065 })?,
6066 None => {
6067 return Err(GraphError::Io(std::io::Error::other(
6068 "backup_to requires a real filesystem (RealFs)",
6069 )))
6070 }
6071 };
6072
6073 std::fs::create_dir_all(dest)?;
6074
6075 let mut files: Vec<String> = Vec::new();
6076 let mut bytes: u64 = 0;
6077
6078 // Helper: copy src_dir/name → dest/name if the file exists.
6079 let mut try_copy = |name: &str| -> std::io::Result<()> {
6080 let src_path = src_dir.join(name);
6081 if src_path.exists() {
6082 let n = std::fs::copy(&src_path, dest.join(name))?;
6083 bytes += n;
6084 files.push(name.to_string());
6085 }
6086 Ok(())
6087 };
6088
6089 try_copy("snapshot.bin")?;
6090 try_copy("snapshot.bin.bak")?;
6091 try_copy("wal.bin")?;
6092 try_copy("wal.floor")?;
6093 try_copy("wal.genesis")?;
6094 try_copy("roles.json")?;
6095
6096 // Copy WAL archives.
6097 let archives = self.fs.list_archives()?;
6098 for n in &archives {
6099 let name = format!("wal.{n}.archive");
6100 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6101 bytes += n_bytes;
6102 files.push(name);
6103 }
6104
6105 files.sort();
6106
6107 // Post-copy verification: open dest and run CRC checks.
6108 let snap_in_dest = dest.join("snapshot.bin").exists();
6109 let crc_ok = if snap_in_dest {
6110 crate::verify_snapshot(dest)
6111 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6112 .unwrap_or(false)
6113 } else {
6114 true // WAL-only store: nothing to CRC-check in snapshot
6115 };
6116 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6117 let verified = crc_ok && opens_ok;
6118
6119 Ok(BackupReport {
6120 files,
6121 bytes,
6122 verified,
6123 })
6124 }
6125
6126 // ── Export helpers ────────────────────────────────────────────────────────
6127
6128 /// All live nodes, sorted by key (deterministic).
6129 ///
6130 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6131 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6132 self.ensure_v8_base_sections_loaded();
6133 let pv = self.props_view();
6134 let mut nodes = Vec::new();
6135 for id in 0..self.ids.len() as u32 {
6136 let Some(key) = self.ids.key_of(id) else {
6137 continue;
6138 };
6139 let Some(&sym) = self.labels.get(id as usize) else {
6140 continue;
6141 };
6142 if sym == u32::MAX {
6143 continue; // tombstoned
6144 }
6145 let Some(label) = self.syms.resolve(sym) else {
6146 continue;
6147 };
6148 let mut props = BTreeMap::new();
6149 for field in pv.field_names() {
6150 if let Some(vr) = pv.get(id, &field) {
6151 props.insert(field, vr.into_value());
6152 }
6153 }
6154 nodes.push(NodeInfo {
6155 key: key.to_string(),
6156 label: label.to_string(),
6157 props,
6158 });
6159 }
6160 nodes.sort_by(|a, b| a.key.cmp(&b.key));
6161 nodes
6162 }
6163
6164 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6165 ///
6166 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6167 /// Manual edges carry `derived: false` and `rule: None`.
6168 /// Deterministic across runs on the same store state.
6169 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6170 self.ensure_v8_base_sections_loaded();
6171
6172 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6173 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6174 for (rule_name, triples) in self.engine.provenance() {
6175 for &(etype, src, dst) in triples {
6176 prov.insert((etype, src, dst), rule_name.clone());
6177 }
6178 }
6179
6180 let tv = self.topo_view();
6181 let mut edges = Vec::new();
6182
6183 for id in 0..self.ids.len() as u32 {
6184 let Some(key) = self.ids.key_of(id) else {
6185 continue;
6186 };
6187 let Some(&lsym) = self.labels.get(id as usize) else {
6188 continue;
6189 };
6190 if lsym == u32::MAX {
6191 continue; // tombstoned
6192 }
6193
6194 for etype_sym in tv.etypes() {
6195 // etype from archived CSR (access_unchecked, no eager CRC).
6196 // Skip edges whose etype is not in the interner; this can only
6197 // occur with a corrupt large TOPOLOGY section (bit-flip on an
6198 // etype field in the archived data). The function returns Vec,
6199 // not Result, so we continue rather than propagate.
6200 let Some(edge_type) = self.syms.resolve(etype_sym) else {
6201 continue;
6202 };
6203 let edge_type = edge_type.to_string();
6204 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6205 let Some(dst_key) = self.ids.key_of(nbr) else {
6206 continue; // skip corrupt entries
6207 };
6208 let prov_key = (etype_sym, id, nbr);
6209 let rule = prov.get(&prov_key).cloned();
6210 let derived = rule.is_some();
6211 edges.push(ExportEdge {
6212 edge_type: edge_type.clone(),
6213 src: key.to_string(),
6214 dst: dst_key.to_string(),
6215 derived,
6216 rule,
6217 });
6218 }
6219 }
6220 }
6221
6222 edges.sort_by(|a, b| {
6223 a.edge_type
6224 .cmp(&b.edge_type)
6225 .then(a.src.cmp(&b.src))
6226 .then(a.dst.cmp(&b.dst))
6227 });
6228 edges
6229 }
6230
6231 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6232 self.view()
6233 .nodes_with_label(label)
6234 .into_iter()
6235 .map(|id| NodeRef { db: self, id })
6236 .collect()
6237 }
6238
6239 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6240 let view = self.view();
6241 view.nodes_with_label(label)
6242 .into_iter()
6243 .filter(|&id| {
6244 eval_filter(filter, &|field| {
6245 view.prop(id, field).map(|vr| vr.into_value())
6246 })
6247 })
6248 .map(|id| NodeRef { db: self, id })
6249 .collect()
6250 }
6251
6252 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6253 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
6254 /// with `label = None` will use the native ANN path rather than the O(n)
6255 /// brute-force scan.
6256 pub fn has_vector_rule(&self, field: &str) -> bool {
6257 self.engine.hnsw_has_rule(field)
6258 }
6259
6260 /// Find nodes whose `field` vector is most similar to `q` (cosine
6261 /// similarity), returning up to `k` results with similarity ≥ `min`,
6262 /// sorted descending.
6263 ///
6264 /// When `label` is `None` the search spans all labels (via
6265 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6266 /// `Some(lbl)` it restricts to nodes with that label.
6267 ///
6268 /// Uses the HNSW index when one is available (fast path); otherwise falls
6269 /// back to an O(n) brute-force scan.
6270 pub fn find_similar_vector(
6271 &self,
6272 field: &str,
6273 label: Option<&str>,
6274 q: &[f64],
6275 k: usize,
6276 min: f64,
6277 ) -> Vec<(String, f64)> {
6278 // Ensure any HNSW blobs retained from the snapshot are deserialized
6279 // before the first ANN query on a clean-open (no-WAL) path.
6280 self.engine.ensure_hnsw_loaded();
6281 // L2-normalise query for cosine via dot product.
6282 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6283 if norm == 0.0 {
6284 return vec![];
6285 }
6286 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6287
6288 // Try HNSW fast path.
6289 // `None` label searches across all VectorSimilar rules covering `field`
6290 // (merging their results); `Some(lbl)` restricts to rules whose
6291 // dst_label matches. Returns `None` when no populated HNSW index
6292 // covers the request — the O(n) brute-force fallback handles that case.
6293 let hnsw_hits = match label {
6294 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6295 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6296 };
6297 if let Some(hits) = hnsw_hits {
6298 let mut out: Vec<(String, f64)> = hits
6299 .into_iter()
6300 .filter(|&(_, sim)| sim >= min)
6301 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6302 .collect();
6303 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6304 out.truncate(k);
6305 return out;
6306 }
6307
6308 // Brute-force fallback: O(n) scan (only reached when no HNSW index
6309 // covers the request).
6310 let view = self.view();
6311 let candidate_ids: Vec<u32> = match label {
6312 Some(lbl) => view.nodes_with_label(lbl),
6313 None => view.nodes_all(),
6314 };
6315 let mut scored: Vec<(String, f64)> = candidate_ids
6316 .into_iter()
6317 .filter_map(|id| {
6318 let v = view.prop(id, field)?;
6319 let v_owned = v.into_value();
6320 let xs = value_as_float_list(&v_owned)?;
6321 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6322 if v_norm == 0.0 {
6323 return None;
6324 }
6325 let dot: f64 = q_unit
6326 .iter()
6327 .zip(xs.iter())
6328 .map(|(a, b)| a * (b / v_norm))
6329 .sum();
6330 if dot < min {
6331 return None;
6332 }
6333 let key = self.ids.key_of(id)?.to_string();
6334 Some((key, dot))
6335 })
6336 .collect();
6337 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6338 scored.truncate(k);
6339 scored
6340 }
6341
6342 /// Like [`find_similar_vector`] but restricts results to nodes visible in
6343 /// `mask`. Hidden nodes never appear in results; the mask is applied
6344 /// **before** k-truncation so a caller still receives up to `k` visible
6345 /// hits.
6346 ///
6347 /// # HNSW path (over-fetch policy)
6348 ///
6349 /// When an HNSW index covers the request, this function fetches `4 * k`
6350 /// candidates from the index and discards hidden nodes in the post-filter
6351 /// step. If fewer than `k` visible nodes remain after filtering the caller
6352 /// receives whatever is available — we do not re-query the index. The 4×
6353 /// multiplier is a heuristic suited for sparsely masked graphs; callers
6354 /// operating under a very selective mask should register a VectorSimilar
6355 /// rule with a non-approximate index, or use the brute-force path (no HNSW
6356 /// rule) which exhaustively filters through the masked [`GraphView`].
6357 ///
6358 /// # Brute-force path
6359 ///
6360 /// When no HNSW index covers the request the function builds a masked
6361 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6362 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6363 /// fewer than `k` exist).
6364 pub fn find_similar_vector_masked(
6365 &self,
6366 field: &str,
6367 label: Option<&str>,
6368 q: &[f64],
6369 k: usize,
6370 min: f64,
6371 mask: &crate::mask::NodeMask,
6372 ) -> Vec<(String, f64)> {
6373 self.engine.ensure_hnsw_loaded();
6374 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6375 if norm == 0.0 {
6376 return vec![];
6377 }
6378 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6379
6380 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6381 // visible hits. See doc comment above for the policy rationale.
6382 let over_k = k.saturating_mul(4).max(k + 1);
6383 let hnsw_hits = match label {
6384 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6385 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6386 };
6387 if let Some(hits) = hnsw_hits {
6388 let mut out: Vec<(String, f64)> = hits
6389 .into_iter()
6390 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6391 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6392 .collect();
6393 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6394 out.truncate(k);
6395 return out;
6396 }
6397
6398 // Brute-force fallback — masked view ensures only visible nodes are
6399 // enumerated by nodes_all(); nodes_with_label() does not filter by
6400 // mask so we apply view.visible() explicitly for the labeled case.
6401 let view = self.view_masked(mask);
6402 let candidate_ids: Vec<u32> = match label {
6403 Some(lbl) => view
6404 .nodes_with_label(lbl)
6405 .into_iter()
6406 .filter(|&id| view.visible(id))
6407 .collect(),
6408 None => view.nodes_all(),
6409 };
6410 let mut scored: Vec<(String, f64)> = candidate_ids
6411 .into_iter()
6412 .filter_map(|id| {
6413 let v = view.prop(id, field)?;
6414 let v_owned = v.into_value();
6415 let xs = value_as_float_list(&v_owned)?;
6416 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6417 if v_norm == 0.0 {
6418 return None;
6419 }
6420 let dot: f64 = q_unit
6421 .iter()
6422 .zip(xs.iter())
6423 .map(|(a, b)| a * (b / v_norm))
6424 .sum();
6425 if dot < min {
6426 return None;
6427 }
6428 let key = self.ids.key_of(id)?.to_string();
6429 Some((key, dot))
6430 })
6431 .collect();
6432 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6433 scored.truncate(k);
6434 scored
6435 }
6436
6437 /// Read a single property from an edge.
6438 ///
6439 /// Returns `None` when the edge does not exist, the field is absent, or any
6440 /// of the string keys cannot be resolved to interned ids. Only edge props
6441 /// written by rules (weight fields) are accessible without a `set_edge_prop`
6442 /// binding; topology-only edges (no props set) return `None` for every field.
6443 pub fn get_edge_prop(
6444 &self,
6445 edge_type: &str,
6446 src_key: &str,
6447 dst_key: &str,
6448 field: &str,
6449 ) -> Option<Value> {
6450 let etype = self.syms.get(edge_type)?;
6451 let src = self.ids.get(src_key)?;
6452 let dst = self.ids.get(dst_key)?;
6453 self.edge_props_view().get(etype, src, dst, field)
6454 }
6455
6456 /// Lex → parse → plan → execute `cypher` over a read-only view.
6457 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6458 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6459 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6460 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6461 detail: format!("lex: {e}"),
6462 })?;
6463 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6464 detail: format!("parse: {e}"),
6465 })?;
6466 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6467 detail: format!("plan: {e}"),
6468 })?;
6469 execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
6470 detail: format!("execute: {e}"),
6471 })
6472 }
6473
6474 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6475 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
6476 /// calling [`GraphDb::query`].
6477 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6478 let map: BTreeMap<String, Value> = params
6479 .iter()
6480 .map(|(k, v)| (k.to_string(), v.clone()))
6481 .collect();
6482 self.query(cypher, &map)
6483 }
6484
6485 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6486 ///
6487 /// All mutations flow through the same `insert_node` / `set_prop` /
6488 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6489 /// fires and the WAL captures everything with one fsync per statement.
6490 ///
6491 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6492 /// and `deleted` matching the write-result contract.
6493 ///
6494 /// **Mutation routing**: mutations are collected into a single
6495 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6496 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6497 /// over `self.view()` — the borrow is dropped before the batch is opened.
6498 ///
6499 /// **Limitations (v1)**:
6500 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6501 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6502 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6503 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6504 /// - Deleting a derived edge → named error "cannot delete derived edge".
6505 pub fn query_write(
6506 &mut self,
6507 cypher: &str,
6508 params: &BTreeMap<String, Value>,
6509 ) -> Result<ResultSet> {
6510 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6511 detail: format!("lex: {e}"),
6512 })?;
6513 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6514 detail: format!("parse: {e}"),
6515 })?;
6516 self.exec_write_stmt(stmt, params)
6517 }
6518
6519 fn exec_write_stmt(
6520 &mut self,
6521 stmt: WriteStatement,
6522 params: &BTreeMap<String, Value>,
6523 ) -> Result<ResultSet> {
6524 match stmt {
6525 WriteStatement::Create(s) => self.exec_create(s, params),
6526 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
6527 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
6528 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
6529 WriteStatement::Merge(s) => self.exec_merge(s, params),
6530 }
6531 }
6532
6533 fn exec_create(
6534 &mut self,
6535 stmt: core_query::cypher::CreateStmt,
6536 params: &BTreeMap<String, Value>,
6537 ) -> Result<ResultSet> {
6538 // Extract the node key from props: require a string-valued `id` field.
6539 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
6540 for node in &stmt.nodes {
6541 let var = node.var.as_deref().unwrap_or("_cn0");
6542 let key = node
6543 .props
6544 .iter()
6545 .find(|(f, _)| f == "id")
6546 .and_then(|(_, v)| {
6547 if let Value::Str(s) = v {
6548 Some(s.clone())
6549 } else {
6550 None
6551 }
6552 })
6553 .ok_or_else(|| GraphError::QueryError {
6554 detail: format!(
6555 "CREATE node ({}:{}) requires a string 'id' property",
6556 var, node.label
6557 ),
6558 })?;
6559 var_to_key.insert(var.to_string(), key);
6560 }
6561
6562 let mut batch = self.batch();
6563 let mut created: usize = 0;
6564 for node in &stmt.nodes {
6565 let var = node.var.as_deref().unwrap_or("_cn0");
6566 let key = &var_to_key[var];
6567 batch.insert_node(&node.label, key, node.props.clone());
6568 created += 1;
6569 }
6570 for edge in &stmt.edges {
6571 let src_key = var_to_key
6572 .get(&edge.src_var)
6573 .ok_or_else(|| GraphError::QueryError {
6574 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
6575 })?;
6576 let dst_key = var_to_key
6577 .get(&edge.dst_var)
6578 .ok_or_else(|| GraphError::QueryError {
6579 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
6580 })?;
6581 batch.insert_edge(&edge.etype, src_key, dst_key);
6582 }
6583 batch.commit()?;
6584
6585 // Optional RETURN clause: project created bindings as a read result.
6586 if let Some(returns) = stmt.returns {
6587 // Each created node is looked up by its key via a separate MATCH pattern.
6588 // Multiple single-node patterns cross-join to produce 1 output row with
6589 // all variables bound (each pattern returns exactly 1 row).
6590 let patterns: Vec<Pattern> = stmt
6591 .nodes
6592 .iter()
6593 .map(|node| {
6594 let var = node.var.as_deref().unwrap_or("_cn0");
6595 let key = var_to_key[var].clone();
6596 Pattern {
6597 start: NodePat {
6598 var: Some(var.to_string()),
6599 label: Some(node.label.clone()),
6600 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
6601 },
6602 chain: vec![],
6603 shortest: false,
6604 }
6605 })
6606 .collect();
6607 let q = Query {
6608 matches: patterns,
6609 optional_clauses: vec![],
6610 where_expr: None,
6611 unwinds: vec![],
6612 post_unwind_where: None,
6613 stages: vec![],
6614 returns,
6615 distinct: false,
6616 order_by: vec![],
6617 skip: None,
6618 limit: None,
6619 };
6620 let ops = plan(&q).map_err(|e| GraphError::QueryError {
6621 detail: format!("plan: {e}"),
6622 })?;
6623 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
6624 GraphError::QueryError {
6625 detail: format!("execute: {e}"),
6626 }
6627 });
6628 }
6629
6630 let mut rs = write_result_set();
6631 rs.push_row(vec![
6632 Some(Value::Int(created as i64)),
6633 Some(Value::Int(0)),
6634 Some(Value::Int(0)),
6635 ]);
6636 Ok(rs)
6637 }
6638
6639 fn exec_match_set(
6640 &mut self,
6641 stmt: core_query::cypher::MatchSetStmt,
6642 params: &BTreeMap<String, Value>,
6643 ) -> Result<ResultSet> {
6644 let project_returns = stmt.returns.clone();
6645 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
6646 // so the post-write projection can look them up by key.
6647 let mut set_vars: Vec<String> = Vec::new();
6648 for s in &stmt.sets {
6649 if !set_vars.contains(&s.var) {
6650 set_vars.push(s.var.clone());
6651 }
6652 }
6653 let rel_vars = pattern_rel_vars(&stmt.matches);
6654 let mut lookup_vars = set_vars.clone();
6655 for v in pattern_node_vars(&stmt.matches) {
6656 add_var(&mut lookup_vars, &v);
6657 }
6658 if let Some(ref returns) = project_returns {
6659 for v in ret_node_vars(returns) {
6660 if !rel_vars.iter().any(|r| r == &v) {
6661 add_var(&mut lookup_vars, &v);
6662 }
6663 }
6664 }
6665
6666 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
6667 // SET values are projected as ScalarExpr items so that arithmetic expressions
6668 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
6669 let mut set_returns: Vec<RetItem> = lookup_vars
6670 .iter()
6671 .map(|v| RetItem {
6672 value: RetVal::Var(v.clone()),
6673 alias: None,
6674 })
6675 .collect();
6676 // One computed column per SET clause; alias is `__sv_<i>`.
6677 let set_val_cols: Vec<String> = stmt
6678 .sets
6679 .iter()
6680 .enumerate()
6681 .map(|(i, _)| format!("__sv_{i}"))
6682 .collect();
6683 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6684 set_returns.push(RetItem {
6685 value: RetVal::ScalarExpr(sc.value.clone()),
6686 alias: Some(col.clone()),
6687 });
6688 }
6689 // Capture relationship types while r is bound; SET does not change them.
6690 for r in &rel_vars {
6691 set_returns.push(RetItem {
6692 value: RetVal::FuncCall {
6693 name: "type".into(),
6694 args: vec![Operand::Var(r.clone())],
6695 },
6696 alias: Some(rel_type_alias(r)),
6697 });
6698 }
6699
6700 let read_q = Query {
6701 matches: stmt.matches.clone(),
6702 optional_clauses: vec![],
6703 where_expr: stmt.where_expr.clone(),
6704 unwinds: vec![],
6705 post_unwind_where: None,
6706 stages: vec![],
6707 returns: set_returns,
6708 distinct: false,
6709 order_by: vec![],
6710 skip: None,
6711 limit: None,
6712 };
6713 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6714 detail: format!("plan: {e}"),
6715 })?;
6716 // MATCH phase is read-only; borrow ends before batch opens.
6717 //
6718 // When a role-scoped write is in flight, run the MATCH read through
6719 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
6720 // zero-rows (no SetProp ops generated, no existence-oracle 403).
6721 // Full-authority writes (pending_write_authz=None) keep view().
6722 let match_rs = {
6723 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6724 if let Some(ref mask) = mask_opt {
6725 execute(&self.view_masked(mask), &ops, &Params(params))
6726 } else {
6727 execute(&self.view(), &ops, &Params(params))
6728 }
6729 }
6730 .map_err(|e| GraphError::QueryError {
6731 detail: format!("execute: {e}"),
6732 })?;
6733
6734 // Collect (key, field, value) for each matched row × each SET clause.
6735 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
6736 for row_i in 0..match_rs.len() {
6737 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6738 let key = match match_rs.get(row_i, &sc.var) {
6739 Some(Value::Str(k)) => k.clone(),
6740 _ => {
6741 return Err(GraphError::QueryError {
6742 detail: format!(
6743 "SET variable '{}' did not resolve to a node key",
6744 sc.var
6745 ),
6746 })
6747 }
6748 };
6749 // The SET value was already evaluated by the executor.
6750 let value = match match_rs.get(row_i, col) {
6751 Some(v) => v.clone(),
6752 None => {
6753 return Err(GraphError::QueryError {
6754 detail: format!(
6755 "SET value for {}.{} evaluated to null",
6756 sc.var, sc.field
6757 ),
6758 })
6759 }
6760 };
6761 set_ops.push((key, sc.field.clone(), value));
6762 }
6763 }
6764
6765 // Apply as one atomic batch.
6766 let props_set = set_ops.len();
6767 let mut batch = self.batch();
6768 for (key, field, value) in set_ops {
6769 batch.set_prop(&key, &field, value);
6770 }
6771 batch.commit()?;
6772
6773 if let Some(returns) = project_returns {
6774 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
6775 }
6776
6777 let mut rs = write_result_set();
6778 rs.push_row(vec![
6779 Some(Value::Int(0)),
6780 Some(Value::Int(props_set as i64)),
6781 Some(Value::Int(0)),
6782 ]);
6783 Ok(rs)
6784 }
6785
6786 fn exec_match_delete(
6787 &mut self,
6788 stmt: core_query::cypher::MatchDeleteStmt,
6789 params: &BTreeMap<String, Value>,
6790 ) -> Result<ResultSet> {
6791 // Collect unique node vars needed to identify edge endpoints.
6792 let mut node_vars: Vec<String> = Vec::new();
6793 for ed in &stmt.deletes {
6794 if !node_vars.contains(&ed.src_var) {
6795 node_vars.push(ed.src_var.clone());
6796 }
6797 if !node_vars.contains(&ed.dst_var) {
6798 node_vars.push(ed.dst_var.clone());
6799 }
6800 }
6801
6802 // Synthesize read query.
6803 let returns: Vec<RetItem> = node_vars
6804 .iter()
6805 .map(|v| RetItem {
6806 value: RetVal::Var(v.clone()),
6807 alias: None,
6808 })
6809 .collect();
6810 let read_q = Query {
6811 matches: stmt.matches,
6812 optional_clauses: vec![],
6813 where_expr: stmt.where_expr,
6814 unwinds: vec![],
6815 post_unwind_where: None,
6816 stages: vec![],
6817 returns,
6818 distinct: false,
6819 order_by: vec![],
6820 skip: None,
6821 limit: None,
6822 };
6823 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6824 detail: format!("plan: {e}"),
6825 })?;
6826 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
6827 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
6828 let match_rs = {
6829 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6830 if let Some(ref mask) = mask_opt {
6831 execute(&self.view_masked(mask), &ops, &Params(params))
6832 } else {
6833 execute(&self.view(), &ops, &Params(params))
6834 }
6835 }
6836 .map_err(|e| GraphError::QueryError {
6837 detail: format!("execute: {e}"),
6838 })?;
6839
6840 // Collect (etype, src_key, dst_key) for each row × each delete target.
6841 let mut del_ops: Vec<(String, String, String)> = Vec::new();
6842 for row_i in 0..match_rs.len() {
6843 for ed in &stmt.deletes {
6844 let src_key = match match_rs.get(row_i, &ed.src_var) {
6845 Some(Value::Str(k)) => k.clone(),
6846 _ => {
6847 return Err(GraphError::QueryError {
6848 detail: format!(
6849 "DELETE src variable '{}' did not resolve to a node key",
6850 ed.src_var
6851 ),
6852 })
6853 }
6854 };
6855 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
6856 Some(Value::Str(k)) => k.clone(),
6857 _ => {
6858 return Err(GraphError::QueryError {
6859 detail: format!(
6860 "DELETE dst variable '{}' did not resolve to a node key",
6861 ed.dst_var
6862 ),
6863 })
6864 }
6865 };
6866 del_ops.push((ed.etype.clone(), src_key, dst_key));
6867 }
6868 }
6869
6870 // Apply as one atomic batch.
6871 let deleted = del_ops.len();
6872 let mut batch = self.batch();
6873 for (etype, src_key, dst_key) in del_ops {
6874 batch.delete_edge(&etype, &src_key, &dst_key);
6875 }
6876 batch.commit().map_err(|e| match e {
6877 GraphError::RuleOwned { .. } => GraphError::QueryError {
6878 detail: "cannot delete derived edge; retract via the rule or change the property"
6879 .to_string(),
6880 },
6881 other => other,
6882 })?;
6883
6884 let mut rs = write_result_set();
6885 rs.push_row(vec![
6886 Some(Value::Int(0)),
6887 Some(Value::Int(0)),
6888 Some(Value::Int(deleted as i64)),
6889 ]);
6890 Ok(rs)
6891 }
6892
6893 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
6894 ///
6895 /// Collects the matching node keys via an ephemeral read query, then calls
6896 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
6897 /// the executor first checks that the node has no incident edges; if any
6898 /// remain it returns a named error matching openCypher semantics.
6899 fn exec_match_delete_node(
6900 &mut self,
6901 stmt: MatchDeleteNodeStmt,
6902 params: &BTreeMap<String, Value>,
6903 ) -> Result<ResultSet> {
6904 // Build a read query returning only the node keys we need.
6905 let returns: Vec<RetItem> = stmt
6906 .node_vars
6907 .iter()
6908 .map(|v| RetItem {
6909 value: RetVal::Var(v.clone()),
6910 alias: None,
6911 })
6912 .collect();
6913 let read_q = Query {
6914 matches: stmt.matches,
6915 optional_clauses: vec![],
6916 where_expr: stmt.where_expr,
6917 unwinds: vec![],
6918 post_unwind_where: None,
6919 stages: vec![],
6920 returns,
6921 distinct: false,
6922 order_by: vec![],
6923 skip: None,
6924 limit: None,
6925 };
6926 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6927 detail: format!("plan: {e}"),
6928 })?;
6929 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
6930 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
6931 let match_rs = {
6932 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6933 if let Some(ref mask) = mask_opt {
6934 execute(&self.view_masked(mask), &ops, &Params(params))
6935 } else {
6936 execute(&self.view(), &ops, &Params(params))
6937 }
6938 }
6939 .map_err(|e| GraphError::QueryError {
6940 detail: format!("execute: {e}"),
6941 })?;
6942
6943 // Collect unique node keys to delete (deduplicate across rows × vars).
6944 let mut keys: Vec<String> = Vec::new();
6945 for row_i in 0..match_rs.len() {
6946 for var in &stmt.node_vars {
6947 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
6948 if !keys.contains(k) {
6949 keys.push(k.clone());
6950 }
6951 }
6952 }
6953 }
6954
6955 if !stmt.detach {
6956 // openCypher bare DELETE: error if any matched node has incident edges.
6957 for key in &keys {
6958 if let Some(id) = self.ids.get(key) {
6959 let tv = self.topo_view();
6960 let has_edges = tv.etypes().any(|et| {
6961 !tv.neighbors(et, Direction::Out, id).is_empty()
6962 || !tv.neighbors(et, Direction::In, id).is_empty()
6963 });
6964 if has_edges {
6965 return Err(GraphError::QueryError {
6966 detail: format!(
6967 "Cannot delete node `{key}` because it still has incident edges. \
6968 Use DETACH DELETE to remove the node and all its edges."
6969 ),
6970 });
6971 }
6972 }
6973 }
6974 }
6975
6976 let mut nodes_deleted = 0i64;
6977 let mut edges_deleted = 0i64;
6978 for key in keys {
6979 match self.delete_node(&key) {
6980 Ok(report) => {
6981 nodes_deleted += 1;
6982 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
6983 }
6984 Err(GraphError::KeyNotFound { .. }) => {
6985 // Node may have been deleted by an earlier iteration (e.g., via
6986 // multiple MATCH rows for the same node). Safe to skip.
6987 }
6988 Err(e) => return Err(e),
6989 }
6990 }
6991
6992 let mut rs = write_result_set();
6993 rs.push_row(vec![
6994 Some(Value::Int(0)),
6995 Some(Value::Int(0)),
6996 Some(Value::Int(nodes_deleted + edges_deleted)),
6997 ]);
6998 Ok(rs)
6999 }
7000
7001 fn exec_merge(
7002 &mut self,
7003 stmt: core_query::cypher::MergeStmt,
7004 params: &BTreeMap<String, Value>,
7005 ) -> Result<ResultSet> {
7006 // MERGE: check if a node with the given key already exists.
7007 let key = match &stmt.key_value {
7008 Value::Str(s) => s.clone(),
7009 _ => {
7010 return Err(GraphError::QueryError {
7011 detail: format!(
7012 "MERGE key value must be a string (got {:?})",
7013 stmt.key_value
7014 ),
7015 })
7016 }
7017 };
7018
7019 if let Some(var) = stmt.var.as_deref() {
7020 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7021 if sc.var != var {
7022 return Err(GraphError::QueryError {
7023 detail: format!(
7024 "SET variable '{}' does not match MERGE variable '{var}'",
7025 sc.var
7026 ),
7027 });
7028 }
7029 }
7030 }
7031
7032 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7033 //
7034 // MERGE scope precondition: check create OR update scope for the
7035 // declared label BEFORE calling `has_node` (timing-oracle closure,
7036 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7037 // unscoped roles — the scope denial fires without touching the key store).
7038 //
7039 // Clone to avoid holding a borrow on `self.pending_write_authz` while
7040 // also calling `self.ids.get(key)`.
7041 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7042 let has_create = authz.scope.create_labels.contains(&stmt.label);
7043 let has_update = authz.scope.update_labels.contains(&stmt.label);
7044 if !has_create && !has_update {
7045 // Scope-before-lookup: 403 without has_node call (timing oracle
7046 // closure — see test_merge_unscoped_no_key_lookup).
7047 return Err(GraphError::RoleWriteDenied {
7048 reason: format!(
7049 "role-bound token: label '{}' not in write scope (create_labels)",
7050 stmt.label
7051 ),
7052 });
7053 }
7054 // Key lookup under mask.
7055 match self.ids.get(key.as_str()) {
7056 Some(id) if authz.mask.contains_id(id) => {
7057 // Visible: must have update scope to proceed to match arm.
7058 if !has_update {
7059 return Err(GraphError::RoleWriteDenied {
7060 reason: format!(
7061 "role-bound token: label '{}' not in write scope (update_labels)",
7062 stmt.label
7063 ),
7064 });
7065 }
7066 true // existed = true → match arm
7067 }
7068 Some(_) => {
7069 // Hidden: same error as absent to the role (spec §3.1/§3.3).
7070 return Err(GraphError::RoleWriteDenied {
7071 reason: "role-bound token: target node not visible".into(),
7072 });
7073 }
7074 None => {
7075 // Absent: must have create scope to proceed to the create arm.
7076 //
7077 // Update-only roles (create_labels empty, update_labels set):
7078 // return the SAME "not visible" error as the hidden-key branch
7079 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7080 // "confirm existence of hidden nodes: No").
7081 //
7082 // Create-scoped roles (has_create=true): absent → create arm
7083 // as before. The accepted structural key-existence disclosure
7084 // (§THREAT-MODEL) applies only when the role holds create scope.
7085 if !has_create {
7086 return Err(GraphError::RoleWriteDenied {
7087 reason: "role-bound token: target node not visible".into(),
7088 });
7089 }
7090 false // existed = false → create arm
7091 }
7092 }
7093 } else {
7094 // Full authority: use the existing non-masked has_node check.
7095 self.has_node(&key)
7096 };
7097
7098 let existed = merge_existed;
7099 let mut created = 0i64;
7100 if !existed || !stmt.on_match.is_empty() {
7101 let mut batch = self.batch();
7102 if !existed {
7103 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7104 batch.insert_node(&stmt.label, &key, props);
7105 for sc in &stmt.on_create {
7106 let value = resolve_merge_set_value(&sc.value, params)?;
7107 batch.set_prop(&key, &sc.field, value);
7108 }
7109 created = 1;
7110 } else {
7111 for sc in &stmt.on_match {
7112 let value = resolve_merge_set_value(&sc.value, params)?;
7113 batch.set_prop(&key, &sc.field, value);
7114 }
7115 }
7116 batch.commit()?;
7117 }
7118
7119 // Refresh the role mask so the just-created node is visible to this
7120 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7121 // (apply_schema subset rule), so the new node's label is already in the
7122 // role's read scope — this never widens beyond the role's declared labels.
7123 if !existed {
7124 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7125 let new_mask = self.mask_for_role(&role)?;
7126 if let Some(a) = self.pending_write_authz.as_mut() {
7127 a.mask = new_mask;
7128 }
7129 }
7130 }
7131
7132 // Optional RETURN clause: project the node (created or matched) as a read result.
7133 if let Some(returns) = stmt.returns {
7134 let var = stmt.var.as_deref().unwrap_or("_mn0");
7135 let q = Query {
7136 matches: vec![Pattern {
7137 start: NodePat {
7138 var: Some(var.to_string()),
7139 label: Some(stmt.label.clone()),
7140 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7141 },
7142 chain: vec![],
7143 shortest: false,
7144 }],
7145 optional_clauses: vec![],
7146 where_expr: None,
7147 unwinds: vec![],
7148 post_unwind_where: None,
7149 stages: vec![],
7150 returns,
7151 distinct: false,
7152 order_by: vec![],
7153 skip: None,
7154 limit: None,
7155 };
7156 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7157 detail: format!("plan: {e}"),
7158 })?;
7159 // Use view_masked when a role-scoped write is in flight so the
7160 // post-merge projection is consistent with the masked read phase.
7161 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7162 return (if let Some(ref mask) = mask_opt {
7163 execute(&self.view_masked(mask), &ops, &Params(params))
7164 } else {
7165 execute(&self.view(), &ops, &Params(params))
7166 })
7167 .map_err(|e| GraphError::QueryError {
7168 detail: format!("execute: {e}"),
7169 });
7170 }
7171
7172 let mut rs = write_result_set();
7173 rs.push_row(vec![
7174 Some(Value::Int(created)),
7175 Some(Value::Int(0)),
7176 Some(Value::Int(0)),
7177 ]);
7178 Ok(rs)
7179 }
7180
7181 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7182 /// annotated with rule name, edge type, direction, and weight.
7183 /// Results are sorted by (rule, edge_type).
7184 /// Returns `Err(KeyNotFound)` if either key is unknown.
7185 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7186 self.ensure_v8_base_sections_loaded();
7187 let id_a = self
7188 .ids
7189 .get(key_a)
7190 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7191 let id_b = self
7192 .ids
7193 .get(key_b)
7194 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7195
7196 let mut results = Vec::new();
7197
7198 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7199 // rather than O(total provenance).
7200 let scan = if self.engine.provenance_touching_len(id_a)
7201 <= self.engine.provenance_touching_len(id_b)
7202 {
7203 id_a
7204 } else {
7205 id_b
7206 };
7207 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7208 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7209 continue;
7210 }
7211 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7212 continue;
7213 };
7214 let edge_type = match self.syms.resolve(etype) {
7215 Some(s) => s.to_string(),
7216 None => continue,
7217 };
7218 // Provenance (src, dst) ids come from the archived PROVENANCE section
7219 // (large, no eager CRC). A corrupt section can produce ids that are
7220 // out of range; return Corrupt rather than panic.
7221 let src_key = self
7222 .ids
7223 .key_of(src)
7224 .ok_or_else(|| GraphError::Corrupt {
7225 detail: format!("v8: provenance src id {src} not in id table"),
7226 })?
7227 .to_string();
7228 let dst_key = self
7229 .ids
7230 .key_of(dst)
7231 .ok_or_else(|| GraphError::Corrupt {
7232 detail: format!("v8: provenance dst id {dst} not in id table"),
7233 })?
7234 .to_string();
7235 let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7236 self.edge_props_view()
7237 .get(etype, src, dst, prop)
7238 .and_then(|v| {
7239 if let Value::Float(f) = v {
7240 Some(f)
7241 } else {
7242 None
7243 }
7244 })
7245 });
7246 results.push(Explanation {
7247 rule: rule_name.to_string(),
7248 edge_type,
7249 src_key,
7250 dst_key,
7251 weight,
7252 predicate: PredicateSummary {
7253 approximate: rule_def.approximate,
7254 ..PredicateSummary::from(&rule_def.predicate)
7255 },
7256 });
7257 }
7258
7259 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7260 Ok(results)
7261 }
7262
7263 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7264 let id = self
7265 .ids
7266 .get(key)
7267 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7268 let Some(sym) = self.syms.get(edge_type) else {
7269 return Ok(Vec::new());
7270 };
7271 self.topo_view()
7272 .neighbors(sym, dir, id)
7273 .iter()
7274 .map(|&n| {
7275 self.ids
7276 .key_of(n)
7277 .map(|k| k.to_string())
7278 .ok_or_else(|| GraphError::Corrupt {
7279 detail: format!("topology id {n} has no key"),
7280 })
7281 })
7282 .collect::<Result<Vec<_>>>()
7283 }
7284
7285 /// Return the last-change commit sequence for `key`, or `None` if the node
7286 /// does not exist or has never been mutated since the last V5-V7 snapshot
7287 /// (horizon-bounded for legacy stores).
7288 ///
7289 /// The returned sequence is a monotonically increasing counter that starts
7290 /// at 1 for the first commit after `open` and increments with every
7291 /// successful write. WAL replay at open also assigns sequences (1..N for N
7292 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7293 ///
7294 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7295 /// in the snapshot but not touched by any WAL frame will return `None`
7296 /// (horizon-bounded: CAS against such nodes is only safe after the first
7297 /// V8 snapshot or after the node is next mutated).
7298 pub fn last_changed(&self, key: &str) -> Option<u64> {
7299 let id = self.ids.get(key)?;
7300 self.last_change.get(&id).copied()
7301 }
7302
7303 /// The current commit sequence (number of successful commits since open,
7304 /// including WAL replay frames). Useful for recording a baseline before
7305 /// a read-modify-write cycle.
7306 pub fn commit_seq(&self) -> u64 {
7307 self.commit_seq
7308 }
7309
7310 /// Check that all `preconds` are satisfied against the current db state.
7311 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7312 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7313 for precond in preconds {
7314 match precond {
7315 Precondition::NodeUnchangedSince { key, expected } => {
7316 // Missing entry means the node predates the WAL window or
7317 // does not exist; treat as 0 (before any commit).
7318 let actual = self.last_changed(key).unwrap_or_default();
7319 if actual != *expected {
7320 return Err(GraphError::CasConflict {
7321 key: key.clone(),
7322 expected: *expected,
7323 actual,
7324 });
7325 }
7326 }
7327 Precondition::NodeAbsent { key } => {
7328 // Node must not exist (not live).
7329 if self.ids.get(key).is_some() {
7330 let actual = self.last_changed(key).unwrap_or(0);
7331 return Err(GraphError::CasConflict {
7332 key: key.clone(),
7333 expected: u64::MAX,
7334 actual,
7335 });
7336 }
7337 }
7338 }
7339 }
7340 Ok(())
7341 }
7342
7343 /// Apply a batch of mutations with compare-and-set preconditions.
7344 ///
7345 /// All preconditions are checked atomically before any operation is applied.
7346 /// If any precondition fails, the entire batch is rejected with
7347 /// [`GraphError::CasConflict`] and no WAL frame is written.
7348 ///
7349 /// # Returns
7350 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7351 ///
7352 /// # Errors
7353 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7354 /// - Any error that [`write_batch`] would return for the ops themselves.
7355 pub fn write_batch_cas(
7356 &mut self,
7357 preconds: Vec<Precondition>,
7358 ops: Vec<BatchOp>,
7359 ) -> Result<(usize, usize)> {
7360 self.check_preconditions(&preconds)?;
7361 self.commit_logged_batch(ops, None, None)
7362 }
7363
7364 /// Update the per-node last-change map for a WAL record at commit `seq`.
7365 ///
7366 /// Called after a successful apply to record which nodes were touched.
7367 /// For replay, called with the WAL-frame's replayed seq.
7368 ///
7369 /// Touch definition (see [`Precondition`] doc):
7370 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7371 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7372 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7373 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7374 /// - Batch → recurse into inner records.
7375 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7376 match rec {
7377 WalRecord::InsertNode { key, .. }
7378 | WalRecord::SetProp { key, .. }
7379 | WalRecord::RemoveProp { key, .. } => {
7380 if let Some(id) = self.ids.get(key) {
7381 self.last_change.insert(id, seq);
7382 }
7383 }
7384 WalRecord::InsertNodeId { key, .. } => {
7385 if let Some(id) = self.ids.get(key) {
7386 self.last_change.insert(id, seq);
7387 }
7388 }
7389 WalRecord::SetPropId { id, .. } => {
7390 self.last_change.insert(*id, seq);
7391 }
7392 WalRecord::InsertEdge {
7393 src_key, dst_key, ..
7394 }
7395 | WalRecord::DeleteEdge {
7396 src_key, dst_key, ..
7397 } => {
7398 if let Some(src_id) = self.ids.get(src_key) {
7399 self.last_change.insert(src_id, seq);
7400 }
7401 if let Some(dst_id) = self.ids.get(dst_key) {
7402 self.last_change.insert(dst_id, seq);
7403 }
7404 }
7405 WalRecord::InsertEdgeId { src, dst, .. } => {
7406 self.last_change.insert(*src, seq);
7407 self.last_change.insert(*dst, seq);
7408 }
7409 // DeleteNode: node is tombstoned; last_changed(key) returns None for
7410 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7411 // History markers: state no-ops; the underlying mutation already
7412 // touched the relevant nodes' last_change entries.
7413 WalRecord::DeleteNode { .. }
7414 | WalRecord::DerivedEdgeAdded { .. }
7415 | WalRecord::DerivedEdgeRetracted { .. }
7416 | WalRecord::Intern { .. }
7417 | WalRecord::CreateRule { .. }
7418 | WalRecord::DeleteRule { .. }
7419 | WalRecord::RebuildRule { .. }
7420 | WalRecord::CreateView { .. }
7421 | WalRecord::DeleteView { .. }
7422 | WalRecord::EnableFulltext { .. }
7423 | WalRecord::DisableFulltext { .. } => {}
7424 // RenameNode: node id is stable; update last_change via the new key.
7425 // Called after apply(), so ids already reflects new_key.
7426 WalRecord::RenameNode { new_key, .. } => {
7427 if let Some(id) = self.ids.get(new_key) {
7428 self.last_change.insert(id, seq);
7429 }
7430 }
7431 WalRecord::Batch(inner) => {
7432 for inner_rec in inner {
7433 self.update_last_change_from_rec(inner_rec, seq);
7434 }
7435 }
7436 }
7437 }
7438
7439 pub fn node_count(&self) -> usize {
7440 self.ids.len()
7441 }
7442
7443 /// Configure archive retention: keep the `N` newest WAL archives at each
7444 /// [`snapshot_with`] call when `archive_wal: true`.
7445 ///
7446 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7447 /// `Some(0)` or `None` → unlimited (no pruning).
7448 ///
7449 /// Pruning only ever happens inside [`snapshot_with`]; this method only
7450 /// stores the policy. Archives below the retention limit are deleted
7451 /// oldest-first. The horizon floor is updated so that
7452 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7453 /// in pruned archives rather than silently returning wrong data.
7454 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7455 self.wal_archive_retention = keep;
7456 }
7457
7458 /// Delete any WAL archives that are fully below the current horizon floor.
7459 ///
7460 /// Orphaned archives arise when the floor is written first during retention
7461 /// pruning and then a crash interrupts the archive-delete sequence. The
7462 /// opening cleanup ensures no subsequent read path sees stale data.
7463 ///
7464 /// Under the monotonic naming scheme, the archive name N equals the
7465 /// cumulative end-frame index of the archive in global commit space (i.e.
7466 /// the archive covers global frames `[prev_n, N)`). An archive is
7467 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7468 /// below the floor and have already been counted in it.
7469 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7470 if self.wal_horizon_floor == 0 {
7471 // Floor at 0 means no pruning has ever occurred; nothing to clean.
7472 return Ok(());
7473 }
7474 let archive_ns = self.fs.list_archives()?;
7475 for n in archive_ns {
7476 if n <= self.wal_horizon_floor {
7477 // Archive N ends at global frame N; all its frames are below
7478 // the floor (floor already accounts for them) → orphaned.
7479 self.fs.delete_archive(n).map_err(GraphError::Io)?;
7480 } else {
7481 // Archives are sorted ascending; first one above floor stops scan.
7482 break;
7483 }
7484 }
7485 Ok(())
7486 }
7487
7488 /// Collect all WAL frames from surviving archives (oldest-first) then the
7489 /// live WAL into one flat list, and return the total along with the number
7490 /// of archive frames at the front of the list.
7491 ///
7492 /// Commit indices into the returned list are LOCAL (0 = first frame of
7493 /// oldest surviving archive). To obtain the GLOBAL index add
7494 /// `self.wal_horizon_floor`.
7495 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7496 let archive_ns = self.fs.list_archives()?;
7497 let mut all: Vec<WalRecord> = Vec::new();
7498 for n in archive_ns {
7499 let bytes = self.fs.read_archive(n)?;
7500 let (frames, _) = decode_all(&bytes);
7501 all.extend(frames);
7502 }
7503 let archive_count = all.len() as u64;
7504 let live_bytes = self.fs.read(FileId::Wal)?;
7505 let (live_frames, _) = decode_all(&live_bytes);
7506 all.extend(live_frames);
7507 Ok((all, archive_count))
7508 }
7509
7510 /// Return the total number of committed WAL frames visible in the current
7511 /// horizon window, including frames in surviving WAL archives.
7512 ///
7513 /// This is the exclusive upper bound for valid `at_commit` indices in
7514 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7515 ///
7516 /// Returns the horizon floor when all surviving history is empty.
7517 pub fn wal_total_commits(&self) -> Result<u64> {
7518 let (frames, _) = self.all_frames()?;
7519 Ok(self.wal_horizon_floor + frames.len() as u64)
7520 }
7521
7522 /// The global frame index of the first commit reachable through surviving
7523 /// archives (0 when no archives have been pruned).
7524 pub fn wal_horizon_floor(&self) -> u64 {
7525 self.wal_horizon_floor
7526 }
7527
7528 /// Return the per-node change history for `key` by scanning the on-disk WAL.
7529 ///
7530 /// ## Horizon
7531 ///
7532 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
7533 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
7534 /// zero-cost contract; a durable history log is out of scope.
7535 ///
7536 /// ## Derived edges
7537 ///
7538 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
7539 /// history. Only edges written directly by the application are recorded.
7540 ///
7541 /// ## Deleted nodes
7542 ///
7543 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
7544 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
7545 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
7546 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
7547 ///
7548 /// ## Dense-id edge entries and tombstoned partners
7549 ///
7550 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
7551 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
7552 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
7553 /// Build commit-bounded alias intervals for `queried_key`.
7554 ///
7555 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
7556 /// A record written under `key` at commit `c` matches the queried identity iff
7557 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
7558 ///
7559 /// Each alias entry carries both a lower and an upper bound so that key-reuse
7560 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
7561 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
7562 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
7563 /// only identity-2's events (commits 7–9 under "a") are in scope.
7564 ///
7565 /// Only **forward aliasing**: querying the *new* key surfaces events written
7566 /// under the *old* key. The reverse direction is not supported.
7567 fn build_key_alias_intervals(
7568 &self,
7569 frames: &[core_storage::wal::WalRecord],
7570 queried_key: &str,
7571 ) -> Vec<(String, u64, Option<u64>)> {
7572 use core_storage::wal::WalRecord;
7573
7574 // Pre-pass: build reverse_rename and key_starts maps.
7575 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
7576 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
7577
7578 for (local_i, frame) in frames.iter().enumerate() {
7579 let commit = self.wal_horizon_floor + local_i as u64;
7580 let records: &[WalRecord] = match frame {
7581 WalRecord::Batch(inner) => inner.as_slice(),
7582 single => std::slice::from_ref(single),
7583 };
7584 for rec in records {
7585 match rec {
7586 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
7587 key_starts.entry(key.clone()).or_default().push(commit);
7588 }
7589 WalRecord::RenameNode { old_key, new_key } => {
7590 // new_key came into existence at this commit.
7591 key_starts.entry(new_key.clone()).or_default().push(commit);
7592 // Record the reverse rename: new_key was introduced by renaming old_key.
7593 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
7594 }
7595 _ => {}
7596 }
7597 }
7598 }
7599
7600 // Build alias intervals by following the reverse rename chain.
7601 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
7602 let mut current_key = queried_key.to_string();
7603 let mut current_valid_until: Option<u64> = None;
7604
7605 loop {
7606 // valid_from: the most recent commit where current_key was assigned to this
7607 // identity. For aliases (valid_until = Some(vu)), find the last start event
7608 // for the key strictly before vu — this is where the alias's occupancy by
7609 // this identity began, correctly excluding prior identities that reused the key.
7610 let valid_from = if let Some(vu) = current_valid_until {
7611 key_starts
7612 .get(¤t_key)
7613 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
7614 .unwrap_or(self.wal_horizon_floor)
7615 } else {
7616 // Queried key — no upper bound; may have been introduced at any commit.
7617 self.wal_horizon_floor
7618 };
7619
7620 result.push((current_key.clone(), valid_from, current_valid_until));
7621
7622 match reverse_rename.get(¤t_key) {
7623 Some((old_key, rename_commit)) => {
7624 current_valid_until = Some(*rename_commit);
7625 current_key = old_key.clone();
7626 }
7627 None => break,
7628 }
7629 }
7630
7631 result
7632 }
7633
7634 /// Returns true if `record_key` matches any alias interval that covers `commit`.
7635 fn aliases_match(
7636 intervals: &[(String, u64, Option<u64>)],
7637 record_key: &str,
7638 commit: u64,
7639 ) -> bool {
7640 intervals
7641 .iter()
7642 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
7643 }
7644
7645 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
7646 use crate::history::{HistoryChange, HistoryEntry};
7647 use core_storage::wal::WalRecord;
7648
7649 let (frames, _) = self.all_frames()?;
7650
7651 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
7652 let alias_intervals = self.build_key_alias_intervals(&frames, key);
7653
7654 let mut out: Vec<HistoryEntry> = Vec::new();
7655
7656 for (local_i, frame) in frames.iter().enumerate() {
7657 let commit = self.wal_horizon_floor + local_i as u64;
7658 // Collect the inner records to process — Batch is one commit, single records are one commit.
7659 let records: &[WalRecord] = match frame {
7660 WalRecord::Batch(inner) => inner.as_slice(),
7661 single => std::slice::from_ref(single),
7662 };
7663
7664 for rec in records {
7665 let change = match rec {
7666 WalRecord::InsertNode { label, key: k, .. }
7667 if Self::aliases_match(&alias_intervals, k, commit) =>
7668 {
7669 Some(HistoryChange::NodeInserted {
7670 label: label.clone(),
7671 })
7672 }
7673 WalRecord::InsertNodeId { label, key: k, .. }
7674 if Self::aliases_match(&alias_intervals, k, commit) =>
7675 {
7676 let label_str = match self.syms.resolve(*label) {
7677 Some(s) => s.to_string(),
7678 None => continue,
7679 };
7680 Some(HistoryChange::NodeInserted { label: label_str })
7681 }
7682 WalRecord::SetProp {
7683 key: k,
7684 field,
7685 value,
7686 } if Self::aliases_match(&alias_intervals, k, commit) => {
7687 Some(HistoryChange::PropSet {
7688 field: field.clone(),
7689 value: value.clone(),
7690 })
7691 }
7692 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
7693 // key_of returns the current (post-rename) key; compare to queried key.
7694 Some(resolved) if resolved == key => {
7695 let field_str = match self.syms.resolve(*field) {
7696 Some(s) => s.to_string(),
7697 None => continue,
7698 };
7699 Some(HistoryChange::PropSet {
7700 field: field_str,
7701 value: value.clone(),
7702 })
7703 }
7704 _ => None,
7705 },
7706 WalRecord::RemoveProp { key: k, field }
7707 if Self::aliases_match(&alias_intervals, k, commit) =>
7708 {
7709 Some(HistoryChange::PropRemoved {
7710 field: field.clone(),
7711 })
7712 }
7713 WalRecord::InsertEdge {
7714 edge_type,
7715 src_key,
7716 dst_key,
7717 } => {
7718 if Self::aliases_match(&alias_intervals, src_key, commit) {
7719 Some(HistoryChange::EdgeAdded {
7720 edge_type: edge_type.clone(),
7721 other: dst_key.clone(),
7722 outgoing: true,
7723 })
7724 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7725 Some(HistoryChange::EdgeAdded {
7726 edge_type: edge_type.clone(),
7727 other: src_key.clone(),
7728 outgoing: false,
7729 })
7730 } else {
7731 None
7732 }
7733 }
7734 WalRecord::InsertEdgeId { etype, src, dst } => {
7735 let etype_str = match self.syms.resolve(*etype) {
7736 Some(s) => s.to_string(),
7737 None => continue,
7738 };
7739 let src_key = self.ids.key_of(*src);
7740 let dst_key = self.ids.key_of(*dst);
7741 if src_key == Some(key) {
7742 let other = match dst_key {
7743 Some(s) => s.to_string(),
7744 None => continue,
7745 };
7746 Some(HistoryChange::EdgeAdded {
7747 edge_type: etype_str,
7748 other,
7749 outgoing: true,
7750 })
7751 } else if dst_key == Some(key) {
7752 let other = match src_key {
7753 Some(s) => s.to_string(),
7754 None => continue,
7755 };
7756 Some(HistoryChange::EdgeAdded {
7757 edge_type: etype_str,
7758 other,
7759 outgoing: false,
7760 })
7761 } else {
7762 None
7763 }
7764 }
7765 WalRecord::DeleteEdge {
7766 edge_type,
7767 src_key,
7768 dst_key,
7769 } => {
7770 if Self::aliases_match(&alias_intervals, src_key, commit) {
7771 Some(HistoryChange::EdgeRemoved {
7772 edge_type: edge_type.clone(),
7773 other: dst_key.clone(),
7774 outgoing: true,
7775 })
7776 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7777 Some(HistoryChange::EdgeRemoved {
7778 edge_type: edge_type.clone(),
7779 other: src_key.clone(),
7780 outgoing: false,
7781 })
7782 } else {
7783 None
7784 }
7785 }
7786 WalRecord::DeleteNode { key: k }
7787 if Self::aliases_match(&alias_intervals, k, commit) =>
7788 {
7789 Some(HistoryChange::NodeDeleted)
7790 }
7791 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
7792 _ => None,
7793 };
7794
7795 if let Some(change) = change {
7796 out.push(HistoryEntry { commit, change });
7797 }
7798 }
7799 }
7800
7801 Ok(out)
7802 }
7803
7804 /// Return the per-edge change history between nodes `a` and `b` by scanning
7805 /// the on-disk WAL.
7806 ///
7807 /// ## Horizon
7808 ///
7809 /// History reaches back only to the last WAL-truncating snapshot, exactly
7810 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
7811 /// `total_commits` (= number of WAL frames), which is the exclusive upper
7812 /// bound for valid commit indices.
7813 ///
7814 /// ## Derived edges
7815 ///
7816 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
7817 /// WAL markers written by `log_then_apply_with` after each rule-firing
7818 /// mutation. The `rule` field of those events carries the rule name.
7819 ///
7820 /// ## DeleteNode
7821 ///
7822 /// When a node is deleted, its manual incident edges are swept inline without
7823 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
7824 /// events for either endpoint and synthesises `Retracted(rule:None)` events
7825 /// for each manual edge that was active at that point. Derived edges active at
7826 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
7827 /// the engine appends immediately after the `DeleteNode` record; those events
7828 /// carry correct rule attribution and are emitted by the marker arm, not the
7829 /// synthetic sweep.
7830 ///
7831 /// ## Masks
7832 ///
7833 /// Like `node_history`, this method has no mask parameter and returns WAL
7834 /// history regardless of any role mask. For masked history semantics, apply
7835 /// the mask at the caller level.
7836 pub fn edge_history(
7837 &self,
7838 a: &str,
7839 b: &str,
7840 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
7841 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
7842 use core_storage::wal::WalRecord;
7843
7844 let (frames, _) = self.all_frames()?;
7845 let total_commits = self.wal_horizon_floor + frames.len() as u64;
7846
7847 // Resolve all historical names for a and b (handles RenameNode in the WAL).
7848 // Intervals are commit-bounded so recycled keys don't contaminate histories.
7849 let alias_a = self.build_key_alias_intervals(&frames, a);
7850 let alias_b = self.build_key_alias_intervals(&frames, b);
7851
7852 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
7853 // The is_derived flag is used by the DeleteNode sweep: manual edges are
7854 // swept with a synthetic Retracted(rule:None); derived edges are skipped
7855 // because the engine writes a DerivedEdgeRetracted marker immediately after
7856 // the DeleteNode record, which carries the correct rule attribution.
7857 let mut active: Vec<(String, String, String, bool)> = Vec::new();
7858 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
7859
7860 for (local_i, frame) in frames.iter().enumerate() {
7861 let commit = self.wal_horizon_floor + local_i as u64;
7862 let records: &[WalRecord] = match frame {
7863 WalRecord::Batch(inner) => inner.as_slice(),
7864 single => std::slice::from_ref(single),
7865 };
7866
7867 for rec in records {
7868 match rec {
7869 WalRecord::InsertEdge {
7870 edge_type,
7871 src_key,
7872 dst_key,
7873 } => {
7874 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7875 && Self::aliases_match(&alias_b, dst_key, commit);
7876 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7877 && Self::aliases_match(&alias_a, dst_key, commit);
7878 if is_ab || is_ba {
7879 active.push((
7880 edge_type.clone(),
7881 src_key.clone(),
7882 dst_key.clone(),
7883 false,
7884 ));
7885 out.push(EdgeHistoryEvent {
7886 edge_type: edge_type.clone(),
7887 commit,
7888 event: EdgeEvent::Added,
7889 rule: None,
7890 });
7891 }
7892 }
7893 WalRecord::InsertEdgeId { etype, src, dst } => {
7894 let etype_str = match self.syms.resolve(*etype) {
7895 Some(s) => s.to_string(),
7896 None => continue,
7897 };
7898 // Use key_of_historical so tombstoned nodes (deleted
7899 // later in the WAL) still resolve during the scan.
7900 let src_key = self.ids.key_of_historical(*src);
7901 let dst_key = self.ids.key_of_historical(*dst);
7902 let is_ab = src_key == Some(a) && dst_key == Some(b);
7903 let is_ba = src_key == Some(b) && dst_key == Some(a);
7904 if is_ab || is_ba {
7905 let src_str = src_key.unwrap().to_string();
7906 let dst_str = dst_key.unwrap().to_string();
7907 active.push((etype_str.clone(), src_str, dst_str, false));
7908 out.push(EdgeHistoryEvent {
7909 edge_type: etype_str,
7910 commit,
7911 event: EdgeEvent::Added,
7912 rule: None,
7913 });
7914 }
7915 }
7916 WalRecord::DeleteEdge {
7917 edge_type,
7918 src_key,
7919 dst_key,
7920 } => {
7921 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7922 && Self::aliases_match(&alias_b, dst_key, commit);
7923 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7924 && Self::aliases_match(&alias_a, dst_key, commit);
7925 if is_ab || is_ba {
7926 // Remove the first matching active entry (flag ignored).
7927 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
7928 et == edge_type && s == src_key && d == dst_key
7929 }) {
7930 active.remove(pos);
7931 }
7932 out.push(EdgeHistoryEvent {
7933 edge_type: edge_type.clone(),
7934 commit,
7935 event: EdgeEvent::Retracted,
7936 rule: None,
7937 });
7938 }
7939 }
7940 WalRecord::DeleteNode { key: k }
7941 if Self::aliases_match(&alias_a, k, commit)
7942 || Self::aliases_match(&alias_b, k, commit) =>
7943 {
7944 // Sweep: implicitly retract only MANUAL active edges.
7945 // Derived active edges are skipped here because the rule
7946 // engine appends a DerivedEdgeRetracted marker immediately
7947 // after this DeleteNode record; that marker produces the
7948 // single correctly-attributed Retracted event. Derived
7949 // entries are dropped from `active` (the marker arm's
7950 // idempotent retain finds nothing to remove).
7951 for (et, _, _, is_derived) in active.drain(..) {
7952 if !is_derived {
7953 out.push(EdgeHistoryEvent {
7954 edge_type: et,
7955 commit,
7956 event: EdgeEvent::Retracted,
7957 rule: None,
7958 });
7959 }
7960 // Derived: drop silently; marker carries the Retracted event.
7961 }
7962 }
7963 WalRecord::DerivedEdgeAdded {
7964 rule,
7965 edge_type: et,
7966 src_key,
7967 dst_key,
7968 } => {
7969 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7970 && Self::aliases_match(&alias_b, dst_key, commit);
7971 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7972 && Self::aliases_match(&alias_a, dst_key, commit);
7973 if is_ab || is_ba {
7974 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
7975 out.push(EdgeHistoryEvent {
7976 edge_type: et.clone(),
7977 commit,
7978 event: EdgeEvent::Added,
7979 rule: Some(rule.clone()),
7980 });
7981 }
7982 }
7983 WalRecord::DerivedEdgeRetracted {
7984 rule,
7985 edge_type: et,
7986 src_key,
7987 dst_key,
7988 } => {
7989 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7990 && Self::aliases_match(&alias_b, dst_key, commit);
7991 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7992 && Self::aliases_match(&alias_a, dst_key, commit);
7993 if is_ab || is_ba {
7994 // Push unconditionally: a derived edge whose Added marker
7995 // predates the history horizon has no `active` entry, but
7996 // the retraction is still a real in-window event.
7997 // Remove from active idempotently if present.
7998 active.retain(|(aet, s, d, _)| {
7999 !(aet == et && s == src_key && d == dst_key)
8000 });
8001 out.push(EdgeHistoryEvent {
8002 edge_type: et.clone(),
8003 commit,
8004 event: EdgeEvent::Retracted,
8005 rule: Some(rule.clone()),
8006 });
8007 }
8008 }
8009 // All other records (InsertNode, SetProp, CreateRule, etc.)
8010 // do not affect edges between a and b.
8011 _ => {}
8012 }
8013 }
8014 }
8015
8016 Ok(HistoryResult {
8017 items: out,
8018 total_commits,
8019 })
8020 }
8021
8022 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8023 /// (in either direction) at the WAL commit `at_commit`.
8024 ///
8025 /// ## Horizon
8026 ///
8027 /// Valid commit indices are `0..total_commits` where `total_commits` is the
8028 /// number of WAL frames. An `at_commit >= total_commits` is outside the
8029 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8030 ///
8031 /// ## Derived edges
8032 ///
8033 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8034 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8035 /// and therefore includes derived edges in its point-in-time evaluation,
8036 /// matching `edge_history`'s fidelity.
8037 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8038 use core_storage::wal::WalRecord;
8039
8040 let (frames, _) = self.all_frames()?;
8041 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8042
8043 // Horizon floor: commits in pruned archives are unreachable.
8044 if at_commit < self.wal_horizon_floor {
8045 return Err(GraphError::CommitOutOfRange {
8046 commit: at_commit,
8047 total: total_commits,
8048 });
8049 }
8050 if at_commit >= total_commits {
8051 return Err(GraphError::CommitOutOfRange {
8052 commit: at_commit,
8053 total: total_commits,
8054 });
8055 }
8056
8057 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8058 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8059 let alias_a = self.build_key_alias_intervals(&frames, a);
8060 let alias_b = self.build_key_alias_intervals(&frames, b);
8061
8062 // Local index into surviving frames (0 = first frame of oldest archive).
8063 let local_commit = at_commit - self.wal_horizon_floor;
8064
8065 // Replay local frames 0..=local_commit, tracking active edges.
8066 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8067
8068 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8069 let commit = self.wal_horizon_floor + local_i as u64;
8070 let records: &[WalRecord] = match frame {
8071 WalRecord::Batch(inner) => inner.as_slice(),
8072 single => std::slice::from_ref(single),
8073 };
8074
8075 for rec in records {
8076 match rec {
8077 WalRecord::InsertEdge {
8078 edge_type: et,
8079 src_key,
8080 dst_key,
8081 } => {
8082 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8083 && Self::aliases_match(&alias_b, dst_key, commit);
8084 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8085 && Self::aliases_match(&alias_a, dst_key, commit);
8086 if is_ab || is_ba {
8087 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8088 }
8089 }
8090 WalRecord::InsertEdgeId { etype, src, dst } => {
8091 let etype_str = match self.syms.resolve(*etype) {
8092 Some(s) => s.to_string(),
8093 None => continue,
8094 };
8095 // Use key_of_historical so tombstoned nodes resolve.
8096 let src_key = self.ids.key_of_historical(*src);
8097 let dst_key = self.ids.key_of_historical(*dst);
8098 let is_ab = src_key == Some(a) && dst_key == Some(b);
8099 let is_ba = src_key == Some(b) && dst_key == Some(a);
8100 if is_ab || is_ba {
8101 active.insert((
8102 etype_str,
8103 src_key.unwrap().to_string(),
8104 dst_key.unwrap().to_string(),
8105 ));
8106 }
8107 }
8108 WalRecord::DeleteEdge {
8109 edge_type: et,
8110 src_key,
8111 dst_key,
8112 } => {
8113 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8114 && Self::aliases_match(&alias_b, dst_key, commit);
8115 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8116 && Self::aliases_match(&alias_a, dst_key, commit);
8117 if is_ab || is_ba {
8118 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8119 }
8120 }
8121 WalRecord::DeleteNode { key: k }
8122 if Self::aliases_match(&alias_a, k, commit)
8123 || Self::aliases_match(&alias_b, k, commit) =>
8124 {
8125 // All edges touching the deleted node are gone.
8126 active.retain(|(_, s, d)| s != k && d != k);
8127 }
8128 WalRecord::DerivedEdgeAdded {
8129 edge_type: et,
8130 src_key,
8131 dst_key,
8132 ..
8133 } => {
8134 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8135 && Self::aliases_match(&alias_b, dst_key, commit);
8136 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8137 && Self::aliases_match(&alias_a, dst_key, commit);
8138 if is_ab || is_ba {
8139 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8140 }
8141 }
8142 WalRecord::DerivedEdgeRetracted {
8143 edge_type: et,
8144 src_key,
8145 dst_key,
8146 ..
8147 } => {
8148 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8149 && Self::aliases_match(&alias_b, dst_key, commit);
8150 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8151 && Self::aliases_match(&alias_a, dst_key, commit);
8152 if is_ab || is_ba {
8153 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8154 }
8155 }
8156 _ => {}
8157 }
8158 }
8159 }
8160
8161 Ok(active.iter().any(|(et, _, _)| et == edge_type))
8162 }
8163
8164 pub fn edge_count(&self) -> u64 {
8165 self.topo_view().edge_count()
8166 }
8167
8168 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8169 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8170 pub fn stats(&self) -> Stats {
8171 self.ensure_v8_base_sections_loaded();
8172 let rules: Vec<RuleStats> = self
8173 .engine
8174 .rules()
8175 .map(|r| RuleStats {
8176 name: r.name.clone(),
8177 edges: self
8178 .engine
8179 .provenance()
8180 .get(&r.name)
8181 .map(|s| s.len() as u64)
8182 .unwrap_or(0),
8183 tripped: self.engine.is_tripped(&r.name),
8184 fires: self.engine.fire_count(&r.name),
8185 approximate: r.approximate,
8186 })
8187 .collect();
8188 Stats {
8189 nodes_live: self.ids.live_len(),
8190 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8191 edges: self.topo_view().edge_count(),
8192 rules,
8193 }
8194 }
8195
8196 /// On-disk snapshot format version this binary writes and reads.
8197 pub fn format_version() -> u16 {
8198 core_storage::snapshot::VERSION
8199 }
8200
8201 /// Test-support: total bytes appended (SimFs only usage).
8202 pub fn fs_total_appended(&self) -> usize
8203 where
8204 F: FsIntrospect,
8205 {
8206 self.fs.total_appended()
8207 }
8208
8209 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8210 pub fn fs_sync_count(&self) -> usize
8211 where
8212 F: FsIntrospect,
8213 {
8214 self.fs.sync_count()
8215 }
8216
8217 /// Consume the db, returning its fs (for crash simulation).
8218 pub fn into_fs(self) -> F {
8219 self.fs
8220 }
8221
8222 pub fn snapshot(&mut self) -> Result<()> {
8223 self.snapshot_with(SnapshotOptions::default())
8224 }
8225
8226 /// Snapshot with explicit options.
8227 ///
8228 /// # `keep_wal`
8229 ///
8230 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8231 /// - The WAL is replaced with a minimal baseline containing one
8232 /// `EnableFulltext` record per active declaration. All pre-snapshot
8233 /// history is discarded; `open_at` can only reach post-snapshot commits.
8234 ///
8235 /// When `keep_wal` is `true`:
8236 /// - The WAL is left intact. All pre-snapshot commits remain reachable
8237 /// via `open_at`. The existing WAL already contains the original
8238 /// `EnableFulltext` records, so no baseline re-write is needed; the
8239 /// recovery guards in `apply()` silently skip any duplicate records on
8240 /// replay.
8241 /// - Crash window: a crash after the snapshot write but before the next
8242 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
8243 /// snapshot is loaded and the WAL replayed idempotently over it — safe
8244 /// because every `apply()` arm is idempotent when replayed over an
8245 /// already-current snapshot.
8246 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8247 if self.read_only {
8248 return Err(GraphError::ReadOnly);
8249 }
8250 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8251 // Used by the archive path's conservative genesis-chain check: if a prior
8252 // snapshot exists but wal.truncated does not, we cannot distinguish a
8253 // legacy store (may have been truncated in an older code version) from a
8254 // new store that only used keep_wal=true. Conservative: refuse genesis in
8255 // both cases. Must be sampled here, before the snapshot write below.
8256 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8257 self.ensure_v8_base_sections_loaded();
8258 // Ensure provenance is decoded before to_persist() clones it.
8259 self.engine.ensure_provenance_loaded_mut();
8260 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8261 let rule_defs = rule_defs_typed
8262 .iter()
8263 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8264 .collect();
8265 // Collect HNSW state and IVF state. When indexes are not yet
8266 // populated (clean open, no mutation since open), pass the retained
8267 // raw bytes through directly so that migrate/snapshot does not
8268 // silently discard fitted approximate-rule indexes.
8269 let hnsw_state = self.engine.export_hnsw_state_passthrough();
8270 let ivf_bytes = if !self.engine.indexes_populated() {
8271 // Pass retained IVF bytes through unchanged (no re-encode).
8272 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8273 } else {
8274 // Indexes live: encode from current state.
8275 let raw_ivf = self.engine.export_ivf_state();
8276 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8277 .into_iter()
8278 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8279 (
8280 name,
8281 core_storage::snapshot::PerRuleIvfState {
8282 src: core_storage::snapshot::SideIvfState {
8283 centroids: sc,
8284 clusters: sa,
8285 drift: sd,
8286 },
8287 dst: core_storage::snapshot::SideIvfState {
8288 centroids: dc,
8289 clusters: da,
8290 drift: dd,
8291 },
8292 },
8293 )
8294 })
8295 .collect();
8296 if ivf_state_map.is_empty() {
8297 Vec::new()
8298 } else {
8299 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8300 }
8301 };
8302 let view_defs: Vec<Vec<u8>> = self
8303 .view_store
8304 .views()
8305 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8306 .collect();
8307 if self.base.is_some() {
8308 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8309 // write it atomically, remap it as the new base, then clear the overlay.
8310 let meta = V8Meta {
8311 labels: self.labels.clone(),
8312 edge_props: self.edge_props.clone(),
8313 rule_defs,
8314 provenance,
8315 rule_tripped,
8316 rule_fires,
8317 ivf_bytes,
8318 view_defs,
8319 wal_truncated: !opts.keep_wal,
8320 hnsw: hnsw_state,
8321 last_change: self.last_change.clone(),
8322 };
8323 let mut buf: Vec<u8> = Vec::new();
8324 {
8325 // Clone the Arc so the old base stays alive while we encode.
8326 // The borrow of archived_csr (into old_base's mmap) is released
8327 // at the end of this block, before we replace self.base.
8328 let old_base = self.base.clone().expect("is_some checked above");
8329 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8330 detail: format!("v8 snapshot: topology section: {e:?}"),
8331 })?;
8332 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8333 detail: format!("v8 snapshot: columns section: {e:?}"),
8334 })?;
8335 let archived_edge_props =
8336 old_base
8337 .edge_props_section()
8338 .map_err(|e| GraphError::Corrupt {
8339 detail: format!("v8 snapshot: edge_props section: {e:?}"),
8340 })?;
8341 let edge_props_raw =
8342 old_base
8343 .edge_props_raw_bytes()
8344 .map_err(|e| GraphError::Corrupt {
8345 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8346 })?;
8347 let prov_raw =
8348 old_base
8349 .provenance_raw_bytes()
8350 .map_err(|e| GraphError::Corrupt {
8351 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8352 })?;
8353 encode_v8(
8354 Some(archived_csr),
8355 Some(archived_cols),
8356 Some((archived_edge_props, edge_props_raw)),
8357 Some(prov_raw),
8358 &self.topo,
8359 &self.props,
8360 &self.ids,
8361 &self.syms,
8362 &meta,
8363 &mut buf,
8364 )?;
8365 }
8366 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8367 // Remap the freshly-written snapshot as the new base.
8368 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8369 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8370 core_storage::v8::MappedBase::map(&snap_path)
8371 } else {
8372 core_storage::v8::MappedBase::from_bytes(buf)
8373 }
8374 .map_err(|e| GraphError::Corrupt {
8375 detail: format!("v8 snapshot: remap new base: {e:?}"),
8376 })?;
8377 self.base = Some(Arc::new(new_base));
8378 // Clear the overlay and prop tombstones — all data is now in the new base.
8379 self.topo = Topology::new();
8380 self.props = core_storage::columns::ColumnStore::new();
8381 } else {
8382 // Legacy path (V5–V7 stores without a V8 base).
8383 //
8384 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8385 // clone and no encode_v8_from_state intermediate clones. The big
8386 // structures (self.topo, self.props) are borrowed, not cloned.
8387 // self.edge_props is moved (not cloned) because we immediately clear it
8388 // when we remap the new V8 snapshot as self.base (see below).
8389 //
8390 // Eliminates from peak RSS vs. the old SnapshotState path:
8391 // • self.topo.clone() (~topology HashMap footprint)
8392 // • self.props.clone() (~column-store footprint)
8393 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8394 let meta = V8Meta {
8395 labels: self.labels.clone(),
8396 wal_truncated: !opts.keep_wal,
8397 // Move edge_props out so the large overlay is freed when meta
8398 // drops at end of this block (self.edge_props is now empty; reads
8399 // after base assignment go through the mmap'd base section).
8400 edge_props: std::mem::take(&mut self.edge_props),
8401 rule_defs,
8402 provenance,
8403 rule_tripped,
8404 rule_fires,
8405 ivf_bytes,
8406 view_defs,
8407 hnsw: hnsw_state,
8408 last_change: self.last_change.clone(),
8409 };
8410 let mut buf = Vec::new();
8411 encode_v8(
8412 None,
8413 None,
8414 None,
8415 None,
8416 &self.topo,
8417 &self.props,
8418 &self.ids,
8419 &self.syms,
8420 &meta,
8421 &mut buf,
8422 )?;
8423 // meta (and the moved edge_props inside it) is no longer needed;
8424 // drop it before the write to keep the peak window narrow.
8425 drop(meta);
8426 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8427 // Remap the freshly-written V8 snapshot as self.base.
8428 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8429 // On SimFs (tests): pass buf to from_bytes.
8430 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8431 drop(buf);
8432 core_storage::v8::MappedBase::map(&snap_path)
8433 } else {
8434 core_storage::v8::MappedBase::from_bytes(buf)
8435 }
8436 .map_err(|e| GraphError::Corrupt {
8437 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8438 })?;
8439 self.base = Some(Arc::new(new_base));
8440 // Free the large heap-allocated decoded state — all data is now in the
8441 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
8442 // self.edge_props was already moved into meta and is effectively empty.
8443 self.topo = Topology::new();
8444 self.props = core_storage::columns::ColumnStore::new();
8445 }
8446
8447 if opts.archive_wal {
8448 // History-preserving snapshot (Task 4):
8449 // 1. Snapshot already written above (write_atomic → fsynced).
8450 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
8451 // Crash window B: crash here leaves archive present, WAL
8452 // absent. Reopen: snapshot loaded (full state), no WAL
8453 // replay. Archive is NOT replayed into live state — it is
8454 // pre-snapshot by construction. Safe.
8455 // 3. Optionally write genesis marker (first archive only, no
8456 // prior WAL truncation).
8457 // 4. Prune old archives (retention), update horizon floor.
8458 // Pruning invalidates the genesis chain; delete marker.
8459 // 5. Write new minimal baseline WAL (write_atomic).
8460 // Crash window C: crash here leaves new archive plus no live
8461 // WAL. Same as window B — handled above.
8462 //
8463 // Sample existing archives BEFORE the rename so we can detect
8464 // whether this is the first archive.
8465 let existing_archives = self.fs.list_archives()?;
8466 let is_first_archive = existing_archives.is_empty();
8467
8468 // Compute a globally-monotonic archive name: the name equals the
8469 // cumulative end-frame index of the archive in global commit space.
8470 //
8471 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8472 // commit_seq is seeded from max(last_change), which underestimates
8473 // the WAL depth when trailing commits (e.g. insert_edge) do not
8474 // update last_change. A session-2 archive could then receive a name
8475 // ≤ the session-1 archive, causing incorrect sort order or collision.
8476 //
8477 // Instead: read and decode the live WAL here (before the rename) to
8478 // get its exact frame count, then add it to the last known global
8479 // end-frame index (the name of the most recent existing archive, or
8480 // wal_horizon_floor if no archives exist). This is O(WAL size) but
8481 // snapshot is already serialising the full graph state, so the cost
8482 // is dominated.
8483 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
8484 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
8485 let archive_n = existing_archives
8486 .last()
8487 .copied()
8488 .unwrap_or(self.wal_horizon_floor)
8489 + live_frames_for_name.len() as u64;
8490 self.fs.archive_wal(archive_n)?;
8491
8492 // Genesis marker: written once when the first archive is taken
8493 // from a store that has never undergone a WAL-truncating snapshot.
8494 // When present, `open_at` may replay archive-resident commits from
8495 // empty state (the archive chain covers from global index 0).
8496 //
8497 // Two conditions must ALL hold:
8498 // 1. This is the first archive (existing_archives was empty).
8499 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
8500 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
8501 // before truncating the WAL, so if any prior truncating snapshot was taken
8502 // — even in a previous session — snapshot.bin is present and this condition
8503 // is false. This subsumes the cross-session truncation case without
8504 // requiring a separate wal.truncated sidecar file.
8505 // For legacy stores (snapshot.bin written by an older code version that
8506 // may have truncated the WAL), the same conservative refusal applies:
8507 // we cannot prove the chain is complete, so we refuse genesis (cost =
8508 // no as-of-through-archives; never silent wrong data).
8509 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
8510 // so SimFs always passes this check.
8511 if is_first_archive && !had_prior_snapshot {
8512 self.fs.write_genesis_marker()?;
8513 self.archive_genesis_chain = true;
8514 }
8515
8516 // Retention pruning: keep newest `keep` archives; delete oldest.
8517 // Pruning is the ONLY deletion site for archives.
8518 //
8519 // Crash-safety ordering (C1 fix):
8520 // 1. Count frames in surplus archives (reads only — no mutation).
8521 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
8522 // rename (atomic). A crash after this point leaves orphaned
8523 // archives on disk, but the floor is correct. The opening
8524 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
8525 // the next open, so the store is always safe to reopen.
8526 // 3. Delete the genesis marker (floor > 0 already blocks open_at
8527 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
8528 // 4. Delete surplus archives. A crash between any two deletes
8529 // leaves the floor committed and orphaned archives cleaned at
8530 // next open — never a stale floor with a missing archive prefix.
8531 if let Some(keep) = self.wal_archive_retention {
8532 if keep > 0 {
8533 let archives = self.fs.list_archives()?;
8534 // archives is sorted ascending (oldest first)
8535 if archives.len() as u32 > keep {
8536 let surplus = archives.len() - keep as usize;
8537 // Step 1: count pruned frames (reads, no mutation).
8538 let mut pruned_frames = 0u64;
8539 for &n in &archives[..surplus] {
8540 let bytes = self.fs.read_archive(n)?;
8541 let (frames, _) = decode_all(&bytes);
8542 pruned_frames += frames.len() as u64;
8543 }
8544 // Step 2: advance and persist floor FIRST.
8545 self.wal_horizon_floor += pruned_frames;
8546 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
8547 // Step 3: delete genesis marker (floor > 0 already
8548 // blocks open_at; this is belt-and-suspenders cleanup).
8549 if pruned_frames > 0 && self.archive_genesis_chain {
8550 self.fs.delete_genesis_marker()?;
8551 self.archive_genesis_chain = false;
8552 }
8553 // Step 4: delete surplus archives. Crash here →
8554 // orphaned archives; cleaned at next open.
8555 for &n in &archives[..surplus] {
8556 self.fs.delete_archive(n)?;
8557 }
8558 }
8559 }
8560 }
8561
8562 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
8563 let mut baseline_wal: Vec<u8> = Vec::new();
8564 for (label, field) in self.fulltext.enabled_pairs() {
8565 let rec = WalRecord::EnableFulltext {
8566 label: label.clone(),
8567 field: field.clone(),
8568 };
8569 baseline_wal.extend_from_slice(&encode_record(&rec));
8570 }
8571 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8572 } else if opts.keep_wal {
8573 // keep_wal=true: WAL is left untouched. The existing WAL already
8574 // contains the EnableFulltext records from the original enable calls;
8575 // replay is idempotent (guards in apply() skip already-live entries).
8576 // No baseline re-write is needed or safe here — the full WAL history
8577 // must remain intact for open_at to reach pre-snapshot commits.
8578 } else {
8579 // keep_wal=false (default): truncate by replacing the WAL with a
8580 // minimal baseline of one EnableFulltext record per active pair.
8581 //
8582 // Crash-ordering: write_atomic is atomic.
8583 // • Crash before snapshot write → WAL unchanged. Safe.
8584 // • Crash after snapshot write but before this WAL write → full
8585 // pre-snapshot WAL still present; open_with replays idempotently.
8586 // • Crash after both writes → normal post-snapshot state.
8587 //
8588 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
8589 // for any archives taken AFTER this point (their WAL slices would
8590 // not start at genesis). Delete any existing genesis marker so that
8591 // open_at refuses archive-resident commits. Future sessions are
8592 // covered by had_prior_snapshot: snapshot.bin written here persists
8593 // across sessions and prevents a later archiving session from
8594 // incorrectly claiming a complete genesis chain.
8595 if self.archive_genesis_chain {
8596 self.fs.delete_genesis_marker()?;
8597 self.archive_genesis_chain = false;
8598 }
8599 let mut baseline_wal: Vec<u8> = Vec::new();
8600 for (label, field) in self.fulltext.enabled_pairs() {
8601 let rec = WalRecord::EnableFulltext {
8602 label: label.clone(),
8603 field: field.clone(),
8604 };
8605 baseline_wal.extend_from_slice(&encode_record(&rec));
8606 }
8607 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8608 }
8609 // After snapshot the overlay may have changed (V8 merge path clears
8610 // self.topo and self.props). Refresh the MVCC fold so future readers
8611 // see the post-snapshot state rather than stale overlay data.
8612 self.fold_now();
8613 Ok(())
8614 }
8615}
8616
8617/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
8618///
8619/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
8620/// callers can build a set of mutations without holding `&mut GraphDb` and
8621/// hand them off to the group-committing writer for durable, batched I/O.
8622pub enum BatchOp {
8623 InsertNode {
8624 label: String,
8625 key: String,
8626 props: Vec<(String, Value)>,
8627 },
8628 InsertEdge {
8629 edge_type: String,
8630 src_key: String,
8631 dst_key: String,
8632 },
8633 SetProp {
8634 key: String,
8635 field: String,
8636 value: Value,
8637 },
8638 RemoveProp {
8639 key: String,
8640 field: String,
8641 },
8642 DeleteEdge {
8643 edge_type: String,
8644 src_key: String,
8645 dst_key: String,
8646 },
8647 DeleteNode {
8648 key: String,
8649 },
8650 CreateRule(RuleDef),
8651 DeleteRule {
8652 name: String,
8653 },
8654 /// Rename a node's key. Validated: old must exist, new must not.
8655 RenameNode {
8656 old_key: String,
8657 new_key: String,
8658 },
8659 /// Insert an edge, auto-creating any missing endpoint as a plain node with
8660 /// `placeholder_label` and no props. Rules fire and last-change is updated
8661 /// for each created endpoint (normal InsertNode semantics in the batch frame).
8662 InsertEdgeUpsert {
8663 edge_type: String,
8664 src_key: String,
8665 dst_key: String,
8666 placeholder_label: String,
8667 },
8668}
8669
8670/// Three-way node visibility status used by `check_single_op_authz`.
8671enum NodeAuthzStatus {
8672 /// Node exists in the store and is in the role's read mask.
8673 Visible(String), // carries the node's label
8674 /// Node exists in the store but is NOT in the role's read mask.
8675 Hidden,
8676 /// Node does not exist in the store.
8677 Absent,
8678}
8679
8680/// Overlay of ops already accepted earlier in the same batch. Never written
8681/// back to the database — validation only.
8682#[derive(Default)]
8683struct Overlay {
8684 extra_keys: BTreeSet<String>,
8685 deleted_keys: BTreeSet<String>,
8686 extra_props: BTreeMap<(String, String), Value>,
8687 removed_props: BTreeSet<(String, String)>,
8688 extra_edges: BTreeSet<(String, String, String)>,
8689 deleted_edges: BTreeSet<(String, String, String)>,
8690 extra_rules: BTreeSet<String>,
8691 deleted_rules: BTreeSet<String>,
8692}
8693
8694/// Read-only view of live db state plus a batch overlay. Shared by single-op
8695/// public methods (empty overlay) and `commit_batch`.
8696struct MutPreview<'a, F: Fs> {
8697 db: &'a GraphDb<F>,
8698 overlay: Overlay,
8699}
8700
8701impl<'a, F: Fs> MutPreview<'a, F> {
8702 fn new(db: &'a GraphDb<F>) -> Self {
8703 Self {
8704 db,
8705 overlay: Overlay::default(),
8706 }
8707 }
8708
8709 fn has_key(&self, key: &str) -> bool {
8710 if self.overlay.extra_keys.contains(key) {
8711 return true;
8712 }
8713 if self.overlay.deleted_keys.contains(key) {
8714 return false;
8715 }
8716 self.db.ids.get(key).is_some()
8717 }
8718
8719 fn has_prop(&self, key: &str, field: &str) -> bool {
8720 if !self.has_key(key) {
8721 return false;
8722 }
8723 let k = (key.to_string(), field.to_string());
8724 if self.overlay.removed_props.contains(&k) {
8725 return false;
8726 }
8727 if self.overlay.extra_props.contains_key(&k) {
8728 return true;
8729 }
8730 // Fresh identity (first insert in this batch, or delete+reinsert):
8731 // ignore props still sitting on the soon-to-be-tombstoned slot.
8732 if self.overlay.extra_keys.contains(key) {
8733 return false;
8734 }
8735 self.db.get_prop(key, field).is_some()
8736 }
8737
8738 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8739 let k = (
8740 edge_type.to_string(),
8741 src_key.to_string(),
8742 dst_key.to_string(),
8743 );
8744 if self.overlay.deleted_edges.contains(&k) {
8745 return false;
8746 }
8747 if self.overlay.extra_edges.contains(&k) {
8748 return true;
8749 }
8750 // A key created in this batch (including reinsert) has no db edges.
8751 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8752 return false;
8753 }
8754 if self.overlay.deleted_keys.contains(src_key)
8755 || self.overlay.deleted_keys.contains(dst_key)
8756 {
8757 return false;
8758 }
8759 let Some(src) = self.db.ids.get(src_key) else {
8760 return false;
8761 };
8762 let Some(dst) = self.db.ids.get(dst_key) else {
8763 return false;
8764 };
8765 let Some(sym) = self.db.syms.get(edge_type) else {
8766 return false;
8767 };
8768 self.db
8769 .topo_view()
8770 .neighbors(sym, Direction::Out, src)
8771 .binary_search(&dst)
8772 .is_ok()
8773 }
8774
8775 fn has_rule(&self, name: &str) -> bool {
8776 if self.overlay.extra_rules.contains(name) {
8777 return true;
8778 }
8779 if self.overlay.deleted_rules.contains(name) {
8780 return false;
8781 }
8782 self.db.engine.rules().any(|r| r.name == name)
8783 }
8784
8785 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8786 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8787 return false;
8788 }
8789 if self.overlay.deleted_keys.contains(src_key)
8790 || self.overlay.deleted_keys.contains(dst_key)
8791 {
8792 return false;
8793 }
8794 let Some(src) = self.db.ids.get(src_key) else {
8795 return false;
8796 };
8797 let Some(dst) = self.db.ids.get(dst_key) else {
8798 return false;
8799 };
8800 let Some(et) = self.db.syms.get(edge_type) else {
8801 return false;
8802 };
8803 // extra_rules is deliberately not consulted: a CreateRule earlier in
8804 // this batch has not fired, so it contributes no provenance. That is
8805 // the documented rule-window gap (see GraphDb::batch).
8806 if self.overlay.deleted_rules.is_empty() {
8807 return self.db.engine.is_owned(et, src, dst);
8808 }
8809 for (rule, triples) in self.db.engine.provenance() {
8810 if self.overlay.deleted_rules.contains(rule) {
8811 continue;
8812 }
8813 if triples.contains(&(et, src, dst)) {
8814 return true;
8815 }
8816 }
8817 false
8818 }
8819
8820 fn check_insert_node(&self, key: &str) -> Result<()> {
8821 if self.has_key(key) {
8822 Err(GraphError::DuplicateKey { key: key.into() })
8823 } else {
8824 Ok(())
8825 }
8826 }
8827
8828 fn check_live_key(&self, key: &str) -> Result<()> {
8829 if self.has_key(key) {
8830 Ok(())
8831 } else {
8832 Err(GraphError::KeyNotFound { key: key.into() })
8833 }
8834 }
8835
8836 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8837 for k in [src_key, dst_key] {
8838 if !self.has_key(k) {
8839 return Err(GraphError::KeyNotFound { key: k.into() });
8840 }
8841 }
8842 if self.is_rule_owned(edge_type, src_key, dst_key) {
8843 return Err(GraphError::RuleOwned {
8844 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
8845 });
8846 }
8847 Ok(!self.has_edge(edge_type, src_key, dst_key))
8848 }
8849
8850 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
8851 self.check_live_key(key)?;
8852 Ok(self.has_prop(key, field))
8853 }
8854
8855 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8856 for k in [src_key, dst_key] {
8857 if !self.has_key(k) {
8858 return Err(GraphError::KeyNotFound { key: k.into() });
8859 }
8860 }
8861 // Provenance-owned OR a live rule would derive this pair. User-first
8862 // edges that a later rule matches are not in `owned`, but deleting
8863 // them would leave a hole `rebuild_rule` immediately fills.
8864 if self.is_rule_owned(edge_type, src_key, dst_key) {
8865 return Err(GraphError::RuleOwned {
8866 detail: format!(
8867 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8868 delete or change the owning rule"
8869 ),
8870 });
8871 }
8872 if self.would_derive(edge_type, src_key, dst_key) {
8873 return Err(GraphError::RuleOwned {
8874 detail: format!(
8875 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8876 delete or change the owning rule, or a live rule would re-derive it"
8877 ),
8878 });
8879 }
8880 Ok(self.has_edge(edge_type, src_key, dst_key))
8881 }
8882
8883 /// True if any live rule (minus overlay-deleted names) would derive
8884 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
8885 /// CreateRule names in `extra_rules` are ignored — same documented
8886 /// same-batch rule-window as [`Self::is_rule_owned`].
8887 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8888 if src_key == dst_key {
8889 return false;
8890 }
8891 let Some(src_label) = self.label_of(src_key) else {
8892 return false;
8893 };
8894 let Some(dst_label) = self.label_of(dst_key) else {
8895 return false;
8896 };
8897 for rule in self.db.engine.rules() {
8898 if self.overlay.deleted_rules.contains(&rule.name) {
8899 continue;
8900 }
8901 if rule.edge_type != edge_type {
8902 continue;
8903 }
8904 if rule.src_label != src_label || rule.dst_label != dst_label {
8905 continue;
8906 }
8907 let src_props = |f: &str| self.prop_value(src_key, f);
8908 let dst_props = |f: &str| self.prop_value(dst_key, f);
8909 let src_view = NodeView {
8910 key: src_key,
8911 props: &src_props,
8912 };
8913 let dst_view = NodeView {
8914 key: dst_key,
8915 props: &dst_props,
8916 };
8917 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
8918 return true;
8919 }
8920 }
8921 false
8922 }
8923
8924 fn label_of(&self, key: &str) -> Option<String> {
8925 if self.overlay.deleted_keys.contains(key) {
8926 return None;
8927 }
8928 // Fresh identities created in this batch have no stored label in the
8929 // overlay; they cannot be provenance-owned yet either.
8930 let id = self.db.ids.get(key)?;
8931 let sym = self.db.labels.get(id as usize).copied()?;
8932 if sym == u32::MAX {
8933 return None;
8934 }
8935 self.db.syms.resolve(sym).map(str::to_string)
8936 }
8937
8938 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
8939 if !self.has_key(key) {
8940 return None;
8941 }
8942 let k = (key.to_string(), field.to_string());
8943 if self.overlay.removed_props.contains(&k) {
8944 return None;
8945 }
8946 if let Some(v) = self.overlay.extra_props.get(&k) {
8947 return Some(v.clone());
8948 }
8949 if self.overlay.extra_keys.contains(key) {
8950 return None;
8951 }
8952 self.db.get_prop(key, field)
8953 }
8954
8955 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
8956 def.validate()
8957 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
8958 if self.has_rule(&def.name) {
8959 return Err(GraphError::RuleInvalid {
8960 detail: format!("rule {:?} already exists", def.name),
8961 });
8962 }
8963 Ok(())
8964 }
8965
8966 fn check_delete_rule(&self, name: &str) -> Result<()> {
8967 if self.has_rule(name) {
8968 Ok(())
8969 } else {
8970 Err(GraphError::RuleNotFound { name: name.into() })
8971 }
8972 }
8973
8974 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
8975 self.overlay.deleted_keys.remove(key);
8976 self.overlay.extra_keys.insert(key.to_string());
8977 self.overlay.extra_props.retain(|(k, _), _| k != key);
8978 self.overlay.removed_props.retain(|(k, _)| k != key);
8979 for (field, value) in props {
8980 self.overlay
8981 .extra_props
8982 .insert((key.to_string(), field.clone()), value.clone());
8983 }
8984 }
8985
8986 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
8987 let k = (
8988 edge_type.to_string(),
8989 src_key.to_string(),
8990 dst_key.to_string(),
8991 );
8992 self.overlay.deleted_edges.remove(&k);
8993 self.overlay.extra_edges.insert(k);
8994 }
8995
8996 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
8997 let k = (key.to_string(), field.to_string());
8998 self.overlay.removed_props.remove(&k);
8999 self.overlay.extra_props.insert(k, value.clone());
9000 }
9001
9002 fn note_remove_prop(&mut self, key: &str, field: &str) {
9003 let k = (key.to_string(), field.to_string());
9004 self.overlay.extra_props.remove(&k);
9005 self.overlay.removed_props.insert(k);
9006 }
9007
9008 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9009 let k = (
9010 edge_type.to_string(),
9011 src_key.to_string(),
9012 dst_key.to_string(),
9013 );
9014 self.overlay.extra_edges.remove(&k);
9015 self.overlay.deleted_edges.insert(k);
9016 }
9017
9018 fn note_delete_node(&mut self, key: &str) {
9019 self.overlay.extra_keys.remove(key);
9020 self.overlay.deleted_keys.insert(key.to_string());
9021 self.overlay.extra_props.retain(|(k, _), _| k != key);
9022 self.overlay.removed_props.retain(|(k, _)| k != key);
9023 self.overlay
9024 .extra_edges
9025 .retain(|(_, s, d)| s != key && d != key);
9026 self.overlay
9027 .deleted_edges
9028 .retain(|(_, s, d)| s != key && d != key);
9029 }
9030
9031 fn note_create_rule(&mut self, name: &str) {
9032 self.overlay.deleted_rules.remove(name);
9033 self.overlay.extra_rules.insert(name.to_string());
9034 }
9035
9036 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9037 if !self.has_key(old) {
9038 return Err(GraphError::KeyNotFound { key: old.into() });
9039 }
9040 if self.has_key(new) {
9041 return Err(GraphError::DuplicateKey { key: new.into() });
9042 }
9043 Ok(())
9044 }
9045
9046 fn note_rename_node(&mut self, old: &str, new: &str) {
9047 // Mark old as deleted so subsequent batch ops cannot reference it.
9048 self.overlay.extra_keys.remove(old);
9049 self.overlay.deleted_keys.insert(old.to_string());
9050 // Mark new as extra so subsequent batch ops can reference it.
9051 self.overlay.deleted_keys.remove(new);
9052 self.overlay.extra_keys.insert(new.to_string());
9053 // Migrate any overlay props from old key to new key.
9054 let new_str = new.to_string();
9055 let transferred: Vec<((String, String), Value)> = self
9056 .overlay
9057 .extra_props
9058 .iter()
9059 .filter(|((k, _), _)| k.as_str() == old)
9060 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9061 .collect();
9062 self.overlay
9063 .extra_props
9064 .retain(|(k, _), _| k.as_str() != old);
9065 for (k, v) in transferred {
9066 self.overlay.extra_props.insert(k, v);
9067 }
9068 // Migrate removed_props.
9069 let transferred_removed: Vec<(String, String)> = self
9070 .overlay
9071 .removed_props
9072 .iter()
9073 .filter(|(k, _)| k.as_str() == old)
9074 .map(|(_, f)| (new_str.clone(), f.clone()))
9075 .collect();
9076 self.overlay
9077 .removed_props
9078 .retain(|(k, _)| k.as_str() != old);
9079 for k in transferred_removed {
9080 self.overlay.removed_props.insert(k);
9081 }
9082 }
9083
9084 fn note_delete_rule(&mut self, name: &str) {
9085 self.overlay.extra_rules.remove(name);
9086 self.overlay.deleted_rules.insert(name.to_string());
9087 // Treat the deleted rule's current provenance as gone so a later
9088 // delete_edge of those triples is a no-op (matches sequential).
9089 if let Some(triples) = self.db.engine.provenance().get(name) {
9090 for &(et, s, d) in triples {
9091 let Some(etype) = self.db.syms.resolve(et) else {
9092 continue;
9093 };
9094 let Some(src) = self.db.ids.key_of(s) else {
9095 continue;
9096 };
9097 let Some(dst) = self.db.ids.key_of(d) else {
9098 continue;
9099 };
9100 let k = (etype.to_string(), src.to_string(), dst.to_string());
9101 self.overlay.extra_edges.remove(&k);
9102 self.overlay.deleted_edges.insert(k);
9103 }
9104 }
9105 }
9106}
9107
9108/// Collects mutations and commits them as one WAL `Batch` frame.
9109///
9110/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9111/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9112/// See [`GraphDb::batch`] for validation and atomicity rules.
9113pub struct BatchBuilder<'a, F: Fs> {
9114 db: &'a mut GraphDb<F>,
9115 ops: Vec<BatchOp>,
9116}
9117
9118impl<'a, F: Fs> BatchBuilder<'a, F> {
9119 pub fn insert_node(
9120 &mut self,
9121 label: &str,
9122 key: &str,
9123 props: Vec<(String, Value)>,
9124 ) -> &mut Self {
9125 self.ops.push(BatchOp::InsertNode {
9126 label: label.into(),
9127 key: key.into(),
9128 props,
9129 });
9130 self
9131 }
9132
9133 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9134 self.ops.push(BatchOp::InsertEdge {
9135 edge_type: edge_type.into(),
9136 src_key: src_key.into(),
9137 dst_key: dst_key.into(),
9138 });
9139 self
9140 }
9141
9142 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9143 self.ops.push(BatchOp::SetProp {
9144 key: key.into(),
9145 field: field.into(),
9146 value,
9147 });
9148 self
9149 }
9150
9151 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9152 self.ops.push(BatchOp::RemoveProp {
9153 key: key.into(),
9154 field: field.into(),
9155 });
9156 self
9157 }
9158
9159 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9160 self.ops.push(BatchOp::DeleteEdge {
9161 edge_type: edge_type.into(),
9162 src_key: src_key.into(),
9163 dst_key: dst_key.into(),
9164 });
9165 self
9166 }
9167
9168 pub fn delete_node(&mut self, key: &str) -> &mut Self {
9169 self.ops.push(BatchOp::DeleteNode { key: key.into() });
9170 self
9171 }
9172
9173 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9174 self.ops.push(BatchOp::CreateRule(def));
9175 self
9176 }
9177
9178 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9179 self.ops.push(BatchOp::DeleteRule { name: name.into() });
9180 self
9181 }
9182
9183 /// Queue a node-rename in this batch.
9184 ///
9185 /// Validation (old exists, new not taken) runs at commit time.
9186 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9187 self.ops.push(BatchOp::RenameNode {
9188 old_key: old_key.into(),
9189 new_key: new_key.into(),
9190 });
9191 self
9192 }
9193
9194 /// Queue an edge insert with endpoint auto-creation.
9195 ///
9196 /// Any missing endpoint is created as a plain node `{key, label:
9197 /// placeholder_label, no props}` inside this batch frame. Rules fire and
9198 /// last-change is updated for each auto-created node.
9199 pub fn insert_edge_upsert(
9200 &mut self,
9201 edge_type: &str,
9202 src_key: &str,
9203 dst_key: &str,
9204 placeholder_label: &str,
9205 ) -> &mut Self {
9206 self.ops.push(BatchOp::InsertEdgeUpsert {
9207 edge_type: edge_type.into(),
9208 src_key: src_key.into(),
9209 dst_key: dst_key.into(),
9210 placeholder_label: placeholder_label.into(),
9211 });
9212 self
9213 }
9214
9215 /// Validate every queued op, then log one `Batch` frame and apply.
9216 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9217 /// A second `commit()` after a successful one is an empty-batch no-op
9218 /// (queued ops were taken).
9219 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9220 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9221 ///
9222 /// **Rule-window limitation:** batch validation cannot see edges that a
9223 /// rule created earlier in the *same* batch will derive at apply time, so
9224 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9225 /// where sequential calls would return `Err(RuleOwned)`. State integrity
9226 /// is unaffected (idempotent apply, provenance intact). Create rules in
9227 /// their own batch, or sequentially, when later ops may touch derived
9228 /// edges.
9229 /// Validate every queued op and commit atomically.
9230 ///
9231 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9232 /// WAL records actually written (duplicate edges are silent no-ops and are
9233 /// NOT counted). Both are 0 when the batch is empty or all-noop.
9234 pub fn commit(&mut self) -> Result<(usize, usize)> {
9235 let ops = std::mem::take(&mut self.ops);
9236 self.db.commit_batch(ops)
9237 }
9238
9239 /// Same as [`commit`](Self::commit) but tail the inner events with
9240 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9241 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9242 let ops = std::mem::take(&mut self.ops);
9243 self.db
9244 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9245 }
9246}
9247
9248pub struct NodeRef<'a, F: Fs> {
9249 db: &'a GraphDb<F>,
9250 id: u32,
9251}
9252
9253impl<'a, F: Fs> NodeRef<'a, F> {
9254 pub fn key(&self) -> &str {
9255 self.db.ids.key_of(self.id).expect("dense ids")
9256 }
9257
9258 pub fn label(&self) -> &str {
9259 let sym = self
9260 .db
9261 .labels
9262 .get(self.id as usize)
9263 .copied()
9264 .filter(|&s| s != u32::MAX)
9265 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9266 self.db.syms.resolve(sym).expect("interned label symbol")
9267 }
9268
9269 pub fn prop(&self, field: &str) -> Option<Value> {
9270 self.db
9271 .props_view()
9272 .get(self.id, field)
9273 .map(|vr| vr.into_value())
9274 }
9275
9276 /// All stored fields for this node, sorted by field name.
9277 ///
9278 /// Reads from the full base+overlay view so that props stored only in the
9279 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9280 pub fn props(&self) -> BTreeMap<String, Value> {
9281 let mut out = BTreeMap::new();
9282 let pv = self.db.props_view();
9283 for field in pv.field_names() {
9284 if let Some(vr) = pv.get(self.id, &field) {
9285 out.insert(field, vr.into_value());
9286 }
9287 }
9288 out
9289 }
9290
9291 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9292 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9293 let view = self.db.view();
9294 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9295 names
9296 .iter()
9297 .filter_map(|name| view.syms.get(name))
9298 .collect()
9299 });
9300 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9301 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9302 for (nid, d) in nb.nodes {
9303 let key = view.key_of(nid);
9304 let label = view
9305 .label_of(nid)
9306 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9307 rs.push_row(vec![
9308 Some(Value::Str(key.to_string())),
9309 Some(Value::Str(label.to_string())),
9310 Some(Value::Int(d as i64)),
9311 ]);
9312 }
9313 rs
9314 }
9315
9316 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9317 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9318 let view = self.db.view();
9319 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9320 for e in expand(&view, self.id, None, Dir::Both) {
9321 // Skip edges with unknown etypes (only possible from corrupt large
9322 // TOPOLOGY section; function returns BTreeMap not Result).
9323 let Some(etype) = view.syms.resolve(e.etype) else {
9324 continue;
9325 };
9326 let etype = etype.to_string();
9327 let nbr = if e.src == self.id { e.dst } else { e.src };
9328 groups
9329 .entry(etype)
9330 .or_default()
9331 .insert(view.key_of(nbr).to_string());
9332 }
9333 groups
9334 .into_iter()
9335 .map(|(k, v)| (k, v.into_iter().collect()))
9336 .collect()
9337 }
9338}
9339
9340#[cfg(test)]
9341mod tests {
9342 use super::*;
9343 use core_rules::Predicate;
9344
9345 fn tmp_dir(name: &str) -> std::path::PathBuf {
9346 let d =
9347 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9348 let _ = std::fs::remove_dir_all(&d);
9349 d
9350 }
9351
9352 fn fk_rule() -> RuleDef {
9353 RuleDef {
9354 name: "works_at".into(),
9355 src_label: "Person".into(),
9356 dst_label: "Org".into(),
9357 predicate: Predicate::KeyMatch {
9358 field: "org_id".into(),
9359 },
9360 edge_type: "WORKS_AT".into(),
9361 weight_prop: None,
9362 max_edges: None,
9363 approximate: false,
9364 via_label: None,
9365 via_edge: None,
9366 via_dir: None,
9367 }
9368 }
9369
9370 /// Regression guard for the no-views delta-copy fast path.
9371 ///
9372 /// When no views are defined, `pending_deltas_since().to_vec()` must never
9373 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9374 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9375 /// a count of 0 after the entire sequence proves the guard fires correctly.
9376 #[test]
9377 fn no_delta_copy_when_no_views() {
9378 DELTA_COPY_COUNT.with(|c| c.set(0));
9379 let dir = tmp_dir("no-delta-copy");
9380 {
9381 let mut db = GraphDb::open(&dir).unwrap();
9382 // Insert 50 Org + 50 Person nodes with FK links.
9383 for i in 0..50u32 {
9384 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9385 }
9386 for i in 0..50u32 {
9387 db.insert_node(
9388 "Person",
9389 &format!("p{i}"),
9390 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9391 )
9392 .unwrap();
9393 }
9394 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9395 db.create_rule(fk_rule()).unwrap();
9396
9397 // Counter must stay 0 — no views, no copies.
9398 let copies = DELTA_COPY_COUNT.with(|c| c.get());
9399 assert_eq!(
9400 copies, 0,
9401 "pending_deltas_since().to_vec() called despite no views"
9402 );
9403
9404 // Derived edges must still be correct (the guard skips only the
9405 // empty delta propagation loop, not the rule application itself).
9406 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9407 assert_eq!(
9408 nbrs,
9409 vec!["o0"],
9410 "rule must derive edges even with no views"
9411 );
9412 }
9413 let _ = std::fs::remove_dir_all(&dir);
9414 }
9415
9416 /// Gating regression: subscribe AFTER a backfill must see no stale events.
9417 /// subscribe BEFORE a backfill must see every edge-fire event.
9418 #[test]
9419 fn subscribe_after_backfill_no_stale_events() {
9420 let dir = tmp_dir("sub-after-backfill");
9421 {
9422 let mut db = GraphDb::open(&dir).unwrap();
9423 for i in 0..10u32 {
9424 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9425 db.insert_node(
9426 "Person",
9427 &format!("p{i}"),
9428 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9429 )
9430 .unwrap();
9431 }
9432 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9433 db.create_rule(fk_rule()).unwrap();
9434
9435 // Subscribe AFTER the backfill — queue must be empty (no stale events).
9436 let sub = db.subscribe_all_rules().unwrap();
9437 // No events should have queued for the prior backfill.
9438 assert!(
9439 sub.try_recv().is_none(),
9440 "subscribe after backfill must see no stale events"
9441 );
9442
9443 // Inserting a new node now should fire an event (emit_deltas is now true).
9444 db.insert_node("Org", "o_new", vec![]).unwrap();
9445 db.insert_node(
9446 "Person",
9447 "p_new",
9448 vec![("org_id".into(), Value::Str("o_new".into()))],
9449 )
9450 .unwrap();
9451 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9452 assert!(
9453 ev.is_some(),
9454 "edge-fire event must arrive after subscribe (emit_deltas=true)"
9455 );
9456 }
9457 let _ = std::fs::remove_dir_all(&dir);
9458 }
9459
9460 /// Gating regression: subscribe BEFORE a backfill → events flow.
9461 #[test]
9462 fn subscribe_before_backfill_events_flow() {
9463 let dir = tmp_dir("sub-before-backfill");
9464 {
9465 let mut db = GraphDb::open(&dir).unwrap();
9466 // Subscribe FIRST — emit_deltas becomes true.
9467 let sub = db.subscribe_all_rules().unwrap();
9468
9469 for i in 0..5u32 {
9470 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9471 db.insert_node(
9472 "Person",
9473 &format!("p{i}"),
9474 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9475 )
9476 .unwrap();
9477 }
9478 // Backfill fires with emit_deltas=true → events queued.
9479 db.create_rule(fk_rule()).unwrap();
9480
9481 // Should receive at least one edge-fired event from the backfill.
9482 let mut received = 0usize;
9483 while sub.try_recv().is_some() {
9484 received += 1;
9485 }
9486 assert!(
9487 received > 0,
9488 "subscribe before backfill must receive edge-fire events (got 0)"
9489 );
9490 }
9491 let _ = std::fs::remove_dir_all(&dir);
9492 }
9493
9494 /// Companion: when a view IS defined, the delta path fires and view values update.
9495 #[test]
9496 fn delta_copy_fires_when_view_exists() {
9497 use core_rules::ViewSource;
9498 DELTA_COPY_COUNT.with(|c| c.set(0));
9499 let dir = tmp_dir("delta-copy-with-view");
9500 {
9501 let mut db = GraphDb::open(&dir).unwrap();
9502 db.insert_node("Org", "o1", vec![]).unwrap();
9503 db.insert_node(
9504 "Person",
9505 "p1",
9506 vec![("org_id".into(), Value::Str("o1".into()))],
9507 )
9508 .unwrap();
9509 // Declare a Degree view so is_empty() returns false.
9510 db.create_view(ViewDef {
9511 name: "degree_out".into(),
9512 label: "Person".into(),
9513 view_prop: "degree_out".into(),
9514 source: ViewSource::Degree {
9515 edge_type: "WORKS_AT".into(),
9516 direction: Direction::Out,
9517 },
9518 })
9519 .unwrap();
9520 db.create_rule(fk_rule()).unwrap();
9521
9522 // At least one delta copy should have happened (CreateRule backfill).
9523 let copies = DELTA_COPY_COUNT.with(|c| c.get());
9524 assert!(
9525 copies > 0,
9526 "expected delta copy to fire when a view is defined"
9527 );
9528
9529 // View value should be computed: p1 has one WORKS_AT out-edge.
9530 let info = db.node_info("p1").unwrap();
9531 let degree = info.props.get("degree_out");
9532 assert!(
9533 degree.is_some(),
9534 "view prop should be written to node props"
9535 );
9536 }
9537 let _ = std::fs::remove_dir_all(&dir);
9538 }
9539
9540 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
9541 /// derived-edge-driven view values reflect the as-of state rather than just
9542 /// the initial backfill written at `CreateView` time.
9543 ///
9544 /// Base WAL frames (indices 0..=5 before history markers):
9545 /// 0: insert Org "o1"
9546 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
9547 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
9548 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
9549 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
9550 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
9551 ///
9552 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
9553 /// no-op), so the total commit count is higher than the base frame count.
9554 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
9555 ///
9556 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
9557 /// initial backfill value (0) instead of reflecting the replayed derived edges.
9558 #[test]
9559 fn open_at_derived_edge_view_values_correct() {
9560 use core_rules::ViewSource;
9561 let dir = tmp_dir("open-at-view-rebuild");
9562 {
9563 let mut db = GraphDb::open(&dir).unwrap();
9564 // frame 0
9565 db.insert_node("Org", "o1", vec![]).unwrap();
9566 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
9567 db.create_view(ViewDef {
9568 name: "employee_count".into(),
9569 label: "Org".into(),
9570 view_prop: "emp".into(),
9571 source: ViewSource::Degree {
9572 edge_type: "WORKS_AT".into(),
9573 direction: Direction::In,
9574 },
9575 })
9576 .unwrap();
9577 // frame 2: create rule — no Persons yet; backfill is a no-op
9578 db.create_rule(fk_rule()).unwrap();
9579 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
9580 db.insert_node(
9581 "Person",
9582 "p1",
9583 vec![("org_id".into(), Value::Str("o1".into()))],
9584 )
9585 .unwrap();
9586 // frame 4: p2 — degree = 2
9587 db.insert_node(
9588 "Person",
9589 "p2",
9590 vec![("org_id".into(), Value::Str("o1".into()))],
9591 )
9592 .unwrap();
9593 // frame 5: p3 — degree = 3
9594 db.insert_node(
9595 "Person",
9596 "p3",
9597 vec![("org_id".into(), Value::Str("o1".into()))],
9598 )
9599 .unwrap();
9600 // Sanity: normal open sees degree = 3.
9601 assert_eq!(
9602 db.get_view_prop("o1", "emp"),
9603 Some(Value::Int(3)),
9604 "normal db must show degree 3 after 3 derived edges"
9605 );
9606 } // WAL flushed
9607
9608 // Re-open normally to get the authoritative reference value.
9609 let normal_db = GraphDb::open(&dir).unwrap();
9610 let normal_emp = normal_db.get_view_prop("o1", "emp");
9611 assert_eq!(
9612 normal_emp,
9613 Some(Value::Int(3)),
9614 "re-opened normal db must show degree 3"
9615 );
9616
9617 // Latest as-of (last WAL commit): must match the normal open.
9618 // History-marker frames are appended after each rule-fire, so the total
9619 // commit count is computed dynamically rather than hardcoded.
9620 let total = crate::wal_commit_count_at(&dir).unwrap();
9621 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
9622 assert_eq!(
9623 aof_latest.get_view_prop("o1", "emp"),
9624 normal_emp,
9625 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
9626 );
9627
9628 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
9629 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
9630 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
9631 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
9632 assert_eq!(
9633 aof_mid.get_view_prop("o1", "emp"),
9634 Some(Value::Int(1)),
9635 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
9636 );
9637
9638 let _ = std::fs::remove_dir_all(&dir);
9639 }
9640
9641 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
9642 /// as-of instances never commit, so distribute_events never runs and any
9643 /// subscription would wait forever.
9644 #[test]
9645 fn subscribe_on_as_of_returns_read_only_error() {
9646 let dir = tmp_dir("sub-as-of-read-only");
9647 {
9648 let mut db = GraphDb::open(&dir).unwrap();
9649 db.insert_node("Org", "o1", vec![]).unwrap();
9650 db.create_rule(fk_rule()).unwrap();
9651 }
9652 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
9653
9654 assert!(
9655 matches!(
9656 aof.subscribe_all_rules(),
9657 Err(core_storage::GraphError::ReadOnly)
9658 ),
9659 "subscribe_all_rules on as-of must return ReadOnly"
9660 );
9661 assert!(
9662 matches!(
9663 aof.subscribe_writes(),
9664 Err(core_storage::GraphError::ReadOnly)
9665 ),
9666 "subscribe_writes on as-of must return ReadOnly"
9667 );
9668 assert!(
9669 matches!(
9670 aof.subscribe_rule("works_at"),
9671 Err(core_storage::GraphError::ReadOnly)
9672 ),
9673 "subscribe_rule on as-of must return ReadOnly"
9674 );
9675 let _ = std::fs::remove_dir_all(&dir);
9676 }
9677
9678 /// Regression: a failed dense WAL rewrite must not leave speculative
9679 /// interns in `syms`. If it does, the next successful mutation logs an
9680 /// `Intern` record with an inflated id; replay (which never saw the
9681 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
9682 #[test]
9683 fn dense_rewrite_error_rolls_back_speculative_interns() {
9684 let dir = tmp_dir("dense-rewrite-rollback");
9685 {
9686 let mut db = GraphDb::open(&dir).unwrap();
9687 db.insert_node("Person", "a", vec![]).unwrap();
9688
9689 // Bypass MutPreview validation to hit the rewrite's own error path
9690 // (same shape as an id-exhaustion failure mid-rewrite). The
9691 // InsertEdge arm interns the edge type before it resolves keys.
9692 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
9693 edge_type: "ORPHAN_TYPE".into(),
9694 src_key: "missing".into(),
9695 dst_key: "a".into(),
9696 }]);
9697 assert!(err.is_err(), "rewrite of a missing src key must fail");
9698 assert_eq!(
9699 db.syms.get("ORPHAN_TYPE"),
9700 None,
9701 "failed rewrite must roll back speculative interns"
9702 );
9703
9704 // A later successful mutation must produce a replayable WAL.
9705 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
9706 }
9707 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
9708 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
9709 let _ = std::fs::remove_dir_all(&dir);
9710 }
9711}