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