core_api/db.rs
1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4 event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::{ret_val_label, ArithOp};
7use core_query::cypher::{
8 execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9 plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10 WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15 ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21 archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22 archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28 ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37 ($phase:literal, $t:expr) => {
38 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39 eprintln!(
40 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41 $phase,
42 $t.elapsed()
43 );
44 }
45 };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51 ($phase:literal, $t:expr) => {
52 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53 eprintln!(
54 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55 $phase,
56 $t.elapsed()
57 );
58 }
59 };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70// Per-thread count of query-subscription `execute` calls in `distribute_events`.
71//
72// Incremented each time a query subscription actually runs its plan (i.e.,
73// the label-skip fast-path did not fire). Because `distribute_events` is
74// called synchronously on the writer thread, this thread-local correctly
75// isolates each test thread's count even when integration tests run in
76// parallel. Read via [`query_sub_exec_count`].
77thread_local! {
78 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
79}
80
81/// Return the number of query-subscription re-executions logged on this
82/// thread since the process started (or since last reset via
83/// [`reset_query_sub_exec_count`]).
84///
85/// Primarily for integration tests that verify the label-skip fast-path.
86#[doc(hidden)]
87pub fn query_sub_exec_count() -> usize {
88 QUERY_SUB_EXECS_TL.with(|c| c.get())
89}
90
91/// Reset the per-thread query-subscription execution counter to zero.
92#[doc(hidden)]
93pub fn reset_query_sub_exec_count() {
94 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
95}
96
97/// Internal state for a single `subscribe_query` subscription.
98///
99/// On every commit, `distribute_events` re-executes `ops` against the current
100/// graph state, diffs the result against `prev_rows`, and pushes
101/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
102///
103/// **Full re-run per commit; use LIMIT to bound execution cost.**
104/// (Differential evaluation is roadmap / Phase 5.)
105pub(crate) struct QuerySubEntry {
106 /// Compiled plan for the subscribed Cypher query.
107 ops: Vec<PlanOp>,
108 /// Column names from the first execution (fixed for the subscription lifetime).
109 columns: Vec<String>,
110 /// Serialized (JSON) row key → row data, representing the result set at
111 /// the end of the last commit. Used to diff against the new result.
112 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
113 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
114 inner: std::sync::Weak<SubInner>,
115 /// Interned label sym captured at subscribe time from the plan's leading scan
116 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
117 ///
118 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
119 /// with a concrete label), and this subscription must re-execute on every
120 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
121 /// queries are never skipped because edges can alter join results regardless
122 /// of which node labels were written.
123 scan_label: Option<u32>,
124}
125
126/// A post-commit mutation notification.
127///
128/// Emitted from `log_then_apply` after the WAL append, fsync, and
129/// in-memory `apply` all succeed. Never emitted for rejected operations
130/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
131/// deletes/removes). Event payloads carry user keys and rule names, never
132/// internal ids.
133///
134/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
135/// `apply` only. Emission lives exclusively in `log_then_apply`, so
136/// recovery is silent even if a sink were installed (it cannot be: the
137/// sink is in-memory and set after open).
138///
139/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
140/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
141/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
142/// or all-noop batch writes no WAL and emits nothing (including no summary).
143///
144/// **Derived edges:** rule-created or retracted edges are not individually
145/// evented — they are recoverable from the triggering mutation plus the live
146/// rule set. Only the triggering record is emitted.
147///
148/// **Wire form:** externally tagged snake_case JSON
149/// (`{"node_inserted":{"label":"A","key":"k"}}`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum MutationEvent {
153 NodeInserted {
154 label: String,
155 key: String,
156 },
157 PropSet {
158 key: String,
159 field: String,
160 },
161 PropRemoved {
162 key: String,
163 field: String,
164 },
165 EdgeInserted {
166 edge_type: String,
167 src: String,
168 dst: String,
169 },
170 EdgeDeleted {
171 edge_type: String,
172 src: String,
173 dst: String,
174 },
175 NodeDeleted {
176 key: String,
177 },
178 RuleCreated {
179 name: String,
180 },
181 RuleDeleted {
182 name: String,
183 },
184 RuleRebuilt {
185 name: String,
186 },
187 BatchApplied {
188 ops: usize,
189 },
190 Ingested {
191 label: String,
192 inserted: usize,
193 },
194}
195
196fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
197 match rec {
198 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
199 label: label.clone(),
200 key: key.clone(),
201 }),
202 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
203 label: intern.resolve(*label)?.to_string(),
204 key: key.clone(),
205 }),
206 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
207 key: key.clone(),
208 field: field.clone(),
209 }),
210 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
211 key: ids.key_of(*id)?.to_string(),
212 field: intern.resolve(*field)?.to_string(),
213 }),
214 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
215 key: key.clone(),
216 field: field.clone(),
217 }),
218 WalRecord::InsertEdge {
219 edge_type,
220 src_key,
221 dst_key,
222 } => Some(MutationEvent::EdgeInserted {
223 edge_type: edge_type.clone(),
224 src: src_key.clone(),
225 dst: dst_key.clone(),
226 }),
227 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
228 edge_type: intern.resolve(*etype)?.to_string(),
229 src: ids.key_of(*src)?.to_string(),
230 dst: ids.key_of(*dst)?.to_string(),
231 }),
232 WalRecord::DeleteEdge {
233 edge_type,
234 src_key,
235 dst_key,
236 } => Some(MutationEvent::EdgeDeleted {
237 edge_type: edge_type.clone(),
238 src: src_key.clone(),
239 dst: dst_key.clone(),
240 }),
241 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
242 WalRecord::CreateRule { def_bytes } => {
243 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
244 Some(MutationEvent::RuleCreated { name: def.name })
245 }
246 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
247 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
248 WalRecord::Batch(_)
249 | WalRecord::CreateView { .. }
250 | WalRecord::DeleteView { .. }
251 | WalRecord::EnableFulltext { .. }
252 | WalRecord::DisableFulltext { .. }
253 | WalRecord::EnableIndex { .. }
254 | WalRecord::DisableIndex { .. }
255 | WalRecord::Intern { .. }
256 // History markers are no-ops for mutation events — they carry no new
257 // state and rules re-derive deterministically on replay.
258 | WalRecord::DerivedEdgeAdded { .. }
259 | WalRecord::DerivedEdgeRetracted { .. }
260 // RenameNode carries no node/edge count change; no special event.
261 | WalRecord::RenameNode { .. } => None,
262 }
263}
264
265/// Database-wide counters plus per-rule budget/fire stats.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct Stats {
268 pub nodes_live: usize,
269 pub nodes_tombstoned: usize,
270 pub edges: u64,
271 pub rules: Vec<RuleStats>,
272 /// How many writes hit the rule-chaining depth cap with work still pending,
273 /// since this handle was opened. Non-zero means some derived edges beyond
274 /// the cap are stale and no single later write will repair them: split the
275 /// rule chain or shorten it. Never persisted, so it resets on reopen.
276 #[serde(default)]
277 pub chain_truncations: u64,
278 /// The oldest commit index history still reaches (the WAL horizon floor).
279 /// `0` means nothing has been pruned and history is complete; a non-zero
280 /// value means events before that commit were pruned and are gone.
281 #[serde(default)]
282 pub history_floor: u64,
283}
284
285/// One rule's provenance size, trip latch, and fire counter.
286///
287/// `tripped` is a one-way latch: once set, the engine adds no new edges for
288/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
289/// set then fits). `fires` counts `on_node_changed` evaluations plus
290/// backfill/rebuild participant ticks (rebuild counts even when it is a
291/// provenance no-op).
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct RuleStats {
294 pub name: String,
295 pub edges: u64,
296 pub tripped: bool,
297 pub fires: u64,
298 /// Whether this rule uses the approximate IVF-Flat candidate path.
299 pub approximate: bool,
300}
301
302/// One entry in the slow-query ring buffer.
303#[derive(Debug, Clone, Serialize)]
304pub struct SlowQueryEntry {
305 /// Execution time in whole milliseconds.
306 pub ms: u64,
307 /// The Cypher query string that was slow.
308 pub query: String,
309 /// The commit sequence number at the time the query ran.
310 pub at_commit: u64,
311}
312
313/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
314#[derive(Debug, Clone, Serialize)]
315pub struct SlowQuerySnapshot {
316 /// Current threshold in milliseconds (0 = disabled).
317 pub threshold_ms: u64,
318 /// Total number of slow queries ever recorded (not capped by ring size).
319 pub count: u64,
320 /// Most-recent slow queries (up to 16), oldest first.
321 pub last: Vec<SlowQueryEntry>,
322}
323
324/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
325/// write to it without a mutable borrow.
326struct SlowQueryLog {
327 entries: std::collections::VecDeque<SlowQueryEntry>,
328 total: u64,
329}
330
331/// Maximum number of entries kept in the slow-query ring buffer.
332const SLOW_QUERY_RING_CAP: usize = 16;
333
334/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
335/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub struct PredicateSummary {
338 pub kind: String,
339 pub fields: Vec<String>,
340 pub min: Option<f64>,
341 pub tolerance: Option<f64>,
342 pub km: Option<f64>,
343 pub parts: Option<Vec<PredicateSummary>>,
344 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
345 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
346 #[serde(default)]
347 pub approximate: bool,
348}
349
350impl From<&Predicate> for PredicateSummary {
351 fn from(p: &Predicate) -> Self {
352 match p {
353 Predicate::KeyMatch { field } => PredicateSummary {
354 kind: "key_match".into(),
355 fields: vec![field.clone()],
356 min: None,
357 tolerance: None,
358 km: None,
359 parts: None,
360 approximate: false,
361 },
362 Predicate::FieldEqual { field } => PredicateSummary {
363 kind: "field_equal".into(),
364 fields: vec![field.clone()],
365 min: None,
366 tolerance: None,
367 km: None,
368 parts: None,
369 approximate: false,
370 },
371 Predicate::Overlap { field, min } => PredicateSummary {
372 kind: "overlap".into(),
373 fields: vec![field.clone()],
374 min: Some(*min),
375 tolerance: None,
376 km: None,
377 parts: None,
378 approximate: false,
379 },
380 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
381 kind: "numeric_within".into(),
382 fields: vec![field.clone()],
383 min: None,
384 tolerance: Some(*tolerance),
385 km: None,
386 parts: None,
387 approximate: false,
388 },
389 Predicate::GeoRadius { field, km } => PredicateSummary {
390 kind: "geo_radius".into(),
391 fields: vec![field.clone()],
392 min: None,
393 tolerance: None,
394 km: Some(*km),
395 parts: None,
396 approximate: false,
397 },
398 Predicate::VectorSimilar { field, min } => PredicateSummary {
399 kind: "vector_similar".into(),
400 fields: vec![field.clone()],
401 min: Some(*min),
402 tolerance: None,
403 km: None,
404 parts: None,
405 approximate: false,
406 },
407 Predicate::All(inner) => {
408 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
409 let mut fields = Vec::new();
410 for part in &parts {
411 for f in &part.fields {
412 if !fields.contains(f) {
413 fields.push(f.clone());
414 }
415 }
416 }
417 PredicateSummary {
418 kind: "all".into(),
419 fields,
420 min: None,
421 tolerance: None,
422 km: None,
423 parts: Some(parts),
424 approximate: false,
425 }
426 }
427 Predicate::Any(inner) => {
428 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
429 let mut fields = Vec::new();
430 for part in &parts {
431 for f in &part.fields {
432 if !fields.contains(f) {
433 fields.push(f.clone());
434 }
435 }
436 }
437 PredicateSummary {
438 kind: "any".into(),
439 fields,
440 min: None,
441 tolerance: None,
442 km: None,
443 parts: Some(parts),
444 approximate: false,
445 }
446 }
447 }
448 }
449}
450
451/// Snapshot of a live node's key, label, and columnar properties.
452///
453/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
454/// regardless of insert order or the columnar store's `HashMap` iteration.
455///
456/// Deliberately does not derive `Serialize`: `Value`'s serde form is
457/// internally tagged. Wire JSON is built by `value_to_json` in the server.
458#[derive(Debug, Clone, PartialEq)]
459pub struct NodeInfo {
460 pub key: String,
461 pub label: String,
462 pub props: BTreeMap<String, Value>,
463}
464
465/// Counts returned by [`GraphDb::delete_node`].
466#[derive(Debug, Clone, PartialEq, Eq, Default)]
467pub struct DeleteReport {
468 /// Number of manual (user-inserted) edges removed.
469 pub manual_edges: u64,
470 /// Number of derived (rule-owned) edges retracted.
471 pub derived_edges: u64,
472}
473
474/// One directed edge incident on a node, with provenance membership.
475///
476/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
477/// Plan-8 `by_node` provenance index.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct EdgeInfo {
480 pub edge_type: String,
481 pub src_key: String,
482 pub dst_key: String,
483 pub derived: bool,
484}
485
486/// One directed edge incident on a node at a point in WAL history, with the
487/// rule that derived it when it is rule-owned.
488///
489/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
490/// and by [`GraphDb::what_if_set_prop`].
491#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
492pub struct EdgeAt {
493 pub edge_type: String,
494 pub src_key: String,
495 pub dst_key: String,
496 /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
497 /// live provenance entry).
498 pub derived: bool,
499 /// The rule that derived the edge. `None` for a manual edge.
500 pub rule: Option<String>,
501}
502
503/// The derived edges a hypothetical property change would retract and derive.
504///
505/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
506/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct WhatIf {
509 /// Derived edges that exist now and would be retracted.
510 pub lost: Vec<EdgeAt>,
511 /// Derived edges that do not exist now and would be derived.
512 pub gained: Vec<EdgeAt>,
513}
514
515/// An edge with mask-aware endpoint visibility.
516///
517/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
518/// mode — hidden endpoints carry `*_restricted: true`.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct MaskedEdge {
521 pub edge_type: String,
522 pub src_key: String,
523 /// `true` when `src_key` is in the DB but hidden from the mask.
524 pub src_restricted: bool,
525 pub dst_key: String,
526 /// `true` when `dst_key` is in the DB but hidden from the mask.
527 pub dst_restricted: bool,
528 pub derived: bool,
529}
530
531/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
532///
533/// `None` from that method means the key does not exist (→ 404).
534/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
535#[derive(Debug, PartialEq)]
536pub enum MaskedNodeResult {
537 Visible(NodeInfo),
538 /// Node exists in the DB but is hidden from this mask.
539 Restricted,
540}
541
542/// One rule-owned edge between two nodes, with the rule name, edge type,
543/// direction (src_key → dst_key), and weight if the rule stores one.
544#[derive(Debug, Clone, PartialEq, Serialize)]
545pub struct Explanation {
546 pub rule: String,
547 pub edge_type: String,
548 pub src_key: String,
549 pub dst_key: String,
550 pub weight: Option<f64>,
551 pub predicate: PredicateSummary,
552 /// For a via-hop rule, the edge type the rule hops over to reach its
553 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
554 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
555 /// was written by another rule in the same commit.
556 #[serde(default)]
557 pub via_edge: Option<String>,
558}
559
560/// Report returned by [`GraphDb::backup_to`].
561#[derive(Debug, Clone)]
562pub struct BackupReport {
563 /// Filenames copied into the destination directory (sorted ascending).
564 pub files: Vec<String>,
565 /// Total bytes written across all copied files.
566 pub bytes: u64,
567 /// `true` when the destination opened cleanly and passed post-copy checks.
568 ///
569 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
570 /// matched **and** the destination opened without error.
571 ///
572 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
573 /// CRC-check; `verified` is `true` when the destination opened and
574 /// replayed the WAL without error (record-level checksums in the WAL
575 /// provide the integrity signal, not section CRCs).
576 pub verified: bool,
577}
578
579/// One directed edge in export form, with optional rule attribution for derived edges.
580///
581/// Returned by [`GraphDb::all_edges_for_export`].
582///
583/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
584/// order. Callers that need a stable edge ordering already sort by
585/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
586#[derive(Debug, Clone, PartialEq, PartialOrd)]
587pub struct ExportEdge {
588 pub edge_type: String,
589 pub src: String,
590 pub dst: String,
591 pub derived: bool,
592 /// Rule name that created this edge, if derived. `None` for manual edges.
593 pub rule: Option<String>,
594 /// The creating rule's declared `weight_prop`, read off this edge, when
595 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
596 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
597 pub weight: Option<f64>,
598}
599
600/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
601///
602/// Deliberately per *type* and not per edge: everything here is a summary a
603/// caller can print in one line, and none of it costs a record per edge.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct EdgeTypeCensus {
606 pub edge_type: String,
607 /// Directed edges of this type. Counted the way
608 /// [`GraphDb::edge_count`] counts: each edge once, from its source.
609 pub edges: u64,
610 /// Every label seen on a source of this type, sorted.
611 pub src_labels: Vec<String>,
612 /// Every label seen on a destination of this type, sorted.
613 pub dst_labels: Vec<String>,
614 /// The rules that declare this `edge_type`, sorted. Empty for a type
615 /// written by hand.
616 pub rules: Vec<String>,
617 /// `(src key, dst key)` of the first edge of this type in the store's own
618 /// id order — a real pair to quote in an example.
619 pub sample: Option<(String, String)>,
620}
621
622/// Construct the standard write-query result set (columns: created, properties_set, deleted).
623fn write_result_set() -> ResultSet {
624 ResultSet::new(vec![
625 "created".into(),
626 "properties_set".into(),
627 "deleted".into(),
628 ])
629}
630
631fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
632 match op {
633 Operand::Lit(v) => Ok(v.clone()),
634 Operand::Param(name) => params
635 .get(name)
636 .cloned()
637 .ok_or_else(|| GraphError::QueryError {
638 detail: format!("missing parameter `{name}`"),
639 }),
640 _ => Err(GraphError::QueryError {
641 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
642 }),
643 }
644}
645
646fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
647 match op {
648 Operand::Prop { var, .. } | Operand::Var(var) => {
649 if !out.contains(var) {
650 out.push(var.clone());
651 }
652 }
653 Operand::FuncCall { args, .. } => {
654 for arg in args {
655 operand_node_vars(arg, out);
656 }
657 }
658 Operand::BinArith { left, right, .. } => {
659 operand_node_vars(left, out);
660 operand_node_vars(right, out);
661 }
662 Operand::Case { branches, default } => {
663 // Branch conditions reference vars already bound (and mask-filtered)
664 // by the MATCH phase, so collecting from the value operands + ELSE
665 // is sufficient for RETURN-projection var discovery.
666 for (_, value) in branches {
667 operand_node_vars(value, out);
668 }
669 if let Some(d) = default {
670 operand_node_vars(d, out);
671 }
672 }
673 Operand::Index { base, index } => {
674 operand_node_vars(base, out);
675 operand_node_vars(index, out);
676 }
677 Operand::Lit(_) | Operand::Param(_) => {}
678 }
679}
680
681fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
682 let mut out = Vec::new();
683 for item in items {
684 match &item.value {
685 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
686 if !out.contains(v) {
687 out.push(v.clone());
688 }
689 }
690 RetVal::FuncCall { args, .. } => {
691 for arg in args {
692 operand_node_vars(arg, &mut out);
693 }
694 }
695 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
696 RetVal::Agg { .. } => {}
697 }
698 }
699 out
700}
701
702fn add_var(out: &mut Vec<String>, v: &str) {
703 if !out.iter().any(|x| x == v) {
704 out.push(v.to_string());
705 }
706}
707
708fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
709 let mut out = Vec::new();
710 for p in pats {
711 if let Some(v) = &p.start.var {
712 add_var(&mut out, v);
713 }
714 for (_, dest) in &p.chain {
715 if let Some(v) = &dest.var {
716 add_var(&mut out, v);
717 }
718 }
719 }
720 out
721}
722
723fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
724 let mut out = Vec::new();
725 for p in pats {
726 for (rel, _) in &p.chain {
727 if rel.hops.is_none() {
728 if let Some(v) = &rel.var {
729 add_var(&mut out, v);
730 }
731 }
732 }
733 }
734 out
735}
736
737fn rel_type_alias(var: &str) -> String {
738 format!("__rt_{var}")
739}
740
741fn ret_column_name(item: &RetItem) -> String {
742 if let Some(alias) = &item.alias {
743 return alias.clone();
744 }
745 // The same naming rule the planner and the executor use, so a
746 // write-statement RETURN names its columns exactly as a read query does.
747 // An aggregate is not legal in a write-statement RETURN; it keeps the
748 // placeholder it always had.
749 ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
750}
751
752fn eval_set_return_operand<F: Fs>(
753 db: &GraphDb<F>,
754 match_rs: &ResultSet,
755 row: usize,
756 rel_vars: &[String],
757 op: &Operand,
758 params: &BTreeMap<String, Value>,
759) -> Result<Option<Value>> {
760 match op {
761 Operand::Lit(v) => Ok(Some(v.clone())),
762 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
763 detail: format!("missing parameter `{name}`"),
764 }).map(Some),
765 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
766 detail: format!(
767 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
768 ),
769 }),
770 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
771 Operand::Prop { var, field } => {
772 if rel_vars.iter().any(|r| r == var) {
773 return Ok(None);
774 }
775 let Some(Value::Str(key)) = match_rs.get(row, var) else {
776 return Ok(None);
777 };
778 Ok(db.get_prop(key, field))
779 }
780 Operand::FuncCall { name, args } => {
781 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
782 }
783 Operand::BinArith { op, left, right } => {
784 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
785 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
786 eval_set_return_arith(op, lv, rv)
787 }
788 // CASE is supported in read-query RETURN; in a write-statement RETURN
789 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
790 Operand::Case { .. } => Err(GraphError::QueryError {
791 detail: "CASE is not supported in a write-statement RETURN projection; \
792 use a read query"
793 .into(),
794 }),
795 // Same as CASE: a list subscript is supported in a read-query RETURN
796 // but not yet in a write-statement RETURN projection.
797 Operand::Index { .. } => Err(GraphError::QueryError {
798 detail: "a list subscript is not supported in a write-statement RETURN \
799 projection; use a read query"
800 .into(),
801 }),
802 }
803}
804
805fn eval_set_return_arith(
806 op: &ArithOp,
807 lv: Option<Value>,
808 rv: Option<Value>,
809) -> Result<Option<Value>> {
810 match (lv, rv) {
811 (None, _) | (_, None) => Ok(None),
812 (Some(Value::Int(a)), Some(Value::Int(b))) => {
813 let result = match op {
814 ArithOp::Sub => a.saturating_sub(b),
815 ArithOp::Mul => a.saturating_mul(b),
816 ArithOp::Add => a.saturating_add(b),
817 ArithOp::Div => {
818 if b == 0 {
819 return Err(GraphError::QueryError {
820 detail: "division by zero".into(),
821 });
822 }
823 a.checked_div(b).unwrap_or(i64::MAX)
824 }
825 };
826 Ok(Some(Value::Int(result)))
827 }
828 (Some(lv), Some(rv)) => {
829 let a = match &lv {
830 Value::Float(f) => *f,
831 Value::Int(i) => *i as f64,
832 _ => {
833 return Err(GraphError::QueryError {
834 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
835 })
836 }
837 };
838 let b = match &rv {
839 Value::Float(f) => *f,
840 Value::Int(i) => *i as f64,
841 _ => {
842 return Err(GraphError::QueryError {
843 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
844 })
845 }
846 };
847 let result = match op {
848 ArithOp::Sub => a - b,
849 ArithOp::Mul => a * b,
850 ArithOp::Add => a + b,
851 ArithOp::Div => {
852 if b == 0.0 {
853 return Err(GraphError::QueryError {
854 detail: "division by zero".into(),
855 });
856 }
857 a / b
858 }
859 };
860 Ok(Some(Value::Float(result)))
861 }
862 }
863}
864
865fn eval_set_return_func<F: Fs>(
866 db: &GraphDb<F>,
867 match_rs: &ResultSet,
868 row: usize,
869 rel_vars: &[String],
870 name: &str,
871 args: &[Operand],
872 params: &BTreeMap<String, Value>,
873) -> Result<Option<Value>> {
874 let norm = name.to_ascii_lowercase();
875 if norm == "type" {
876 if args.len() != 1 {
877 return Err(GraphError::QueryError {
878 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
879 });
880 }
881 let Operand::Var(rel) = &args[0] else {
882 return Err(GraphError::QueryError {
883 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
884 });
885 };
886 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
887 }
888 if norm == "key" {
889 if args.len() != 1 {
890 return Err(GraphError::QueryError {
891 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
892 });
893 }
894 let Operand::Var(var) = &args[0] else {
895 return Err(GraphError::QueryError {
896 detail: "key() argument must be a node variable (e.g. key(n))".into(),
897 });
898 };
899 if rel_vars.iter().any(|r| r == var) {
900 return Err(GraphError::QueryError {
901 detail: format!("key() argument `{var}` is a relationship, not a node"),
902 });
903 }
904 // MATCH rows bind node variables to their key string, so the column
905 // value *is* the key.
906 return Ok(match_rs.get(row, var).cloned());
907 }
908 let mut vals = Vec::with_capacity(args.len());
909 for arg in args {
910 vals.push(eval_set_return_operand(
911 db, match_rs, row, rel_vars, arg, params,
912 )?);
913 }
914 match norm.as_str() {
915 "tolower" => {
916 if vals.len() != 1 {
917 return Err(GraphError::QueryError {
918 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
919 });
920 }
921 Ok(vals[0].clone().map(|val| match val {
922 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
923 other => other,
924 }))
925 }
926 "toupper" => {
927 if vals.len() != 1 {
928 return Err(GraphError::QueryError {
929 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
930 });
931 }
932 Ok(vals[0].clone().map(|val| match val {
933 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
934 other => other,
935 }))
936 }
937 "size" => match vals.first().cloned().flatten() {
938 None => Ok(None),
939 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
940 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
941 Some(_) => Ok(None),
942 },
943 "coalesce" => Ok(vals.into_iter().flatten().next()),
944 "abs" => match vals.first().cloned().flatten() {
945 None => Ok(None),
946 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
947 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
948 Some(_) => Ok(None),
949 },
950 "round" => match vals.first().cloned().flatten() {
951 None => Ok(None),
952 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
953 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
954 Some(_) => Ok(None),
955 },
956 "decay" => {
957 if vals.len() != 3 {
958 return Err(GraphError::QueryError {
959 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
960 });
961 }
962 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
963 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
964 (Some(b), Some(a), Some(h)) => {
965 let numeric = |v: Value| -> Result<f64> {
966 match v {
967 Value::Int(n) => Ok(n as f64),
968 Value::Float(f) => Ok(f),
969 other => Err(GraphError::QueryError {
970 detail: format!(
971 "decay() requires numeric arguments, got {other:?}"
972 ),
973 }),
974 }
975 };
976 let b = numeric(b)?;
977 let a = numeric(a)?;
978 let h = numeric(h)?;
979 if h <= 0.0 {
980 return Err(GraphError::QueryError {
981 detail: "decay() requires halflife > 0".into(),
982 });
983 }
984 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
985 }
986 }
987 }
988 _ => Err(GraphError::QueryError {
989 detail: format!(
990 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
991 ),
992 }),
993 }
994}
995
996fn eval_set_return_item<F: Fs>(
997 db: &GraphDb<F>,
998 match_rs: &ResultSet,
999 row: usize,
1000 rel_vars: &[String],
1001 item: &RetItem,
1002 params: &BTreeMap<String, Value>,
1003) -> Result<Option<Value>> {
1004 match &item.value {
1005 RetVal::Var(v) => eval_set_return_operand(
1006 db,
1007 match_rs,
1008 row,
1009 rel_vars,
1010 &Operand::Var(v.clone()),
1011 params,
1012 ),
1013 RetVal::Prop { var, field } => eval_set_return_operand(
1014 db,
1015 match_rs,
1016 row,
1017 rel_vars,
1018 &Operand::Prop {
1019 var: var.clone(),
1020 field: field.clone(),
1021 },
1022 params,
1023 ),
1024 RetVal::FuncCall { name, args } => {
1025 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1026 }
1027 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1028 RetVal::Agg { .. } => Err(GraphError::QueryError {
1029 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1030 }),
1031 }
1032}
1033
1034/// Project user RETURN from original MATCH rows after SET. No rematch.
1035fn project_set_return_rows<F: Fs>(
1036 db: &GraphDb<F>,
1037 rel_vars: &[String],
1038 match_rs: &ResultSet,
1039 returns: &[RetItem],
1040 params: &BTreeMap<String, Value>,
1041) -> Result<ResultSet> {
1042 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1043 let mut out = ResultSet::new(columns);
1044 for row in 0..match_rs.len() {
1045 let mut cells = Vec::with_capacity(returns.len());
1046 for item in returns {
1047 cells.push(eval_set_return_item(
1048 db, match_rs, row, rel_vars, item, params,
1049 )?);
1050 }
1051 out.push_row(cells);
1052 }
1053 Ok(out)
1054}
1055
1056/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1057/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1058/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1059/// Returns `None` for non-list values or lists with non-numeric elements.
1060fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1061 match v {
1062 Value::List(items) => items
1063 .iter()
1064 .map(|item| match item {
1065 Value::Float(f) => Some(*f),
1066 Value::Int(i) => Some(*i as f64),
1067 _ => None,
1068 })
1069 .collect(),
1070 _ => None,
1071 }
1072}
1073
1074fn make_graph_mut<'a>(
1075 ids: &'a IdMap,
1076 syms: &'a mut Interner,
1077 labels: &'a [u32],
1078 props: core_storage::v8::seam::ColumnsView<'a>,
1079 topo: &'a mut Topology,
1080 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1081 edge_props: &'a mut EdgeProps,
1082) -> GraphMut<'a> {
1083 GraphMut {
1084 ids,
1085 syms,
1086 labels,
1087 props,
1088 topo,
1089 base_topo: base_csr(base),
1090 edge_props,
1091 }
1092}
1093
1094/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1095///
1096/// A store opened from a snapshot keeps its edges in the mapping and its
1097/// overlay empty, so a rule that reads the graph's shape has to see both.
1098fn base_csr(
1099 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1100) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1101 base.as_ref().map(|b| {
1102 b.topology()
1103 .expect("base topology section bounds validated at open")
1104 })
1105}
1106
1107/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1108///
1109/// Takes explicit field references rather than `&self` so the caller can hold
1110/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1111fn build_props_view<'a>(
1112 props: &'a ColumnStore,
1113 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1114) -> core_storage::v8::seam::ColumnsView<'a> {
1115 match base {
1116 None => core_storage::v8::seam::ColumnsView::owned(props),
1117 Some(b) => {
1118 let archived = b
1119 .columns()
1120 .expect("base columns section bounds validated at open");
1121 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1122 .with_shared_strings(base_string_table(b))
1123 }
1124 }
1125}
1126
1127/// The base columns section paired with the string table that resolves its
1128/// string ids — what `ViewStore` needs to read a neighbour's string property
1129/// out of a V9 snapshot.
1130fn base_columns(
1131 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1132) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1133 base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1134 cols: b
1135 .columns()
1136 .expect("base columns section bounds validated at open"),
1137 strings: base_string_table(b),
1138 })
1139}
1140
1141/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1142///
1143/// Every `ColumnsView` built over a base must carry it: without it a V9
1144/// snapshot's string columns, whose own tables are empty, read back as absent.
1145fn base_string_table(
1146 base: &core_storage::v8::MappedBase,
1147) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1148 base.string_table()
1149 .transpose()
1150 .expect("base strings section bounds validated at open")
1151}
1152
1153fn build_topo_view<'a>(
1154 overlay: &'a Topology,
1155 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1156) -> core_storage::v8::seam::TopologyView<'a> {
1157 match base {
1158 None => core_storage::v8::seam::TopologyView::owned(overlay),
1159 Some(b) => {
1160 let archived_csr = b
1161 .topology()
1162 .expect("base topology section bounds validated at open");
1163 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1164 }
1165 }
1166}
1167
1168/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1169///
1170/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1171/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1172/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1173/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1174/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1175#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1176pub enum FsyncPolicy {
1177 /// Every WAL commit calls `fs.sync` (today's behavior).
1178 #[default]
1179 Strict,
1180 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1181 /// this policy is set on the database.
1182 Batched,
1183 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1184 Relaxed,
1185}
1186
1187/// A precondition for a compare-and-set batch write.
1188///
1189/// All preconditions in a [`GraphDb::write_batch_cas`] or
1190/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1191/// any operation in the batch is applied. If any precondition fails, the
1192/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1193/// is written.
1194///
1195/// # Touch definition
1196///
1197/// A node's last-change commit (`last_changed`) is updated when any of the
1198/// following state-changing WAL records touch it:
1199///
1200/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1201/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1202/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1203/// endpoints (an edge change touches both sides).
1204/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1205/// for deleted keys so the pre-deletion entry is never observed.
1206///
1207/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1208/// state no-ops. The underlying mutation that triggered rule firing already
1209/// updated the relevant nodes' last-change entries. Rule-management records
1210/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1211/// do not touch any node's last-change.
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213pub enum Precondition {
1214 /// The node's last-change commit must equal `expected`.
1215 ///
1216 /// Fails with [`GraphError::CasConflict`] when:
1217 /// - The node does not exist (`last_changed` returns `None`), or
1218 /// - The recorded commit seq does not match `expected`.
1219 NodeUnchangedSince { key: String, expected: u64 },
1220 /// The node must not exist (not inserted, or already deleted).
1221 ///
1222 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1223 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1224 NodeAbsent { key: String },
1225}
1226
1227pub struct GraphDb<F: Fs> {
1228 fs: F,
1229 ids: IdMap,
1230 syms: Interner,
1231 topo: Topology,
1232 props: ColumnStore,
1233 labels: Vec<u32>, // node id -> label symbol
1234 edge_props: EdgeProps,
1235 engine: RuleEngine,
1236 view_store: ViewStore,
1237 /// Incremental inverted index for full-text-lite search.
1238 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1239 fulltext: FulltextIndex,
1240 /// Opt-in equality index over scalar node properties.
1241 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1242 /// open end (mirrors `fulltext`).
1243 prop_index: PropertyIndex,
1244 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1245 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1246 fsync: FsyncPolicy,
1247 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1248 /// call increments this once; all events emitted from that call share the same
1249 /// `commit_seq` value.
1250 commit_seq: u64,
1251 /// RBAC role definitions loaded from `roles.json` at open.
1252 ///
1253 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1254 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1255 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1256 roles: Option<Vec<RoleDef>>,
1257 /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1258 /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1259 ///
1260 /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1261 /// taken from this handle. Replaced (not cleared) whenever the role
1262 /// definitions change or the store is reloaded, which `commit_seq` does not
1263 /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1264 role_masks: Arc<crate::mask::RoleMaskCache>,
1265 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1266 /// distribute_events call.
1267 subscriptions: Vec<SubEntry>,
1268 /// Live query subscriptions. Re-executed on every commit when non-empty.
1269 /// Dead `Weak` entries are pruned inside `distribute_events`.
1270 query_subscriptions: Vec<QuerySubEntry>,
1271 /// Queue capacity for new subscriptions created by this db. Default is
1272 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1273 /// to test Lagged behaviour with small queues.
1274 sub_capacity: usize,
1275 /// True for as-of instances opened via [`GraphDb::open_at`].
1276 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1277 /// when this flag is set.
1278 read_only: bool,
1279 /// Total WAL commit count at the time [`open_at`] was called.
1280 /// 0 for normal (non-as-of) instances.
1281 total_wal_commits: u64,
1282 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1283 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1284 /// reads go through a merged `TopologyView`. `self.props` is always
1285 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1286 base: Option<Arc<core_storage::v8::MappedBase>>,
1287 // ── MVCC epoch reader state ───────────────────────────────────────────────
1288 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1289 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1290 /// `None` only between struct creation and the first fold.
1291 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1292 /// Per-commit deltas accumulated since the last fold.
1293 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1294 /// How many commits have occurred since the last fold.
1295 commits_since_fold: usize,
1296 /// When true, `log_then_apply_with` buffers event notifications instead of
1297 /// firing them immediately. Used by the group-commit drain thread to defer
1298 /// events until after the group fsync (R2: durability before notification).
1299 /// Cleared to false once the drain thread flushes or discards the buffer.
1300 defer_events: bool,
1301 /// Buffered events accumulated while `defer_events` is true.
1302 deferred_events: Vec<DeferredEvent>,
1303 /// Set to true by the group-commit drain thread when a group fsync fails
1304 /// after WAL truncation. All subsequent mutation attempts return an IO
1305 /// error until the database is reopened.
1306 degraded: bool,
1307 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1308 /// HNSW, and IVF sections from the mmap base into the engine's retained
1309 /// fields. `false` on all opens until first use; always `true` for non-V8
1310 /// opens (base is None, fast-path sets flag immediately).
1311 v8_sections_loaded: std::sync::atomic::AtomicBool,
1312 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1313 v8_sections_mutex: std::sync::Mutex<()>,
1314 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1315 /// the node was last modified by commit `seq`.
1316 ///
1317 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1318 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1319 /// nodes return `None` from `last_changed` until they are next mutated.
1320 ///
1321 /// See [`Precondition`] for the full touch definition.
1322 last_change: HashMap<u32, u64>,
1323 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1324 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1325 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1326 wal_archive_retention: Option<u32>,
1327 /// Global frame index of the first commit that is still reachable through
1328 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1329 /// Default 0 = all history reachable.
1330 wal_horizon_floor: u64,
1331 /// True when the surviving archive chain forms a continuous WAL history
1332 /// starting from the store's first commit (the genesis chain).
1333 ///
1334 /// `open_at` may replay archive-resident commits from empty state only when
1335 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1336 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1337 /// already exist (breaks the chain for subsequent archives), or
1338 /// - any archive is pruned (floor advances past zero).
1339 ///
1340 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1341 archive_genesis_chain: bool,
1342 /// Transient write-authz context set by `write_batch_authz` /
1343 /// `query_write_authz` for the duration of ONE mutation call.
1344 /// Always `None` at rest. Never serialized, never WAL-replayed.
1345 pending_write_authz: Option<WriteAuthz>,
1346 /// Slow-query threshold in milliseconds. 0 = disabled.
1347 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1348 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1349 /// — env vars are process-global and race parallel test threads).
1350 slow_query_threshold_ms: u64,
1351 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1352 /// can record entries without requiring `&mut self`).
1353 slow_queries: std::sync::Mutex<SlowQueryLog>,
1354 /// Instant at which the database was opened (used by `/metrics` uptime).
1355 started_at: std::time::Instant,
1356 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1357 /// Byte offset of the WAL prefix already applied to in-memory state.
1358 ///
1359 /// Advanced by exactly the encoded length of every frame this handle
1360 /// appends, and by the decoded byte count of every tail
1361 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1362 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1363 /// drain thread truncates a failed group. Compared against the WAL's
1364 /// on-disk length to decide staleness.
1365 wal_consumed: u64,
1366 /// Identity of the snapshot this handle's base state came from, as
1367 /// `(len, mtime_nanos)`. A different value means another process replaced
1368 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1369 snapshot_ident: Option<(u64, u64)>,
1370 /// The options this handle was opened with. Replayed verbatim when
1371 /// `refresh` has to rebuild from disk.
1372 open_opts: OpenOptions,
1373 /// True when this handle holds the cross-process write lock for its whole
1374 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1375 /// no-op on such a handle, and never releases the lock.
1376 holds_lifetime_lock: bool,
1377 /// True between a failed lock acquisition and the end of the write scope
1378 /// that failed. Makes every WAL-appending mutation in that scope return
1379 /// [`GraphError::Busy`] instead of writing.
1380 lock_denied: bool,
1381 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1382 /// pinned to one commit, so it is never stale and never refreshes — later
1383 /// commits by any process are deliberately invisible to it.
1384 pinned: bool,
1385}
1386
1387/// One group of deferred event notifications, held until the group fsync
1388/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1389struct DeferredEvent {
1390 rec: core_storage::WalRecord,
1391 engine_deltas: Vec<EngineEdgeDelta>,
1392 seq: u64,
1393 ingest: Option<(String, usize)>,
1394}
1395
1396/// Options for [`GraphDb::open_with_options`].
1397#[derive(Clone, Copy, Debug)]
1398pub struct OpenOptions {
1399 /// Rewrite an old-format snapshot to the current VERSION after a
1400 /// successful load (default `true`). The old snapshot is kept as
1401 /// `snapshot.bin.bak` until the next clean open at the current version,
1402 /// at which point the `.bak` is deleted.
1403 ///
1404 /// Set to `false` to open a store without touching any on-disk files
1405 /// (useful for read-only inspection of a store at an older format).
1406 pub auto_migrate: bool,
1407
1408 /// Write the valid WAL prefix back over a torn tail on open (default
1409 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1410 ///
1411 /// Set to `false` for an unattended reader. The valid prefix is still
1412 /// decoded and replayed in memory, but nothing is written: a reader that
1413 /// opens while another process is mid-append would otherwise discard a
1414 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1415 /// every prompt, passes `false` for exactly this reason.
1416 pub repair_wal: bool,
1417
1418 /// Open without ever writing to the store (default `false`).
1419 ///
1420 /// A read-only handle:
1421 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1422 /// `snapshot()`;
1423 /// - performs no disk write at open — no WAL repair write-back and no
1424 /// auto-migration rewrite, whatever the other two flags say;
1425 /// - never takes the cross-process write lock, so it opens immediately even
1426 /// while another process is writing, and never makes a writer wait.
1427 ///
1428 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1429 /// normally, so a read-only handle can follow another process's commits.
1430 pub read_only: bool,
1431}
1432
1433impl Default for OpenOptions {
1434 fn default() -> Self {
1435 Self {
1436 auto_migrate: true,
1437 repair_wal: true,
1438 read_only: false,
1439 }
1440 }
1441}
1442
1443/// How long a writer polls for the cross-process write lock before giving up
1444/// with [`GraphError::Busy`].
1445///
1446/// Long enough to ride out another process's commit (a batch apply plus one
1447/// fsync), short enough that a stuck peer surfaces as an error rather than a
1448/// hang.
1449pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1450
1451/// Interval between poll attempts while waiting for the cross-process lock.
1452pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1453
1454/// Why `load_from_disk` is running, which decides whether it may repair.
1455#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1456enum LoadOrigin {
1457 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1458 /// the signature of a crash and truncating it is correct, and archives
1459 /// orphaned by an interrupted prune can be swept.
1460 Open,
1461 /// A reload driven by [`GraphDb::refresh`], because another process
1462 /// replaced the snapshot. Nothing here is crash recovery — the store is
1463 /// live and someone else is writing it — so this origin writes nothing.
1464 Reload,
1465}
1466
1467/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1468///
1469/// `None` at the call site = full authority (today's zero-cost behavior).
1470/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1471/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1472/// record is built. A denial returns an error with no WAL frame written.
1473///
1474/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1475/// hidden-node existence to callers.
1476#[derive(Clone, Debug)]
1477pub struct WriteAuthz {
1478 pub role: String,
1479 pub scope: WriteScope,
1480 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1481 /// Always `Omit`-mode — never `Stub`.
1482 pub mask: crate::mask::NodeMask,
1483}
1484
1485/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1486///
1487/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1488/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1489/// syncs the directory entry. This is the only correct path for writing the
1490/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1491/// the directory sync.
1492pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1493 use core_storage::fs::{FileId, Fs as _};
1494 RealFs::new(dir)
1495 .map_err(core_storage::GraphError::Io)?
1496 .write_atomic(FileId::SnapshotBak, bytes)
1497 .map_err(core_storage::GraphError::Io)
1498}
1499
1500/// Return the on-disk snapshot format version without decoding the full snapshot.
1501///
1502/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1503/// snapshot file exists (WAL-only store). Returns an error if the header is
1504/// malformed.
1505pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1506 use std::io::Read as _;
1507 let path = dir.join("snapshot.bin");
1508 let mut header = [0u8; 6];
1509 let n = match std::fs::File::open(&path) {
1510 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1511 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1512 Err(e) => return Err(core_storage::GraphError::Io(e)),
1513 };
1514 core_storage::snapshot::peek_version(&header[..n])
1515}
1516
1517/// Options for [`GraphDb::snapshot_with`].
1518#[derive(Debug, Clone, Default)]
1519pub struct SnapshotOptions {
1520 /// When `true`, the WAL is preserved after the snapshot write.
1521 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1522 /// When `false` (the default), the WAL is truncated to a minimal
1523 /// baseline so cold-start replay stays fast.
1524 pub keep_wal: bool,
1525 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1526 /// before a fresh WAL baseline is written (history-preserving snapshot).
1527 ///
1528 /// This is the feature opt-in: `false` (the default) leaves the existing
1529 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1530 /// precedence over `keep_wal` when both are set.
1531 ///
1532 /// Archives can be scanned by [`GraphDb::node_history`],
1533 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1534 /// [`GraphDb::open_at`], extending the reachable history horizon across
1535 /// snapshot boundaries.
1536 pub archive_wal: bool,
1537}
1538
1539/// Derive the scan-label sym for the commit-skip fast-path.
1540///
1541/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1542/// or `IndexIntersect`) with a concrete label string, then interns it.
1543///
1544/// Returns `None` in all cases where skipping is unsafe:
1545/// - Any `Expand` op is present (edge traversal; edges change results regardless
1546/// of node labels).
1547/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1548/// - No recognizable leading scan op is found.
1549///
1550/// This is the conservative v0.4.3 boundary. The caller stores the result in
1551/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1552fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1553 // Any Expand → must always re-execute (edges can change join results).
1554 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1555 return None;
1556 }
1557 for op in ops {
1558 match op {
1559 PlanOp::ScanLabel {
1560 label: Some(label), ..
1561 } => return Some(syms.intern(label)),
1562 PlanOp::IndexScan {
1563 label: Some(label), ..
1564 } => return Some(syms.intern(label)),
1565 PlanOp::IndexIntersect {
1566 label: Some(label), ..
1567 } => return Some(syms.intern(label)),
1568 _ => {}
1569 }
1570 }
1571 None
1572}
1573
1574/// How an as-of read is restricted — the argument to
1575/// [`GraphDb::query_at_scoped`].
1576///
1577/// Every variant is resolved against the graph **as it was at the requested
1578/// commit**, not against the current graph.
1579#[derive(Debug, Clone, Copy)]
1580pub enum AsOfScope<'a> {
1581 /// Everything the named role may see. The role *definition* is the current
1582 /// one — `roles.json` is a sidecar and has no past version — but its
1583 /// `keys` and `labels` are resolved against the as-of graph.
1584 Role(&'a str),
1585 /// An explicit node-key allow-list. Keys that did not exist at that commit
1586 /// resolve to nothing.
1587 Keys(&'a [String]),
1588 /// A role intersected with a client-supplied allow-list. The intersection
1589 /// is the never-widen rule: a client mask can only narrow a role.
1590 RoleAndKeys(&'a str, &'a [String]),
1591}
1592
1593impl GraphDb<RealFs> {
1594 /// Open the database at `dir` with default options.
1595 ///
1596 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1597 /// Old-format snapshots (V5, V6) are automatically migrated to the
1598 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1599 pub fn open(dir: &std::path::Path) -> Result<Self> {
1600 Self::open_with_options(dir, OpenOptions::default())
1601 }
1602
1603 /// Open the database at `dir` with explicit options.
1604 ///
1605 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1606 /// snapshot is an older format version, this function:
1607 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1608 /// + fsynced) before any modification.
1609 /// 2. Rewrites `snapshot.bin` at the current format version via
1610 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1611 ///
1612 /// If migration fails the error is returned and the original files are
1613 /// intact (the `.bak` was written before the new snapshot was attempted).
1614 ///
1615 /// A clean open that finds the snapshot already at the current version
1616 /// deletes any leftover `.bak` file.
1617 ///
1618 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1619 ///
1620 /// `opts.repair_wal` controls the other write this function can make; see
1621 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1622 /// no file on disk.
1623 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1624 Self::open_dir(dir, opts, true)
1625 }
1626
1627 /// Open without taking the cross-process write lock for the handle's
1628 /// lifetime.
1629 ///
1630 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1631 /// its handle open indefinitely, so it takes the lock per write instead of
1632 /// keeping every other process out of the store for as long as it runs.
1633 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1634 Self::open_dir(dir, OpenOptions::default(), false)
1635 }
1636
1637 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1638 // Header-only peek — 6 bytes, no full decode.
1639 let snap_version = snapshot_version_at(dir)?;
1640
1641 // Full load: decode snapshot + replay WAL + rebuild indexes.
1642 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1643
1644 // A read-only handle writes nothing at open, so it never migrates —
1645 // the old-format snapshot is loaded and left exactly as it is.
1646 if opts.auto_migrate && !opts.read_only {
1647 match snap_version {
1648 Some(ver) if ver < core_storage::snapshot::VERSION => {
1649 let _tm = std::time::Instant::now();
1650 // Copy the original snapshot to .bak at OS level — no in-memory
1651 // buffer required for a 2+ GiB file.
1652 //
1653 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1654 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1655 // A torn .bak on crash is acceptable because the original
1656 // snapshot.bin is the authoritative source until after the rename.
1657 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1658 .map_err(core_storage::GraphError::Io)?;
1659 trace_migrate!("bak copy done", _tm);
1660 // Rewrite snapshot at current version; keep WAL intact.
1661 db.snapshot_with(SnapshotOptions {
1662 keep_wal: true,
1663 ..SnapshotOptions::default()
1664 })?;
1665 trace_migrate!("snapshot_with done", _tm);
1666 }
1667 Some(_) => {
1668 // Already current version: remove any leftover .bak.
1669 let bak = dir.join("snapshot.bin.bak");
1670 if bak.exists() {
1671 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1672 }
1673 }
1674 None => {
1675 // WAL-only store — nothing to migrate on open.
1676 }
1677 }
1678 }
1679
1680 Ok(db)
1681 }
1682
1683 /// Open a read-only view of the database as it existed after `commit`.
1684 ///
1685 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1686 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1687 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1688 ///
1689 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1690 /// so as-of can only reach commits recorded in the current WAL (those
1691 /// written after the most recent snapshot, or all commits if no snapshot
1692 /// was ever taken). Commit 0 in `open_at` always refers to the first
1693 /// frame in the WAL that exists on disk, not the first ever write to the
1694 /// database. When the on-disk snapshot recorded that it truncated the
1695 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1696 /// before frame replay, so the as-of view includes all pre-snapshot data.
1697 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1698 /// are ignored and replay is WAL-only, as before.
1699 ///
1700 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1701 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1702 /// `stats()` work normally.
1703 ///
1704 /// # Errors
1705 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1706 /// when the WAL is empty after a snapshot).
1707 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1708 Self::open_at_with(RealFs::new(dir)?, commit)
1709 }
1710
1711 /// Run a **read-only** Cypher query against the graph as it existed at
1712 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1713 /// of this store's directory at that commit and executes the read there.
1714 ///
1715 /// The current instance is unaffected. Write statements are rejected (the
1716 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1717 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1718 /// state. Prefer this over holding many historical instances open.
1719 ///
1720 /// # Errors
1721 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1722 /// - A query error for a malformed or write query.
1723 pub fn query_at(
1724 &self,
1725 commit: u64,
1726 cypher: &str,
1727 params: &std::collections::BTreeMap<String, Value>,
1728 ) -> Result<ResultSet> {
1729 let temporal = self.open_at_for_read(commit, cypher)?;
1730 temporal.query(cypher, params)
1731 }
1732
1733 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1734 ///
1735 /// The **graph** is as of `commit`; the **role definition** is as it is
1736 /// now, because `roles.json` is a sidecar and is never a WAL record — it
1737 /// has no past version to read. A role's `keys` and `labels` are resolved
1738 /// against the commit-`commit` graph, so a role that may see a label sees
1739 /// exactly the nodes that carried it then, and an explicit key that did
1740 /// not exist yet resolves to nothing.
1741 ///
1742 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1743 /// only narrow what a role may see, never widen it.
1744 ///
1745 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1746 /// them.
1747 ///
1748 /// # Errors
1749 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1750 /// range; the error carries that range.
1751 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1752 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1753 /// - A query error for a malformed or write query.
1754 pub fn query_at_scoped(
1755 &self,
1756 commit: u64,
1757 cypher: &str,
1758 params: &std::collections::BTreeMap<String, Value>,
1759 scope: AsOfScope<'_>,
1760 ) -> Result<ResultSet> {
1761 let temporal = self.open_at_for_read(commit, cypher)?;
1762 // One resolver answers "what may this role see" — `mask_for_role` — and
1763 // it runs against the temporal handle, so the answer is the as-of one.
1764 let mask = match scope {
1765 AsOfScope::Role(role) => temporal.mask_for_role(role)?,
1766 AsOfScope::Keys(keys) => {
1767 crate::mask::NodeMask::from_keys(&temporal, keys.iter().map(String::as_str))
1768 }
1769 AsOfScope::RoleAndKeys(role, keys) => {
1770 temporal
1771 .mask_for_role(role)?
1772 .intersect(&crate::mask::NodeMask::from_keys(
1773 &temporal,
1774 keys.iter().map(String::as_str),
1775 ))
1776 }
1777 };
1778 temporal.query_masked(cypher, params, &mask)
1779 }
1780
1781 /// Open the temporal view for a time-travel read and refuse write Cypher.
1782 ///
1783 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1784 /// resolve the commit and reject writes identically.
1785 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1786 let dir = self.fs.dir().to_path_buf();
1787 let temporal = Self::open_at(&dir, commit)?;
1788 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1789 detail: format!("lex: {e}"),
1790 })?) {
1791 return Err(GraphError::QueryError {
1792 detail: "query_at is read-only: write statements are not permitted in a \
1793 time-travel query"
1794 .into(),
1795 });
1796 }
1797 Ok(temporal)
1798 }
1799}
1800
1801impl<F: Fs> GraphDb<F> {
1802 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1803 pub fn open_with(fs: F) -> Result<Self> {
1804 Self::open_with_repair(fs, true)
1805 }
1806
1807 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1808 /// prefix without writing the truncation back. See
1809 /// [`OpenOptions::repair_wal`].
1810 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1811 Self::open_generic(
1812 fs,
1813 OpenOptions {
1814 repair_wal,
1815 ..OpenOptions::default()
1816 },
1817 true,
1818 )
1819 }
1820
1821 /// Shared open path.
1822 ///
1823 /// `hold_lock` requests the cross-process write lock for the whole handle
1824 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1825 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1826 /// `false` and takes the lock per write instead, so that a long-lived
1827 /// server does not keep every other process out of the store.
1828 ///
1829 /// A read-only open never takes the lock regardless of `hold_lock`.
1830 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1831 let mut db = Self::new_empty(fs, opts);
1832 db.read_only = opts.read_only;
1833 if hold_lock && !opts.read_only {
1834 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1835 return Err(GraphError::Busy { holder: None });
1836 }
1837 db.holds_lifetime_lock = true;
1838 }
1839 db.load_from_disk(LoadOrigin::Open)?;
1840 Ok(db)
1841 }
1842
1843 /// A handle with no state loaded: every field at its empty value, the
1844 /// filesystem and options in place. Only [`load_from_disk`] makes it
1845 /// usable.
1846 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1847 Self {
1848 fs,
1849 ids: IdMap::new(),
1850 syms: Interner::new(),
1851 topo: Topology::new(),
1852 props: ColumnStore::new(),
1853 labels: Vec::new(),
1854 edge_props: EdgeProps::new(),
1855 engine: RuleEngine::new(),
1856 view_store: ViewStore::new(),
1857 fulltext: FulltextIndex::new(),
1858 prop_index: PropertyIndex::new(),
1859 event_sink: None,
1860 fsync: FsyncPolicy::Strict,
1861 commit_seq: 0,
1862 roles: Some(vec![]),
1863 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1864 subscriptions: Vec::new(),
1865 query_subscriptions: Vec::new(),
1866 sub_capacity: DEFAULT_SUB_CAPACITY,
1867 read_only: false,
1868 total_wal_commits: 0,
1869 base: None,
1870 fold_overlay: None,
1871 delta_tail: Vec::new(),
1872 commits_since_fold: 0,
1873 defer_events: false,
1874 deferred_events: Vec::new(),
1875 degraded: false,
1876 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1877 v8_sections_mutex: std::sync::Mutex::new(()),
1878 last_change: HashMap::new(),
1879 wal_archive_retention: None,
1880 wal_horizon_floor: 0,
1881 archive_genesis_chain: false,
1882 pending_write_authz: None,
1883 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1884 .ok()
1885 .and_then(|v| v.parse().ok())
1886 .unwrap_or(100),
1887 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1888 entries: std::collections::VecDeque::new(),
1889 total: 0,
1890 }),
1891 started_at: std::time::Instant::now(),
1892 wal_consumed: 0,
1893 snapshot_ident: None,
1894 open_opts: opts,
1895 holds_lifetime_lock: false,
1896 lock_denied: false,
1897 pinned: false,
1898 }
1899 }
1900
1901 /// Return every field describing stored graph state to its empty value,
1902 /// leaving this handle's own identity alone.
1903 ///
1904 /// Preserved on purpose: the filesystem, open options, lock ownership, the
1905 /// event sink and subscriptions, fsync policy, degraded flag, and the
1906 /// slow-query configuration and log. A caller that registered a sink or a
1907 /// subscription keeps it across a reload.
1908 fn reset_for_reload(&mut self) {
1909 self.ids = IdMap::new();
1910 self.syms = Interner::new();
1911 self.topo = Topology::new();
1912 self.props = ColumnStore::new();
1913 self.labels = Vec::new();
1914 self.edge_props = EdgeProps::new();
1915 self.engine = RuleEngine::new();
1916 self.view_store = ViewStore::new();
1917 self.fulltext = FulltextIndex::new();
1918 self.prop_index = PropertyIndex::new();
1919 self.commit_seq = 0;
1920 self.roles = Some(vec![]);
1921 // A fresh cache, not a cleared one: any reader snapshot still holding
1922 // the old `Arc` keeps it to itself, so nothing it memoised against the
1923 // pre-reload store can be read back through this handle.
1924 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
1925 self.total_wal_commits = 0;
1926 self.base = None;
1927 self.fold_overlay = None;
1928 self.delta_tail = Vec::new();
1929 self.commits_since_fold = 0;
1930 self.deferred_events = Vec::new();
1931 self.v8_sections_loaded
1932 .store(false, std::sync::atomic::Ordering::Release);
1933 self.last_change = HashMap::new();
1934 self.wal_horizon_floor = 0;
1935 self.archive_genesis_chain = false;
1936 self.pending_write_authz = None;
1937 self.wal_consumed = 0;
1938 self.snapshot_ident = None;
1939 }
1940
1941 /// Load the snapshot base and replay the WAL into an empty handle — the
1942 /// whole of what opening a store does after the struct exists.
1943 ///
1944 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1945 /// rebuild a handle in place, without ownership of `F`, when another
1946 /// process replaces the snapshot underneath it.
1947 ///
1948 /// `origin` decides whether the two repair writes this function can make
1949 /// are appropriate; see [`LoadOrigin`].
1950 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1951 // Both writes below are crash recovery, and only an open is entitled to
1952 // perform them. A read-only handle promises to touch nothing, and a
1953 // reload driven by `refresh` is looking at a store another process is
1954 // actively writing: what looks like a torn tail there is a peer
1955 // mid-append, and what looks like an orphaned archive may be one that
1956 // peer is about to reference.
1957 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1958 let repair_wal = self.open_opts.repair_wal && may_repair;
1959 let db = self;
1960 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1961 db.archive_genesis_chain = db.fs.has_genesis_marker();
1962 // Opening cleanup: remove orphaned archives — archives whose frames all
1963 // fall below the horizon floor. Orphans arise when a crash interrupted
1964 // the retention-prune sequence after the floor was written but before
1965 // all surplus archives were deleted. Safe to delete: floor already
1966 // accounts for their frames.
1967 if may_repair {
1968 db.cleanup_orphaned_archives()?;
1969 }
1970 let _t0 = std::time::Instant::now();
1971 // Peek 6 bytes to determine snapshot version without reading the full
1972 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1973 // default impl reads all bytes and truncates (still correct).
1974 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1975 // V8 and V9 share the mmap-able container; V9 only adds section 12.
1976 let is_v8 = snap_header.len() >= 6
1977 && &snap_header[0..4] == b"GDB1"
1978 && matches!(
1979 u16::from_le_bytes([snap_header[4], snap_header[5]]),
1980 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
1981 );
1982 if is_v8 {
1983 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1984 // No 2.4GB heap Vec is allocated on RealFs.
1985 let mapped = Arc::new(
1986 if let Some(snap_path) = db.fs.snapshot_path() {
1987 core_storage::v8::MappedBase::map(&snap_path)
1988 } else {
1989 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1990 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1991 }
1992 .map_err(|e| GraphError::Corrupt {
1993 detail: format!("v8: mmap open: {e:?}"),
1994 })?,
1995 );
1996 db.restore_v8_base(Arc::clone(&mapped))?;
1997 trace_open!("restore_v8_base", _t0);
1998 db.base = Some(mapped);
1999 trace_open!("base assigned", _t0);
2000 } else if !snap_header.is_empty() {
2001 // Legacy V5-V7: full read required for decode.
2002 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2003 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2004 db.restore_snapshot_state(state)?;
2005 }
2006 }
2007 // else: snap_header is empty = no snapshot file, fresh store.
2008 //
2009 // Seed commit_seq from the highest seq persisted in last_change so that
2010 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2011 // already stored in the snapshot. Without this, a db with one snapshot
2012 // commit would save last_change["a"]=1, then on reopen the first WAL
2013 // frame would replay at seq=1 again — colliding and making WAL-tail
2014 // mutations indistinguishable from the snapshot baseline.
2015 //
2016 // Safety invariant (seq-recycling):
2017 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2018 // last_change because they belonged to a previous db lifetime — a new
2019 // db starts at commit_seq=0 with an empty last_change. Therefore no
2020 // CAS precondition can carry a recycled seq as its `expected` value
2021 // and accidentally match a live node's last_change entry.
2022 //
2023 // `expected:0` on a deleted-then-reinserted node:
2024 // After deletion, last_changed() returns None; callers that call
2025 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2026 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2027 // expected=0 correctly conflicts. The only way to observe actual=0 in
2028 // a CasConflict would be a caller that invented expected=0 without ever
2029 // calling last_changed() — unreachable via the documented API contract.
2030 if let Some(&max_seq) = db.last_change.values().max() {
2031 db.commit_seq = db.commit_seq.max(max_seq);
2032 }
2033 let bytes = db.fs.read(FileId::Wal)?;
2034 let (records, valid_len) = decode_all(&bytes);
2035 // The valid prefix is replayed either way; `repair_wal` only decides
2036 // whether the truncation is written back. A reader that races a live
2037 // appender must not persist a truncation the writer never asked for.
2038 if valid_len < bytes.len() && repair_wal {
2039 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2040 }
2041 // WAL-present path: build indexes eagerly BEFORE replay so that the
2042 // first replayed record does not trigger the lazy-init guard (which
2043 // would call reindex_all_load_state on an empty graph, defeating the
2044 // point of restoring IVF/HNSW blobs from the snapshot).
2045 if !records.is_empty() {
2046 db.ensure_v8_base_sections_loaded();
2047 trace_open!("lazy sections loaded (WAL path)", _t0);
2048 }
2049 let replayed = db.apply_frames(records)?;
2050 // The cursor sits at the end of the valid prefix, not the end of the
2051 // file: a torn or still-being-written tail is unconsumed by definition
2052 // and stays visible to `is_stale` until it decodes.
2053 db.wal_consumed = valid_len as u64;
2054 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2055 trace_open!("wal replay done", _t0);
2056 // Rebuild view values after WAL replay only when there is no V8 base.
2057 // With a V8 base, view values are correct in the snapshot and are updated
2058 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2059 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2060 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2061 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2062 // base value).
2063 if db.base.is_none() {
2064 let topo_view = TopologyView::owned(&db.topo);
2065 db.view_store
2066 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2067 }
2068 // Rebuild full-text index after WAL replay. Corrects drift from
2069 // per-record incremental apply during replay.
2070 db.fulltext.rebuild_all(
2071 &db.ids,
2072 &db.labels,
2073 &db.syms,
2074 build_props_view(&db.props, &db.base),
2075 );
2076 db.prop_index.rebuild_all(
2077 &db.ids,
2078 &db.labels,
2079 &db.syms,
2080 build_props_view(&db.props, &db.base),
2081 );
2082 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2083 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2084 db.roles = Self::load_roles_from_fs(&db.fs)?;
2085 // Capture the initial MVCC fold so reader() is ready immediately.
2086 db.fold_now();
2087 trace_open!("open_with complete", _t0);
2088 Ok(replayed)
2089 }
2090
2091 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2092 /// replay does — same `apply` calls, same per-frame delta drain, same
2093 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2094 /// edges appear identically whether a frame arrives at open, from a local
2095 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2096 ///
2097 /// Returns the number of frames applied.
2098 ///
2099 /// Deltas are drained and discarded per frame: replayed frames are already
2100 /// reflected on disk, so they are not news to a subscriber, and draining
2101 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2102 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2103 if records.is_empty() {
2104 return Ok(0);
2105 }
2106 // Materialize any state retained in the mmap base before the first
2107 // frame lands, so a replayed record cannot trip the lazy-init guard and
2108 // rebuild indexes from an empty graph. Both calls are idempotent.
2109 self.ensure_v8_base_sections_loaded();
2110 self.engine.consume_retained_state_eager(
2111 &self.ids,
2112 &self.syms,
2113 &self.labels,
2114 build_props_view(&self.props, &self.base),
2115 );
2116 let applied = records.len();
2117 for rec in records {
2118 self.apply(&rec)?;
2119 let _ = self.engine.drain_deltas();
2120 // Track commit_seq during replay so last_change entries are
2121 // consistent with the seqs assigned by log_then_apply_with on
2122 // subsequent live commits. After N replayed frames, commit_seq=N;
2123 // live commits begin at N+1.
2124 self.commit_seq += 1;
2125 let replay_seq = self.commit_seq;
2126 self.update_last_change_from_rec(&rec, replay_seq);
2127 }
2128 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2129 // this assert catches the regression in debug builds immediately.
2130 debug_assert_eq!(
2131 self.engine.pending_delta_count(),
2132 0,
2133 "pending_deltas non-empty after replay — \
2134 per-frame drain must run inside the loop to keep memory O(1)"
2135 );
2136 // T2 note: the per-frame drain IS the suppression seam for replay.
2137 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2138 // replaying subscribers; the mechanism is already in place.
2139 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2140 Ok(applied)
2141 }
2142
2143 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2144 //
2145 // mushroomdb is many-readers / one-writer across processes. Writers take an
2146 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2147 // Every handle tracks how much of the WAL it has consumed, so it can pick
2148 // up another process's commits by decoding only the new tail rather than
2149 // reopening. See `docs/site/concurrency.md`.
2150
2151 /// Whether the store on disk has moved ahead of (or out from under) this
2152 /// handle's in-memory state.
2153 ///
2154 /// True when the WAL's length differs from this handle's cursor — another
2155 /// process committed, or is mid-append — or when the snapshot file's
2156 /// identity changed. Costs two metadata lookups and reads no file contents,
2157 /// so it is cheap enough for a read path to call.
2158 ///
2159 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2160 /// pinned to one commit and later commits are deliberately invisible to it.
2161 pub fn is_stale(&self) -> Result<bool> {
2162 if self.pinned {
2163 return Ok(false);
2164 }
2165 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2166 return Ok(true);
2167 }
2168 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2169 }
2170
2171 /// Bring this handle up to date with every commit other processes have made,
2172 /// and return how many frames were applied.
2173 ///
2174 /// The WAL tail is decoded from this handle's cursor and applied through the
2175 /// same path the open replay uses, so rules fire and derived edges appear
2176 /// exactly as they would on a fresh open. Interners, id maps and indexes
2177 /// stay valid for the same reason.
2178 ///
2179 /// A frame another process is still writing is left alone: a trailing
2180 /// partial frame is a wait, not a corruption, and the handle stays stale
2181 /// until that frame is complete. Nothing is written to disk, so a read-only
2182 /// handle can refresh freely.
2183 ///
2184 /// When the snapshot file's identity changed, or the WAL is shorter than
2185 /// this handle's cursor, the WAL no longer continues our state — another
2186 /// process snapshotted or archived. The handle is then rebuilt from disk
2187 /// with the options it was opened with, and the return value is the number
2188 /// of frames in the new WAL.
2189 ///
2190 /// Returns 0 for an as-of view, which never follows later commits.
2191 ///
2192 /// # Errors
2193 ///
2194 /// An error here leaves the handle **degraded**: it got partway through
2195 /// applying the tail, or partway through a reload, so its in-memory state
2196 /// no longer matches any point on disk. Further mutations are refused and
2197 /// the handle must be reopened. Nothing on disk was damaged — the store
2198 /// itself is fine, and a fresh open recovers it.
2199 pub fn refresh(&mut self) -> Result<u64> {
2200 if self.pinned {
2201 return Ok(0);
2202 }
2203 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2204 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2205 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2206 // The WAL no longer continues our state: rebuild from disk. State
2207 // is cleared first, so a failed load leaves an empty handle — mark
2208 // it degraded rather than let a caller read an empty graph as if
2209 // it were the store's contents.
2210 self.reset_for_reload();
2211 return match self.load_from_disk(LoadOrigin::Reload) {
2212 Ok(frames) => Ok(frames as u64),
2213 Err(e) => {
2214 self.degraded = true;
2215 Err(e)
2216 }
2217 };
2218 }
2219 if wal_len == self.wal_consumed {
2220 return Ok(0);
2221 }
2222 let tail = self
2223 .fs
2224 .read_range(FileId::Wal, self.wal_consumed)
2225 .map_err(GraphError::Io)?;
2226 let (records, valid_len) = decode_all(&tail);
2227 let applied = match self.apply_frames(records) {
2228 Ok(n) => n,
2229 Err(e) => {
2230 // Some frames landed and some did not, and the cursor cannot
2231 // say how many. Advancing it would skip the rest; leaving it
2232 // would replay what already applied. Neither is recoverable in
2233 // place, so refuse further writes and require a reopen.
2234 self.degraded = true;
2235 return Err(e);
2236 }
2237 };
2238 // Advance by the bytes actually decoded, never by the file length: an
2239 // incomplete trailing frame stays unconsumed for the next refresh.
2240 self.wal_consumed += valid_len as u64;
2241 if applied > 0 {
2242 // Peer commits must reach `reader()` snapshots taken from here on.
2243 // A full fold is what open does; refresh does not build per-commit
2244 // deltas, so there is nothing cheaper that stays correct.
2245 self.fold_now();
2246 }
2247 Ok(applied as u64)
2248 }
2249
2250 /// Byte offset of the WAL prefix this handle has applied.
2251 ///
2252 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2253 #[doc(hidden)]
2254 pub fn wal_consumed(&self) -> u64 {
2255 self.wal_consumed
2256 }
2257
2258 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2259 /// failed group off the tail, so the cursor still describes the file.
2260 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2261 self.wal_consumed = len;
2262 }
2263
2264 /// One non-blocking attempt at the cross-process write lock.
2265 ///
2266 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2267 /// in-process write guard. That ordering is what keeps a busy peer in
2268 /// another process from stalling this process's readers.
2269 ///
2270 /// A handle that owns the lock for its lifetime always succeeds.
2271 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2272 if self.holds_lifetime_lock {
2273 return Ok(true);
2274 }
2275 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2276 }
2277
2278 /// Poll for the cross-process write lock until `wait` elapses.
2279 ///
2280 /// One attempt is always made, so a zero wait is a single try. Returns
2281 /// `false` when the lock is still held elsewhere at the deadline; nothing
2282 /// has been written and retrying later is safe.
2283 ///
2284 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2285 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2286 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2287 /// that it holds no in-process guard while it waits.
2288 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2289 let deadline = std::time::Instant::now() + wait;
2290 loop {
2291 if self.try_cross_process_lock()? {
2292 return Ok(true);
2293 }
2294 let now = std::time::Instant::now();
2295 if now >= deadline {
2296 return Ok(false);
2297 }
2298 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2299 }
2300 }
2301
2302 /// Open a cross-process write scope, given the outcome of an already-made
2303 /// lock attempt.
2304 ///
2305 /// The caller polls for the lock first — outside any in-process guard — and
2306 /// passes what it got. On success this refreshes, so the writes about to
2307 /// happen land on top of every other process's commits. On failure the
2308 /// handle refuses WAL-appending mutations and `snapshot()` with
2309 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2310 /// closes the scope, so a caller holding a guard cannot write behind
2311 /// another process's back.
2312 ///
2313 /// A handle that already owns the lock for its lifetime skips the refresh:
2314 /// no other process can have written, so there is nothing to pick up.
2315 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2316 self.lock_denied = !acquired;
2317 if !acquired || self.holds_lifetime_lock {
2318 return Ok(());
2319 }
2320 if let Err(e) = self.refresh() {
2321 // Do not hold a lock we cannot use: release it and let the caller
2322 // see the underlying failure.
2323 let _ = self.fs.unlock();
2324 self.lock_denied = true;
2325 return Err(e);
2326 }
2327 Ok(())
2328 }
2329
2330 /// Close a cross-process write scope opened by
2331 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2332 /// clear the Busy latch. Safe to call when the lock was never taken.
2333 pub(crate) fn end_write_lock(&mut self) {
2334 self.lock_denied = false;
2335 if !self.holds_lifetime_lock {
2336 // Releasing a lock we do not hold is a no-op; a failure to release
2337 // is reported by the OS closing the descriptor at handle drop.
2338 let _ = self.fs.unlock();
2339 }
2340 }
2341
2342 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2343 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2344 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2345 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2346 /// Restore all persisted state from a decoded snapshot. Shared by
2347 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2348 fn restore_snapshot_state(
2349 &mut self,
2350 state: core_storage::snapshot::SnapshotState,
2351 ) -> Result<()> {
2352 self.ids = state.ids;
2353 self.syms = state.syms;
2354 self.topo = state.topo;
2355 self.props = state.props;
2356 self.labels = state.labels;
2357 self.edge_props = state.edge_props;
2358 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2359 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2360 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2361 // open successfully and panic later in `NodeRef::label()` or
2362 // `neighborhood_masked()`. Catching it here turns those into typed
2363 // `GraphError::Corrupt` at open time.
2364 {
2365 let ids_len = self.ids.len();
2366 if self.labels.len() != ids_len {
2367 return Err(GraphError::Corrupt {
2368 detail: format!(
2369 "snapshot: labels vec has {} entries but id table has {} total slots",
2370 self.labels.len(),
2371 ids_len,
2372 ),
2373 });
2374 }
2375 let syms_len = self.syms.len() as u32;
2376 for (i, &sym) in self.labels.iter().enumerate() {
2377 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2378 if sym == u32::MAX {
2379 if !is_tombstoned {
2380 return Err(GraphError::Corrupt {
2381 detail: format!(
2382 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2383 ),
2384 });
2385 }
2386 } else if sym >= syms_len {
2387 return Err(GraphError::Corrupt {
2388 detail: format!(
2389 "snapshot: label at id slot {i} references sym {sym} \
2390 which is out of interner range ({syms_len})"
2391 ),
2392 });
2393 }
2394 }
2395 }
2396 let defs: Vec<RuleDef> = state
2397 .rule_defs
2398 .iter()
2399 .map(|b| {
2400 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2401 detail: format!("snapshot rule_def deserialize: {e}"),
2402 })
2403 })
2404 .collect::<Result<Vec<_>>>()?;
2405 self.engine =
2406 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2407 // Candidate indexes are rebuilt lazily on the first mutation (see
2408 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2409 // snapshot are retained without deserializing so that:
2410 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2411 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2412 // lazy-init guard which calls reindex_all_load_state (the scan
2413 // skips the HNSW build for every side the blob supplies).
2414 // - WAL-present: open_with calls consume_retained_state_eager before
2415 // replay so HNSW/IVF are live before any record fires the hooks.
2416 let ivf_bytes = if state.ivf_state.is_empty() {
2417 Vec::new()
2418 } else {
2419 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2420 };
2421 // Store blobs without eagerly deserializing them.
2422 self.engine
2423 .store_snapshot_state(state.hnsw_state, ivf_bytes);
2424 // Restore view defs from snapshot (V5).
2425 // The ColumnStore already contains view values from the snapshot;
2426 // use restore_view (no collision check, no backfill) so the store
2427 // is aware of the definitions. rebuild_all runs after WAL replay.
2428 for def_bytes in &state.view_defs {
2429 let def: ViewDef =
2430 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2431 detail: format!("snapshot view_def deserialize: {e}"),
2432 })?;
2433 self.view_store
2434 .restore_view(def)
2435 .map_err(|e| GraphError::Corrupt {
2436 detail: format!("snapshot view restore: {e}"),
2437 })?;
2438 }
2439 Ok(())
2440 }
2441
2442 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2443 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2444 ///
2445 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2446 /// deserialization and view rebuild have access to all column data.
2447 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2448 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2449 detail: format!("v8: ids section: {e:?}"),
2450 })?);
2451 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2452 detail: format!("v8: syms section: {e:?}"),
2453 })?);
2454
2455 // C1: self.props is left as an empty overlay. Column reads go through
2456 // props_view() (ColumnsView::with_base), which consults the archived base
2457 // section zero-copy. This avoids the O(columns) heap copy at every open.
2458
2459 // self.topo deliberately left as Topology::new() — overlay path.
2460
2461 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2462 detail: format!("v8: meta section: {e:?}"),
2463 })?)
2464 .map_err(|e| GraphError::Corrupt {
2465 detail: format!("v8: meta decode: {e:?}"),
2466 })?;
2467 self.labels = meta.labels;
2468 // Cross-section label integrity: labels must cover every id slot (live
2469 // and tombstoned), every non-sentinel sym must be within the interner's
2470 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2471 // sentinel label. Without this check, a crafted snapshot where the META
2472 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2473 // sym ids, or a sentinel label on a live node, would open successfully
2474 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2475 // related read paths. Catching the inconsistency here converts those
2476 // panics into typed `GraphError::Corrupt` at open time.
2477 {
2478 let ids_len = self.ids.len();
2479 if self.labels.len() != ids_len {
2480 return Err(GraphError::Corrupt {
2481 detail: format!(
2482 "v8: labels section has {} entries but id table has {} total slots",
2483 self.labels.len(),
2484 ids_len,
2485 ),
2486 });
2487 }
2488 let syms_len = self.syms.len() as u32;
2489 for (i, &sym) in self.labels.iter().enumerate() {
2490 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2491 if sym == u32::MAX {
2492 // Sentinel is only valid for tombstoned slots.
2493 if !is_tombstoned {
2494 return Err(GraphError::Corrupt {
2495 detail: format!(
2496 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2497 ),
2498 });
2499 }
2500 } else if sym >= syms_len {
2501 return Err(GraphError::Corrupt {
2502 detail: format!(
2503 "v8: label at id slot {i} references sym {sym} \
2504 which is out of interner range ({syms_len})"
2505 ),
2506 });
2507 }
2508 }
2509 }
2510 // C3: self.edge_props stays as an empty overlay. Reads go through
2511 // edge_props_view() which consults the mmap'd base section zero-copy
2512 // via EdgePropsView::with_base. No heap decode at open time.
2513
2514 // Restore rule engine.
2515 let (rule_def_bytes, rule_tripped, rule_fires) =
2516 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2517 GraphError::Corrupt {
2518 detail: format!("v8: rules_meta section: {e:?}"),
2519 }
2520 })?);
2521 let defs: Vec<RuleDef> = rule_def_bytes
2522 .iter()
2523 .map(|b| {
2524 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2525 detail: format!("v8: rule_def deserialize: {e}"),
2526 })
2527 })
2528 .collect::<Result<Vec<_>>>()?;
2529 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2530 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2531 // `ensure_v8_base_sections_loaded` reads them on first use from
2532 // `self.base` (set by the caller immediately after this returns).
2533 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2534
2535 // Restore view definitions.
2536 let view_defs =
2537 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2538 detail: format!("v8: views section: {e:?}"),
2539 })?);
2540 for def_bytes in &view_defs {
2541 let def: ViewDef =
2542 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2543 detail: format!("v8: view_def deserialize: {e}"),
2544 })?;
2545 self.view_store
2546 .restore_view(def)
2547 .map_err(|e| GraphError::Corrupt {
2548 detail: format!("v8: view restore: {e}"),
2549 })?;
2550 }
2551 // Load the last-change map from section 11 (small section; load eagerly).
2552 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2553 // in that case and `decode_last_change_bytes` returns an empty map.
2554 let last_change_raw = mapped
2555 .last_change_bytes()
2556 .map_err(|e| GraphError::Corrupt {
2557 detail: format!("v8: last_change section: {e:?}"),
2558 })?;
2559 self.last_change = decode_last_change_bytes(last_change_raw);
2560
2561 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2562 // the file. Pure bounds check — no bytes read, no page faults triggered.
2563 // Catches truncated snapshots at open time before the lazy deferred reads.
2564 mapped.validate_section_bounds().map_err(|e| match e {
2565 GraphError::Corrupt { detail } => GraphError::Corrupt {
2566 detail: format!("v8: section bounds: {detail}"),
2567 },
2568 other => other,
2569 })?;
2570 Ok(())
2571 }
2572
2573 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2574 /// engine's retained fields on first call. Subsequent calls are a no-op
2575 /// (AtomicBool fast-path).
2576 ///
2577 /// Must be called before any code path that reads or mutates engine
2578 /// provenance, HNSW, or IVF state:
2579 /// - WAL replay (before `consume_retained_state_eager`)
2580 /// - First mutation (`log_then_apply_with`)
2581 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2582 /// - Snapshot (`snapshot_with`)
2583 ///
2584 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2585 fn ensure_v8_base_sections_loaded(&self) {
2586 use std::sync::atomic::Ordering;
2587 if self.v8_sections_loaded.load(Ordering::Acquire) {
2588 return;
2589 }
2590 let _guard = self
2591 .v8_sections_mutex
2592 .lock()
2593 .expect("v8 sections mutex poisoned");
2594 if self.v8_sections_loaded.load(Ordering::Acquire) {
2595 return; // another caller populated while we waited
2596 }
2597 let _t = std::time::Instant::now();
2598 if let Some(base) = &self.base {
2599 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2600 // Bounds are already validated at open time (restore_v8_base →
2601 // validate_section_bounds) — unreachable post-validate_section_bounds;
2602 // unwrap_or_default is a safety belt against impossible errors.
2603 let prov_bytes = base
2604 .provenance_raw_bytes()
2605 .map(|b| b.to_vec())
2606 .unwrap_or_default();
2607 self.engine.store_provenance_bytes(prov_bytes);
2608 // HNSW: decode rkyv blobs into owned map.
2609 let hnsw_state = base
2610 .hnsw_section()
2611 .map(archived_hnsw_to_owned)
2612 .unwrap_or_default();
2613 // IVF: raw bincode bytes; deserialized on first mutation/query.
2614 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2615 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2616 }
2617 self.v8_sections_loaded.store(true, Ordering::Release);
2618 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2619 eprintln!(
2620 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2621 _t.elapsed()
2622 );
2623 }
2624 }
2625
2626 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2627 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2628 /// the full merged topology without going through `self.view()`.
2629 fn topo_view(&self) -> TopologyView<'_> {
2630 match self.base {
2631 None => TopologyView::owned(&self.topo),
2632 Some(ref base) => {
2633 // SAFETY: base lives as long as self; section bounds validated at open.
2634 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2635 let archived = base
2636 .topology()
2637 .expect("base topology section bounds validated at open");
2638 TopologyView::with_base(&self.topo, archived)
2639 }
2640 }
2641 }
2642
2643 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2644 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2645 /// overlay first, then fall through to the archived base section zero-copy.
2646 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2647 match self.base {
2648 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2649 Some(ref base) => {
2650 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2651 let archived = base
2652 .columns()
2653 .expect("base columns section bounds validated at open");
2654 core_storage::v8::seam::ColumnsView::with_base_cached(
2655 &self.props,
2656 archived,
2657 base.mixed_cache(),
2658 )
2659 .with_shared_strings(base_string_table(base))
2660 }
2661 }
2662 }
2663
2664 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2665 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2666 ///
2667 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2668 /// through to the archived base section zero-copy. Tombstones in the
2669 /// overlay mask deleted-from-base entries.
2670 fn edge_props_view(&self) -> EdgePropsView<'_> {
2671 match self.base {
2672 None => EdgePropsView::owned(&self.edge_props),
2673 Some(ref base) => {
2674 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2675 let archived = base
2676 .edge_props_section()
2677 .expect("base edge_props section bounds validated at open");
2678 EdgePropsView::with_base(&self.edge_props, archived)
2679 }
2680 }
2681 }
2682
2683 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2684 // An as-of view never writes and is pinned to one commit: it takes no
2685 // cross-process lock and does not follow later commits.
2686 let mut db = Self::new_empty(
2687 fs,
2688 OpenOptions {
2689 repair_wal: false,
2690 auto_migrate: false,
2691 read_only: true,
2692 },
2693 );
2694 db.pinned = true; // read_only is set after replay, but pinning is immediate
2695 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2696 db.archive_genesis_chain = db.fs.has_genesis_marker();
2697 // Same orphaned-archive cleanup as open_with: floor was written first
2698 // during pruning, so a crash may have left stale archives below floor.
2699 db.cleanup_orphaned_archives()?;
2700 // Collect archive frames (oldest-first) and live WAL frames.
2701 // Archives represent pre-snapshot history; the snapshot captures the
2702 // cumulative state at the time of archiving. Crash-window guarantee:
2703 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2704 // B: crash after rename, before new WAL → archive present, WAL
2705 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2706 // C: crash after new baseline WAL written → normal post-archive.
2707 let archive_ns = db.fs.list_archives()?;
2708 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2709 for n in &archive_ns {
2710 let arc_bytes = db.fs.read_archive(*n)?;
2711 let (arc_frames, _) = decode_all(&arc_bytes);
2712 archive_frames_all.extend(arc_frames);
2713 }
2714 let total_archive_frames = archive_frames_all.len() as u64;
2715
2716 let live_bytes = db.fs.read(FileId::Wal)?;
2717 let (live_records, _valid_len) = decode_all(&live_bytes);
2718 let total_surviving = total_archive_frames + live_records.len() as u64;
2719 // Global total including any pruned history below the horizon floor.
2720 let total = db.wal_horizon_floor + total_surviving;
2721
2722 // Horizon and range check.
2723 if commit < db.wal_horizon_floor {
2724 return Err(GraphError::CommitOutOfRange {
2725 commit,
2726 total,
2727 floor: db.wal_horizon_floor,
2728 });
2729 }
2730 if commit >= total {
2731 return Err(GraphError::CommitOutOfRange {
2732 commit,
2733 total,
2734 floor: db.wal_horizon_floor,
2735 });
2736 }
2737
2738 // Local index into surviving frames (0 = first frame of oldest archive).
2739 let local = commit - db.wal_horizon_floor;
2740
2741 if local < total_archive_frames {
2742 // Target commit is in an archive. Correct replay from empty state
2743 // is only possible when the archive chain is an uninterrupted
2744 // genesis chain (first archive taken from a fresh store, no prior
2745 // WAL truncation) and no archives have been pruned (floor == 0).
2746 //
2747 // If either condition is violated the prefix needed to reconstruct
2748 // the requested state is gone; refuse rather than return wrong data.
2749 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2750 return Err(GraphError::CommitOutOfRange {
2751 commit,
2752 total,
2753 floor: db.wal_horizon_floor,
2754 });
2755 }
2756 // Replay all archive frames up to and including the target commit
2757 // from an empty database state. Archives must be replayed in order
2758 // so that dense-id intern tables are built up correctly.
2759 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2760 db.apply(&rec)?;
2761 let _ = db.engine.drain_deltas();
2762 }
2763 } else {
2764 // Target commit is in the live WAL: load snapshot as base, then
2765 // replay the needed live WAL prefix.
2766 //
2767 // Base state: a truncating snapshot (wal_truncated=true) compacts
2768 // all pre-truncation / pre-archive commits. Dense-id records in
2769 // the live WAL reference ids/interns that the snapshot provides.
2770 // Peek 6 bytes (same pattern as open_with).
2771 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2772 let is_v8 = snap_header.len() >= 6
2773 && &snap_header[0..4] == b"GDB1"
2774 && matches!(
2775 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2776 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2777 );
2778 if is_v8 {
2779 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2780 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2781 GraphError::Corrupt {
2782 detail: format!("v8: open_at mmap: {e:?}"),
2783 }
2784 })?;
2785 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2786 } else {
2787 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2788 core_storage::snapshot::decode(&snap_bytes)?
2789 };
2790 if let Some(state) = state {
2791 if state.wal_truncated {
2792 db.restore_snapshot_state(state)?;
2793 }
2794 }
2795 } else if !snap_header.is_empty() {
2796 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2797 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2798 if state.wal_truncated {
2799 db.restore_snapshot_state(state)?;
2800 }
2801 }
2802 }
2803 // else: snap_header empty = no snapshot file.
2804 let live_local = local - total_archive_frames;
2805 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2806 db.apply(&rec)?;
2807 let _ = db.engine.drain_deltas();
2808 }
2809 }
2810 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2811 // post-loop assert in open_with.
2812 debug_assert_eq!(
2813 db.engine.pending_delta_count(),
2814 0,
2815 "pending_deltas non-empty after open_at replay — \
2816 per-frame drain must run inside the loop to keep memory O(1)"
2817 );
2818 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2819 // Rebuild view values after WAL replay so derived-edge-driven views
2820 // reflect the as-of state. open_at always uses the legacy path (no V8
2821 // base), so topo_view is always owned.
2822 {
2823 let topo_view = TopologyView::owned(&db.topo);
2824 db.view_store
2825 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2826 }
2827 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2828 db.fulltext.rebuild_all(
2829 &db.ids,
2830 &db.labels,
2831 &db.syms,
2832 build_props_view(&db.props, &db.base),
2833 );
2834 db.prop_index.rebuild_all(
2835 &db.ids,
2836 &db.labels,
2837 &db.syms,
2838 build_props_view(&db.props, &db.base),
2839 );
2840 // Load roles sidecar (current roles, not point-in-time).
2841 db.roles = Self::load_roles_from_fs(&db.fs)?;
2842 db.read_only = true;
2843 db.total_wal_commits = total;
2844 // Capture initial fold so reader() is immediately usable.
2845 db.fold_now();
2846 Ok(db)
2847 }
2848
2849 /// Whether this instance is a read-only as-of view.
2850 pub fn is_read_only(&self) -> bool {
2851 self.read_only
2852 }
2853
2854 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2855
2856 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2857 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2858 /// the end of `open_with` / `open_at_with` to prime the reader.
2859 fn fold_now(&mut self) {
2860 let frozen = crate::reader::FrozenOverlay {
2861 ids: self.ids.clone(),
2862 syms: self.syms.clone(),
2863 topo: self.topo.clone(),
2864 props: self.props.clone(),
2865 labels: self.labels.clone(),
2866 edge_props: self.edge_props.clone(),
2867 roles: self.roles.clone(),
2868 fulltext: self.fulltext.clone(),
2869 };
2870 self.fold_overlay = Some(Arc::new(frozen));
2871 self.delta_tail.clear();
2872 self.commits_since_fold = 0;
2873 }
2874
2875 /// Capture a lock-free reader snapshot of the current db state.
2876 ///
2877 /// The read lock is held only for the duration of this call (to clone a
2878 /// handful of `Arc` handles). Subsequent query operations run without any
2879 /// lock.
2880 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2881 crate::reader::ReaderSnapshot::new(
2882 self.fold_overlay
2883 .clone()
2884 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2885 self.base.clone(),
2886 self.delta_tail.clone(),
2887 // The snapshot's effective state is exactly this handle's state at
2888 // this commit, so it shares the memo and its version key.
2889 self.commit_seq,
2890 Arc::clone(&self.role_masks),
2891 )
2892 }
2893
2894 /// Total number of WAL commits at the time [`open_at`] was called.
2895 /// Returns 0 for normal (non-as-of) instances.
2896 pub fn total_wal_commits(&self) -> u64 {
2897 self.total_wal_commits
2898 }
2899
2900 /// Apply a record to in-memory state. Used by both live writes and replay,
2901 /// so replay is definitionally identical to the original execution.
2902 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2903 match rec {
2904 WalRecord::InsertNode { label, key, props } => {
2905 let id = self.ids.try_insert(key)?;
2906 let sym = self.syms.intern(label);
2907 if self.labels.len() <= id as usize {
2908 // gap slots are sentinels, never valid label symbols
2909 self.labels.resize(id as usize + 1, u32::MAX);
2910 }
2911 self.labels[id as usize] = sym;
2912 for (field, value) in props {
2913 self.props.set(id, field, value.clone());
2914 }
2915 // Initialize view values for the new node before the engine runs so
2916 // delta-based increments start from a known zero baseline.
2917 self.view_store
2918 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2919 // Fire rules for the newly inserted node.
2920 let cursor = self.engine.pending_delta_count();
2921 let mut eng = std::mem::take(&mut self.engine);
2922 {
2923 let mut gm = make_graph_mut(
2924 &self.ids,
2925 &mut self.syms,
2926 &self.labels,
2927 build_props_view(&self.props, &self.base),
2928 &mut self.topo,
2929 &self.base,
2930 &mut self.edge_props,
2931 );
2932 eng.on_node_changed(id, None, &mut gm);
2933 }
2934 self.engine = eng;
2935 // Process derived-edge deltas for view maintenance.
2936 // Fast path: skip the O(delta_count) allocation when no views exist.
2937 if !self.view_store.is_empty() {
2938 #[cfg(test)]
2939 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2940 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2941 for d in &new_deltas {
2942 self.view_store.on_edge_changed(
2943 d.etype_sym,
2944 d.src_id,
2945 d.dst_id,
2946 d.fired,
2947 &mut self.props,
2948 &build_topo_view(&self.topo, &self.base),
2949 &self.ids,
2950 &self.syms,
2951 &self.labels,
2952 base_columns(&self.base),
2953 );
2954 }
2955 }
2956 // Full-text index maintenance: index enabled fields for this label.
2957 if self.fulltext.has_label(label) {
2958 for (field, value) in props {
2959 if self.fulltext.is_enabled(label, field) {
2960 self.fulltext.add_tokens(id, field, value);
2961 }
2962 }
2963 }
2964 // Property (equality) index maintenance.
2965 if self.prop_index.has_label(label) {
2966 for (field, value) in props {
2967 self.prop_index.set(label, field, id, value);
2968 }
2969 }
2970 }
2971 WalRecord::InsertEdge {
2972 edge_type,
2973 src_key,
2974 dst_key,
2975 } => {
2976 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2977 detail: format!("wal replay references unknown key {src_key}"),
2978 })?;
2979 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2980 detail: format!("wal replay references unknown key {dst_key}"),
2981 })?;
2982 let etype = self.syms.intern(edge_type);
2983 // Skip if the edge is already visible in the merged base+overlay
2984 // view. This keeps WAL replay idempotent when the WAL contains
2985 // pre-snapshot records that are already encoded in a V8 base
2986 // (keep_wal=true opens and crash-before-truncation scenarios).
2987 if self.base.is_some()
2988 && self
2989 .topo_view()
2990 .neighbors(etype, Direction::Out, src)
2991 .contains(&dst)
2992 {
2993 return Ok(());
2994 }
2995 self.topo.add_edge(etype, src, dst);
2996 // View maintenance for manual edge insert.
2997 self.view_store.on_edge_changed(
2998 etype,
2999 src,
3000 dst,
3001 true,
3002 &mut self.props,
3003 &build_topo_view(&self.topo, &self.base),
3004 &self.ids,
3005 &self.syms,
3006 &self.labels,
3007 base_columns(&self.base),
3008 );
3009 // Rule engine: via-hop rules must update when user edges change.
3010 let cursor = self.engine.pending_delta_count();
3011 let mut eng = std::mem::take(&mut self.engine);
3012 {
3013 let mut gm = make_graph_mut(
3014 &self.ids,
3015 &mut self.syms,
3016 &self.labels,
3017 build_props_view(&self.props, &self.base),
3018 &mut self.topo,
3019 &self.base,
3020 &mut self.edge_props,
3021 );
3022 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3023 }
3024 self.engine = eng;
3025 if !self.view_store.is_empty() {
3026 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3027 for d in &new_deltas {
3028 self.view_store.on_edge_changed(
3029 d.etype_sym,
3030 d.src_id,
3031 d.dst_id,
3032 d.fired,
3033 &mut self.props,
3034 &build_topo_view(&self.topo, &self.base),
3035 &self.ids,
3036 &self.syms,
3037 &self.labels,
3038 base_columns(&self.base),
3039 );
3040 }
3041 }
3042 }
3043 WalRecord::SetProp { key, field, value } => {
3044 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3045 detail: format!("wal replay references unknown key {key}"),
3046 })?;
3047 let old_value = build_props_view(&self.props, &self.base)
3048 .get(id, field)
3049 .map(|vr| vr.into_value());
3050 self.props.set(id, field, value.clone());
3051 // Fire rules for the changed field.
3052 let cursor = self.engine.pending_delta_count();
3053 let mut eng = std::mem::take(&mut self.engine);
3054 {
3055 let mut gm = make_graph_mut(
3056 &self.ids,
3057 &mut self.syms,
3058 &self.labels,
3059 build_props_view(&self.props, &self.base),
3060 &mut self.topo,
3061 &self.base,
3062 &mut self.edge_props,
3063 );
3064 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3065 }
3066 self.engine = eng;
3067 // Derived-edge deltas → view updates.
3068 if !self.view_store.is_empty() {
3069 #[cfg(test)]
3070 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3071 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3072 for d in &new_deltas {
3073 self.view_store.on_edge_changed(
3074 d.etype_sym,
3075 d.src_id,
3076 d.dst_id,
3077 d.fired,
3078 &mut self.props,
3079 &build_topo_view(&self.topo, &self.base),
3080 &self.ids,
3081 &self.syms,
3082 &self.labels,
3083 base_columns(&self.base),
3084 );
3085 }
3086 }
3087 // Neighbor-aggregate views that read `field` must also update.
3088 self.view_store.on_prop_changed(
3089 id,
3090 field,
3091 &mut self.props,
3092 &build_topo_view(&self.topo, &self.base),
3093 &self.ids,
3094 &self.syms,
3095 &self.labels,
3096 base_columns(&self.base),
3097 );
3098 // Full-text index maintenance: update tokens for this field if indexed.
3099 if self.fulltext.field_indexed(field) {
3100 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3101 if sym == u32::MAX {
3102 None
3103 } else {
3104 self.syms.resolve(sym)
3105 }
3106 });
3107 if let Some(label) = label_opt {
3108 if self.fulltext.is_enabled(label, field) {
3109 self.fulltext.remove_node_field(id, field);
3110 self.fulltext.add_tokens(id, field, value);
3111 }
3112 }
3113 }
3114 // Property (equality) index maintenance: re-key this node's value.
3115 if self.prop_index.field_indexed(field) {
3116 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3117 if sym == u32::MAX {
3118 None
3119 } else {
3120 self.syms.resolve(sym)
3121 }
3122 });
3123 if let Some(label) = label_opt {
3124 self.prop_index.set(label, field, id, value);
3125 }
3126 }
3127 }
3128 WalRecord::Intern { id, text } => {
3129 if let Some(existing) = self.syms.get(text) {
3130 if existing != *id {
3131 return Err(GraphError::Corrupt {
3132 detail: format!(
3133 "wal intern mismatch for {text:?}: have {existing}, record {id}"
3134 ),
3135 });
3136 }
3137 } else {
3138 let got = self.syms.intern(text);
3139 if got != *id {
3140 return Err(GraphError::Corrupt {
3141 detail: format!(
3142 "wal intern assigned {got} for {text:?}, record wanted {id}"
3143 ),
3144 });
3145 }
3146 }
3147 }
3148 WalRecord::InsertNodeId { label, key, props } => {
3149 let id = self.ids.try_insert(key)?;
3150 if self.labels.len() <= id as usize {
3151 self.labels.resize(id as usize + 1, u32::MAX);
3152 }
3153 self.labels[id as usize] = *label;
3154 let label_str = self
3155 .syms
3156 .resolve(*label)
3157 .ok_or_else(|| GraphError::Corrupt {
3158 detail: format!("wal InsertNodeId unknown label intern {label}"),
3159 })?
3160 .to_string();
3161 for (field_sym, value) in props {
3162 let field =
3163 self.syms
3164 .resolve(*field_sym)
3165 .ok_or_else(|| GraphError::Corrupt {
3166 detail: format!(
3167 "wal InsertNodeId unknown field intern {field_sym}"
3168 ),
3169 })?;
3170 self.props.set(id, field, value.clone());
3171 }
3172 self.view_store
3173 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3174 let cursor = self.engine.pending_delta_count();
3175 let mut eng = std::mem::take(&mut self.engine);
3176 {
3177 let mut gm = make_graph_mut(
3178 &self.ids,
3179 &mut self.syms,
3180 &self.labels,
3181 build_props_view(&self.props, &self.base),
3182 &mut self.topo,
3183 &self.base,
3184 &mut self.edge_props,
3185 );
3186 eng.on_node_changed(id, None, &mut gm);
3187 }
3188 self.engine = eng;
3189 if !self.view_store.is_empty() {
3190 #[cfg(test)]
3191 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3192 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3193 for d in &new_deltas {
3194 self.view_store.on_edge_changed(
3195 d.etype_sym,
3196 d.src_id,
3197 d.dst_id,
3198 d.fired,
3199 &mut self.props,
3200 &build_topo_view(&self.topo, &self.base),
3201 &self.ids,
3202 &self.syms,
3203 &self.labels,
3204 base_columns(&self.base),
3205 );
3206 }
3207 }
3208 if self.fulltext.has_label(&label_str) {
3209 for (field_sym, value) in props {
3210 let Some(field) = self.syms.resolve(*field_sym) else {
3211 continue;
3212 };
3213 if self.fulltext.is_enabled(&label_str, field) {
3214 self.fulltext.add_tokens(id, field, value);
3215 }
3216 }
3217 }
3218 if self.prop_index.has_label(&label_str) {
3219 for (field_sym, value) in props {
3220 let Some(field) = self.syms.resolve(*field_sym) else {
3221 continue;
3222 };
3223 self.prop_index.set(&label_str, field, id, value);
3224 }
3225 }
3226 }
3227 WalRecord::InsertEdgeId { etype, src, dst } => {
3228 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3229 // already be tombstoned. Skip rather than attaching edges to
3230 // dead ids (DeleteNode keys the live re-insert, not the old id).
3231 if self.ids.is_tombstoned(*src)
3232 || self.ids.is_tombstoned(*dst)
3233 || self.ids.key_of(*src).is_none()
3234 || self.ids.key_of(*dst).is_none()
3235 {
3236 return Ok(());
3237 }
3238 // Skip if already visible in the merged view (same idempotency
3239 // guard as InsertEdge above: prevents double-counting when
3240 // pre-snapshot WAL records are replayed over a V8 base).
3241 if self.base.is_some()
3242 && self
3243 .topo_view()
3244 .neighbors(*etype, Direction::Out, *src)
3245 .contains(dst)
3246 {
3247 return Ok(());
3248 }
3249 self.topo.add_edge(*etype, *src, *dst);
3250 self.view_store.on_edge_changed(
3251 *etype,
3252 *src,
3253 *dst,
3254 true,
3255 &mut self.props,
3256 &build_topo_view(&self.topo, &self.base),
3257 &self.ids,
3258 &self.syms,
3259 &self.labels,
3260 base_columns(&self.base),
3261 );
3262 // Rule engine: via-hop rules fire when user via-edges are inserted.
3263 // Resolve etype back to string so on_edge_changed can match rules by name.
3264 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3265 let cursor = self.engine.pending_delta_count();
3266 let mut eng = std::mem::take(&mut self.engine);
3267 {
3268 let mut gm = make_graph_mut(
3269 &self.ids,
3270 &mut self.syms,
3271 &self.labels,
3272 build_props_view(&self.props, &self.base),
3273 &mut self.topo,
3274 &self.base,
3275 &mut self.edge_props,
3276 );
3277 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3278 }
3279 self.engine = eng;
3280 if !self.view_store.is_empty() {
3281 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3282 for d in &new_deltas {
3283 self.view_store.on_edge_changed(
3284 d.etype_sym,
3285 d.src_id,
3286 d.dst_id,
3287 d.fired,
3288 &mut self.props,
3289 &build_topo_view(&self.topo, &self.base),
3290 &self.ids,
3291 &self.syms,
3292 &self.labels,
3293 base_columns(&self.base),
3294 );
3295 }
3296 }
3297 }
3298 }
3299 WalRecord::SetPropId { id, field, value } => {
3300 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3301 return Ok(());
3302 }
3303 let field_str = self
3304 .syms
3305 .resolve(*field)
3306 .ok_or_else(|| GraphError::Corrupt {
3307 detail: format!("wal SetPropId unknown field intern {field}"),
3308 })?
3309 .to_string();
3310 let old_value = build_props_view(&self.props, &self.base)
3311 .get(*id, &field_str)
3312 .map(|vr| vr.into_value());
3313 self.props.set(*id, &field_str, value.clone());
3314 let cursor = self.engine.pending_delta_count();
3315 let mut eng = std::mem::take(&mut self.engine);
3316 {
3317 let mut gm = make_graph_mut(
3318 &self.ids,
3319 &mut self.syms,
3320 &self.labels,
3321 build_props_view(&self.props, &self.base),
3322 &mut self.topo,
3323 &self.base,
3324 &mut self.edge_props,
3325 );
3326 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3327 }
3328 self.engine = eng;
3329 if !self.view_store.is_empty() {
3330 #[cfg(test)]
3331 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3332 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3333 for d in &new_deltas {
3334 self.view_store.on_edge_changed(
3335 d.etype_sym,
3336 d.src_id,
3337 d.dst_id,
3338 d.fired,
3339 &mut self.props,
3340 &build_topo_view(&self.topo, &self.base),
3341 &self.ids,
3342 &self.syms,
3343 &self.labels,
3344 base_columns(&self.base),
3345 );
3346 }
3347 }
3348 self.view_store.on_prop_changed(
3349 *id,
3350 &field_str,
3351 &mut self.props,
3352 &build_topo_view(&self.topo, &self.base),
3353 &self.ids,
3354 &self.syms,
3355 &self.labels,
3356 base_columns(&self.base),
3357 );
3358 if self.fulltext.field_indexed(&field_str) {
3359 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3360 if sym == u32::MAX {
3361 None
3362 } else {
3363 self.syms.resolve(sym)
3364 }
3365 });
3366 if let Some(label) = label_opt {
3367 if self.fulltext.is_enabled(label, &field_str) {
3368 self.fulltext.remove_node_field(*id, &field_str);
3369 self.fulltext.add_tokens(*id, &field_str, value);
3370 }
3371 }
3372 }
3373 if self.prop_index.field_indexed(&field_str) {
3374 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3375 if sym == u32::MAX {
3376 None
3377 } else {
3378 self.syms.resolve(sym)
3379 }
3380 });
3381 if let Some(label) = label_opt {
3382 self.prop_index.set(label, &field_str, *id, value);
3383 }
3384 }
3385 }
3386 WalRecord::CreateRule { def_bytes } => {
3387 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3388 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3389 })?;
3390 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3391 // so the engine already has it; silently skip to avoid a spurious
3392 // RuleInvalid error in the crash window between snapshot write and WAL
3393 // truncation.
3394 if self.engine.rules().any(|r| r.name == def.name) {
3395 return Ok(());
3396 }
3397 let cursor = self.engine.pending_delta_count();
3398 let mut eng = std::mem::take(&mut self.engine);
3399 let result = {
3400 let mut gm = make_graph_mut(
3401 &self.ids,
3402 &mut self.syms,
3403 &self.labels,
3404 build_props_view(&self.props, &self.base),
3405 &mut self.topo,
3406 &self.base,
3407 &mut self.edge_props,
3408 );
3409 eng.create_rule(def, &mut gm)
3410 };
3411 self.engine = eng;
3412 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3413 // Derived-edge fires from backfill → view updates.
3414 // Fast path: skip O(edge_count) allocation when no views exist.
3415 if !self.view_store.is_empty() {
3416 #[cfg(test)]
3417 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3418 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3419 for d in &new_deltas {
3420 self.view_store.on_edge_changed(
3421 d.etype_sym,
3422 d.src_id,
3423 d.dst_id,
3424 d.fired,
3425 &mut self.props,
3426 &build_topo_view(&self.topo, &self.base),
3427 &self.ids,
3428 &self.syms,
3429 &self.labels,
3430 base_columns(&self.base),
3431 );
3432 }
3433 }
3434 }
3435 WalRecord::DeleteRule { name } => {
3436 // Replay-over-snapshot idempotency: the snapshot already captured the
3437 // post-delete state so the rule is absent; silently skip to avoid a
3438 // spurious RuleNotFound error in the crash window between snapshot write
3439 // and WAL truncation.
3440 if !self.engine.rules().any(|r| r.name == *name) {
3441 return Ok(());
3442 }
3443 let cursor = self.engine.pending_delta_count();
3444 let mut eng = std::mem::take(&mut self.engine);
3445 let result = {
3446 let mut gm = make_graph_mut(
3447 &self.ids,
3448 &mut self.syms,
3449 &self.labels,
3450 build_props_view(&self.props, &self.base),
3451 &mut self.topo,
3452 &self.base,
3453 &mut self.edge_props,
3454 );
3455 eng.delete_rule(name, &mut gm)
3456 };
3457 self.engine = eng;
3458 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3459 // Derived-edge retractions → view updates.
3460 if !self.view_store.is_empty() {
3461 #[cfg(test)]
3462 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3463 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3464 for d in &new_deltas {
3465 self.view_store.on_edge_changed(
3466 d.etype_sym,
3467 d.src_id,
3468 d.dst_id,
3469 d.fired,
3470 &mut self.props,
3471 &build_topo_view(&self.topo, &self.base),
3472 &self.ids,
3473 &self.syms,
3474 &self.labels,
3475 base_columns(&self.base),
3476 );
3477 }
3478 }
3479 }
3480 WalRecord::RemoveProp { key, field } => {
3481 // Recovery-safe: unknown key or already-absent field is a
3482 // clean no-op. Crash-window replay over a snapshot that
3483 // already applied this record must not Err.
3484 let Some(id) = self.ids.get(key) else {
3485 return Ok(());
3486 };
3487 // Read old value through the seam for rule retraction.
3488 let old = build_props_view(&self.props, &self.base)
3489 .get(id, field)
3490 .map(|vr| vr.into_value());
3491 self.props.remove(id, field);
3492 // If the base still supplies the value after the overlay removal,
3493 // record a tombstone so ColumnsView::get does not resurrect it.
3494 // This covers both the base-only case AND the both-resident case:
3495 // base-only (in_overlay=false): old prop was only in base, remove
3496 // is a no-op on overlay, base still visible → tombstone needed.
3497 // both-resident (in_overlay=true): overlay had v2, base has v1;
3498 // removing overlay uncovers v1 → tombstone needed.
3499 // Idempotent on double-replay: second pass sees the tombstone →
3500 // get() returns None → condition is false → no duplicate tombstone.
3501 if build_props_view(&self.props, &self.base)
3502 .get(id, field)
3503 .is_some()
3504 {
3505 self.props.record_prop_tombstone(id, field);
3506 }
3507 let cursor = self.engine.pending_delta_count();
3508 let mut eng = std::mem::take(&mut self.engine);
3509 {
3510 let mut gm = make_graph_mut(
3511 &self.ids,
3512 &mut self.syms,
3513 &self.labels,
3514 build_props_view(&self.props, &self.base),
3515 &mut self.topo,
3516 &self.base,
3517 &mut self.edge_props,
3518 );
3519 eng.on_node_changed(id, Some((field, old)), &mut gm);
3520 }
3521 self.engine = eng;
3522 // Derived-edge deltas → view updates.
3523 if !self.view_store.is_empty() {
3524 #[cfg(test)]
3525 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3526 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3527 for d in &new_deltas {
3528 self.view_store.on_edge_changed(
3529 d.etype_sym,
3530 d.src_id,
3531 d.dst_id,
3532 d.fired,
3533 &mut self.props,
3534 &build_topo_view(&self.topo, &self.base),
3535 &self.ids,
3536 &self.syms,
3537 &self.labels,
3538 base_columns(&self.base),
3539 );
3540 }
3541 }
3542 // Neighbor-aggregate views that read `field` must also update.
3543 self.view_store.on_prop_changed(
3544 id,
3545 field,
3546 &mut self.props,
3547 &build_topo_view(&self.topo, &self.base),
3548 &self.ids,
3549 &self.syms,
3550 &self.labels,
3551 base_columns(&self.base),
3552 );
3553 // Full-text index maintenance: remove tokens for this field.
3554 if self.fulltext.field_indexed(field) {
3555 self.fulltext.remove_node_field(id, field);
3556 }
3557 // Property (equality) index maintenance: drop this node's entry.
3558 if self.prop_index.field_indexed(field) {
3559 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3560 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3561 }) {
3562 self.prop_index.remove_node(label, field, id);
3563 }
3564 }
3565 }
3566 WalRecord::DeleteEdge {
3567 edge_type,
3568 src_key,
3569 dst_key,
3570 } => {
3571 // Recovery-safe: unknown keys, unknown etype, or already-
3572 // absent edge is a clean no-op (remove_edge returns false).
3573 let Some(src) = self.ids.get(src_key) else {
3574 return Ok(());
3575 };
3576 let Some(dst) = self.ids.get(dst_key) else {
3577 return Ok(());
3578 };
3579 let Some(etype) = self.syms.get(edge_type) else {
3580 return Ok(());
3581 };
3582 // I3: phantom-tombstone guard. When a V8 base is present, a
3583 // DeleteEdge WAL record for an edge that was already absorbed into
3584 // the new base (i.e. neither in overlay nor in base) must be skipped.
3585 // Without this guard, remove_edge records a tombstone for an edge
3586 // that no longer exists, incorrectly understating edge_count.
3587 if self.base.is_some()
3588 && !self
3589 .topo_view()
3590 .neighbors(etype, core_storage::topology::Direction::Out, src)
3591 .contains(&dst)
3592 {
3593 return Ok(());
3594 }
3595 self.topo.remove_edge(etype, src, dst);
3596 self.edge_props.remove_edge(etype, src, dst);
3597 // View maintenance for manual edge delete (topo already updated above).
3598 self.view_store.on_edge_changed(
3599 etype,
3600 src,
3601 dst,
3602 false,
3603 &mut self.props,
3604 &build_topo_view(&self.topo, &self.base),
3605 &self.ids,
3606 &self.syms,
3607 &self.labels,
3608 base_columns(&self.base),
3609 );
3610 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3611 let cursor = self.engine.pending_delta_count();
3612 let mut eng = std::mem::take(&mut self.engine);
3613 {
3614 let mut gm = make_graph_mut(
3615 &self.ids,
3616 &mut self.syms,
3617 &self.labels,
3618 build_props_view(&self.props, &self.base),
3619 &mut self.topo,
3620 &self.base,
3621 &mut self.edge_props,
3622 );
3623 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3624 }
3625 self.engine = eng;
3626 if !self.view_store.is_empty() {
3627 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3628 for d in &new_deltas {
3629 self.view_store.on_edge_changed(
3630 d.etype_sym,
3631 d.src_id,
3632 d.dst_id,
3633 d.fired,
3634 &mut self.props,
3635 &build_topo_view(&self.topo, &self.base),
3636 &self.ids,
3637 &self.syms,
3638 &self.labels,
3639 base_columns(&self.base),
3640 );
3641 }
3642 }
3643 }
3644 WalRecord::DeleteNode { key } => {
3645 // Recovery-safe: already-tombstoned / unknown key is a clean
3646 // no-op. Crash-window replay over a snapshot that already
3647 // applied this record cannot recover the retired id from the
3648 // key (`IdMap::get` is None), so every subsequent step is
3649 // skipped. Each step is independently idempotent if invoked
3650 // twice on a still-live id: retraction is a no-op on empty
3651 // provenance, `remove_edge` returns false, `remove_all` is a
3652 // no-op, `ids.delete` returns None, label sentinel is sticky.
3653 let Some(n) = self.ids.get(key) else {
3654 return Ok(());
3655 };
3656
3657 // (1) Retract derived edges + de-index while props/labels live.
3658 let cursor = self.engine.pending_delta_count();
3659 let mut eng = std::mem::take(&mut self.engine);
3660 {
3661 let mut gm = make_graph_mut(
3662 &self.ids,
3663 &mut self.syms,
3664 &self.labels,
3665 build_props_view(&self.props, &self.base),
3666 &mut self.topo,
3667 &self.base,
3668 &mut self.edge_props,
3669 );
3670 eng.on_node_removed(n, &mut gm);
3671 }
3672 self.engine = eng;
3673 // Derived-edge retractions → view updates for neighbors.
3674 if !self.view_store.is_empty() {
3675 #[cfg(test)]
3676 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3677 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3678 for d in &new_deltas {
3679 self.view_store.on_edge_changed(
3680 d.etype_sym,
3681 d.src_id,
3682 d.dst_id,
3683 d.fired,
3684 &mut self.props,
3685 &build_topo_view(&self.topo, &self.base),
3686 &self.ids,
3687 &self.syms,
3688 &self.labels,
3689 base_columns(&self.base),
3690 );
3691 }
3692 }
3693
3694 // (2) Sweep ALL remaining edges incident to n, both directions,
3695 // every etype. This cascade is intentionally mask-independent:
3696 // topology integrity requires removing every edge touching the
3697 // deleted node regardless of the caller's visibility scope.
3698 // (The mask limits which nodes a role's read phase can return;
3699 // the WAL delete always executes with full storage authority.)
3700 // Collect then remove so neighbor slices stay valid during
3701 // iteration. Remove from topo first, then call view maintenance
3702 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3703 let etypes: Vec<u32> = self.topo.etypes().collect();
3704 let mut doomed = Vec::new();
3705 for et in &etypes {
3706 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3707 doomed.push((*et, n, dst));
3708 }
3709 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3710 doomed.push((*et, src, n));
3711 }
3712 }
3713 for (et, s, d) in doomed {
3714 self.topo.remove_edge(et, s, d);
3715 self.edge_props.remove_edge(et, s, d);
3716 // View maintenance: n's own view values will be cleared by
3717 // remove_all below; only update surviving neighbors.
3718 self.view_store.on_edge_changed(
3719 et,
3720 s,
3721 d,
3722 false,
3723 &mut self.props,
3724 &build_topo_view(&self.topo, &self.base),
3725 &self.ids,
3726 &self.syms,
3727 &self.labels,
3728 base_columns(&self.base),
3729 );
3730 }
3731
3732 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3733 self.props.remove_all(n);
3734 // Full-text index maintenance: remove all tokens for this node.
3735 self.fulltext.remove_node(n);
3736 // Property (equality) index maintenance: drop all entries for n.
3737 self.prop_index.remove_node_all(n);
3738
3739 // (4) Retire the dense id and stamp the label sentinel.
3740 self.ids.delete(key);
3741 if let Some(slot) = self.labels.get_mut(n as usize) {
3742 *slot = u32::MAX;
3743 }
3744 }
3745 WalRecord::Batch(inner) => {
3746 // Apply each inner record in order through the same apply path.
3747 // Inner records are validated free of nested Batch by encode_record.
3748 for rec in inner {
3749 self.apply(rec)?;
3750 }
3751 }
3752 WalRecord::RebuildRule { name } => {
3753 // Replay-over-snapshot idempotency: the snapshot may already
3754 // reflect a later delete_rule, so the rule is absent; skip.
3755 if !self.engine.rules().any(|r| r.name == *name) {
3756 return Ok(());
3757 }
3758 let cursor = self.engine.pending_delta_count();
3759 let mut eng = std::mem::take(&mut self.engine);
3760 let result = {
3761 let mut gm = make_graph_mut(
3762 &self.ids,
3763 &mut self.syms,
3764 &self.labels,
3765 build_props_view(&self.props, &self.base),
3766 &mut self.topo,
3767 &self.base,
3768 &mut self.edge_props,
3769 );
3770 eng.rebuild(name, &mut gm)
3771 };
3772 self.engine = eng;
3773 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3774 // Derived-edge delta changes → view updates.
3775 if !self.view_store.is_empty() {
3776 #[cfg(test)]
3777 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3778 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3779 for d in &new_deltas {
3780 self.view_store.on_edge_changed(
3781 d.etype_sym,
3782 d.src_id,
3783 d.dst_id,
3784 d.fired,
3785 &mut self.props,
3786 &build_topo_view(&self.topo, &self.base),
3787 &self.ids,
3788 &self.syms,
3789 &self.labels,
3790 base_columns(&self.base),
3791 );
3792 }
3793 }
3794 }
3795 WalRecord::CreateView { def_bytes } => {
3796 let def: ViewDef =
3797 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3798 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3799 })?;
3800 // Replay-over-snapshot idempotency: view already present → skip.
3801 if self.view_store.has_view(&def.name) {
3802 return Ok(());
3803 }
3804 self.view_store
3805 .create_view(
3806 def,
3807 &mut self.props,
3808 &build_topo_view(&self.topo, &self.base),
3809 &self.ids,
3810 &self.syms,
3811 &self.labels,
3812 )
3813 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3814 }
3815 WalRecord::DeleteView { name } => {
3816 // Replay-over-snapshot idempotency: view already absent → skip.
3817 if !self.view_store.has_view(name) {
3818 return Ok(());
3819 }
3820 self.view_store
3821 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3822 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3823 }
3824 WalRecord::EnableFulltext { label, field } => {
3825 // Replay-over-snapshot idempotency: already enabled → skip.
3826 if self.fulltext.is_enabled(label, field) {
3827 return Ok(());
3828 }
3829 self.fulltext.enable(label, field);
3830 // Backfill: index all live nodes of this label that have the field.
3831 let n = self.ids.len() as u32;
3832 for id in 0..n {
3833 let Some(&sym) = self.labels.get(id as usize) else {
3834 continue;
3835 };
3836 if sym == u32::MAX {
3837 continue; // tombstoned
3838 }
3839 let Some(lbl) = self.syms.resolve(sym) else {
3840 continue;
3841 };
3842 if lbl != label {
3843 continue;
3844 }
3845 if let Some(value) = build_props_view(&self.props, &self.base)
3846 .get(id, field)
3847 .map(|vr| vr.into_value())
3848 {
3849 self.fulltext.add_tokens(id, field, &value);
3850 }
3851 }
3852 }
3853 WalRecord::DisableFulltext { label, field } => {
3854 // Replay-over-snapshot idempotency: already disabled → skip.
3855 if !self.fulltext.is_enabled(label, field) {
3856 return Ok(());
3857 }
3858 // If another label still indexes this field, the postings column
3859 // is kept — but it must not contain node_ids from the now-disabled
3860 // label. Remove them before calling disable() so the field_indexed
3861 // guard inside disable() sees the correct post-removal state.
3862 if self.fulltext.field_indexed_by_other(label, field) {
3863 if let Some(label_sym) = self.syms.get(label) {
3864 for (node_id, &lsym) in self.labels.iter().enumerate() {
3865 if lsym == label_sym {
3866 self.fulltext.remove_node_field(node_id as u32, field);
3867 }
3868 }
3869 }
3870 }
3871 self.fulltext.disable(label, field);
3872 }
3873 WalRecord::EnableIndex { label, field } => {
3874 // Replay-over-snapshot idempotency: already enabled → skip.
3875 if self.prop_index.is_enabled(label, field) {
3876 return Ok(());
3877 }
3878 self.prop_index.enable(label, field);
3879 // Backfill: index all live nodes of this label that have the field.
3880 let n = self.ids.len() as u32;
3881 for id in 0..n {
3882 let Some(&sym) = self.labels.get(id as usize) else {
3883 continue;
3884 };
3885 if sym == u32::MAX {
3886 continue; // tombstoned
3887 }
3888 let Some(lbl) = self.syms.resolve(sym) else {
3889 continue;
3890 };
3891 if lbl != label {
3892 continue;
3893 }
3894 if let Some(value) = build_props_view(&self.props, &self.base)
3895 .get(id, field)
3896 .map(|vr| vr.into_value())
3897 {
3898 self.prop_index.set(label, field, id, &value);
3899 }
3900 }
3901 }
3902 WalRecord::DisableIndex { label, field } => {
3903 self.prop_index.disable(label, field);
3904 }
3905 // History markers carry no replay state — rules re-derive edges
3906 // deterministically on open/replay. Skip unconditionally.
3907 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3908 // ── rename_node ──────────────────────────────────────────────────
3909 WalRecord::RenameNode { old_key, new_key } => {
3910 // Recovery-safe: if old_key is already gone (key was renamed
3911 // by a snapshot or a prior replay frame), skip cleanly.
3912 if self.ids.get(old_key).is_none() {
3913 return Ok(());
3914 }
3915 // The rename only updates the key-table; the dense id, all
3916 // topo edges, props, labels, and rule state are id-indexed and
3917 // require no change.
3918 self.ids
3919 .rename(old_key, new_key)
3920 .map_err(|e| GraphError::Corrupt {
3921 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3922 })?;
3923 }
3924 }
3925 Ok(())
3926 }
3927
3928 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3929 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3930 /// idempotent when the string is already bound. Always emit: after
3931 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3932 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3933 let id = if let Some(id) = self.syms.get(s) {
3934 id
3935 } else {
3936 self.syms.intern(s)
3937 };
3938 (
3939 id,
3940 WalRecord::Intern {
3941 id,
3942 text: s.to_string(),
3943 },
3944 )
3945 }
3946
3947 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3948 /// state is left mutated: speculative interns made while building the
3949 /// output are rolled back, so a later successful mutation cannot log an
3950 /// `Intern` record whose id replay would never reproduce.
3951 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3952 let syms_checkpoint = self.syms.len();
3953 let result = self.rewrite_wal_dense_inner(recs);
3954 if result.is_err() {
3955 self.syms.truncate(syms_checkpoint);
3956 }
3957 result
3958 }
3959
3960 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3961 let mut out = Vec::with_capacity(recs.len());
3962 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3963 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3964 let mut interned = std::collections::HashSet::<u32>::new();
3965 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3966 detail: "id space exhausted".into(),
3967 })?;
3968 let lookup = |ids: &IdMap,
3969 pending: &std::collections::HashMap<String, u32>,
3970 key: &str|
3971 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3972 for rec in recs {
3973 match rec {
3974 WalRecord::InsertNode { label, key, props } => {
3975 let (label_id, intern) = self.intern_wal(&label);
3976 if interned.insert(label_id) {
3977 out.push(intern);
3978 }
3979 let mut props_id = Vec::with_capacity(props.len());
3980 for (field, value) in props {
3981 let (field_id, intern) = self.intern_wal(&field);
3982 if interned.insert(field_id) {
3983 out.push(intern);
3984 }
3985 props_id.push((field_id, value));
3986 }
3987 if lookup(&self.ids, &pending, &key).is_none() {
3988 pending.insert(key.clone(), next);
3989 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3990 detail: "id space exhausted".into(),
3991 })?;
3992 }
3993 out.push(WalRecord::InsertNodeId {
3994 label: label_id,
3995 key,
3996 props: props_id,
3997 });
3998 }
3999 WalRecord::SetProp { key, field, value } => {
4000 let id =
4001 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4002 detail: format!("dense WAL rewrite missing key {key}"),
4003 })?;
4004 let (field_id, intern) = self.intern_wal(&field);
4005 if interned.insert(field_id) {
4006 out.push(intern);
4007 }
4008 out.push(WalRecord::SetPropId {
4009 id,
4010 field: field_id,
4011 value,
4012 });
4013 }
4014 WalRecord::InsertEdge {
4015 edge_type,
4016 src_key,
4017 dst_key,
4018 } => {
4019 let (etype, intern) = self.intern_wal(&edge_type);
4020 if interned.insert(etype) {
4021 out.push(intern);
4022 }
4023 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4024 GraphError::Corrupt {
4025 detail: format!("dense WAL rewrite missing src {src_key}"),
4026 }
4027 })?;
4028 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4029 GraphError::Corrupt {
4030 detail: format!("dense WAL rewrite missing dst {dst_key}"),
4031 }
4032 })?;
4033 out.push(WalRecord::InsertEdgeId { etype, src, dst });
4034 }
4035 WalRecord::RenameNode {
4036 ref old_key,
4037 ref new_key,
4038 } => {
4039 // Track the rename in `pending` so subsequent InsertEdge /
4040 // SetProp records in this batch can resolve the new key.
4041 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4042 GraphError::Corrupt {
4043 detail: format!(
4044 "dense WAL rewrite: RenameNode old key {old_key} not found"
4045 ),
4046 }
4047 })?;
4048 pending.remove(old_key.as_str());
4049 pending.insert(new_key.clone(), id);
4050 out.push(rec);
4051 }
4052 // # Symbol-order invariant (load-bearing)
4053 //
4054 // Write-time and replay-time symbol assignment must agree: every
4055 // symbol in a `Batch` frame has to receive the same dense id when
4056 // the frame's records are replayed in order as it received when
4057 // the frame was written.
4058 //
4059 // A rule's backfill interns its `edge_type` lazily
4060 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4061 // site), and that backfill runs from `apply` — during the
4062 // `CreateRule` record itself, and again from any later
4063 // `InsertNodeId` in the same frame that makes the rule fire. At
4064 // write time the whole batch is rewritten before any of it is
4065 // applied, so a later `InsertEdge` in the same batch would win the
4066 // lower id for its edge type; on replay the rule's lazy intern
4067 // gets there first and steals it, and the `Intern` record fails at
4068 // the `wal intern assigned …` check in `apply`.
4069 //
4070 // Pre-interning the rule's `edge_type` here, and emitting its
4071 // `Intern` record ahead of the `CreateRule` record, makes both
4072 // orders identical. `weight_prop` needs no pre-intern:
4073 // `EdgeProps::set` keys props by `String`, never through the
4074 // interner. `via_edge` needs none either: via-hop rules resolve it
4075 // with `syms.get` and skip when it is absent.
4076 //
4077 // `RebuildRule` and `DeleteRule` need no such handling here:
4078 // `RebuildRule` has no `BatchOp` variant, so it never appears
4079 // inside a `Batch` today — it is only ever issued as its own
4080 // standalone commit (`rebuild_rule`, or the auto-rebuild path
4081 // that logs it as a second commit after the triggering op).
4082 // `DeleteRule` does have a `BatchOp` variant and can appear
4083 // inside a `Batch`, but it carries only a rule `name` — no
4084 // `edge_type` or other symbol that needs pre-interning — so
4085 // only `CreateRule` needs this arm.
4086 WalRecord::CreateRule { ref def_bytes } => {
4087 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4088 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4089 })?;
4090 let (etype, intern) = self.intern_wal(&def.edge_type);
4091 if interned.insert(etype) {
4092 out.push(intern);
4093 }
4094 out.push(rec);
4095 }
4096 other => out.push(other),
4097 }
4098 }
4099 Ok(out)
4100 }
4101
4102 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4103 let recs = self.rewrite_wal_dense(recs)?;
4104 match recs.len() {
4105 0 => Ok(()),
4106 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4107 _ => self.log_then_apply(WalRecord::Batch(recs)),
4108 }
4109 }
4110
4111 /// Durable write, then notify the event sink. Replay (`apply` during
4112 /// `open`) never enters this function, so it is the replay-silent seam.
4113 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4114 self.log_then_apply_with(rec, None, self.fsync)
4115 }
4116
4117 /// Whether this frame must fsync under `policy`.
4118 ///
4119 /// Batched contract: user-visible batches (>1 mutation) fsync; single
4120 /// mutations do not. The dense rewrite wraps a single mutation in a
4121 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4122 /// from the count — removing that filter would make every single-op write
4123 /// fsync under Batched (or, if the threshold were raised instead, skip a
4124 /// needed fsync for real two-op batches).
4125 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4126 match policy {
4127 FsyncPolicy::Relaxed => false,
4128 FsyncPolicy::Strict => true,
4129 FsyncPolicy::Batched => match rec {
4130 // Intern + one mutation is the single-op rewrite, not a user batch.
4131 WalRecord::Batch(inner) => {
4132 inner
4133 .iter()
4134 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4135 .count()
4136 > 1
4137 }
4138 _ => false,
4139 },
4140 }
4141 }
4142
4143 /// # Apply-infallibility invariant (load-bearing)
4144 ///
4145 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4146 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4147 /// the full frame while in-memory state would reflect only the ops before
4148 /// the failure. On reopen, WAL replay would then apply the entire batch —
4149 /// diverging permanently from what the pre-crash process had in memory.
4150 ///
4151 /// For `Batch` frames this situation cannot arise because:
4152 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4153 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4154 /// use, with no concurrent mutation between validation exit and apply entry.
4155 /// - Every `apply` arm for a validated op is either infallible by construction
4156 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4157 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4158 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4159 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4160 ///
4161 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4162 /// `Err` for a `Batch` frame, making any future regression immediately visible
4163 /// in tests rather than silently diverging crash-recovery behaviour.
4164 fn log_then_apply_with(
4165 &mut self,
4166 rec: WalRecord,
4167 ingest: Option<(String, usize)>,
4168 policy: FsyncPolicy,
4169 ) -> Result<()> {
4170 // Read-only guard: as-of instances must never write the WAL.
4171 if self.read_only {
4172 return Err(GraphError::ReadOnly);
4173 }
4174 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4175 // partway; in-memory state is ahead of (or out of step with) the
4176 // on-disk WAL, so further mutations would deepen the divergence.
4177 // Reopen the database to recover. Checked before the lock guard: this
4178 // is the more serious condition and the more useful error.
4179 if self.degraded {
4180 return Err(GraphError::Io(std::io::Error::other(
4181 "database degraded after group-commit fsync failure; reopen required",
4182 )));
4183 }
4184 // Cross-process guard: this write scope asked for the store's write
4185 // lock and did not get it. Writing anyway would append frames on top of
4186 // a WAL another process is extending, so refuse instead.
4187 if self.lock_denied {
4188 return Err(GraphError::Busy { holder: None });
4189 }
4190 // Ensure retained provenance bytes are decoded into the live mutable
4191 // fields before any mutation touches self.engine.provenance. This is a
4192 // no-op if provenance was never stored (fresh store) or has already been
4193 // consumed (subsequent mutations). WAL replay calls apply() directly
4194 // and is covered by consume_retained_state_eager before replay.
4195 self.ensure_v8_base_sections_loaded();
4196 self.engine.ensure_provenance_loaded_mut();
4197 // Invariant (I-1): no stale deltas may enter from a previous apply.
4198 // If any engine method ever accumulates deltas before erroring, they would
4199 // contaminate the *next* commit's event stream. This assert fires in debug
4200 // builds, making any future regression visible at the earliest point.
4201 debug_assert_eq!(
4202 self.engine.pending_delta_count(),
4203 0,
4204 "stale engine deltas at log_then_apply_with entry — \
4205 a previous apply arm may have accumulated deltas before erroring; \
4206 the caller must drain_deltas() on any error path before returning"
4207 );
4208 let frame = encode_record(&rec);
4209 self.fs.append(FileId::Wal, &frame)?;
4210 // The cursor advances by exactly the bytes appended: these frames are
4211 // ours and already applied, so a later refresh must not replay them.
4212 self.wal_consumed += frame.len() as u64;
4213 if Self::wal_needs_sync(policy, &rec) {
4214 self.fs.sync(FileId::Wal)?;
4215 }
4216 // Marker writing always needs the engine deltas, but the engine only
4217 // accumulates them when emit_deltas is true (normally gated on subscribers
4218 // or views being present). Enable emission for this apply if it is
4219 // currently off, then restore the original state unconditionally via an
4220 // RAII guard — this prevents a panic in apply() from leaking the flag.
4221 // The same guard resets the engine's transient chaining state. A panic
4222 // unwinding out of a rule hook would otherwise leave `chain_depth`
4223 // non-zero, which makes every later `begin_chain` decide chaining is
4224 // already running and silently switch it off for good.
4225 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4226 impl Drop for RestoreEmitDeltas {
4227 fn drop(&mut self) {
4228 // SAFETY: pointer into self (GraphDb); guard is dropped within
4229 // this frame before log_then_apply_with returns.
4230 unsafe {
4231 (*self.0).set_emit_deltas(self.1);
4232 (*self.0).reset_chain_state();
4233 }
4234 }
4235 }
4236 let original_emit = self.engine.emit_deltas();
4237 if !original_emit {
4238 self.engine.set_emit_deltas(true);
4239 }
4240 // SAFETY: raw pointer into self; guard dropped within this frame.
4241 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4242
4243 let apply_result = self.apply(&rec);
4244 // For Batch frames, post-validation apply must be infallible (see above).
4245 // A debug_assert here catches any future change that makes apply fallible
4246 // before the caller notices via silent WAL/memory divergence.
4247 if matches!(&rec, WalRecord::Batch(_)) {
4248 debug_assert!(
4249 apply_result.is_ok(),
4250 "Batch apply returned Err after successful WAL write — \
4251 the validate-then-apply invariant has been violated; \
4252 see log_then_apply_with invariant doc"
4253 );
4254 }
4255 if apply_result.is_err() {
4256 // Discard any partial deltas accumulated by the failed apply.
4257 // They must not ride the next commit's event stream (I-1).
4258 // _emit_guard restores emit_deltas on drop automatically.
4259 let _ = self.engine.drain_deltas();
4260 let _ = self.engine.take_rebuild_needed();
4261 apply_result?;
4262 }
4263 self.commit_seq += 1;
4264 let seq = self.commit_seq;
4265 // Update per-node last-change map for the committed record.
4266 // Must happen after commit_seq is incremented so the seq is correct.
4267 self.update_last_change_from_rec(&rec, seq);
4268 // Drain engine deltas and distribute to subscribers before the existing
4269 // MutationEvent sink fires — both happen post-fsync, post-apply.
4270 // _emit_guard restores emit_deltas after this line when it drops.
4271 let engine_deltas = self.engine.drain_deltas();
4272
4273 // Append history-marker WAL records for any derived-edge changes so
4274 // that `edge_history` and `was_linked` can surface rule-attributed
4275 // events. Markers are STATE NO-OPS during replay; they are written
4276 // without an additional fsync (the triggering commit's sync already
4277 // happened; the next commit's sync covers these lazily).
4278 if !engine_deltas.is_empty() {
4279 let markers: Vec<WalRecord> = engine_deltas
4280 .iter()
4281 .map(|d| {
4282 if d.fired {
4283 WalRecord::DerivedEdgeAdded {
4284 rule: d.rule.clone(),
4285 edge_type: d.edge_type.clone(),
4286 src_key: d.src_key.clone(),
4287 dst_key: d.dst_key.clone(),
4288 }
4289 } else {
4290 WalRecord::DerivedEdgeRetracted {
4291 rule: d.rule.clone(),
4292 edge_type: d.edge_type.clone(),
4293 src_key: d.src_key.clone(),
4294 dst_key: d.dst_key.clone(),
4295 }
4296 }
4297 })
4298 .collect();
4299 let marker_frame = if markers.len() == 1 {
4300 markers.into_iter().next().unwrap()
4301 } else {
4302 WalRecord::Batch(markers)
4303 };
4304 // Ignore append errors: markers are best-effort history
4305 // annotations. Losing them does not affect state correctness.
4306 // The cursor only advances when the bytes actually landed.
4307 let marker_bytes = encode_record(&marker_frame);
4308 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4309 self.wal_consumed += marker_bytes.len() as u64;
4310 }
4311 }
4312
4313 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4314 // stored as-is (including any nested Batch / Intern records); the
4315 // ReaderSnapshot's apply_one function handles all variants.
4316 {
4317 let derived_inserts = engine_deltas
4318 .iter()
4319 .filter(|d| d.fired)
4320 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4321 .collect();
4322 let derived_deletes = engine_deltas
4323 .iter()
4324 .filter(|d| !d.fired)
4325 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4326 .collect();
4327 let delta = Arc::new(crate::reader::CommitDelta {
4328 records: vec![rec.clone()],
4329 derived_inserts,
4330 derived_deletes,
4331 });
4332 self.delta_tail.push(delta);
4333 self.commits_since_fold += 1;
4334 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4335 self.fold_now();
4336 }
4337 }
4338
4339 if self.defer_events {
4340 // Group-commit drain thread: hold events until after the group
4341 // fsync so subscribers only observe durable data (R2).
4342 self.deferred_events.push(DeferredEvent {
4343 rec: rec.clone(),
4344 engine_deltas,
4345 seq,
4346 ingest,
4347 });
4348 } else {
4349 self.distribute_events(&rec, &engine_deltas, seq);
4350 self.emit_committed(&rec, ingest);
4351 }
4352 // Drift is only known after apply, so auto-rebuild cannot join the
4353 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4354 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4355 // retrigger loop is impossible if the fit succeeded, but we still
4356 // drain the flag so a leftover cannot re-enter.
4357 let rebuilds = self.engine.take_rebuild_needed();
4358 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4359 let mut failed = Vec::new();
4360 for name in rebuilds {
4361 if self.engine.rules().any(|r| r.name == name) {
4362 // User op is already durable. A failed second commit must
4363 // not surface as the caller's error.
4364 if let Err(e) =
4365 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4366 {
4367 eprintln!(
4368 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4369 );
4370 failed.push(name);
4371 }
4372 }
4373 }
4374 for name in failed {
4375 self.engine.queue_rebuild_needed(name);
4376 }
4377 }
4378 Ok(())
4379 }
4380
4381 /// Install a post-commit hook. Replaces any previous sink.
4382 ///
4383 /// The sink runs inside `log_then_apply` after a successful
4384 /// durable commit, while the caller still holds `&mut self`. When this
4385 /// database is behind a [`crate::SharedDb`], that means the **write
4386 /// guard is held**. The sink must never call `read` / `write` (or any
4387 /// other method) on the same `SharedDb` — the `RwLock` is not
4388 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4389 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4390 /// Intended examples: `std::sync::mpsc::SyncSender`,
4391 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4392 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4393 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4394 self.event_sink = Some(sink);
4395 }
4396
4397 /// Whether a post-commit event sink is currently installed.
4398 pub fn has_event_sink(&self) -> bool {
4399 self.event_sink.is_some()
4400 }
4401
4402 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4403 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4404 self.fsync = p;
4405 }
4406
4407 /// Return the current WAL fsync cadence.
4408 pub fn fsync_policy(&self) -> FsyncPolicy {
4409 self.fsync
4410 }
4411
4412 // ── Group-commit event deferral ───────────────────────────────────────────
4413
4414 /// Enable or disable deferred event mode.
4415 ///
4416 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4417 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4418 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4419 /// or [`discard_deferred_events`] if the fsync failed and the group must
4420 /// be treated as lost.
4421 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4422 self.defer_events = defer;
4423 }
4424
4425 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4426 /// was set to true. Clears the buffer.
4427 ///
4428 /// Called by the drain thread AFTER a successful group fsync, so
4429 /// subscribers observe only data that is durably on disk.
4430 pub fn flush_deferred_events(&mut self) {
4431 let events = std::mem::take(&mut self.deferred_events);
4432 for de in events {
4433 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4434 self.emit_committed(&de.rec, de.ingest);
4435 }
4436 }
4437
4438 /// Discard all buffered events without firing them.
4439 ///
4440 /// Called by the drain thread when a group fsync fails: the WAL has been
4441 /// truncated back to the pre-group offset, so the committed-but-unsynced
4442 /// ops must not be observable to subscribers.
4443 pub fn discard_deferred_events(&mut self) {
4444 self.deferred_events.clear();
4445 }
4446
4447 // ── Degraded state ────────────────────────────────────────────────────────
4448
4449 /// Mark this database as degraded.
4450 ///
4451 /// Called by the group-commit drain thread after a group fsync failure and
4452 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4453 /// further mutations would deepen the divergence. All subsequent calls to
4454 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4455 pub fn set_degraded(&mut self) {
4456 self.degraded = true;
4457 }
4458
4459 fn emit(&self, ev: MutationEvent) {
4460 if let Some(sink) = &self.event_sink {
4461 sink(ev);
4462 }
4463 }
4464
4465 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4466 match rec {
4467 WalRecord::Batch(inner) => {
4468 for r in inner {
4469 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4470 self.emit(ev);
4471 }
4472 }
4473 match ingest {
4474 Some((label, inserted)) => {
4475 self.emit(MutationEvent::Ingested { label, inserted })
4476 }
4477 None => {
4478 let ops = inner
4479 .iter()
4480 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4481 .count();
4482 if ops > 1 {
4483 self.emit(MutationEvent::BatchApplied { ops });
4484 }
4485 }
4486 }
4487 }
4488 other => {
4489 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4490 self.emit(ev);
4491 }
4492 }
4493 }
4494 }
4495
4496 // -----------------------------------------------------------------------
4497 // Subscription API
4498 // -----------------------------------------------------------------------
4499
4500 /// Distribute post-commit events to all live subscribers.
4501 ///
4502 /// Build a row-key → row-data map from a [`ResultSet`].
4503 ///
4504 /// Each row is serialized to JSON to form its key; a debug fallback is used
4505 /// if serialization fails. Used by both the initial-seed path in
4506 /// [`Self::subscribe_query`] and the per-commit diff path in
4507 /// [`Self::distribute_events`] to keep the two in sync.
4508 fn result_to_row_map(
4509 result: &core_query::ResultSet,
4510 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4511 (0..result.len())
4512 .map(|i| {
4513 let row = result.row(i).to_vec();
4514 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4515 (key, row)
4516 })
4517 .collect()
4518 }
4519
4520 /// Collect the set of label syms touched by a WAL record.
4521 ///
4522 /// Returns `Some(set)` when every record in this commit can be attributed to
4523 /// a known label sym. Returns `None` when the commit must not be skipped:
4524 /// edge records, unresolvable key→label lookups, or any record type not in
4525 /// the explicit handled set.
4526 ///
4527 /// Handled record types and their actions:
4528 /// - `InsertNode` → look up label in interner (fails → None)
4529 /// - `InsertNodeId` → label sym is carried directly
4530 /// - `SetProp` → resolve key→id→label (fails → None)
4531 /// - `DeleteNode` → resolve key→id→label (fails → None)
4532 /// - `Batch` → recurse into every inner record
4533 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4534 /// - everything else → None (conservative)
4535 fn commit_touched_labels(
4536 rec: &WalRecord,
4537 syms: &Interner,
4538 ids: &IdMap,
4539 labels: &[u32],
4540 ) -> Option<BTreeSet<u32>> {
4541 let mut out = BTreeSet::new();
4542 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4543 Some(out)
4544 } else {
4545 None
4546 }
4547 }
4548
4549 fn collect_touched_labels(
4550 rec: &WalRecord,
4551 syms: &Interner,
4552 ids: &IdMap,
4553 labels: &[u32],
4554 out: &mut BTreeSet<u32>,
4555 ) -> bool {
4556 match rec {
4557 // String-key insert: the dense rewrite converts this to
4558 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4559 // records written before the dense path was added.
4560 WalRecord::InsertNode { label, .. } => {
4561 if let Some(sym) = syms.get(label) {
4562 out.insert(sym);
4563 true
4564 } else {
4565 false
4566 }
4567 }
4568 // Dense-id insert (produced by rewrite_wal_dense for every
4569 // insert_node call in the current codebase).
4570 WalRecord::InsertNodeId { label, .. } => {
4571 out.insert(*label);
4572 true
4573 }
4574 // String-key prop set: dense path converts to [Intern, SetPropId].
4575 WalRecord::SetProp { key, .. } => {
4576 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4577 out.insert(sym);
4578 true
4579 } else {
4580 false
4581 }
4582 }
4583 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4584 WalRecord::SetPropId { id, .. } => {
4585 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4586 out.insert(sym);
4587 true
4588 } else {
4589 false
4590 }
4591 }
4592 WalRecord::DeleteNode { key } => {
4593 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4594 out.insert(sym);
4595 true
4596 } else {
4597 false
4598 }
4599 }
4600 WalRecord::Batch(inner) => inner
4601 .iter()
4602 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4603 // Intern is a pure metadata record — it does not touch any node's
4604 // label and is safe to skip for the label-skip predicate.
4605 WalRecord::Intern { .. } => true,
4606 // Edge records: always re-execute (edges can change join results).
4607 WalRecord::InsertEdge { .. }
4608 | WalRecord::DeleteEdge { .. }
4609 | WalRecord::InsertEdgeId { .. } => false,
4610 _ => false,
4611 }
4612 }
4613
4614 /// Resolve a node key to its label sym via the dense id table.
4615 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4616 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4617 let id = ids.get(key)?;
4618 let sym = labels.get(id as usize).copied()?;
4619 (sym != u32::MAX).then_some(sym)
4620 }
4621
4622 /// Distribute post-commit events to all live subscribers.
4623 ///
4624 /// Called from `log_then_apply_with` after apply + fsync, before the
4625 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4626 ///
4627 /// Query subscriptions (subscribe_query) re-execute their plan on every
4628 /// call and diff the result against the previous run. Zero overhead when
4629 /// no query subscriptions are active.
4630 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4631 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4632 return;
4633 }
4634
4635 if !self.subscriptions.is_empty() {
4636 // Build write events from the WAL record.
4637 let write_events: Vec<DbEvent> =
4638 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4639
4640 // Build edge events from engine deltas. Weight is looked up from
4641 // edge_props at distribution time (after apply), so it's always fresh.
4642 let edge_events: Vec<DbEvent> = engine_deltas
4643 .iter()
4644 .map(|d| {
4645 if d.fired {
4646 // The score lives under the rule's declared weight_prop,
4647 // which is not always the literal "weight".
4648 let prop = self
4649 .engine
4650 .rules()
4651 .find(|r| r.name == d.rule)
4652 .and_then(|r| r.weight_prop.as_deref());
4653 let weight = prop.and_then(|p| {
4654 self.edge_props
4655 .get(d.etype_sym, d.src_id, d.dst_id, p)
4656 .and_then(|v| {
4657 if let core_storage::Value::Float(f) = v {
4658 Some(*f)
4659 } else {
4660 None
4661 }
4662 })
4663 });
4664 DbEvent::EdgeFired {
4665 rule: d.rule.clone(),
4666 src_key: d.src_key.clone(),
4667 dst_key: d.dst_key.clone(),
4668 edge_type: d.edge_type.clone(),
4669 weight,
4670 commit_seq: seq,
4671 }
4672 } else {
4673 DbEvent::EdgeRetracted {
4674 rule: d.rule.clone(),
4675 src_key: d.src_key.clone(),
4676 dst_key: d.dst_key.clone(),
4677 edge_type: d.edge_type.clone(),
4678 commit_seq: seq,
4679 }
4680 }
4681 })
4682 .collect();
4683
4684 // Prune dead entries; push matching events to live ones.
4685 self.subscriptions.retain(|entry| {
4686 let Some(inner) = entry.inner.upgrade() else {
4687 return false;
4688 };
4689 for ev in &write_events {
4690 if event_matches(ev, &entry.filter) {
4691 inner.push(ev.clone());
4692 }
4693 }
4694 for ev in &edge_events {
4695 if event_matches(ev, &entry.filter) {
4696 inner.push(ev.clone());
4697 }
4698 }
4699 true
4700 });
4701
4702 // Turn off delta accumulation if all subscribers dropped and no views remain.
4703 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4704 self.engine.set_emit_deltas(false);
4705 }
4706 }
4707
4708 // Query subscriptions: full re-run per commit, then diff rows.
4709 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4710 // Differential evaluation is roadmap / Phase 5.
4711 if !self.query_subscriptions.is_empty() {
4712 // Take the list out so we can call self.view() without borrow conflict.
4713 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4714 let empty_params = BTreeMap::new();
4715 query_subs.retain_mut(|entry| {
4716 let Some(inner) = entry.inner.upgrade() else {
4717 return false; // subscriber dropped — prune
4718 };
4719 // Label-skip: if the plan has a known scan label and this commit
4720 // can be proven to touch only different labels (and no rule-derived
4721 // edge deltas fired), the result set cannot have changed — skip.
4722 if let Some(scan_sym) = entry.scan_label {
4723 if engine_deltas.is_empty() {
4724 let touched =
4725 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4726 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4727 return true; // safe to skip — result set unchanged
4728 }
4729 }
4730 }
4731 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4732 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4733 Ok(r) => r,
4734 Err(e) => {
4735 // Keep the subscription alive; skip the diff for this commit.
4736 // Re-run errors are transient (e.g., planner change) and
4737 // self-heal when the next commit succeeds.
4738 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4739 return true;
4740 }
4741 };
4742 // Build new row map: serialized-key → row data.
4743 let new_row_map = Self::result_to_row_map(&result);
4744 // Removed rows: in prev but not in new.
4745 for (key, row) in &entry.prev_row_map {
4746 if !new_row_map.contains_key(key) {
4747 inner.push(DbEvent::QueryRowRemoved {
4748 columns: entry.columns.clone(),
4749 row: row.clone(),
4750 });
4751 }
4752 }
4753 // Added rows: in new but not in prev.
4754 for (key, row) in &new_row_map {
4755 if !entry.prev_row_map.contains_key(key) {
4756 inner.push(DbEvent::QueryRowAdded {
4757 columns: entry.columns.clone(),
4758 row: row.clone(),
4759 });
4760 }
4761 }
4762 entry.prev_row_map = new_row_map;
4763 true
4764 });
4765 self.query_subscriptions = query_subs;
4766 }
4767 }
4768
4769 /// Returns `true` if any live subscriber or view definition requires delta
4770 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4771 fn needs_emit_deltas(&self) -> bool {
4772 !self.view_store.is_empty()
4773 || self
4774 .subscriptions
4775 .iter()
4776 .any(|e| e.inner.upgrade().is_some())
4777 }
4778
4779 /// Convert a WAL record into `DbEvent` write events with the given seq.
4780 fn write_events_from_record(
4781 rec: &WalRecord,
4782 seq: u64,
4783 intern: &Interner,
4784 ids: &IdMap,
4785 ) -> Vec<DbEvent> {
4786 match rec {
4787 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4788 label: label.clone(),
4789 key: key.clone(),
4790 commit_seq: seq,
4791 }],
4792 // *Id arms run after a successful apply, so resolution can only
4793 // fail on a programming error. Skip the event rather than emit a
4794 // fabricated "" that clients can't tell from a real empty value
4795 // (mirrors event_from_record returning None).
4796 WalRecord::InsertNodeId { label, key, .. } => intern
4797 .resolve(*label)
4798 .map(|label| DbEvent::NodeInserted {
4799 label: label.to_string(),
4800 key: key.clone(),
4801 commit_seq: seq,
4802 })
4803 .into_iter()
4804 .collect(),
4805 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4806 key: key.clone(),
4807 field: field.clone(),
4808 commit_seq: seq,
4809 }],
4810 WalRecord::SetPropId { id, field, .. } => ids
4811 .key_of(*id)
4812 .zip(intern.resolve(*field))
4813 .map(|(key, field)| DbEvent::PropSet {
4814 key: key.to_string(),
4815 field: field.to_string(),
4816 commit_seq: seq,
4817 })
4818 .into_iter()
4819 .collect(),
4820 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4821 key: key.clone(),
4822 field: field.clone(),
4823 commit_seq: seq,
4824 }],
4825 WalRecord::InsertEdge {
4826 edge_type,
4827 src_key,
4828 dst_key,
4829 } => vec![DbEvent::EdgeInserted {
4830 edge_type: edge_type.clone(),
4831 src: src_key.clone(),
4832 dst: dst_key.clone(),
4833 commit_seq: seq,
4834 }],
4835 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4836 Some(DbEvent::EdgeInserted {
4837 edge_type: intern.resolve(*etype)?.to_string(),
4838 src: ids.key_of(*src)?.to_string(),
4839 dst: ids.key_of(*dst)?.to_string(),
4840 commit_seq: seq,
4841 })
4842 })()
4843 .into_iter()
4844 .collect(),
4845 WalRecord::DeleteEdge {
4846 edge_type,
4847 src_key,
4848 dst_key,
4849 } => vec![DbEvent::EdgeDeleted {
4850 edge_type: edge_type.clone(),
4851 src: src_key.clone(),
4852 dst: dst_key.clone(),
4853 commit_seq: seq,
4854 }],
4855 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4856 key: key.clone(),
4857 commit_seq: seq,
4858 }],
4859 WalRecord::Batch(inner) => inner
4860 .iter()
4861 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4862 .collect(),
4863 WalRecord::CreateRule { .. }
4864 | WalRecord::DeleteRule { .. }
4865 | WalRecord::RebuildRule { .. }
4866 | WalRecord::CreateView { .. }
4867 | WalRecord::DeleteView { .. }
4868 | WalRecord::EnableFulltext { .. }
4869 | WalRecord::DisableFulltext { .. }
4870 | WalRecord::EnableIndex { .. }
4871 | WalRecord::DisableIndex { .. }
4872 | WalRecord::Intern { .. }
4873 // History markers produce no DbEvent — the engine delta already
4874 // fired the EdgeFired/EdgeRetracted subscription events.
4875 | WalRecord::DerivedEdgeAdded { .. }
4876 | WalRecord::DerivedEdgeRetracted { .. }
4877 | WalRecord::RenameNode { .. } => vec![],
4878 }
4879 }
4880
4881 /// Subscribe to edge-fire and edge-retract events for one named rule.
4882 ///
4883 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4884 /// currently registered. Dropping the returned [`Subscription`] handle
4885 /// unregisters the subscriber — no further events are queued, no
4886 /// resources leak.
4887 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4888 if self.read_only {
4889 return Err(core_storage::GraphError::ReadOnly);
4890 }
4891 if !self.engine.rules().any(|r| r.name == rule_name) {
4892 return Err(core_storage::GraphError::RuleNotFound {
4893 name: rule_name.to_string(),
4894 });
4895 }
4896 let inner = SubInner::new(self.sub_capacity());
4897 self.subscriptions.push(SubEntry {
4898 filter: SubFilter::Rule(rule_name.to_string()),
4899 inner: std::sync::Arc::downgrade(&inner),
4900 });
4901 self.engine.set_emit_deltas(true);
4902 Ok(Subscription(inner))
4903 }
4904
4905 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4906 ///
4907 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4908 /// as-of instances never commit, so `distribute_events` never runs and the
4909 /// subscription would never deliver events.
4910 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4911 if self.read_only {
4912 return Err(core_storage::GraphError::ReadOnly);
4913 }
4914 let inner = SubInner::new(self.sub_capacity());
4915 self.subscriptions.push(SubEntry {
4916 filter: SubFilter::AllRules,
4917 inner: std::sync::Arc::downgrade(&inner),
4918 });
4919 self.engine.set_emit_deltas(true);
4920 Ok(Subscription(inner))
4921 }
4922
4923 /// Subscribe to write events: node insert/delete, prop set/remove.
4924 ///
4925 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4926 ///
4927 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4928 /// as-of instances never commit, so `distribute_events` never runs and the
4929 /// subscription would never deliver events.
4930 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4931 if self.read_only {
4932 return Err(core_storage::GraphError::ReadOnly);
4933 }
4934 let inner = SubInner::new(self.sub_capacity());
4935 self.subscriptions.push(SubEntry {
4936 filter: SubFilter::Writes,
4937 inner: std::sync::Arc::downgrade(&inner),
4938 });
4939 self.engine.set_emit_deltas(true);
4940 Ok(Subscription(inner))
4941 }
4942
4943 /// Subscribe to incremental Cypher query results.
4944 ///
4945 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4946 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4947 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4948 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4949 ///
4950 /// SKIP is not supported — it shifts the result window on every commit,
4951 /// causing spurious Added/Removed churn for rows whose data never changed.
4952 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4953 /// widens scope beyond the documented single-scan / single-hop subset.
4954 ///
4955 /// After each successful commit, the plan is **fully re-executed** and the
4956 /// result is diffed against the previous run. Added rows produce
4957 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4958 /// [`DbEvent::QueryRowRemoved`].
4959 ///
4960 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4961 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4962 /// is roadmap / Phase 5.
4963 ///
4964 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4965 /// as-of instances never commit, so `distribute_events` never runs and the
4966 /// subscription would never deliver events.
4967 ///
4968 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4969 /// or if the plan shape is not in the allowlist.
4970 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4971 if self.read_only {
4972 return Err(GraphError::ReadOnly);
4973 }
4974 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4975 detail: format!("lex: {e}"),
4976 })?;
4977 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4978 detail: format!("parse: {e}"),
4979 })?;
4980 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4981 detail: format!("plan: {e}"),
4982 })?;
4983 if !is_subscribable(&ops) {
4984 return Err(GraphError::QueryError {
4985 detail: "subscribe_query only supports allowlisted plan shapes: \
4986 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4987 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4988 Not supported: multi-hop Expand chains, SKIP (creates \
4989 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4990 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4991 Use LIMIT to bound re-execution cost."
4992 .to_string(),
4993 });
4994 }
4995 // Execute once to capture initial state (initial rows are not emitted as
4996 // events — the subscriber learns the baseline via the first query call).
4997 let empty_params = BTreeMap::new();
4998 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4999 GraphError::QueryError {
5000 detail: format!("execute: {e}"),
5001 }
5002 })?;
5003 let columns = initial.columns().to_vec();
5004 let prev_row_map = Self::result_to_row_map(&initial);
5005 let inner = SubInner::new(self.sub_capacity());
5006 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
5007 // or unrecognized leading scan → None (always re-execute).
5008 let scan_label = extract_scan_label(&ops, &mut self.syms);
5009 self.query_subscriptions.push(QuerySubEntry {
5010 ops,
5011 columns,
5012 prev_row_map,
5013 inner: std::sync::Arc::downgrade(&inner),
5014 scan_label,
5015 });
5016 Ok(Subscription(inner))
5017 }
5018
5019 /// Queue capacity used for new subscriptions.
5020 fn sub_capacity(&self) -> usize {
5021 self.sub_capacity
5022 }
5023
5024 /// Override per-subscriber queue capacity for subsequently created
5025 /// subscriptions on this db instance.
5026 ///
5027 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5028 /// value in tests to exercise the [`DbEvent::Lagged`] path without
5029 /// generating tens of thousands of events.
5030 ///
5031 /// This is a test-support escape hatch. Calling it in production reduces
5032 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5033 /// to discourage accidental production use.
5034 #[doc(hidden)]
5035 pub fn set_sub_capacity(&mut self, capacity: usize) {
5036 self.sub_capacity = capacity;
5037 }
5038
5039 // -----------------------------------------------------------------------
5040
5041 /// Start an atomic batch.
5042 ///
5043 /// The returned [`BatchBuilder`] borrows `self` mutably until
5044 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5045 /// validation, no WAL I/O. `commit` validates every queued op against
5046 /// live state plus preceding ops in this batch (duplicate key inside
5047 /// the batch is `Err`; an edge between two nodes created earlier in
5048 /// the batch is valid; `delete_node` then insert of the same key is a
5049 /// fresh identity). Validation never mutates the database. Any failure
5050 /// leaves WAL bytes and in-memory state identical to before `commit`.
5051 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5052 /// and each inner record is applied in order so rules fire per record.
5053 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5054 ///
5055 /// **Rule-window limitation:** batch validation cannot see edges that a
5056 /// rule created earlier in the *same* batch will derive at apply time, so
5057 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5058 /// where sequential calls would return `Err(RuleOwned)`. State integrity
5059 /// is unaffected (idempotent apply, provenance intact). Create rules in
5060 /// their own batch, or sequentially, when later ops may touch derived
5061 /// edges.
5062 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5063 BatchBuilder {
5064 db: self,
5065 ops: Vec::new(),
5066 }
5067 }
5068
5069 /// Closure-style atomic write batch.
5070 ///
5071 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5072 /// then committing. All ops queued inside `build` are validated in order and
5073 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5074 /// once per inner record, in order, after commit — semantically identical to
5075 /// sequential single-op writes.
5076 ///
5077 /// **Error semantics — validate-then-apply.** `build` queues ops without
5078 /// touching the database. [`BatchBuilder::commit`] validates every op against
5079 /// live state plus earlier ops in this batch before writing anything. If op N
5080 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5081 /// entire batch is rejected: no WAL bytes are written and no in-memory state
5082 /// changes. The database is identical to its state before `write_batch` was
5083 /// called.
5084 ///
5085 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5086 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5087 /// either fully applied or not at all. However, while applying a committed
5088 /// batch, concurrent readers may observe intermediate states as ops are applied
5089 /// sequentially in memory. There is no interactive transaction isolation in v1.
5090 /// This is documented as "crash-atomic write batches; no interactive
5091 /// transactions or read isolation."
5092 ///
5093 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5094 /// writes zero WAL bytes and returns `(0, 0)`.
5095 ///
5096 /// # Example
5097 ///
5098 /// ```rust,ignore
5099 /// let (nodes, edges) = db.write_batch(|b| {
5100 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5101 /// b.insert_node("Person", "bob", vec![]);
5102 /// b.insert_edge("KNOWS", "alice", "bob");
5103 /// b.set_prop("alice", "role", Value::Str("admin".into()));
5104 /// b.delete_node("old_key");
5105 /// })?;
5106 /// // One fsync; on crash replay: all five ops land or none do.
5107 /// ```
5108 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5109 where
5110 C: FnOnce(&mut BatchBuilder<'_, F>),
5111 {
5112 let mut b = self.batch();
5113 build(&mut b);
5114 b.commit()
5115 }
5116
5117 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5118 /// auto-declared KeyMatch rules (if any) first, then the accepted node
5119 /// inserts, so incremental fire sees the new rules. Per-row key problems
5120 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5121 /// `Err` means nothing was applied.
5122 ///
5123 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5124 /// distinct source labels sharing an FK field each get their own rule.
5125 pub fn ingest(
5126 &mut self,
5127 label: &str,
5128 rows: Vec<BTreeMap<String, Value>>,
5129 opts: &IngestOptions,
5130 ) -> Result<IngestReport> {
5131 self.ingest_with_edges(label, rows, opts, &[])
5132 }
5133
5134 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5135 /// A failing edge rejects the whole request; nothing is applied.
5136 pub fn ingest_with_edges(
5137 &mut self,
5138 label: &str,
5139 rows: Vec<BTreeMap<String, Value>>,
5140 opts: &IngestOptions,
5141 edges: &[(String, String, String)],
5142 ) -> Result<IngestReport> {
5143 crate::ingest::run(self, label, rows, opts, edges)
5144 }
5145
5146 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5147 ///
5148 /// JSON `null` fields are silently omitted (not stored, not a row error).
5149 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5150 /// Parse failures and a top-level value that is not an array of objects
5151 /// return [`GraphError::IngestError`].
5152 pub fn ingest_json(
5153 &mut self,
5154 label: &str,
5155 json: &str,
5156 opts: &IngestOptions,
5157 ) -> Result<IngestReport> {
5158 crate::ingest::run_json(self, label, json, opts)
5159 }
5160
5161 fn commit_logged_batch(
5162 &mut self,
5163 ops: Vec<BatchOp>,
5164 ingest: Option<(String, usize)>,
5165 // Two-source rule: write_batch_authz threads authz here directly (never
5166 // touches pending_write_authz); query_write_authz sets the field instead
5167 // and passes None. Only one source is non-None per call.
5168 param_authz: Option<WriteAuthz>,
5169 ) -> Result<(usize, usize)> {
5170 // Read-only guard: catches empty-batch calls before the early-return
5171 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5172 if self.read_only {
5173 return Err(GraphError::ReadOnly);
5174 }
5175 // Ensure provenance is decoded before MutPreview accesses it
5176 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5177 self.engine.ensure_provenance_loaded_mut();
5178
5179 // ── Authz pre-check ──────────────────────────────────────────────────
5180 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5181 // produces no WAL frame (all-or-nothing at the authz boundary extends
5182 // the existing validate-then-apply contract to role-scope checks).
5183 //
5184 // `batch_created` tracks key→label for nodes created by earlier ops in
5185 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5186 // as visible without needing to call `self.ids.get` on not-yet-committed
5187 // keys (they won't be there yet).
5188 //
5189 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5190 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5191 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5192 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5193 if let Some(ref authz) = authz_opt {
5194 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5195 for op in &ops {
5196 self.check_single_op_authz(authz, op, &batch_created)?;
5197 // Update batch_created after a passing authz check so that
5198 // subsequent ops in this batch see the nodes as "about to exist".
5199 match op {
5200 BatchOp::InsertNode { label, key, .. } => {
5201 // Only track genuinely new nodes (absent from the
5202 // snapshot at authz-check time). A pre-existing visible
5203 // key would be a DuplicateKey — not a real creation —
5204 // so MutPreview handles it. Letting it into batch_created
5205 // would allow a later SetProp to bypass update_labels
5206 // via the "batch-created → always updatable" ruling
5207 // (delete+recreate exploit, fix for I1 review round 2).
5208 //
5209 // Accepted edge: for a delete+recreate-with-different-
5210 // label batch, node_status resolves the pre-delete
5211 // (store) label for any subsequent update checks. This
5212 // grants no net-new capability — a role that can delete+
5213 // create can already place arbitrary props via
5214 // InsertNode's own props field.
5215 if self.ids.get(key.as_str()).is_none() {
5216 batch_created.insert(key.clone(), label.clone());
5217 }
5218 }
5219 BatchOp::InsertEdgeUpsert {
5220 placeholder_label,
5221 src_key,
5222 dst_key,
5223 ..
5224 } => {
5225 // Both endpoints will be created if not already in store.
5226 for ep_key in [src_key, dst_key] {
5227 if self.ids.get(ep_key.as_str()).is_none()
5228 && !batch_created.contains_key(ep_key.as_str())
5229 {
5230 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5231 }
5232 }
5233 }
5234 _ => {}
5235 }
5236 }
5237 }
5238
5239 let recs = {
5240 let mut preview = MutPreview::new(self);
5241 let mut recs = Vec::with_capacity(ops.len());
5242 for op in ops {
5243 match op {
5244 BatchOp::InsertNode { label, key, props } => {
5245 preview.check_insert_node(&key)?;
5246 preview.note_insert_node(&key, &props);
5247 recs.push(WalRecord::InsertNode { label, key, props });
5248 }
5249 BatchOp::InsertEdge {
5250 edge_type,
5251 src_key,
5252 dst_key,
5253 } => {
5254 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5255 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5256 recs.push(WalRecord::InsertEdge {
5257 edge_type,
5258 src_key,
5259 dst_key,
5260 });
5261 }
5262 }
5263 BatchOp::SetProp { key, field, value } => {
5264 preview.check_live_key(&key)?;
5265 preview.note_set_prop(&key, &field, &value);
5266 recs.push(WalRecord::SetProp { key, field, value });
5267 }
5268 BatchOp::RemoveProp { key, field } => {
5269 if preview.prepare_remove_prop(&key, &field)? {
5270 preview.note_remove_prop(&key, &field);
5271 recs.push(WalRecord::RemoveProp { key, field });
5272 }
5273 }
5274 BatchOp::DeleteEdge {
5275 edge_type,
5276 src_key,
5277 dst_key,
5278 } => {
5279 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5280 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5281 recs.push(WalRecord::DeleteEdge {
5282 edge_type,
5283 src_key,
5284 dst_key,
5285 });
5286 }
5287 }
5288 BatchOp::DeleteNode { key } => {
5289 preview.check_live_key(&key)?;
5290 preview.note_delete_node(&key);
5291 recs.push(WalRecord::DeleteNode { key });
5292 }
5293 BatchOp::CreateRule(def) => {
5294 preview.check_create_rule(&def)?;
5295 let def_bytes =
5296 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5297 detail: format!("serialize rule: {e}"),
5298 })?;
5299 preview.note_create_rule(&def);
5300 recs.push(WalRecord::CreateRule { def_bytes });
5301 }
5302 BatchOp::DeleteRule { name } => {
5303 preview.check_delete_rule(&name)?;
5304 preview.note_delete_rule(&name);
5305 recs.push(WalRecord::DeleteRule { name });
5306 }
5307 BatchOp::RenameNode { old_key, new_key } => {
5308 preview.check_rename_node(&old_key, &new_key)?;
5309 preview.note_rename_node(&old_key, &new_key);
5310 recs.push(WalRecord::RenameNode { old_key, new_key });
5311 }
5312 BatchOp::InsertEdgeUpsert {
5313 edge_type,
5314 src_key,
5315 dst_key,
5316 placeholder_label,
5317 } => {
5318 // Auto-create any missing endpoints as plain InsertNode ops.
5319 // Rules fire and last-change is updated for each created node.
5320 for key in [&src_key, &dst_key] {
5321 if !preview.has_key(key) {
5322 preview.check_insert_node(key)?;
5323 preview.note_insert_node(key, &[]);
5324 recs.push(WalRecord::InsertNode {
5325 label: placeholder_label.clone(),
5326 key: key.clone(),
5327 props: vec![],
5328 });
5329 }
5330 }
5331 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5332 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5333 recs.push(WalRecord::InsertEdge {
5334 edge_type,
5335 src_key,
5336 dst_key,
5337 });
5338 }
5339 }
5340 }
5341 }
5342 recs
5343 };
5344 if recs.is_empty() {
5345 return Ok((0, 0));
5346 }
5347 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5348 // *Id form, so only the dense variants can appear in `recs` here.
5349 let recs = self.rewrite_wal_dense(recs)?;
5350 let nodes_inserted = recs
5351 .iter()
5352 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5353 .count();
5354 let edges_inserted = recs
5355 .iter()
5356 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5357 .count();
5358 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5359 // under Strict. Pass self.fsync directly so Strict stays Strict —
5360 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5361 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5362 // short-circuit on single-op batches and silently skip the fsync.
5363 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5364 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5365 Ok((nodes_inserted, edges_inserted))
5366 }
5367
5368 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5369 self.commit_logged_batch(ops, None, None)
5370 }
5371
5372 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5373 /// and the group-commit drain thread, which do a single group fsync later.
5374 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5375 // Restore fsync policy even on panic via a raw-pointer drop guard.
5376 // A panic here would poison the RwLock anyway, but the correct policy
5377 // must be in place if the guard is ever unwrapped.
5378 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5379 impl Drop for RestoreFsync {
5380 fn drop(&mut self) {
5381 // SAFETY: the pointer is valid for the full duration of
5382 // commit_batch_nosync; the guard is dropped before the frame
5383 // returns, and GraphDb outlives this frame.
5384 unsafe {
5385 *self.0 = self.1;
5386 }
5387 }
5388 }
5389 let saved = self.fsync;
5390 // SAFETY: raw pointer into self; guard dropped within this frame.
5391 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5392 self.fsync = FsyncPolicy::Relaxed;
5393 self.commit_logged_batch(ops, None, None)
5394 }
5395
5396 /// Commit multiple op-batches as a **group**: each submission gets its own
5397 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5398 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5399 ///
5400 /// # Durability semantics
5401 ///
5402 /// A crash before the group fsync may lose **all** submissions in the group.
5403 /// A crash after the group fsync preserves all of them. No submission is
5404 /// ever torn: each WAL frame is either fully applied on replay or dropped
5405 /// in its entirety (CRC-protected frame boundaries).
5406 ///
5407 /// Events and subscription notifications fire per-submission immediately
5408 /// after apply, which may be before the group fsync. From a subscriber's
5409 /// perspective this is equivalent to the `Relaxed` durability window.
5410 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5411 /// fsync, so from their perspective durability is fully guaranteed.
5412 ///
5413 /// # MVCC interplay
5414 ///
5415 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5416 /// increments per submission (not per group), preserving existing reader
5417 /// snapshot semantics.
5418 ///
5419 /// # Returns
5420 ///
5421 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5422 /// in order. Failures are per-submission (validation errors); the group
5423 /// fsync error (if any) is returned as the second tuple element.
5424 pub fn commit_group(
5425 &mut self,
5426 groups: Vec<Vec<BatchOp>>,
5427 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5428 let mut results = Vec::with_capacity(groups.len());
5429 for ops in groups {
5430 results.push(self.commit_batch_nosync(ops));
5431 }
5432 let any_ok = results.iter().any(|r| r.is_ok());
5433 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5434 self.fs
5435 .sync(core_storage::fs::FileId::Wal)
5436 .map_err(GraphError::Io)
5437 .err()
5438 } else {
5439 None
5440 };
5441 (results, sync_err)
5442 }
5443
5444 /// Like [`commit_group`] but skips the group fsync entirely.
5445 ///
5446 /// Used by the drain thread to apply submissions under the write lock and
5447 /// then perform the single fsync OUTSIDE the lock (via
5448 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5449 /// to concurrent readers.
5450 pub fn commit_group_nosync(
5451 &mut self,
5452 groups: Vec<Vec<BatchOp>>,
5453 ) -> Vec<Result<(usize, usize)>> {
5454 let mut results = Vec::with_capacity(groups.len());
5455 for ops in groups {
5456 results.push(self.commit_batch_nosync(ops));
5457 }
5458 results
5459 }
5460
5461 pub fn insert_node(
5462 &mut self,
5463 label: &str,
5464 key: &str,
5465 props: Vec<(String, Value)>,
5466 ) -> Result<()> {
5467 if self.read_only {
5468 return Err(GraphError::ReadOnly);
5469 }
5470 MutPreview::new(self).check_insert_node(key)?;
5471 self.log_dense(vec![WalRecord::InsertNode {
5472 label: label.into(),
5473 key: key.into(),
5474 props,
5475 }])
5476 }
5477
5478 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5479 if self.read_only {
5480 return Err(GraphError::ReadOnly);
5481 }
5482 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5483 return Ok(false);
5484 }
5485 self.log_dense(vec![WalRecord::InsertEdge {
5486 edge_type: edge_type.into(),
5487 src_key: src_key.into(),
5488 dst_key: dst_key.into(),
5489 }])?;
5490 Ok(true)
5491 }
5492
5493 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5494 if self.read_only {
5495 return Err(GraphError::ReadOnly);
5496 }
5497 if let Some(view_name) = self.view_store.view_for_prop(field) {
5498 return Err(GraphError::ViewPropReadOnly {
5499 view_name: view_name.to_string(),
5500 });
5501 }
5502 MutPreview::new(self).check_live_key(key)?;
5503 self.log_dense(vec![WalRecord::SetProp {
5504 key: key.into(),
5505 field: field.into(),
5506 value,
5507 }])
5508 }
5509
5510 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5511 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5512 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5513 if self.read_only {
5514 return Err(GraphError::ReadOnly);
5515 }
5516 if let Some(view_name) = self.view_store.view_for_prop(field) {
5517 return Err(GraphError::ViewPropReadOnly {
5518 view_name: view_name.to_string(),
5519 });
5520 }
5521 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5522 return Ok(false);
5523 }
5524 self.log_then_apply(WalRecord::RemoveProp {
5525 key: key.into(),
5526 field: field.into(),
5527 })?;
5528 Ok(true)
5529 }
5530
5531 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5532 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5533 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5534 /// (the rule would just put the edge back; delete or change the rule).
5535 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5536 if self.read_only {
5537 return Err(GraphError::ReadOnly);
5538 }
5539 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5540 return Ok(false);
5541 }
5542 self.log_then_apply(WalRecord::DeleteEdge {
5543 edge_type: edge_type.into(),
5544 src_key: src_key.into(),
5545 dst_key: dst_key.into(),
5546 })?;
5547 Ok(true)
5548 }
5549
5550 /// Delete a live node. Unknown or already-tombstoned keys are
5551 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5552 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5553 /// (crash window) is a clean no-op.
5554 ///
5555 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5556 /// removed (computed from live state before the deletion is applied).
5557 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5558 if self.read_only {
5559 return Err(GraphError::ReadOnly);
5560 }
5561 // Provenance must be loaded before we query provenance_touching.
5562 self.engine.ensure_provenance_loaded_mut();
5563 let id = self
5564 .ids
5565 .get(key)
5566 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5567
5568 // Count edges before the delete is applied so we can report counts.
5569 let derived_set: BTreeSet<(u32, u32, u32)> = self
5570 .engine
5571 .provenance_touching(id)
5572 .map(|(_, etype, src, dst)| (etype, src, dst))
5573 .collect();
5574 let derived_edges = derived_set.len() as u64;
5575
5576 let mut total_topo = 0u64;
5577 let tv = self.topo_view();
5578 for et in tv.etypes() {
5579 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5580 + tv.neighbors(et, Direction::In, id).len() as u64;
5581 }
5582 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5583 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5584 // The subtraction remains correct because both counts include both directions.
5585 let manual_edges = total_topo.saturating_sub(derived_edges);
5586
5587 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5588 Ok(DeleteReport {
5589 manual_edges,
5590 derived_edges,
5591 })
5592 }
5593
5594 /// Rename a live node's key. The dense id (and therefore all edges,
5595 /// props, history, and last-change tracking) is unaffected.
5596 ///
5597 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5598 /// Returns `Err(DuplicateKey)` if `new` is already live.
5599 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5600 if self.read_only {
5601 return Err(GraphError::ReadOnly);
5602 }
5603 MutPreview::new(self).check_rename_node(old, new)?;
5604 self.log_then_apply(WalRecord::RenameNode {
5605 old_key: old.into(),
5606 new_key: new.into(),
5607 })
5608 }
5609
5610 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5611 /// `None` if the rule does not exist or is not approximate.
5612 ///
5613 /// The drift counter increments on IVF insert/remove after the last fit.
5614 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5615 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5616 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5617 // SideIvfExport = (centroids, node→cluster, drift)
5618 self.engine
5619 .export_ivf_state()
5620 .remove(rule)
5621 .map(|(_src, dst)| dst.2)
5622 }
5623
5624 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5625 /// Validation and duplicate-name check run before logging so invalid rules
5626 /// never enter the WAL.
5627 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5628 if self.read_only {
5629 return Err(GraphError::ReadOnly);
5630 }
5631 MutPreview::new(self).check_create_rule(&def)?;
5632 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5633 detail: format!("serialize rule: {e}"),
5634 })?;
5635 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5636 }
5637
5638 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5639 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5640 if self.read_only {
5641 return Err(GraphError::ReadOnly);
5642 }
5643 MutPreview::new(self).check_delete_rule(name)?;
5644 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5645 }
5646
5647 /// Return a snapshot of all registered rules.
5648 pub fn rules(&self) -> Vec<RuleDef> {
5649 self.engine.rules().cloned().collect()
5650 }
5651
5652 // -----------------------------------------------------------------------
5653 // Rule suggestion API
5654 // -----------------------------------------------------------------------
5655
5656 /// Profile the database and suggest linking rules with previewed edge counts.
5657 ///
5658 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5659 /// sampling. Suggestions are sorted by estimated edge count (descending).
5660 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5661 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5662 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5663 }
5664
5665 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5666 /// reproducibility. Same seed + same data = identical output.
5667 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5668 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5669 .suggestions
5670 }
5671
5672 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5673 ///
5674 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5675 /// and a `truncated` flag indicating whether the global budget fired before all
5676 /// candidates were evaluated.
5677 pub fn suggest_rules_with_config(
5678 &self,
5679 config: &core_rules::suggest::SuggestConfig,
5680 seed: u64,
5681 ) -> core_rules::SuggestReport {
5682 use std::collections::BTreeMap;
5683
5684 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5685 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5686 for id in 0..self.ids.len() as u32 {
5687 let Some(key) = self.ids.key_of(id) else {
5688 continue;
5689 };
5690 let Some(&sym) = self.labels.get(id as usize) else {
5691 continue;
5692 };
5693 if sym == u32::MAX {
5694 continue; // tombstoned
5695 }
5696 let Some(label) = self.syms.resolve(sym) else {
5697 continue;
5698 };
5699 label_nodes
5700 .entry(label.to_string())
5701 .or_default()
5702 .push((id, key.to_string()));
5703 }
5704
5705 let existing = self.rules();
5706 let pv = build_props_view(&self.props, &self.base);
5707 let all_fields: Vec<String> = pv.field_names();
5708
5709 core_rules::suggest::suggest_rules(
5710 &label_nodes,
5711 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5712 &all_fields,
5713 &existing,
5714 config,
5715 seed,
5716 )
5717 }
5718
5719 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5720 /// plus later mutations replay identically (rebuild is a pure function
5721 /// of state).
5722 ///
5723 /// Only exit from the tripped latch: if the full desired set fits the
5724 /// budget, it is applied completely and `tripped` clears; if it still
5725 /// exceeds the budget, provenance is left untouched and `tripped` stays
5726 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5727 /// Unknown rule → `RuleNotFound`, nothing logged.
5728 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5729 if self.read_only {
5730 return Err(GraphError::ReadOnly);
5731 }
5732 if !self.engine.rules().any(|r| r.name == name) {
5733 return Err(GraphError::RuleNotFound { name: name.into() });
5734 }
5735 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5736 }
5737
5738 // -----------------------------------------------------------------------
5739 // Materialized view API
5740 // -----------------------------------------------------------------------
5741
5742 /// Register a new materialized property view, backfill its values for all
5743 /// existing nodes, and WAL-log the definition.
5744 ///
5745 /// # Errors
5746 /// - `ReadOnly`: called on an as-of instance.
5747 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5748 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5749 if self.read_only {
5750 return Err(GraphError::ReadOnly);
5751 }
5752 // Pre-validate before WAL write.
5753 def.validate()
5754 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5755 if self.view_store.has_view(&def.name) {
5756 return Err(GraphError::RuleInvalid {
5757 detail: format!("view {:?} already exists", def.name),
5758 });
5759 }
5760 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5761 return Err(GraphError::RuleInvalid {
5762 detail: format!(
5763 "view_prop {:?} is already used by view {:?}",
5764 def.view_prop, existing
5765 ),
5766 });
5767 }
5768 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5769 detail: format!("serialize view: {e}"),
5770 })?;
5771 // Enable delta accumulation before the view is registered so subsequent
5772 // incremental edge events reach view maintenance from this point onward.
5773 // (The backfill inside create_view reads topo directly; it does not rely
5774 // on pending deltas.)
5775 self.engine.set_emit_deltas(true);
5776 self.log_then_apply(WalRecord::CreateView { def_bytes })
5777 }
5778
5779 /// Remove a named view and delete its values from every node.
5780 ///
5781 /// # Errors
5782 /// - `ReadOnly`: called on an as-of instance.
5783 /// - `RuleNotFound`: view does not exist.
5784 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5785 if self.read_only {
5786 return Err(GraphError::ReadOnly);
5787 }
5788 if !self.view_store.has_view(name) {
5789 return Err(GraphError::RuleNotFound { name: name.into() });
5790 }
5791 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5792 // After deletion, disable accumulation if no listeners remain.
5793 if !self.needs_emit_deltas() {
5794 self.engine.set_emit_deltas(false);
5795 }
5796 result
5797 }
5798
5799 /// Snapshot of all registered view definitions.
5800 pub fn views(&self) -> Vec<ViewDef> {
5801 self.view_store.views().cloned().collect()
5802 }
5803
5804 // -----------------------------------------------------------------------
5805 // Full-text-lite API
5806 // -----------------------------------------------------------------------
5807
5808 /// Enable full-text indexing for all nodes of `label` on property `field`.
5809 ///
5810 /// After this call, every subsequent write to `(label, field)` is reflected
5811 /// in the index incrementally. Existing nodes are backfilled immediately.
5812 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5813 /// from scratch on re-open (no snapshot format changes).
5814 ///
5815 /// # Errors
5816 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5817 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5818 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5819 if self.read_only {
5820 return Err(GraphError::ReadOnly);
5821 }
5822 if self.fulltext.is_enabled(label, field) {
5823 return Err(GraphError::RuleInvalid {
5824 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5825 });
5826 }
5827 self.log_then_apply(WalRecord::EnableFulltext {
5828 label: label.into(),
5829 field: field.into(),
5830 })
5831 }
5832
5833 /// Disable full-text indexing for `(label, field)` and drop its postings.
5834 ///
5835 /// # Errors
5836 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5837 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5838 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5839 if self.read_only {
5840 return Err(GraphError::ReadOnly);
5841 }
5842 if !self.fulltext.is_enabled(label, field) {
5843 return Err(GraphError::RuleNotFound {
5844 name: format!("fulltext({label},{field})"),
5845 });
5846 }
5847 self.log_then_apply(WalRecord::DisableFulltext {
5848 label: label.into(),
5849 field: field.into(),
5850 })
5851 }
5852
5853 /// Whether `(label, field)` is currently indexed for full-text search.
5854 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5855 self.fulltext.is_enabled(label, field)
5856 }
5857
5858 /// Every `(label, field)` pair with a live full-text index, sorted.
5859 ///
5860 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5861 /// declares which nodes are *indexed*, so callers that want to search
5862 /// everything indexed should query each distinct field once.
5863 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5864 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5865 v.sort();
5866 v
5867 }
5868
5869 /// Enable an equality index for all nodes of `label` on scalar property
5870 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5871 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5872 /// declaration persists via WAL and the postings rebuild on re-open.
5873 ///
5874 /// # Errors
5875 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5876 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5877 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5878 if self.read_only {
5879 return Err(GraphError::ReadOnly);
5880 }
5881 if self.prop_index.is_enabled(label, field) {
5882 return Err(GraphError::RuleInvalid {
5883 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5884 });
5885 }
5886 self.log_then_apply(WalRecord::EnableIndex {
5887 label: label.into(),
5888 field: field.into(),
5889 })
5890 }
5891
5892 /// Disable the equality index for `(label, field)` and drop its postings.
5893 ///
5894 /// # Errors
5895 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5896 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5897 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5898 if self.read_only {
5899 return Err(GraphError::ReadOnly);
5900 }
5901 if !self.prop_index.is_enabled(label, field) {
5902 return Err(GraphError::RuleNotFound {
5903 name: format!("index({label},{field})"),
5904 });
5905 }
5906 self.log_then_apply(WalRecord::DisableIndex {
5907 label: label.into(),
5908 field: field.into(),
5909 })
5910 }
5911
5912 /// Whether `(label, field)` currently has an equality index.
5913 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5914 self.prop_index.is_enabled(label, field)
5915 }
5916
5917 /// Search a full-text-indexed field.
5918 ///
5919 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5920 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5921 ///
5922 /// **Query syntax:**
5923 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5924 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5925 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5926 /// - `AND` keyword is accepted explicitly and is the default.
5927 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5928 ///
5929 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5930 /// Pin: this is the documented, tested, stable behavior for v1.
5931 ///
5932 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5933 /// in-memory and proportional to total indexed text across all enabled fields.
5934 ///
5935 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5936 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5937 /// key ascending for deterministic tiebreaking.
5938 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5939 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5940 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5941 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5942 // which diverges from key order when nodes were not inserted in key-lex order.
5943 let mut results: Vec<(String, f64)> = self
5944 .fulltext
5945 .search(field, query, 0)
5946 .into_iter()
5947 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5948 .collect();
5949 results.sort_by(|a, b| {
5950 b.1.partial_cmp(&a.1)
5951 .unwrap_or(std::cmp::Ordering::Equal)
5952 .then(a.0.cmp(&b.0))
5953 });
5954 results
5955 }
5956
5957 /// [`search`](Self::search), stopping at the `k` best hits.
5958 ///
5959 /// Same ranking and the same deterministic tiebreak, but the index drops
5960 /// everything past `k` before any key is resolved, so a caller that wants
5961 /// the top few out of a field that matched thousands does not pay to
5962 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5963 /// [`search`](Self::search) behaves.
5964 ///
5965 /// The BM25 scoring itself is not bounded by `k` — every candidate is
5966 /// scored either way — so this trims the resolve and the sort, not the
5967 /// search.
5968 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5969 // A tombstoned id resolves to nothing, so asking the index for exactly
5970 // `k` could return fewer. Over-fetching a little and truncating after
5971 // the filter keeps the count right without unbounding the call.
5972 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5973 let mut results: Vec<(String, f64)> = self
5974 .fulltext
5975 .search(field, query, want)
5976 .into_iter()
5977 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5978 .collect();
5979 results.sort_by(|a, b| {
5980 b.1.partial_cmp(&a.1)
5981 .unwrap_or(std::cmp::Ordering::Equal)
5982 .then(a.0.cmp(&b.0))
5983 });
5984 if k > 0 {
5985 results.truncate(k);
5986 }
5987 results
5988 }
5989
5990 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5991 ///
5992 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5993 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5994 /// them with RRF using a fixed constant of 60.
5995 ///
5996 /// ```text
5997 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5998 /// ```
5999 ///
6000 /// Returns the top `k` nodes by fused score, ties broken by node key
6001 /// ascending (deterministic).
6002 ///
6003 /// # Vector leg fallback
6004 ///
6005 /// When `query_vec` is empty the vector leg is skipped entirely and
6006 /// results are ranked by the text list alone through the same RRF path
6007 /// (each text result scores `1/(60 + rank)` from that single list).
6008 ///
6009 /// When `label` is `None`, the vector leg **always** returns empty results.
6010 /// Internally `label` is mapped to `""`, which does not match any rule-created
6011 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6012 /// the brute-force fallback finds no nodes with an empty label. The fused
6013 /// ranking is therefore text-only in this case.
6014 pub fn search_hybrid(
6015 &self,
6016 text_field: &str,
6017 query_text: &str,
6018 vector_field: &str,
6019 query_vec: &[f64],
6020 label: Option<&str>,
6021 k: usize,
6022 ) -> Vec<(String, f64)> {
6023 use std::collections::HashMap;
6024
6025 const RRF_K: f64 = 60.0;
6026 let pool = 4 * k;
6027
6028 // Accumulate per-node RRF scores.
6029 let mut scores: HashMap<String, f64> = HashMap::new();
6030
6031 // Text leg.
6032 let text_hits = self.search(text_field, query_text);
6033 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6034 let rank = (rank0 + 1) as f64;
6035 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6036 }
6037
6038 // Vector leg (skipped when query_vec is empty).
6039 if !query_vec.is_empty() {
6040 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6041 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6042 let rank = (rank0 + 1) as f64;
6043 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6044 }
6045 }
6046
6047 // Sort: score DESC, then key ASC for deterministic tie-breaking.
6048 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6049 ranked.sort_by(|a, b| {
6050 b.1.partial_cmp(&a.1)
6051 .unwrap_or(std::cmp::Ordering::Equal)
6052 .then(a.0.cmp(&b.0))
6053 });
6054 ranked.truncate(k);
6055 ranked
6056 }
6057
6058 /// For DST/testing: scratch BM25 search over live nodes without the index.
6059 /// Walks every live node, re-stems field tokens, computes corpus stats, and
6060 /// returns BM25-ranked results.
6061 ///
6062 /// The oracle: the ordered key list of `search(field, q)` must equal that of
6063 /// `scratch_search(field, q)` at every quiescent state.
6064 #[doc(hidden)]
6065 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6066 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6067 use std::collections::BTreeMap;
6068
6069 let groups = parse_query(query);
6070 if groups.is_empty() {
6071 return vec![];
6072 }
6073
6074 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6075 struct NodeData {
6076 key: String,
6077 /// stemmed_token → positions (sorted)
6078 tokens: BTreeMap<String, Vec<u32>>,
6079 dl: u32,
6080 }
6081
6082 let mut nodes: Vec<NodeData> = Vec::new();
6083 for id in 0..self.ids.len() as u32 {
6084 let Some(key) = self.ids.key_of(id) else {
6085 continue;
6086 };
6087 let Some(&sym) = self.labels.get(id as usize) else {
6088 continue;
6089 };
6090 if sym == u32::MAX {
6091 continue;
6092 }
6093 let label = match self.syms.resolve(sym) {
6094 Some(l) => l,
6095 None => continue,
6096 };
6097 if !self.fulltext.is_enabled(label, field) {
6098 continue;
6099 }
6100 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6101 continue;
6102 };
6103 // Use value_tokens_stemmed_with_positions so list elements are
6104 // separated by POSITION_GAP — identical to the index path, which
6105 // prevents phrase queries from matching across element boundaries.
6106 let stemmed_with_pos = match &value {
6107 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6108 _ => continue,
6109 };
6110 let dl = stemmed_with_pos.len() as u32;
6111 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6112 for (tok, pos) in stemmed_with_pos {
6113 tok_map.entry(tok).or_default().push(pos);
6114 }
6115 nodes.push(NodeData {
6116 key: key.to_string(),
6117 tokens: tok_map,
6118 dl,
6119 });
6120 }
6121
6122 if nodes.is_empty() {
6123 return vec![];
6124 }
6125
6126 // --- BM25 corpus stats ---
6127 let n = nodes.len() as f64;
6128 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6129 // df per stemmed token across all live indexed nodes.
6130 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6131 for nd in &nodes {
6132 for tok in nd.tokens.keys() {
6133 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6134 }
6135 }
6136
6137 const K1: f64 = 1.2;
6138 const B: f64 = 0.75;
6139
6140 // --- Pass 2: score each node against each OR-group ---
6141 let mut results: Vec<(String, f64)> = Vec::new();
6142 for nd in &nodes {
6143 let dl = nd.dl as f64;
6144 let mut total_score = 0.0f64;
6145
6146 'group: for group in &groups {
6147 let mut group_score = 0.0f64;
6148
6149 for term in group {
6150 if term.negated {
6151 // Negated: if doc has this stemmed token → group fails.
6152 let present = if term.prefix {
6153 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6154 } else {
6155 nd.tokens.contains_key(term.token.as_str())
6156 };
6157 if present {
6158 continue 'group;
6159 }
6160 continue;
6161 }
6162 if term.prefix {
6163 // Prefix: sum BM25 for all matching stemmed tokens.
6164 let mut prefix_matched = false;
6165 for (tok, positions) in &nd.tokens {
6166 if tok.starts_with(term.token.as_str()) {
6167 let tf = positions.len() as f64;
6168 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6169 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6170 let tf_norm =
6171 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6172 group_score += idf * tf_norm;
6173 prefix_matched = true;
6174 }
6175 }
6176 if !prefix_matched {
6177 continue 'group;
6178 }
6179 } else {
6180 // term.token is already stemmed by parse_query; use directly.
6181 match nd.tokens.get(term.token.as_str()) {
6182 None => continue 'group,
6183 Some(positions) => {
6184 let tf = positions.len() as f64;
6185 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6186 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6187 let tf_norm =
6188 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6189 group_score += idf * tf_norm;
6190 }
6191 }
6192 }
6193 }
6194
6195 if group_score > 0.0 {
6196 total_score += group_score;
6197 }
6198 }
6199
6200 if total_score > 0.0 {
6201 results.push((nd.key.clone(), total_score));
6202 }
6203 }
6204
6205 results.sort_by(|a, b| {
6206 b.1.partial_cmp(&a.1)
6207 .unwrap_or(std::cmp::Ordering::Equal)
6208 .then(a.0.cmp(&b.0))
6209 });
6210 results
6211 }
6212
6213 /// Return the current view-maintained value of `view_prop` for node `key`.
6214 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6215 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6216 let id = self.ids.get(key)?;
6217 self.props_view()
6218 .get(id, view_prop)
6219 .map(|vr| vr.into_value())
6220 }
6221
6222 /// For testing / DST oracle: scratch recompute of a view value for one node.
6223 ///
6224 /// Returns `None` if the node does not exist, the view does not exist, or
6225 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6226 #[doc(hidden)]
6227 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6228 let node = self.ids.get(key)?;
6229 let def = self.view_store.views().find(|v| v.name == view_name)?;
6230 // Use TopologyView so that NeighborAgg sees base + overlay edges
6231 // without materialising a temporary Topology (I1).
6232 let topo_view = self.topo_view();
6233 core_rules::views::compute_view_value(
6234 def,
6235 node,
6236 self.props_view(),
6237 &topo_view,
6238 &self.ids,
6239 &self.syms,
6240 &self.labels,
6241 )
6242 }
6243
6244 // -----------------------------------------------------------------------
6245 // Graph algorithm API
6246 // -----------------------------------------------------------------------
6247
6248 /// Run PageRank over the unified topology (manual + derived edges).
6249 ///
6250 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6251 /// ascending). Set `config.edge_type` to restrict to one edge type.
6252 /// `config.converged` is `true` only when the power iteration converged
6253 /// within `config.max_iters` and within any time budget.
6254 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6255 let topo = build_topo_view(&self.topo, &self.base);
6256 let edge_props = self.edge_props_view();
6257 crate::algo::pagerank(
6258 &topo,
6259 &self.ids,
6260 &self.syms,
6261 &self.labels,
6262 &edge_props,
6263 config,
6264 )
6265 }
6266
6267 /// Weakly-connected components over the unified topology (treated as
6268 /// undirected regardless of how edges were inserted).
6269 ///
6270 /// Component IDs are the key of the smallest member in the component
6271 /// (deterministic). Result sorted by (component_id, key).
6272 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6273 let topo = build_topo_view(&self.topo, &self.base);
6274 let edge_props = self.edge_props_view();
6275 crate::algo::wcc(
6276 &topo,
6277 &self.ids,
6278 &self.syms,
6279 &self.labels,
6280 &edge_props,
6281 config,
6282 )
6283 }
6284
6285 /// Degree centrality for every live node.
6286 ///
6287 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6288 /// `AlgoDir::Both` = out + in (total directed degree).
6289 ///
6290 /// For one-shot ranking use this; for a live property updated on every
6291 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6292 pub fn degree_centrality(
6293 &self,
6294 config: &crate::algo::DegreeConfig,
6295 ) -> crate::algo::DegreeReport {
6296 let topo = build_topo_view(&self.topo, &self.base);
6297 let edge_props = self.edge_props_view();
6298 crate::algo::degree_centrality(
6299 &topo,
6300 &self.ids,
6301 &self.syms,
6302 &self.labels,
6303 &edge_props,
6304 config,
6305 )
6306 }
6307
6308 /// Louvain community detection over the unified topology (undirected).
6309 ///
6310 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6311 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6312 /// result (communities sorted size-desc, then smallest member key asc).
6313 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6314 let topo = build_topo_view(&self.topo, &self.base);
6315 let edge_props = self.edge_props_view();
6316 crate::algo::louvain(
6317 &topo,
6318 &self.ids,
6319 &self.syms,
6320 &self.labels,
6321 &edge_props,
6322 config,
6323 )
6324 }
6325
6326 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6327 /// atomically via a single write-batch (one WAL frame, one fsync).
6328 ///
6329 /// # Errors
6330 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6331 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6332 /// (collision check mirrors `create_view`).
6333 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6334 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6335 if self.read_only {
6336 return Err(GraphError::ReadOnly);
6337 }
6338 // Collision check: refuse if prop_name is view-managed.
6339 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6340 return Err(GraphError::RuleInvalid {
6341 detail: format!(
6342 "prop {:?} is managed by view {:?} and cannot be written as scores",
6343 prop_name, view_name
6344 ),
6345 });
6346 }
6347 // Refuse if prop_name is a view name itself (confusing namespace collision).
6348 if self.view_store.has_view(prop_name) {
6349 return Err(GraphError::RuleInvalid {
6350 detail: format!(
6351 "prop_name {:?} collides with an existing view name",
6352 prop_name
6353 ),
6354 });
6355 }
6356 // Write all scores in a single crash-atomic batch.
6357 self.write_batch(|b| {
6358 for (key, score) in scores {
6359 b.set_prop(key, prop_name, Value::Float(*score));
6360 }
6361 })?;
6362 Ok(())
6363 }
6364
6365 /// Return the value of `field` for the node with key `key`, or `None` if
6366 /// the node or field is absent. Reads through the overlay-over-base
6367 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6368 /// overlay hits; one clone per base hit).
6369 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6370 let id = self.ids.get(key)?;
6371 self.props_view().get(id, field).map(|vr| vr.into_value())
6372 }
6373
6374 pub fn has_node(&self, key: &str) -> bool {
6375 self.ids.get(key).is_some()
6376 }
6377
6378 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6379 pub(crate) fn ids(&self) -> &IdMap {
6380 &self.ids
6381 }
6382
6383 // -----------------------------------------------------------------------
6384 // RBAC role resolution
6385 // -----------------------------------------------------------------------
6386
6387 /// Parse `roles.json` bytes from `fs`.
6388 ///
6389 /// Return values:
6390 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6391 /// and valid; in both cases `mask_for_role` uses the
6392 /// list normally (an absent file means no roles defined).
6393 /// `Ok(None)` — file present but corrupt or unrecognised version
6394 /// → poisoned state; `mask_for_role` returns `Err` for
6395 /// any role name until the file is fixed and the DB
6396 /// re-opened (or `apply_schema` is called to repair it).
6397 ///
6398 /// Note: `None` signals corruption, not absence — the opposite of what an
6399 /// optional "file missing" convention would suggest. The open path stores
6400 /// this result on `db.roles` directly.
6401 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6402 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6403 if bytes.is_empty() {
6404 // Empty bytes means either the file is absent or zero-byte — both
6405 // are treated identically as "no roles defined". A zero-byte
6406 // roles.json does NOT widen access: an absent file and a zero-byte
6407 // file both resolve to an empty role list (sees nothing by default).
6408 return Ok(Some(vec![]));
6409 }
6410 match serde_json::from_slice::<RolesFile>(&bytes) {
6411 Ok(f) if matches!(f.version, 1..=3) => Ok(Some(f.roles)),
6412 // Corrupt or unrecognised version (>3): poison the roles state.
6413 // Never widen: a version this binary does not know may carry a
6414 // narrowing this binary would not apply.
6415 _ => Ok(None),
6416 }
6417 }
6418
6419 /// Resolve a role to a node-visibility mask against the current graph state.
6420 ///
6421 /// Returns `Err` when:
6422 /// - `roles.json` was present but corrupt at open (poisoned state), or
6423 /// - `role` does not match any defined role name.
6424 ///
6425 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6426 /// all live nodes carrying any label in `labels` that also pass the role's
6427 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
6428 /// has one. Label resolution is live — new nodes of an allowed label are
6429 /// visible without re-applying the schema, and a property edited out of the
6430 /// predicate takes its node out of the mask on the next read. An empty
6431 /// union = empty mask = sees nothing.
6432 ///
6433 /// This is the one resolver every read path calls, live and as-of alike, so
6434 /// the predicate applies everywhere at once. On an as-of handle the role
6435 /// *definition* is the current one and the graph is the historical one: the
6436 /// predicate is evaluated against the property values at the commit being
6437 /// read.
6438 ///
6439 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
6440 /// between two writes resolves the role once. See
6441 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
6442 /// stale.
6443 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6444 self.role_masks
6445 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
6446 .map(|m| (*m).clone())
6447 }
6448
6449 /// Resolve `role` against the current graph, ignoring the memo.
6450 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6451 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6452 detail:
6453 "roles.json was corrupt at open; fix the file and re-open to restore role access"
6454 .into(),
6455 })?;
6456 let def = roles
6457 .iter()
6458 .find(|r| r.name == role)
6459 .ok_or_else(|| GraphError::KeyNotFound {
6460 key: format!("role:{role}"),
6461 })?;
6462
6463 let mut visible = std::collections::HashSet::new();
6464
6465 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6466 // An administrative grant, never narrowed by the predicate.
6467 for key in &def.keys {
6468 if let Some(id) = self.ids.get(key) {
6469 visible.insert(id);
6470 }
6471 }
6472
6473 // Label leg: live scan — iterate labels vec for matching symbol, and
6474 // when the role carries a predicate, test the property as well. The
6475 // property comes from the store's own merged view (overlay over the
6476 // mmap'd base), so an as-of handle reads the values of its own commit.
6477 let props = def.visible_where.as_ref().map(|_| self.props_view());
6478 for label_name in &def.labels {
6479 if let Some(sym) = self.syms.get(label_name) {
6480 for (i, &s) in self.labels.iter().enumerate() {
6481 if s != sym {
6482 continue;
6483 }
6484 let id = i as u32;
6485 match (&def.visible_where, &props) {
6486 (Some(pred), Some(view)) => {
6487 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
6488 if pred.holds(value.as_ref()) {
6489 visible.insert(id);
6490 }
6491 }
6492 _ => {
6493 visible.insert(id);
6494 }
6495 }
6496 }
6497 }
6498 }
6499
6500 Ok(crate::mask::NodeMask::from_ids(visible))
6501 }
6502
6503 /// Return the current list of role definitions.
6504 ///
6505 /// Returns an empty list when no roles are defined or when `roles.json`
6506 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6507 /// the fail-loud error in that case).
6508 pub fn roles(&self) -> Vec<RoleDef> {
6509 self.roles.as_deref().unwrap_or(&[]).to_vec()
6510 }
6511
6512 // ── Role-scoped write authz ───────────────────────────────────────────────
6513
6514 /// Execute `ops` with optional role-scoped write authorization.
6515 ///
6516 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6517 /// (zero-cost bypass of all authz checks).
6518 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6519 /// record is built. A denial returns an error with no WAL frame written
6520 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6521 ///
6522 /// See the plan's "authz decision table" section for the full semantics.
6523 pub fn write_batch_authz(
6524 &mut self,
6525 authz: Option<&WriteAuthz>,
6526 ops: Vec<BatchOp>,
6527 ) -> Result<(usize, usize)> {
6528 // Thread authz as a direct parameter — never touches pending_write_authz.
6529 self.commit_logged_batch(ops, None, authz.cloned())
6530 }
6531
6532 /// Execute a Cypher write statement with role-scoped write authorization.
6533 ///
6534 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6535 /// lifetime as execution, satisfying §5 lock discipline). The resolved
6536 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6537 /// call so that all inner `batch.commit()` calls are authz-checked.
6538 ///
6539 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6540 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6541 /// timing-oracle item (hidden ≡ absent for unscoped roles).
6542 ///
6543 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6544 /// "this endpoint is not permitted".
6545 pub fn query_write_authz(
6546 &mut self,
6547 role: &str,
6548 cypher: &str,
6549 params: &BTreeMap<String, Value>,
6550 ) -> Result<ResultSet> {
6551 // Resolve scope (fails fast if role has no write scope).
6552 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6553 let scope =
6554 {
6555 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6556 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6557 })?;
6558 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6559 GraphError::KeyNotFound {
6560 key: format!("role:{role}"),
6561 }
6562 })?;
6563 def.write
6564 .clone()
6565 .ok_or_else(|| GraphError::RoleWriteDenied {
6566 reason: "role-bound token: writes are not permitted".into(),
6567 })?
6568 };
6569 // Resolve mask inside the call (same guard, §5 coherence).
6570 let mask = self.mask_for_role(role)?;
6571 self.pending_write_authz = Some(WriteAuthz {
6572 role: role.into(),
6573 scope,
6574 mask,
6575 });
6576 // RAII guard: always clears pending_write_authz on scope exit, including
6577 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6578 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6579 impl Drop for ClearPendingAuthzOnDrop {
6580 fn drop(&mut self) {
6581 // SAFETY: pointer into the owning GraphDb; guard is dropped
6582 // within this function's frame before it returns.
6583 unsafe { *self.0 = None };
6584 }
6585 }
6586 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6587 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6588 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6589 detail: format!("lex: {e}"),
6590 })?;
6591 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6592 detail: format!("parse: {e}"),
6593 })?;
6594 self.exec_write_stmt(stmt, params)
6595 }
6596
6597 /// Execute `ops` with optional role-scoped write authorization, suppressing
6598 /// fsync (for use inside the group-commit drain thread, which performs one
6599 /// group fsync after releasing the write lock).
6600 ///
6601 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6602 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6603 /// contract established by [`commit_batch_nosync`].
6604 pub(crate) fn write_batch_authz_nosync(
6605 &mut self,
6606 authz: Option<&WriteAuthz>,
6607 ops: Vec<BatchOp>,
6608 ) -> Result<(usize, usize)> {
6609 let saved = self.fsync;
6610 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6611 impl Drop for RestoreFsync {
6612 fn drop(&mut self) {
6613 // SAFETY: pointer into the owning GraphDb; guard is dropped
6614 // within the enclosing function's frame before it returns.
6615 unsafe { *self.0 = self.1 };
6616 }
6617 }
6618 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6619 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6620 self.fsync = FsyncPolicy::Relaxed;
6621 self.commit_logged_batch(ops, None, authz.cloned())
6622 }
6623
6624 /// Execute a `/ingest` request with role-scoped write authorization.
6625 ///
6626 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6627 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6628 /// Sets `pending_write_authz` for the duration of the call so that the
6629 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6630 /// and evaluates the decision table per-op before any WAL write.
6631 ///
6632 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6633 /// denied by the decision table with the appropriate §4.3 scope reason;
6634 /// no special HTTP-layer check is needed.
6635 ///
6636 /// Roles with `write: None` return `RoleWriteDenied` with
6637 /// "writes are not permitted" (byte-identical to v1 blanket 403).
6638 pub fn ingest_with_edges_authz(
6639 &mut self,
6640 role: &str,
6641 label: &str,
6642 rows: Vec<std::collections::BTreeMap<String, Value>>,
6643 opts: &crate::ingest::IngestOptions,
6644 edges: &[(String, String, String)],
6645 ) -> Result<crate::ingest::IngestReport> {
6646 // Resolve scope (fails fast if role has no write scope).
6647 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6648 let scope =
6649 {
6650 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6651 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6652 })?;
6653 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6654 GraphError::KeyNotFound {
6655 key: format!("role:{role}"),
6656 }
6657 })?;
6658 def.write
6659 .clone()
6660 .ok_or_else(|| GraphError::RoleWriteDenied {
6661 reason: "role-bound token: writes are not permitted".into(),
6662 })?
6663 };
6664 let mask = self.mask_for_role(role)?;
6665 self.pending_write_authz = Some(WriteAuthz {
6666 role: role.into(),
6667 scope,
6668 mask,
6669 });
6670 // RAII guard: always clears pending_write_authz on scope exit, including
6671 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6672 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6673 impl Drop for ClearPendingAuthzOnDrop {
6674 fn drop(&mut self) {
6675 // SAFETY: pointer into the owning GraphDb; guard is dropped
6676 // within this function's frame before it returns.
6677 unsafe { *self.0 = None };
6678 }
6679 }
6680 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6681 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6682 self.ingest_with_edges(label, rows, opts, edges)
6683 }
6684
6685 /// Evaluate the write-authz decision table for one `BatchOp`.
6686 ///
6687 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6688 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
6689 /// the remaining ops are not evaluated and no WAL frame is written.
6690 ///
6691 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6692 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
6693 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6694 /// batch creates counts as visible if its label passed the create-class gate").
6695 fn check_single_op_authz(
6696 &self,
6697 authz: &WriteAuthz,
6698 op: &BatchOp,
6699 batch_created: &BTreeMap<String, String>,
6700 ) -> Result<()> {
6701 // Helper: 3-way node status under the authz mask.
6702 //
6703 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6704 // as Visible with their recorded label — their create gate already passed
6705 // and they are not yet in self.ids (not committed). This fixes the
6706 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6707 // the SetProp must not see the node as Absent.
6708 let node_status = |key: &str| -> NodeAuthzStatus {
6709 if let Some(label) = batch_created.get(key) {
6710 return NodeAuthzStatus::Visible(label.clone());
6711 }
6712 match self.ids.get(key) {
6713 None => NodeAuthzStatus::Absent,
6714 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6715 Some(id) => {
6716 let label = self
6717 .labels
6718 .get(id as usize)
6719 .and_then(|&sym| {
6720 if sym == u32::MAX {
6721 None
6722 } else {
6723 self.syms.resolve(sym).map(str::to_string)
6724 }
6725 })
6726 .unwrap_or_default();
6727 NodeAuthzStatus::Visible(label)
6728 }
6729 }
6730 };
6731
6732 // Helper: is an InsertEdgeUpsert endpoint visible?
6733 // A same-batch placeholder counts as visible if its label passed
6734 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6735 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6736 // In store and visible?
6737 if let Some(id) = self.ids.get(ep_key) {
6738 return authz.mask.contains_id(id);
6739 }
6740 // Created by an earlier op in this batch?
6741 if let Some(created_label) = batch_created.get(ep_key) {
6742 return authz.scope.create_labels.contains(created_label);
6743 }
6744 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6745 // must pass the create-class gate.
6746 authz
6747 .scope
6748 .create_labels
6749 .contains(&placeholder_label.to_string())
6750 };
6751
6752 match op {
6753 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6754 // These ops are never routed to role-scoped paths by the HTTP layer,
6755 // but we 403 them here to close any future bypass route.
6756 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6757 return Err(GraphError::RoleWriteDenied {
6758 reason: "role-bound token: this endpoint is not permitted".into(),
6759 });
6760 }
6761
6762 // ── CREATE-class: InsertNode ─────────────────────────────────────
6763 //
6764 // Decision table row 1 (scope-before-lookup): check label in
6765 // create_labels BEFORE any key lookup. This is the structural
6766 // closure of the §6.2 timing-oracle item — the denial fires even
6767 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6768 BatchOp::InsertNode { label, key, .. } => {
6769 if !authz.scope.create_labels.contains(label) {
6770 return Err(GraphError::RoleWriteDenied {
6771 reason: format!(
6772 "role-bound token: label '{}' not in write scope (create_labels)",
6773 label
6774 ),
6775 });
6776 }
6777 // Row 2/3: key lookup.
6778 match self.ids.get(key.as_str()) {
6779 Some(id) if authz.mask.contains_id(id) => {
6780 // Visible: DuplicateKey — let MutPreview handle this.
6781 }
6782 Some(_) => {
6783 // Hidden: indistinguishable from absent to the role.
6784 return Err(GraphError::RoleWriteDenied {
6785 reason: "role-bound token: target node not visible".into(),
6786 });
6787 }
6788 None => {
6789 // Absent: proceed (create).
6790 }
6791 }
6792 }
6793
6794 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6795 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6796 if batch_created.contains_key(key.as_str()) {
6797 // Batch-created node: create gate already passed this batch.
6798 // Updating it in the same batch is always allowed, regardless
6799 // of update_labels (ruling §3.5: "writer just created it").
6800 } else {
6801 let label = match node_status(key) {
6802 NodeAuthzStatus::Visible(lbl) => lbl,
6803 _ => {
6804 return Err(GraphError::RoleWriteDenied {
6805 reason: "role-bound token: target node not visible".into(),
6806 });
6807 }
6808 };
6809 if !authz.scope.update_labels.contains(&label) {
6810 return Err(GraphError::RoleWriteDenied {
6811 reason: format!(
6812 "role-bound token: label '{}' not in write scope (update_labels)",
6813 label
6814 ),
6815 });
6816 }
6817 }
6818 }
6819
6820 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6821 BatchOp::DeleteNode { key } => {
6822 let label = match node_status(key) {
6823 NodeAuthzStatus::Visible(lbl) => lbl,
6824 _ => {
6825 return Err(GraphError::RoleWriteDenied {
6826 reason: "role-bound token: target node not visible".into(),
6827 });
6828 }
6829 };
6830 if !authz.scope.delete_labels.contains(&label) {
6831 return Err(GraphError::RoleWriteDenied {
6832 reason: format!(
6833 "role-bound token: label '{}' not in write scope (delete_labels)",
6834 label
6835 ),
6836 });
6837 }
6838 }
6839
6840 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6841 //
6842 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6843 // check (spec §3.5: "existing derived-edge rejection precedes
6844 // delete_edge_types check").
6845 BatchOp::DeleteEdge {
6846 edge_type,
6847 src_key,
6848 dst_key,
6849 } => {
6850 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6851 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6852 self.ids.get(src_key.as_str()),
6853 self.ids.get(dst_key.as_str()),
6854 self.syms.get(edge_type.as_str()),
6855 ) {
6856 if self.engine.is_owned(et_sym, src_id, dst_id) {
6857 return Err(GraphError::RuleOwned {
6858 detail: format!(
6859 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6860 delete or change the owning rule"
6861 ),
6862 });
6863 }
6864 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6865 let preview = MutPreview::new(self);
6866 if preview.would_derive(edge_type, src_key, dst_key) {
6867 return Err(GraphError::RuleOwned {
6868 detail: format!(
6869 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6870 delete or change the owning rule, or a live rule would \
6871 re-derive it"
6872 ),
6873 });
6874 }
6875 }
6876 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6877 if !authz.scope.delete_edge_types.contains(edge_type) {
6878 return Err(GraphError::RoleWriteDenied {
6879 reason: format!(
6880 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6881 edge_type
6882 ),
6883 });
6884 }
6885 // Both endpoints must be visible.
6886 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6887 match self.ids.get(ep_key) {
6888 None => {
6889 return Err(GraphError::RoleWriteDenied {
6890 reason: "role-bound token: edge endpoint not visible".into(),
6891 });
6892 }
6893 Some(id) if !authz.mask.contains_id(id) => {
6894 return Err(GraphError::RoleWriteDenied {
6895 reason: "role-bound token: edge endpoint not visible".into(),
6896 });
6897 }
6898 _ => {}
6899 }
6900 }
6901 }
6902
6903 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6904 //
6905 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6906 BatchOp::InsertEdge {
6907 edge_type,
6908 src_key,
6909 dst_key,
6910 } => {
6911 if !authz.scope.create_edge_types.contains(edge_type) {
6912 return Err(GraphError::RoleWriteDenied {
6913 reason: format!(
6914 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6915 edge_type
6916 ),
6917 });
6918 }
6919 // Both endpoints must be visible. A node created by an earlier
6920 // InsertNode in the same batch (tracked in batch_created) counts
6921 // as visible if its label passed the create-class gate.
6922 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6923 if batch_created.contains_key(ep_key) {
6924 // Created earlier this batch — already scope-checked.
6925 continue;
6926 }
6927 match self.ids.get(ep_key) {
6928 None => {
6929 return Err(GraphError::RoleWriteDenied {
6930 reason: "role-bound token: edge endpoint not visible".into(),
6931 });
6932 }
6933 Some(id) if !authz.mask.contains_id(id) => {
6934 return Err(GraphError::RoleWriteDenied {
6935 reason: "role-bound token: edge endpoint not visible".into(),
6936 });
6937 }
6938 _ => {}
6939 }
6940 }
6941 }
6942
6943 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6944 //
6945 // Scope check first; then endpoint visibility using same-batch
6946 // placeholder awareness (spec: "a placeholder endpoint the SAME
6947 // batch creates counts as visible if its label passed the
6948 // create-class gate").
6949 BatchOp::InsertEdgeUpsert {
6950 edge_type,
6951 src_key,
6952 dst_key,
6953 placeholder_label,
6954 } => {
6955 if !authz.scope.create_edge_types.contains(edge_type) {
6956 return Err(GraphError::RoleWriteDenied {
6957 reason: format!(
6958 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6959 edge_type
6960 ),
6961 });
6962 }
6963 // Check placeholder label against create_labels (create-class gate).
6964 // This ensures the auto-created endpoints are scope-allowed.
6965 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6966 if !upsert_ep_visible(ep_key, placeholder_label) {
6967 return Err(GraphError::RoleWriteDenied {
6968 reason: "role-bound token: edge endpoint not visible".into(),
6969 });
6970 }
6971 }
6972 }
6973 }
6974 Ok(())
6975 }
6976
6977 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6978 ///
6979 /// Called by `apply_schema` when roles change. Never called on unchanged
6980 /// re-apply — this preserves byte-identical idempotency.
6981 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6982 let file = RolesFile::new_versioned(roles.clone());
6983 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6984 detail: format!("roles serialization: {e}"),
6985 })?;
6986 self.fs
6987 .write_atomic(FileId::Roles, &bytes)
6988 .map_err(GraphError::Io)?;
6989 self.roles = Some(roles);
6990 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
6991 // and a memoised mask would still match its version. Install a fresh
6992 // cache instead of clearing the shared one: a reader snapshot frozen
6993 // against the old definitions keeps the old `Arc` to itself and can
6994 // never publish an answer this handle would read back.
6995 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
6996 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6997 // updated role definitions without waiting for the next K-commit fold.
6998 self.fold_now();
6999 Ok(())
7000 }
7001
7002 fn view(&self) -> GraphView<'_> {
7003 GraphView {
7004 ids: &self.ids,
7005 syms: &self.syms,
7006 labels: &self.labels,
7007 props: self.props_view(),
7008 topo: self.topo_view(),
7009 edge_props: self.edge_props_view(),
7010 mask: None,
7011 prop_index: Some(&self.prop_index),
7012 }
7013 }
7014
7015 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7016 GraphView {
7017 ids: &self.ids,
7018 syms: &self.syms,
7019 labels: &self.labels,
7020 props: self.props_view(),
7021 topo: self.topo_view(),
7022 edge_props: self.edge_props_view(),
7023 mask: Some(&mask.visible),
7024 prop_index: Some(&self.prop_index),
7025 }
7026 }
7027
7028 /// Execute a read-only Cypher query with a node visibility mask.
7029 ///
7030 /// Only nodes whose key is in `mask` are accessible: label scans, key
7031 /// lookups, and neighbor expansions all respect the mask. Edges where
7032 /// either endpoint is hidden are silently dropped.
7033 ///
7034 /// Returns `Err` with a "masked queries are read-only" message when
7035 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7036 pub fn query_masked(
7037 &self,
7038 cypher: &str,
7039 params: &std::collections::BTreeMap<String, Value>,
7040 mask: &crate::mask::NodeMask,
7041 ) -> Result<ResultSet> {
7042 // Reject write statements up front.
7043 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7044 detail: format!("lex: {e}"),
7045 })?;
7046 if is_write_tokens(&tokens) {
7047 return Err(GraphError::MaskedReadOnly);
7048 }
7049 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7050 detail: format!("parse: {e}"),
7051 })?;
7052 // Each UNION part executes against the same masked view, so the mask
7053 // applies uniformly across the chain.
7054 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7055 GraphError::QueryError {
7056 detail: format!("execute: {e}"),
7057 }
7058 })
7059 }
7060
7061 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7062 let id = self.ids.get(key)?;
7063 Some(NodeRef { db: self, id })
7064 }
7065
7066 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7067 ///
7068 /// Hidden nodes are never used as traversal intermediaries in either
7069 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7070 /// only through a hidden node will not appear in results.
7071 ///
7072 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7073 /// a visited visible node are appended to the result as stub rows
7074 /// (`label` column is `null`, same key+depth columns as visible rows).
7075 /// They are NOT added to the BFS frontier.
7076 ///
7077 /// Returns `None` when `key` does not exist (caller should 404).
7078 ///
7079 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7080 /// stub rows are never produced on the role path.
7081 pub fn neighborhood_masked(
7082 &self,
7083 key: &str,
7084 depth: u32,
7085 edge_types: Option<&[&str]>,
7086 dir: Dir,
7087 mask: &crate::mask::NodeMask,
7088 ) -> Option<ResultSet> {
7089 let start_id = self.ids.get(key)?;
7090 let view = self.view_masked(mask);
7091 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7092 names
7093 .iter()
7094 .filter_map(|name| view.syms.get(name))
7095 .collect()
7096 });
7097 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7098 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7099 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7100 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7101 visited.push((start_id, 0));
7102 for (nid, d) in &nb.nodes {
7103 let k = view.key_of(*nid);
7104 let label = view
7105 .label_of(*nid)
7106 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7107 rs.push_row(vec![
7108 Some(Value::Str(k.to_string())),
7109 Some(Value::Str(label.to_string())),
7110 Some(Value::Int(*d as i64)),
7111 ]);
7112 visited.push((*nid, *d));
7113 }
7114 // Stub mode: add hidden direct neighbours of each visited node as stubs.
7115 // Hidden nodes are edge-endpoints only — they are not added to the BFS
7116 // frontier, so the BFS never expands through them.
7117 if mask.mode() == crate::mask::MaskMode::Stub {
7118 let raw_view = self.view();
7119 let mut seen: std::collections::HashSet<u32> =
7120 visited.iter().map(|(id, _)| *id).collect();
7121 for (node_id, node_depth) in &visited {
7122 if *node_depth >= depth {
7123 continue;
7124 }
7125 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7126 let nbr = if e.src == *node_id { e.dst } else { e.src };
7127 if !mask.contains_id(nbr) && seen.insert(nbr) {
7128 if let Some(k) = self.ids.key_of(nbr) {
7129 rs.push_row(vec![
7130 Some(Value::Str(k.to_string())),
7131 None,
7132 Some(Value::Int((*node_depth + 1) as i64)),
7133 ]);
7134 }
7135 }
7136 }
7137 }
7138 }
7139 Some(rs)
7140 }
7141
7142 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7143 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7144 let n = self.node_ref(key)?;
7145 Some(NodeInfo {
7146 key: n.key().to_string(),
7147 label: n.label().to_string(),
7148 props: n.props(),
7149 })
7150 }
7151
7152 /// Look up a node with mask awareness.
7153 ///
7154 /// | Key state | Omit mode | Stub mode |
7155 /// |-------------------|-----------------|------------------------|
7156 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
7157 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
7158 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
7159 ///
7160 /// **SECURITY**: only call from client-mask (full-token) paths.
7161 /// Role-token paths must use [`node_info`] after an explicit visibility check.
7162 pub fn node_info_masked(
7163 &self,
7164 key: &str,
7165 mask: &crate::mask::NodeMask,
7166 ) -> Option<MaskedNodeResult> {
7167 let id = self.ids.get(key)?;
7168 if mask.contains_id(id) {
7169 Some(MaskedNodeResult::Visible(self.node_info(key)?))
7170 } else {
7171 match mask.mode() {
7172 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7173 crate::mask::MaskMode::Omit => None,
7174 }
7175 }
7176 }
7177
7178 /// Get edges for `key` with mask-aware hidden-endpoint handling.
7179 ///
7180 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7181 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7182 /// is `true` for each hidden endpoint.
7183 ///
7184 /// Unknown key → [`GraphError::KeyNotFound`].
7185 ///
7186 /// **SECURITY**: only call from client-mask (full-token) paths.
7187 pub fn node_edges_masked(
7188 &self,
7189 key: &str,
7190 mask: &crate::mask::NodeMask,
7191 ) -> Result<Vec<MaskedEdge>> {
7192 self.ensure_v8_base_sections_loaded();
7193 let id = self
7194 .ids
7195 .get(key)
7196 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7197 let derived: BTreeSet<(u32, u32, u32)> = self
7198 .engine
7199 .provenance_touching(id)
7200 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7201 .collect();
7202 let mut edges = Vec::new();
7203 let tv = self.topo_view();
7204 for etype in tv.etypes() {
7205 // etype comes from the archived CSR (access_unchecked, no eager CRC).
7206 // A bit-flip in the large TOPOLOGY section can produce an etype id
7207 // that is not in the interner. Return Corrupt rather than panic.
7208 let edge_type = self
7209 .syms
7210 .resolve(etype)
7211 .ok_or_else(|| GraphError::Corrupt {
7212 detail: format!("v8: topology etype {etype} not in interner"),
7213 })?
7214 .to_string();
7215 for dir in [Direction::Out, Direction::In] {
7216 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7217 let nbr_restricted = !mask.contains_id(nbr);
7218 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7219 continue;
7220 }
7221 let nbr_key = self
7222 .ids
7223 .key_of(nbr)
7224 .ok_or_else(|| GraphError::Corrupt {
7225 detail: format!("topology id {nbr} has no key"),
7226 })?
7227 .to_string();
7228 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7229 match dir {
7230 Direction::Out => {
7231 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7232 }
7233 Direction::In => {
7234 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7235 }
7236 };
7237 edges.push(MaskedEdge {
7238 edge_type: edge_type.clone(),
7239 src_key,
7240 src_restricted,
7241 dst_key,
7242 dst_restricted,
7243 derived: derived.contains(&(etype, src_id, dst_id)),
7244 });
7245 }
7246 }
7247 }
7248 edges.sort_by(|a, b| {
7249 a.edge_type
7250 .cmp(&b.edge_type)
7251 .then(a.src_key.cmp(&b.src_key))
7252 .then(a.dst_key.cmp(&b.dst_key))
7253 });
7254 edges.dedup_by(|a, b| {
7255 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7256 });
7257 Ok(edges)
7258 }
7259
7260 /// Every directed edge incident on `key`, both directions, every etype.
7261 ///
7262 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7263 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7264 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7265 /// Unknown key → [`GraphError::KeyNotFound`].
7266 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7267 self.ensure_v8_base_sections_loaded();
7268 let id = self
7269 .ids
7270 .get(key)
7271 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7272 let derived: BTreeSet<(u32, u32, u32)> = self
7273 .engine
7274 .provenance_touching(id)
7275 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7276 .collect();
7277 let mut edges = Vec::new();
7278 let tv = self.topo_view();
7279 for etype in tv.etypes() {
7280 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7281 let edge_type = self
7282 .syms
7283 .resolve(etype)
7284 .ok_or_else(|| GraphError::Corrupt {
7285 detail: format!("v8: topology etype {etype} not in interner"),
7286 })?
7287 .to_string();
7288 for dir in [Direction::Out, Direction::In] {
7289 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7290 let (src, dst, src_key, dst_key) = match dir {
7291 Direction::Out => (
7292 id,
7293 nbr,
7294 key.to_string(),
7295 self.ids
7296 .key_of(nbr)
7297 .ok_or_else(|| GraphError::Corrupt {
7298 detail: format!("topology id {nbr} has no key"),
7299 })?
7300 .to_string(),
7301 ),
7302 Direction::In => (
7303 nbr,
7304 id,
7305 self.ids
7306 .key_of(nbr)
7307 .ok_or_else(|| GraphError::Corrupt {
7308 detail: format!("topology id {nbr} has no key"),
7309 })?
7310 .to_string(),
7311 key.to_string(),
7312 ),
7313 };
7314 edges.push(EdgeInfo {
7315 edge_type: edge_type.clone(),
7316 src_key,
7317 dst_key,
7318 derived: derived.contains(&(etype, src, dst)),
7319 });
7320 }
7321 }
7322 }
7323 edges.sort_by(|a, b| {
7324 a.edge_type
7325 .cmp(&b.edge_type)
7326 .then(a.src_key.cmp(&b.src_key))
7327 .then(a.dst_key.cmp(&b.dst_key))
7328 });
7329 // Self-loops appear in both Out and In; sort makes the pair adjacent
7330 // (sort key matches PartialEq for this case) so one pass drops the dup.
7331 edges.dedup();
7332 Ok(edges)
7333 }
7334
7335 // ── Backup ────────────────────────────────────────────────────────────────
7336
7337 /// Copy this store to `dest` as a consistent, verified snapshot.
7338 ///
7339 /// Copies every durable file in the database directory — `snapshot.bin`,
7340 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7341 /// `roles.json` — into a freshly created `dest` directory using OS-level
7342 /// `copy` calls (no large in-process buffers).
7343 ///
7344 /// # Consistency guarantee
7345 ///
7346 /// The guarantee is **process-local**: the caller holds `&self`, which
7347 /// prevents any concurrent writer in the **same process** from modifying
7348 /// the files during the copy. Running `mushroomdb backup` against a
7349 /// directory that is **concurrently being written by another process** (e.g.
7350 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
7351 /// `verified: true` result reduces but does not eliminate the risk of a
7352 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7353 /// consistent mid-write snapshot).
7354 ///
7355 /// **The safe path for a live-served store is `POST /backup` on the HTTP
7356 /// server.** That handler acquires the read lock on the shared database
7357 /// before calling this method, which is the correct cross-process
7358 /// synchronisation point because the server is the single process writing
7359 /// the files.
7360 ///
7361 /// After copying, opens the destination read-only and runs the CRC section
7362 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7363 /// `BackupReport::verified` reflects whether both checks passed.
7364 ///
7365 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7366 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7367 // Derive source directory from snapshot_path (RealFs only).
7368 let src_dir = match self.fs.snapshot_path() {
7369 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7370 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7371 })?,
7372 None => {
7373 return Err(GraphError::Io(std::io::Error::other(
7374 "backup_to requires a real filesystem (RealFs)",
7375 )))
7376 }
7377 };
7378
7379 std::fs::create_dir_all(dest)?;
7380
7381 let mut files: Vec<String> = Vec::new();
7382 let mut bytes: u64 = 0;
7383
7384 // Helper: copy src_dir/name → dest/name if the file exists.
7385 let mut try_copy = |name: &str| -> std::io::Result<()> {
7386 let src_path = src_dir.join(name);
7387 if src_path.exists() {
7388 let n = std::fs::copy(&src_path, dest.join(name))?;
7389 bytes += n;
7390 files.push(name.to_string());
7391 }
7392 Ok(())
7393 };
7394
7395 try_copy("snapshot.bin")?;
7396 try_copy("snapshot.bin.bak")?;
7397 try_copy("wal.bin")?;
7398 try_copy("wal.floor")?;
7399 try_copy("wal.genesis")?;
7400 try_copy("roles.json")?;
7401
7402 // Copy WAL archives.
7403 let archives = self.fs.list_archives()?;
7404 for n in &archives {
7405 let name = format!("wal.{n}.archive");
7406 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7407 bytes += n_bytes;
7408 files.push(name);
7409 }
7410
7411 files.sort();
7412
7413 // Post-copy verification: open dest and run CRC checks.
7414 let snap_in_dest = dest.join("snapshot.bin").exists();
7415 let crc_ok = if snap_in_dest {
7416 crate::verify_snapshot(dest)
7417 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7418 .unwrap_or(false)
7419 } else {
7420 true // WAL-only store: nothing to CRC-check in snapshot
7421 };
7422 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7423 let verified = crc_ok && opens_ok;
7424
7425 Ok(BackupReport {
7426 files,
7427 bytes,
7428 verified,
7429 })
7430 }
7431
7432 // ── Export helpers ────────────────────────────────────────────────────────
7433
7434 /// All live nodes, sorted by key (deterministic).
7435 ///
7436 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7437 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7438 self.ensure_v8_base_sections_loaded();
7439 let pv = self.props_view();
7440 let mut nodes = Vec::new();
7441 for id in 0..self.ids.len() as u32 {
7442 let Some(key) = self.ids.key_of(id) else {
7443 continue;
7444 };
7445 let Some(&sym) = self.labels.get(id as usize) else {
7446 continue;
7447 };
7448 if sym == u32::MAX {
7449 continue; // tombstoned
7450 }
7451 let Some(label) = self.syms.resolve(sym) else {
7452 continue;
7453 };
7454 let mut props = BTreeMap::new();
7455 for field in pv.field_names() {
7456 if let Some(vr) = pv.get(id, &field) {
7457 props.insert(field, vr.into_value());
7458 }
7459 }
7460 nodes.push(NodeInfo {
7461 key: key.to_string(),
7462 label: label.to_string(),
7463 props,
7464 });
7465 }
7466 nodes.sort_by(|a, b| a.key.cmp(&b.key));
7467 nodes
7468 }
7469
7470 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7471 ///
7472 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7473 /// Manual edges carry `derived: false` and `rule: None`.
7474 /// `weight` is the creating rule's `weight_prop` value read off the edge
7475 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7476 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7477 /// store state.
7478 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7479 self.ensure_v8_base_sections_loaded();
7480
7481 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7482 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7483 for (rule_name, triples) in self.engine.provenance() {
7484 for &(etype, src, dst) in triples {
7485 prov.insert((etype, src, dst), rule_name.clone());
7486 }
7487 }
7488
7489 // rule_name → weight_prop, for O(1) lookup per derived edge.
7490 let weight_props: HashMap<&str, Option<&str>> = self
7491 .engine
7492 .rules()
7493 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7494 .collect();
7495
7496 let tv = self.topo_view();
7497 let ep = self.edge_props_view();
7498 let mut edges = Vec::new();
7499
7500 for id in 0..self.ids.len() as u32 {
7501 let Some(key) = self.ids.key_of(id) else {
7502 continue;
7503 };
7504 let Some(&lsym) = self.labels.get(id as usize) else {
7505 continue;
7506 };
7507 if lsym == u32::MAX {
7508 continue; // tombstoned
7509 }
7510
7511 for etype_sym in tv.etypes() {
7512 // etype from archived CSR (access_unchecked, no eager CRC).
7513 // Skip edges whose etype is not in the interner; this can only
7514 // occur with a corrupt large TOPOLOGY section (bit-flip on an
7515 // etype field in the archived data). The function returns Vec,
7516 // not Result, so we continue rather than propagate.
7517 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7518 continue;
7519 };
7520 let edge_type = edge_type.to_string();
7521 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7522 let Some(dst_key) = self.ids.key_of(nbr) else {
7523 continue; // skip corrupt entries
7524 };
7525 let prov_key = (etype_sym, id, nbr);
7526 let rule = prov.get(&prov_key).cloned();
7527 let derived = rule.is_some();
7528 let weight = rule
7529 .as_deref()
7530 .and_then(|rn| weight_props.get(rn).copied().flatten())
7531 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7532 Some(Value::Float(f)) => Some(f),
7533 Some(Value::Int(i)) => Some(i as f64),
7534 _ => None,
7535 });
7536 edges.push(ExportEdge {
7537 edge_type: edge_type.clone(),
7538 src: key.to_string(),
7539 dst: dst_key.to_string(),
7540 derived,
7541 rule,
7542 weight,
7543 });
7544 }
7545 }
7546 }
7547
7548 edges.sort_by(|a, b| {
7549 a.edge_type
7550 .cmp(&b.edge_type)
7551 .then(a.src.cmp(&b.src))
7552 .then(a.dst.cmp(&b.dst))
7553 });
7554 edges
7555 }
7556
7557 /// What each edge type *is*, without building one record per edge.
7558 ///
7559 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
7560 /// question by materialising every edge — three `String`s apiece, a
7561 /// provenance `HashMap` over every derived edge, and a final sort. That is
7562 /// the right shape for an export, and the wrong one for a summary: on a
7563 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
7564 /// produce nine lines. This walks the topology instead, summing neighbour
7565 /// slice lengths and collecting *label symbols* rather than label strings,
7566 /// so the per-edge cost is an integer add and a set insert on a set with
7567 /// as many members as the store has labels.
7568 ///
7569 /// The rule names come off the rule *definitions*, which each declare the
7570 /// `edge_type` they derive, so naming them costs one pass over the rules
7571 /// rather than one provenance lookup per edge. That is also why `rules`
7572 /// is a list: two rules may derive the same type — the association store
7573 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
7574 /// talent→job rule — and naming only one of them would be a half-truth.
7575 /// A type with no rules is one written by hand.
7576 ///
7577 /// `sample` is the first edge of the type in the store's own id order,
7578 /// which is insertion order: deterministic for a given store, and not the
7579 /// same as key order, which cannot be had without resolving a key per
7580 /// edge. Sorted by `edge_type`.
7581 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
7582 self.ensure_v8_base_sections_loaded();
7583
7584 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
7585 for r in self.engine.rules() {
7586 rules_by_type
7587 .entry(r.edge_type.as_str())
7588 .or_default()
7589 .insert(r.name.as_str());
7590 }
7591
7592 let tv = self.topo_view();
7593 let node_count = self.ids.len() as u32;
7594 let mut out = Vec::new();
7595 for etype_sym in tv.etypes() {
7596 // An etype the interner cannot resolve means a corrupt TOPOLOGY
7597 // section; skip it rather than name it, as `all_edges_for_export`
7598 // does for the same reason.
7599 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7600 continue;
7601 };
7602 let mut edges: u64 = 0;
7603 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
7604 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
7605 let mut sample: Option<(u32, u32)> = None;
7606 for id in 0..node_count {
7607 let Some(&lsym) = self.labels.get(id as usize) else {
7608 continue;
7609 };
7610 if lsym == u32::MAX {
7611 continue; // tombstoned
7612 }
7613 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
7614 let nbrs = nbrs.as_ref();
7615 if nbrs.is_empty() {
7616 continue;
7617 }
7618 edges += nbrs.len() as u64;
7619 src_syms.insert(lsym);
7620 for &nbr in nbrs {
7621 if let Some(&dsym) = self.labels.get(nbr as usize) {
7622 if dsym != u32::MAX {
7623 dst_syms.insert(dsym);
7624 }
7625 }
7626 }
7627 if sample.is_none() {
7628 sample = Some((id, nbrs[0]));
7629 }
7630 }
7631 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
7632 syms.iter()
7633 .filter_map(|&s| self.syms.resolve(s))
7634 .map(ToString::to_string)
7635 .collect()
7636 };
7637 out.push(EdgeTypeCensus {
7638 edge_type: edge_type.to_string(),
7639 edges,
7640 src_labels: resolve(&src_syms),
7641 dst_labels: resolve(&dst_syms),
7642 rules: rules_by_type
7643 .get(edge_type)
7644 .map(|rs| rs.iter().map(ToString::to_string).collect())
7645 .unwrap_or_default(),
7646 sample: sample.and_then(|(s, d)| {
7647 Some((
7648 self.ids.key_of(s)?.to_string(),
7649 self.ids.key_of(d)?.to_string(),
7650 ))
7651 }),
7652 });
7653 }
7654 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
7655 out
7656 }
7657
7658 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7659 /// on each edge when given.
7660 ///
7661 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7662 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7663 /// `None` — callers that want a default weight (e.g. `1.0` for missing
7664 /// props) apply it themselves, matching the convention used internally
7665 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7666 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7667 ///
7668 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7669 /// (manual + rule-derived edges). An unknown `edge_type` returns an
7670 /// empty vec.
7671 pub fn weighted_edges(
7672 &self,
7673 edge_type: &str,
7674 weight_prop: Option<&str>,
7675 ) -> Vec<(String, String, Option<f64>)> {
7676 let Some(etype_sym) = self.syms.get(edge_type) else {
7677 return Vec::new();
7678 };
7679 let tv = self.topo_view();
7680 let ep = self.edge_props_view();
7681 let mut out = Vec::new();
7682 for id in 0..self.ids.len() as u32 {
7683 let Some(key) = self.ids.key_of(id) else {
7684 continue;
7685 };
7686 let Some(&sym) = self.labels.get(id as usize) else {
7687 continue;
7688 };
7689 if sym == u32::MAX {
7690 continue; // tombstoned
7691 }
7692 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7693 let Some(dst_key) = self.ids.key_of(nbr) else {
7694 continue;
7695 };
7696 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7697 Some(Value::Float(f)) => Some(f),
7698 Some(Value::Int(i)) => Some(i as f64),
7699 _ => None,
7700 });
7701 out.push((key.to_string(), dst_key.to_string(), weight));
7702 }
7703 }
7704 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7705 out
7706 }
7707
7708 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7709 self.view()
7710 .nodes_with_label(label)
7711 .into_iter()
7712 .map(|id| NodeRef { db: self, id })
7713 .collect()
7714 }
7715
7716 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7717 let view = self.view();
7718 view.nodes_with_label(label)
7719 .into_iter()
7720 .filter(|&id| {
7721 eval_filter(filter, &|field| {
7722 view.prop(id, field).map(|vr| vr.into_value())
7723 })
7724 })
7725 .map(|id| NodeRef { db: self, id })
7726 .collect()
7727 }
7728
7729 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7730 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
7731 /// with `label = None` will use the native ANN path rather than the O(n)
7732 /// brute-force scan.
7733 pub fn has_vector_rule(&self, field: &str) -> bool {
7734 self.engine.hnsw_has_rule(field)
7735 }
7736
7737 /// How many HNSW graphs this handle has built from scratch since it was
7738 /// opened (one per side of an approximate rule).
7739 ///
7740 /// An open that restored every graph from the snapshot reports `0`.
7741 /// Exposed for tests that assert the open path reuses the persisted index
7742 /// rather than rebuilding it; not part of the stable surface.
7743 #[doc(hidden)]
7744 pub fn hnsw_build_count(&self) -> u64 {
7745 self.engine.hnsw_build_count()
7746 }
7747
7748 /// Rules whose vector graphs the clean-open read path still holds a second
7749 /// copy of. Zero before the first ANN query and again after the first
7750 /// write. Exposed for tests; not part of the stable surface.
7751 #[doc(hidden)]
7752 pub fn lazy_hnsw_len(&self) -> usize {
7753 self.engine.lazy_hnsw_len()
7754 }
7755
7756 /// Find nodes whose `field` vector is most similar to `q` (cosine
7757 /// similarity), returning up to `k` results with similarity ≥ `min`,
7758 /// sorted descending.
7759 ///
7760 /// When `label` is `None` the search spans all labels (via
7761 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7762 /// `Some(lbl)` it restricts to nodes with that label.
7763 ///
7764 /// Uses the HNSW index when one is available (fast path); otherwise falls
7765 /// back to an O(n) brute-force scan.
7766 pub fn find_similar_vector(
7767 &self,
7768 field: &str,
7769 label: Option<&str>,
7770 q: &[f64],
7771 k: usize,
7772 min: f64,
7773 ) -> Vec<(String, f64)> {
7774 // Ensure any HNSW blobs retained from the snapshot are deserialized
7775 // before the first ANN query on a clean-open (no-WAL) path. The
7776 // section read has to come first: on a clean open nothing else has
7777 // called it, so without it `retained_hnsw_blobs` is empty,
7778 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
7779 // approximate query on the handle runs brute force — correct results,
7780 // silently off the index. Both calls are idempotent and cheap once hot.
7781 self.ensure_v8_base_sections_loaded();
7782 self.engine.ensure_hnsw_loaded();
7783 // L2-normalise query for cosine via dot product.
7784 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7785 if norm == 0.0 {
7786 return vec![];
7787 }
7788 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7789
7790 // Try HNSW fast path.
7791 // `None` label searches across all VectorSimilar rules covering `field`
7792 // (merging their results); `Some(lbl)` restricts to rules whose
7793 // dst_label matches. Returns `None` when no populated HNSW index
7794 // covers the request — the O(n) brute-force fallback handles that case.
7795 let hnsw_hits = match label {
7796 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7797 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7798 };
7799 if let Some(hits) = hnsw_hits {
7800 let mut out: Vec<(String, f64)> = hits
7801 .into_iter()
7802 .filter(|&(_, sim)| sim >= min)
7803 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7804 .collect();
7805 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7806 out.truncate(k);
7807 return out;
7808 }
7809
7810 // Brute-force fallback: O(n) scan (only reached when no HNSW index
7811 // covers the request).
7812 let view = self.view();
7813 let candidate_ids: Vec<u32> = match label {
7814 Some(lbl) => view.nodes_with_label(lbl),
7815 None => view.nodes_all(),
7816 };
7817 let mut scored: Vec<(String, f64)> = candidate_ids
7818 .into_iter()
7819 .filter_map(|id| {
7820 let v = view.prop(id, field)?;
7821 let v_owned = v.into_value();
7822 let xs = value_as_float_list(&v_owned)?;
7823 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7824 if v_norm == 0.0 {
7825 return None;
7826 }
7827 let dot: f64 = q_unit
7828 .iter()
7829 .zip(xs.iter())
7830 .map(|(a, b)| a * (b / v_norm))
7831 .sum();
7832 if dot < min {
7833 return None;
7834 }
7835 let key = self.ids.key_of(id)?.to_string();
7836 Some((key, dot))
7837 })
7838 .collect();
7839 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7840 scored.truncate(k);
7841 scored
7842 }
7843
7844 /// Like [`find_similar_vector`] but restricts results to nodes visible in
7845 /// `mask`. Hidden nodes never appear in results; the mask is applied
7846 /// **before** k-truncation so a caller still receives up to `k` visible
7847 /// hits.
7848 ///
7849 /// # HNSW path (over-fetch policy)
7850 ///
7851 /// When an HNSW index covers the request, this function fetches `4 * k`
7852 /// candidates from the index and discards hidden nodes in the post-filter
7853 /// step. If fewer than `k` visible nodes remain after filtering the caller
7854 /// receives whatever is available — we do not re-query the index. The 4×
7855 /// multiplier is a heuristic suited for sparsely masked graphs; callers
7856 /// operating under a very selective mask should register a VectorSimilar
7857 /// rule with a non-approximate index, or use the brute-force path (no HNSW
7858 /// rule) which exhaustively filters through the masked [`GraphView`].
7859 ///
7860 /// # Brute-force path
7861 ///
7862 /// When no HNSW index covers the request the function builds a masked
7863 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7864 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7865 /// fewer than `k` exist).
7866 pub fn find_similar_vector_masked(
7867 &self,
7868 field: &str,
7869 label: Option<&str>,
7870 q: &[f64],
7871 k: usize,
7872 min: f64,
7873 mask: &crate::mask::NodeMask,
7874 ) -> Vec<(String, f64)> {
7875 // Section read before the blob decode — see `find_similar_vector`.
7876 self.ensure_v8_base_sections_loaded();
7877 self.engine.ensure_hnsw_loaded();
7878 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7879 if norm == 0.0 {
7880 return vec![];
7881 }
7882 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7883
7884 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7885 // visible hits. See doc comment above for the policy rationale.
7886 let over_k = k.saturating_mul(4).max(k + 1);
7887 let hnsw_hits = match label {
7888 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7889 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7890 };
7891 if let Some(hits) = hnsw_hits {
7892 let mut out: Vec<(String, f64)> = hits
7893 .into_iter()
7894 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7895 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7896 .collect();
7897 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7898 out.truncate(k);
7899 return out;
7900 }
7901
7902 // Brute-force fallback — masked view ensures only visible nodes are
7903 // enumerated by nodes_all(); nodes_with_label() does not filter by
7904 // mask so we apply view.visible() explicitly for the labeled case.
7905 let view = self.view_masked(mask);
7906 let candidate_ids: Vec<u32> = match label {
7907 Some(lbl) => view
7908 .nodes_with_label(lbl)
7909 .into_iter()
7910 .filter(|&id| view.visible(id))
7911 .collect(),
7912 None => view.nodes_all(),
7913 };
7914 let mut scored: Vec<(String, f64)> = candidate_ids
7915 .into_iter()
7916 .filter_map(|id| {
7917 let v = view.prop(id, field)?;
7918 let v_owned = v.into_value();
7919 let xs = value_as_float_list(&v_owned)?;
7920 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7921 if v_norm == 0.0 {
7922 return None;
7923 }
7924 let dot: f64 = q_unit
7925 .iter()
7926 .zip(xs.iter())
7927 .map(|(a, b)| a * (b / v_norm))
7928 .sum();
7929 if dot < min {
7930 return None;
7931 }
7932 let key = self.ids.key_of(id)?.to_string();
7933 Some((key, dot))
7934 })
7935 .collect();
7936 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7937 scored.truncate(k);
7938 scored
7939 }
7940
7941 /// Read a single property from an edge.
7942 ///
7943 /// Returns `None` when the edge does not exist, the field is absent, or any
7944 /// of the string keys cannot be resolved to interned ids. Only edge props
7945 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7946 /// binding; topology-only edges (no props set) return `None` for every field.
7947 pub fn get_edge_prop(
7948 &self,
7949 edge_type: &str,
7950 src_key: &str,
7951 dst_key: &str,
7952 field: &str,
7953 ) -> Option<Value> {
7954 let etype = self.syms.get(edge_type)?;
7955 let src = self.ids.get(src_key)?;
7956 let dst = self.ids.get(dst_key)?;
7957 self.edge_props_view().get(etype, src, dst, field)
7958 }
7959
7960 /// Lex → parse → plan → execute `cypher` over a read-only view.
7961 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7962 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7963 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7964 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7965 detail: format!("lex: {e}"),
7966 })?;
7967 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7968 detail: format!("parse: {e}"),
7969 })?;
7970 let t0 = std::time::Instant::now();
7971 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7972 GraphError::QueryError {
7973 detail: format!("execute: {e}"),
7974 }
7975 });
7976 let elapsed_ms = t0.elapsed().as_millis() as u64;
7977 let threshold = self.slow_query_threshold_ms;
7978 if threshold > 0 && elapsed_ms >= threshold {
7979 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7980 let entry = SlowQueryEntry {
7981 ms: elapsed_ms,
7982 query: cypher.to_string(),
7983 at_commit: self.commit_seq,
7984 };
7985 if let Ok(mut log) = self.slow_queries.lock() {
7986 if log.entries.len() == SLOW_QUERY_RING_CAP {
7987 log.entries.pop_front();
7988 }
7989 log.entries.push_back(entry);
7990 log.total += 1;
7991 }
7992 }
7993 result
7994 }
7995
7996 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7997 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7998 /// calling [`GraphDb::query`].
7999 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
8000 let map: BTreeMap<String, Value> = params
8001 .iter()
8002 .map(|(k, v)| (k.to_string(), v.clone()))
8003 .collect();
8004 self.query(cypher, &map)
8005 }
8006
8007 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
8008 ///
8009 /// All mutations flow through the same `insert_node` / `set_prop` /
8010 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
8011 /// fires and the WAL captures everything with one fsync per statement.
8012 ///
8013 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
8014 /// and `deleted` matching the write-result contract.
8015 ///
8016 /// **Mutation routing**: mutations are collected into a single
8017 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
8018 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
8019 /// over `self.view()` — the borrow is dropped before the batch is opened.
8020 ///
8021 /// **Limitations (v1)**:
8022 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
8023 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
8024 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
8025 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
8026 /// - Deleting a derived edge → named error "cannot delete derived edge".
8027 pub fn query_write(
8028 &mut self,
8029 cypher: &str,
8030 params: &BTreeMap<String, Value>,
8031 ) -> Result<ResultSet> {
8032 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8033 detail: format!("lex: {e}"),
8034 })?;
8035 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8036 detail: format!("parse: {e}"),
8037 })?;
8038 self.exec_write_stmt(stmt, params)
8039 }
8040
8041 fn exec_write_stmt(
8042 &mut self,
8043 stmt: WriteStatement,
8044 params: &BTreeMap<String, Value>,
8045 ) -> Result<ResultSet> {
8046 match stmt {
8047 WriteStatement::Create(s) => self.exec_create(s, params),
8048 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
8049 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
8050 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
8051 WriteStatement::Merge(s) => self.exec_merge(s, params),
8052 }
8053 }
8054
8055 fn exec_create(
8056 &mut self,
8057 stmt: core_query::cypher::CreateStmt,
8058 params: &BTreeMap<String, Value>,
8059 ) -> Result<ResultSet> {
8060 // Extract the node key from props: require a string-valued `id` field.
8061 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
8062 for node in &stmt.nodes {
8063 let var = node.var.as_deref().unwrap_or("_cn0");
8064 let key = node
8065 .props
8066 .iter()
8067 .find(|(f, _)| f == "id")
8068 .and_then(|(_, v)| {
8069 if let Value::Str(s) = v {
8070 Some(s.clone())
8071 } else {
8072 None
8073 }
8074 })
8075 .ok_or_else(|| GraphError::QueryError {
8076 detail: format!(
8077 "CREATE node ({}:{}) requires a string 'id' property",
8078 var, node.label
8079 ),
8080 })?;
8081 var_to_key.insert(var.to_string(), key);
8082 }
8083
8084 let mut batch = self.batch();
8085 let mut created: usize = 0;
8086 for node in &stmt.nodes {
8087 let var = node.var.as_deref().unwrap_or("_cn0");
8088 let key = &var_to_key[var];
8089 batch.insert_node(&node.label, key, node.props.clone());
8090 created += 1;
8091 }
8092 for edge in &stmt.edges {
8093 let src_key = var_to_key
8094 .get(&edge.src_var)
8095 .ok_or_else(|| GraphError::QueryError {
8096 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
8097 })?;
8098 let dst_key = var_to_key
8099 .get(&edge.dst_var)
8100 .ok_or_else(|| GraphError::QueryError {
8101 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
8102 })?;
8103 batch.insert_edge(&edge.etype, src_key, dst_key);
8104 }
8105 batch.commit()?;
8106
8107 // Optional RETURN clause: project created bindings as a read result.
8108 if let Some(returns) = stmt.returns {
8109 // Each created node is looked up by its key via a separate MATCH pattern.
8110 // Multiple single-node patterns cross-join to produce 1 output row with
8111 // all variables bound (each pattern returns exactly 1 row).
8112 let patterns: Vec<Pattern> = stmt
8113 .nodes
8114 .iter()
8115 .map(|node| {
8116 let var = node.var.as_deref().unwrap_or("_cn0");
8117 let key = var_to_key[var].clone();
8118 Pattern {
8119 start: NodePat {
8120 var: Some(var.to_string()),
8121 label: Some(node.label.clone()),
8122 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
8123 },
8124 chain: vec![],
8125 shortest: false,
8126 }
8127 })
8128 .collect();
8129 let q = Query {
8130 matches: patterns,
8131 optional_clauses: vec![],
8132 where_expr: None,
8133 unwinds: vec![],
8134 post_unwind_where: None,
8135 stages: vec![],
8136 returns,
8137 distinct: false,
8138 order_by: vec![],
8139 skip: None,
8140 limit: None,
8141 };
8142 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8143 detail: format!("plan: {e}"),
8144 })?;
8145 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
8146 GraphError::QueryError {
8147 detail: format!("execute: {e}"),
8148 }
8149 });
8150 }
8151
8152 let mut rs = write_result_set();
8153 rs.push_row(vec![
8154 Some(Value::Int(created as i64)),
8155 Some(Value::Int(0)),
8156 Some(Value::Int(0)),
8157 ]);
8158 Ok(rs)
8159 }
8160
8161 fn exec_match_set(
8162 &mut self,
8163 stmt: core_query::cypher::MatchSetStmt,
8164 params: &BTreeMap<String, Value>,
8165 ) -> Result<ResultSet> {
8166 let project_returns = stmt.returns.clone();
8167 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8168 // so the post-write projection can look them up by key.
8169 let mut set_vars: Vec<String> = Vec::new();
8170 for s in &stmt.sets {
8171 if !set_vars.contains(&s.var) {
8172 set_vars.push(s.var.clone());
8173 }
8174 }
8175 let rel_vars = pattern_rel_vars(&stmt.matches);
8176 let mut lookup_vars = set_vars.clone();
8177 for v in pattern_node_vars(&stmt.matches) {
8178 add_var(&mut lookup_vars, &v);
8179 }
8180 if let Some(ref returns) = project_returns {
8181 for v in ret_node_vars(returns) {
8182 if !rel_vars.iter().any(|r| r == &v) {
8183 add_var(&mut lookup_vars, &v);
8184 }
8185 }
8186 }
8187
8188 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8189 // SET values are projected as ScalarExpr items so that arithmetic expressions
8190 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8191 let mut set_returns: Vec<RetItem> = lookup_vars
8192 .iter()
8193 .map(|v| RetItem {
8194 value: RetVal::Var(v.clone()),
8195 alias: None,
8196 })
8197 .collect();
8198 // One computed column per SET clause; alias is `__sv_<i>`.
8199 let set_val_cols: Vec<String> = stmt
8200 .sets
8201 .iter()
8202 .enumerate()
8203 .map(|(i, _)| format!("__sv_{i}"))
8204 .collect();
8205 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8206 set_returns.push(RetItem {
8207 value: RetVal::ScalarExpr(sc.value.clone()),
8208 alias: Some(col.clone()),
8209 });
8210 }
8211 // Capture relationship types while r is bound; SET does not change them.
8212 for r in &rel_vars {
8213 set_returns.push(RetItem {
8214 value: RetVal::FuncCall {
8215 name: "type".into(),
8216 args: vec![Operand::Var(r.clone())],
8217 },
8218 alias: Some(rel_type_alias(r)),
8219 });
8220 }
8221
8222 let read_q = Query {
8223 matches: stmt.matches.clone(),
8224 optional_clauses: vec![],
8225 where_expr: stmt.where_expr.clone(),
8226 unwinds: vec![],
8227 post_unwind_where: None,
8228 stages: vec![],
8229 returns: set_returns,
8230 distinct: false,
8231 order_by: vec![],
8232 skip: None,
8233 limit: None,
8234 };
8235 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8236 detail: format!("plan: {e}"),
8237 })?;
8238 // MATCH phase is read-only; borrow ends before batch opens.
8239 //
8240 // When a role-scoped write is in flight, run the MATCH read through
8241 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8242 // zero-rows (no SetProp ops generated, no existence-oracle 403).
8243 // Full-authority writes (pending_write_authz=None) keep view().
8244 let match_rs = {
8245 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8246 if let Some(ref mask) = mask_opt {
8247 execute(&self.view_masked(mask), &ops, &Params(params))
8248 } else {
8249 execute(&self.view(), &ops, &Params(params))
8250 }
8251 }
8252 .map_err(|e| GraphError::QueryError {
8253 detail: format!("execute: {e}"),
8254 })?;
8255
8256 // Collect (key, field, value) for each matched row × each SET clause.
8257 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8258 for row_i in 0..match_rs.len() {
8259 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8260 let key = match match_rs.get(row_i, &sc.var) {
8261 Some(Value::Str(k)) => k.clone(),
8262 _ => {
8263 return Err(GraphError::QueryError {
8264 detail: format!(
8265 "SET variable '{}' did not resolve to a node key",
8266 sc.var
8267 ),
8268 })
8269 }
8270 };
8271 // The SET value was already evaluated by the executor.
8272 let value = match match_rs.get(row_i, col) {
8273 Some(v) => v.clone(),
8274 None => {
8275 return Err(GraphError::QueryError {
8276 detail: format!(
8277 "SET value for {}.{} evaluated to null",
8278 sc.var, sc.field
8279 ),
8280 })
8281 }
8282 };
8283 set_ops.push((key, sc.field.clone(), value));
8284 }
8285 }
8286
8287 // Apply as one atomic batch.
8288 let props_set = set_ops.len();
8289 let mut batch = self.batch();
8290 for (key, field, value) in set_ops {
8291 batch.set_prop(&key, &field, value);
8292 }
8293 batch.commit()?;
8294
8295 if let Some(returns) = project_returns {
8296 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
8297 }
8298
8299 let mut rs = write_result_set();
8300 rs.push_row(vec![
8301 Some(Value::Int(0)),
8302 Some(Value::Int(props_set as i64)),
8303 Some(Value::Int(0)),
8304 ]);
8305 Ok(rs)
8306 }
8307
8308 fn exec_match_delete(
8309 &mut self,
8310 stmt: core_query::cypher::MatchDeleteStmt,
8311 params: &BTreeMap<String, Value>,
8312 ) -> Result<ResultSet> {
8313 // Collect unique node vars needed to identify edge endpoints.
8314 let mut node_vars: Vec<String> = Vec::new();
8315 for ed in &stmt.deletes {
8316 if !node_vars.contains(&ed.src_var) {
8317 node_vars.push(ed.src_var.clone());
8318 }
8319 if !node_vars.contains(&ed.dst_var) {
8320 node_vars.push(ed.dst_var.clone());
8321 }
8322 }
8323
8324 // Synthesize read query.
8325 let returns: Vec<RetItem> = node_vars
8326 .iter()
8327 .map(|v| RetItem {
8328 value: RetVal::Var(v.clone()),
8329 alias: None,
8330 })
8331 .collect();
8332 let read_q = Query {
8333 matches: stmt.matches,
8334 optional_clauses: vec![],
8335 where_expr: stmt.where_expr,
8336 unwinds: vec![],
8337 post_unwind_where: None,
8338 stages: vec![],
8339 returns,
8340 distinct: false,
8341 order_by: vec![],
8342 skip: None,
8343 limit: None,
8344 };
8345 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8346 detail: format!("plan: {e}"),
8347 })?;
8348 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8349 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8350 let match_rs = {
8351 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8352 if let Some(ref mask) = mask_opt {
8353 execute(&self.view_masked(mask), &ops, &Params(params))
8354 } else {
8355 execute(&self.view(), &ops, &Params(params))
8356 }
8357 }
8358 .map_err(|e| GraphError::QueryError {
8359 detail: format!("execute: {e}"),
8360 })?;
8361
8362 // Collect (etype, src_key, dst_key) for each row × each delete target.
8363 let mut del_ops: Vec<(String, String, String)> = Vec::new();
8364 for row_i in 0..match_rs.len() {
8365 for ed in &stmt.deletes {
8366 let src_key = match match_rs.get(row_i, &ed.src_var) {
8367 Some(Value::Str(k)) => k.clone(),
8368 _ => {
8369 return Err(GraphError::QueryError {
8370 detail: format!(
8371 "DELETE src variable '{}' did not resolve to a node key",
8372 ed.src_var
8373 ),
8374 })
8375 }
8376 };
8377 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8378 Some(Value::Str(k)) => k.clone(),
8379 _ => {
8380 return Err(GraphError::QueryError {
8381 detail: format!(
8382 "DELETE dst variable '{}' did not resolve to a node key",
8383 ed.dst_var
8384 ),
8385 })
8386 }
8387 };
8388 del_ops.push((ed.etype.clone(), src_key, dst_key));
8389 }
8390 }
8391
8392 // Apply as one atomic batch.
8393 let deleted = del_ops.len();
8394 let mut batch = self.batch();
8395 for (etype, src_key, dst_key) in del_ops {
8396 batch.delete_edge(&etype, &src_key, &dst_key);
8397 }
8398 batch.commit().map_err(|e| match e {
8399 GraphError::RuleOwned { .. } => GraphError::QueryError {
8400 detail: "cannot delete derived edge; retract via the rule or change the property"
8401 .to_string(),
8402 },
8403 other => other,
8404 })?;
8405
8406 let mut rs = write_result_set();
8407 rs.push_row(vec![
8408 Some(Value::Int(0)),
8409 Some(Value::Int(0)),
8410 Some(Value::Int(deleted as i64)),
8411 ]);
8412 Ok(rs)
8413 }
8414
8415 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8416 ///
8417 /// Collects the matching node keys via an ephemeral read query, then calls
8418 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
8419 /// the executor first checks that the node has no incident edges; if any
8420 /// remain it returns a named error matching openCypher semantics.
8421 fn exec_match_delete_node(
8422 &mut self,
8423 stmt: MatchDeleteNodeStmt,
8424 params: &BTreeMap<String, Value>,
8425 ) -> Result<ResultSet> {
8426 // Build a read query returning only the node keys we need.
8427 let returns: Vec<RetItem> = stmt
8428 .node_vars
8429 .iter()
8430 .map(|v| RetItem {
8431 value: RetVal::Var(v.clone()),
8432 alias: None,
8433 })
8434 .collect();
8435 let read_q = Query {
8436 matches: stmt.matches,
8437 optional_clauses: vec![],
8438 where_expr: stmt.where_expr,
8439 unwinds: vec![],
8440 post_unwind_where: None,
8441 stages: vec![],
8442 returns,
8443 distinct: false,
8444 order_by: vec![],
8445 skip: None,
8446 limit: None,
8447 };
8448 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8449 detail: format!("plan: {e}"),
8450 })?;
8451 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8452 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8453 let match_rs = {
8454 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8455 if let Some(ref mask) = mask_opt {
8456 execute(&self.view_masked(mask), &ops, &Params(params))
8457 } else {
8458 execute(&self.view(), &ops, &Params(params))
8459 }
8460 }
8461 .map_err(|e| GraphError::QueryError {
8462 detail: format!("execute: {e}"),
8463 })?;
8464
8465 // Collect unique node keys to delete (deduplicate across rows × vars).
8466 let mut keys: Vec<String> = Vec::new();
8467 for row_i in 0..match_rs.len() {
8468 for var in &stmt.node_vars {
8469 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8470 if !keys.contains(k) {
8471 keys.push(k.clone());
8472 }
8473 }
8474 }
8475 }
8476
8477 if !stmt.detach {
8478 // openCypher bare DELETE: error if any matched node has incident edges.
8479 for key in &keys {
8480 if let Some(id) = self.ids.get(key) {
8481 let tv = self.topo_view();
8482 let has_edges = tv.etypes().any(|et| {
8483 !tv.neighbors(et, Direction::Out, id).is_empty()
8484 || !tv.neighbors(et, Direction::In, id).is_empty()
8485 });
8486 if has_edges {
8487 return Err(GraphError::QueryError {
8488 detail: format!(
8489 "Cannot delete node `{key}` because it still has incident edges. \
8490 Use DETACH DELETE to remove the node and all its edges."
8491 ),
8492 });
8493 }
8494 }
8495 }
8496 }
8497
8498 let mut nodes_deleted = 0i64;
8499 let mut edges_deleted = 0i64;
8500 for key in keys {
8501 match self.delete_node(&key) {
8502 Ok(report) => {
8503 nodes_deleted += 1;
8504 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8505 }
8506 Err(GraphError::KeyNotFound { .. }) => {
8507 // Node may have been deleted by an earlier iteration (e.g., via
8508 // multiple MATCH rows for the same node). Safe to skip.
8509 }
8510 Err(e) => return Err(e),
8511 }
8512 }
8513
8514 let mut rs = write_result_set();
8515 rs.push_row(vec![
8516 Some(Value::Int(0)),
8517 Some(Value::Int(0)),
8518 Some(Value::Int(nodes_deleted + edges_deleted)),
8519 ]);
8520 Ok(rs)
8521 }
8522
8523 fn exec_merge(
8524 &mut self,
8525 stmt: core_query::cypher::MergeStmt,
8526 params: &BTreeMap<String, Value>,
8527 ) -> Result<ResultSet> {
8528 // MERGE: check if a node with the given key already exists.
8529 let key = match &stmt.key_value {
8530 Value::Str(s) => s.clone(),
8531 _ => {
8532 return Err(GraphError::QueryError {
8533 detail: format!(
8534 "MERGE key value must be a string (got {:?})",
8535 stmt.key_value
8536 ),
8537 })
8538 }
8539 };
8540
8541 if let Some(var) = stmt.var.as_deref() {
8542 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8543 if sc.var != var {
8544 return Err(GraphError::QueryError {
8545 detail: format!(
8546 "SET variable '{}' does not match MERGE variable '{var}'",
8547 sc.var
8548 ),
8549 });
8550 }
8551 }
8552 }
8553
8554 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8555 //
8556 // MERGE scope precondition: check create OR update scope for the
8557 // declared label BEFORE calling `has_node` (timing-oracle closure,
8558 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8559 // unscoped roles — the scope denial fires without touching the key store).
8560 //
8561 // Clone to avoid holding a borrow on `self.pending_write_authz` while
8562 // also calling `self.ids.get(key)`.
8563 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8564 let has_create = authz.scope.create_labels.contains(&stmt.label);
8565 let has_update = authz.scope.update_labels.contains(&stmt.label);
8566 if !has_create && !has_update {
8567 // Scope-before-lookup: 403 without has_node call (timing oracle
8568 // closure — see test_merge_unscoped_no_key_lookup).
8569 return Err(GraphError::RoleWriteDenied {
8570 reason: format!(
8571 "role-bound token: label '{}' not in write scope (create_labels)",
8572 stmt.label
8573 ),
8574 });
8575 }
8576 // Key lookup under mask.
8577 match self.ids.get(key.as_str()) {
8578 Some(id) if authz.mask.contains_id(id) => {
8579 // Visible: must have update scope to proceed to match arm.
8580 if !has_update {
8581 return Err(GraphError::RoleWriteDenied {
8582 reason: format!(
8583 "role-bound token: label '{}' not in write scope (update_labels)",
8584 stmt.label
8585 ),
8586 });
8587 }
8588 true // existed = true → match arm
8589 }
8590 Some(_) => {
8591 // Hidden: same error as absent to the role (spec §3.1/§3.3).
8592 return Err(GraphError::RoleWriteDenied {
8593 reason: "role-bound token: target node not visible".into(),
8594 });
8595 }
8596 None => {
8597 // Absent: must have create scope to proceed to the create arm.
8598 //
8599 // Update-only roles (create_labels empty, update_labels set):
8600 // return the SAME "not visible" error as the hidden-key branch
8601 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8602 // "confirm existence of hidden nodes: No").
8603 //
8604 // Create-scoped roles (has_create=true): absent → create arm
8605 // as before. The accepted structural key-existence disclosure
8606 // (§THREAT-MODEL) applies only when the role holds create scope.
8607 if !has_create {
8608 return Err(GraphError::RoleWriteDenied {
8609 reason: "role-bound token: target node not visible".into(),
8610 });
8611 }
8612 false // existed = false → create arm
8613 }
8614 }
8615 } else {
8616 // Full authority: use the existing non-masked has_node check.
8617 self.has_node(&key)
8618 };
8619
8620 let existed = merge_existed;
8621 let mut created = 0i64;
8622 if !existed || !stmt.on_match.is_empty() {
8623 let mut batch = self.batch();
8624 if !existed {
8625 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8626 batch.insert_node(&stmt.label, &key, props);
8627 for sc in &stmt.on_create {
8628 let value = resolve_merge_set_value(&sc.value, params)?;
8629 batch.set_prop(&key, &sc.field, value);
8630 }
8631 created = 1;
8632 } else {
8633 for sc in &stmt.on_match {
8634 let value = resolve_merge_set_value(&sc.value, params)?;
8635 batch.set_prop(&key, &sc.field, value);
8636 }
8637 }
8638 batch.commit()?;
8639 }
8640
8641 // Refresh the role mask so the just-created node is visible to this
8642 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8643 // (apply_schema subset rule), so the new node's label is already in the
8644 // role's read scope — this never widens beyond the role's declared labels.
8645 if !existed {
8646 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8647 let new_mask = self.mask_for_role(&role)?;
8648 if let Some(a) = self.pending_write_authz.as_mut() {
8649 a.mask = new_mask;
8650 }
8651 }
8652 }
8653
8654 // Optional RETURN clause: project the node (created or matched) as a read result.
8655 if let Some(returns) = stmt.returns {
8656 let var = stmt.var.as_deref().unwrap_or("_mn0");
8657 let q = Query {
8658 matches: vec![Pattern {
8659 start: NodePat {
8660 var: Some(var.to_string()),
8661 label: Some(stmt.label.clone()),
8662 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8663 },
8664 chain: vec![],
8665 shortest: false,
8666 }],
8667 optional_clauses: vec![],
8668 where_expr: None,
8669 unwinds: vec![],
8670 post_unwind_where: None,
8671 stages: vec![],
8672 returns,
8673 distinct: false,
8674 order_by: vec![],
8675 skip: None,
8676 limit: None,
8677 };
8678 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8679 detail: format!("plan: {e}"),
8680 })?;
8681 // Use view_masked when a role-scoped write is in flight so the
8682 // post-merge projection is consistent with the masked read phase.
8683 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8684 return (if let Some(ref mask) = mask_opt {
8685 execute(&self.view_masked(mask), &ops, &Params(params))
8686 } else {
8687 execute(&self.view(), &ops, &Params(params))
8688 })
8689 .map_err(|e| GraphError::QueryError {
8690 detail: format!("execute: {e}"),
8691 });
8692 }
8693
8694 let mut rs = write_result_set();
8695 rs.push_row(vec![
8696 Some(Value::Int(created)),
8697 Some(Value::Int(0)),
8698 Some(Value::Int(0)),
8699 ]);
8700 Ok(rs)
8701 }
8702
8703 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8704 /// annotated with rule name, edge type, direction, and weight.
8705 /// Results are sorted by (rule, edge_type).
8706 /// Returns `Err(KeyNotFound)` if either key is unknown.
8707 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8708 self.ensure_v8_base_sections_loaded();
8709 let id_a = self
8710 .ids
8711 .get(key_a)
8712 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8713 let id_b = self
8714 .ids
8715 .get(key_b)
8716 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8717
8718 let mut results = Vec::new();
8719
8720 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8721 // rather than O(total provenance).
8722 let scan = if self.engine.provenance_touching_len(id_a)
8723 <= self.engine.provenance_touching_len(id_b)
8724 {
8725 id_a
8726 } else {
8727 id_b
8728 };
8729 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8730 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8731 continue;
8732 }
8733 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8734 continue;
8735 };
8736 let edge_type = match self.syms.resolve(etype) {
8737 Some(s) => s.to_string(),
8738 None => continue,
8739 };
8740 // Provenance (src, dst) ids come from the archived PROVENANCE section
8741 // (large, no eager CRC). A corrupt section can produce ids that are
8742 // out of range; return Corrupt rather than panic.
8743 let src_key = self
8744 .ids
8745 .key_of(src)
8746 .ok_or_else(|| GraphError::Corrupt {
8747 detail: format!("v8: provenance src id {src} not in id table"),
8748 })?
8749 .to_string();
8750 let dst_key = self
8751 .ids
8752 .key_of(dst)
8753 .ok_or_else(|| GraphError::Corrupt {
8754 detail: format!("v8: provenance dst id {dst} not in id table"),
8755 })?
8756 .to_string();
8757 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8758 self.edge_props_view()
8759 .get(etype, src, dst, prop)
8760 .and_then(|v| {
8761 if let Value::Float(f) = v {
8762 Some(f)
8763 } else {
8764 None
8765 }
8766 })
8767 });
8768 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8769 // still have a score: recompute it from the predicate so explain
8770 // never reports "no score" for an edge the engine scored. Via-hop
8771 // rules score over their via set, not over (src, dst), so leave
8772 // those None rather than report a number the rule did not produce.
8773 let weight = stored.or_else(|| {
8774 if rule_def.via_edge.is_some() {
8775 return None;
8776 }
8777 let props_view = build_props_view(&self.props, &self.base);
8778 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8779 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8780 let src_view = NodeView {
8781 key: &src_key,
8782 props: &src_get,
8783 };
8784 let dst_view = NodeView {
8785 key: &dst_key,
8786 props: &dst_get,
8787 };
8788 evaluate(&rule_def.predicate, &src_view, &dst_view)
8789 });
8790 results.push(Explanation {
8791 rule: rule_name.to_string(),
8792 edge_type,
8793 src_key,
8794 dst_key,
8795 weight,
8796 predicate: PredicateSummary {
8797 approximate: rule_def.approximate,
8798 ..PredicateSummary::from(&rule_def.predicate)
8799 },
8800 via_edge: rule_def.via_edge.clone(),
8801 });
8802 }
8803
8804 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8805 Ok(results)
8806 }
8807
8808 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8809 let id = self
8810 .ids
8811 .get(key)
8812 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8813 let Some(sym) = self.syms.get(edge_type) else {
8814 return Ok(Vec::new());
8815 };
8816 self.topo_view()
8817 .neighbors(sym, dir, id)
8818 .iter()
8819 .map(|&n| {
8820 self.ids
8821 .key_of(n)
8822 .map(|k| k.to_string())
8823 .ok_or_else(|| GraphError::Corrupt {
8824 detail: format!("topology id {n} has no key"),
8825 })
8826 })
8827 .collect::<Result<Vec<_>>>()
8828 }
8829
8830 /// Return the last-change commit sequence for `key`, or `None` if the node
8831 /// does not exist or has never been mutated since the last V5-V7 snapshot
8832 /// (horizon-bounded for legacy stores).
8833 ///
8834 /// The returned sequence is a monotonically increasing counter that starts
8835 /// at 1 for the first commit after `open` and increments with every
8836 /// successful write. WAL replay at open also assigns sequences (1..N for N
8837 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8838 ///
8839 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8840 /// in the snapshot but not touched by any WAL frame will return `None`
8841 /// (horizon-bounded: CAS against such nodes is only safe after the first
8842 /// V8 snapshot or after the node is next mutated).
8843 pub fn last_changed(&self, key: &str) -> Option<u64> {
8844 let id = self.ids.get(key)?;
8845 self.last_change.get(&id).copied()
8846 }
8847
8848 /// The current commit sequence (number of successful commits since open,
8849 /// including WAL replay frames). Useful for recording a baseline before
8850 /// a read-modify-write cycle.
8851 pub fn commit_seq(&self) -> u64 {
8852 self.commit_seq
8853 }
8854
8855 /// Check that all `preconds` are satisfied against the current db state.
8856 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8857 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8858 for precond in preconds {
8859 match precond {
8860 Precondition::NodeUnchangedSince { key, expected } => {
8861 // Missing entry means the node predates the WAL window or
8862 // does not exist; treat as 0 (before any commit).
8863 let actual = self.last_changed(key).unwrap_or_default();
8864 if actual != *expected {
8865 return Err(GraphError::CasConflict {
8866 key: key.clone(),
8867 expected: *expected,
8868 actual,
8869 });
8870 }
8871 }
8872 Precondition::NodeAbsent { key } => {
8873 // Node must not exist (not live).
8874 if self.ids.get(key).is_some() {
8875 let actual = self.last_changed(key).unwrap_or(0);
8876 return Err(GraphError::CasConflict {
8877 key: key.clone(),
8878 expected: u64::MAX,
8879 actual,
8880 });
8881 }
8882 }
8883 }
8884 }
8885 Ok(())
8886 }
8887
8888 /// Apply a batch of mutations with compare-and-set preconditions.
8889 ///
8890 /// All preconditions are checked atomically before any operation is applied.
8891 /// If any precondition fails, the entire batch is rejected with
8892 /// [`GraphError::CasConflict`] and no WAL frame is written.
8893 ///
8894 /// # Returns
8895 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8896 ///
8897 /// # Errors
8898 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8899 /// - Any error that [`write_batch`] would return for the ops themselves.
8900 pub fn write_batch_cas(
8901 &mut self,
8902 preconds: Vec<Precondition>,
8903 ops: Vec<BatchOp>,
8904 ) -> Result<(usize, usize)> {
8905 self.check_preconditions(&preconds)?;
8906 self.commit_logged_batch(ops, None, None)
8907 }
8908
8909 /// Update the per-node last-change map for a WAL record at commit `seq`.
8910 ///
8911 /// Called after a successful apply to record which nodes were touched.
8912 /// For replay, called with the WAL-frame's replayed seq.
8913 ///
8914 /// Touch definition (see [`Precondition`] doc):
8915 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8916 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8917 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8918 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8919 /// - Batch → recurse into inner records.
8920 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8921 match rec {
8922 WalRecord::InsertNode { key, .. }
8923 | WalRecord::SetProp { key, .. }
8924 | WalRecord::RemoveProp { key, .. } => {
8925 if let Some(id) = self.ids.get(key) {
8926 self.last_change.insert(id, seq);
8927 }
8928 }
8929 WalRecord::InsertNodeId { key, .. } => {
8930 if let Some(id) = self.ids.get(key) {
8931 self.last_change.insert(id, seq);
8932 }
8933 }
8934 WalRecord::SetPropId { id, .. } => {
8935 self.last_change.insert(*id, seq);
8936 }
8937 WalRecord::InsertEdge {
8938 src_key, dst_key, ..
8939 }
8940 | WalRecord::DeleteEdge {
8941 src_key, dst_key, ..
8942 } => {
8943 if let Some(src_id) = self.ids.get(src_key) {
8944 self.last_change.insert(src_id, seq);
8945 }
8946 if let Some(dst_id) = self.ids.get(dst_key) {
8947 self.last_change.insert(dst_id, seq);
8948 }
8949 }
8950 WalRecord::InsertEdgeId { src, dst, .. } => {
8951 self.last_change.insert(*src, seq);
8952 self.last_change.insert(*dst, seq);
8953 }
8954 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8955 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8956 // History markers: state no-ops; the underlying mutation already
8957 // touched the relevant nodes' last_change entries.
8958 WalRecord::DeleteNode { .. }
8959 | WalRecord::DerivedEdgeAdded { .. }
8960 | WalRecord::DerivedEdgeRetracted { .. }
8961 | WalRecord::Intern { .. }
8962 | WalRecord::CreateRule { .. }
8963 | WalRecord::DeleteRule { .. }
8964 | WalRecord::RebuildRule { .. }
8965 | WalRecord::CreateView { .. }
8966 | WalRecord::DeleteView { .. }
8967 | WalRecord::EnableFulltext { .. }
8968 | WalRecord::DisableFulltext { .. }
8969 | WalRecord::EnableIndex { .. }
8970 | WalRecord::DisableIndex { .. } => {}
8971 // RenameNode: node id is stable; update last_change via the new key.
8972 // Called after apply(), so ids already reflects new_key.
8973 WalRecord::RenameNode { new_key, .. } => {
8974 if let Some(id) = self.ids.get(new_key) {
8975 self.last_change.insert(id, seq);
8976 }
8977 }
8978 WalRecord::Batch(inner) => {
8979 for inner_rec in inner {
8980 self.update_last_change_from_rec(inner_rec, seq);
8981 }
8982 }
8983 }
8984 }
8985
8986 pub fn node_count(&self) -> usize {
8987 self.ids.len()
8988 }
8989
8990 /// Configure archive retention: keep the `N` newest WAL archives at each
8991 /// [`snapshot_with`] call when `archive_wal: true`.
8992 ///
8993 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8994 /// `Some(0)` or `None` → unlimited (no pruning).
8995 ///
8996 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8997 /// stores the policy. Archives below the retention limit are deleted
8998 /// oldest-first. The horizon floor is updated so that
8999 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
9000 /// in pruned archives rather than silently returning wrong data.
9001 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
9002 self.wal_archive_retention = keep;
9003 }
9004
9005 /// Delete any WAL archives that are fully below the current horizon floor.
9006 ///
9007 /// Orphaned archives arise when the floor is written first during retention
9008 /// pruning and then a crash interrupts the archive-delete sequence. The
9009 /// opening cleanup ensures no subsequent read path sees stale data.
9010 ///
9011 /// Under the monotonic naming scheme, the archive name N equals the
9012 /// cumulative end-frame index of the archive in global commit space (i.e.
9013 /// the archive covers global frames `[prev_n, N)`). An archive is
9014 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
9015 /// below the floor and have already been counted in it.
9016 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
9017 if self.wal_horizon_floor == 0 {
9018 // Floor at 0 means no pruning has ever occurred; nothing to clean.
9019 return Ok(());
9020 }
9021 let archive_ns = self.fs.list_archives()?;
9022 for n in archive_ns {
9023 if n <= self.wal_horizon_floor {
9024 // Archive N ends at global frame N; all its frames are below
9025 // the floor (floor already accounts for them) → orphaned.
9026 self.fs.delete_archive(n).map_err(GraphError::Io)?;
9027 } else {
9028 // Archives are sorted ascending; first one above floor stops scan.
9029 break;
9030 }
9031 }
9032 Ok(())
9033 }
9034
9035 /// Collect all WAL frames from surviving archives (oldest-first) then the
9036 /// live WAL into one flat list, and return the total along with the number
9037 /// of archive frames at the front of the list.
9038 ///
9039 /// Commit indices into the returned list are LOCAL (0 = first frame of
9040 /// oldest surviving archive). To obtain the GLOBAL index add
9041 /// `self.wal_horizon_floor`.
9042 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
9043 let archive_ns = self.fs.list_archives()?;
9044 let mut all: Vec<WalRecord> = Vec::new();
9045 for n in archive_ns {
9046 let bytes = self.fs.read_archive(n)?;
9047 let (frames, _) = decode_all(&bytes);
9048 all.extend(frames);
9049 }
9050 let archive_count = all.len() as u64;
9051 let live_bytes = self.fs.read(FileId::Wal)?;
9052 let (live_frames, _) = decode_all(&live_bytes);
9053 all.extend(live_frames);
9054 Ok((all, archive_count))
9055 }
9056
9057 /// Return the total number of committed WAL frames visible in the current
9058 /// horizon window, including frames in surviving WAL archives.
9059 ///
9060 /// This is the exclusive upper bound for valid `at_commit` indices in
9061 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
9062 ///
9063 /// Returns the horizon floor when all surviving history is empty.
9064 pub fn wal_total_commits(&self) -> Result<u64> {
9065 let (frames, _) = self.all_frames()?;
9066 Ok(self.wal_horizon_floor + frames.len() as u64)
9067 }
9068
9069 /// The global frame index of the first commit reachable through surviving
9070 /// archives (0 when no archives have been pruned).
9071 pub fn wal_horizon_floor(&self) -> u64 {
9072 self.wal_horizon_floor
9073 }
9074
9075 /// Return the per-node change history for `key` by scanning the on-disk WAL.
9076 ///
9077 /// ## Horizon
9078 ///
9079 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
9080 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
9081 /// zero-cost contract; a durable history log is out of scope.
9082 ///
9083 /// ## Derived edges
9084 ///
9085 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
9086 /// history. Only edges written directly by the application are recorded.
9087 ///
9088 /// ## Deleted nodes
9089 ///
9090 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
9091 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
9092 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
9093 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
9094 ///
9095 /// ## Dense-id edge entries and tombstoned partners
9096 ///
9097 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
9098 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
9099 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
9100 /// Build commit-bounded alias intervals for `queried_key`.
9101 ///
9102 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
9103 /// A record written under `key` at commit `c` matches the queried identity iff
9104 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
9105 ///
9106 /// Each alias entry carries both a lower and an upper bound so that key-reuse
9107 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
9108 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
9109 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
9110 /// only identity-2's events (commits 7–9 under "a") are in scope.
9111 ///
9112 /// Only **forward aliasing**: querying the *new* key surfaces events written
9113 /// under the *old* key. The reverse direction is not supported.
9114 fn build_key_alias_intervals(
9115 &self,
9116 frames: &[core_storage::wal::WalRecord],
9117 queried_key: &str,
9118 ) -> Vec<(String, u64, Option<u64>)> {
9119 use core_storage::wal::WalRecord;
9120
9121 // Pre-pass: build reverse_rename and key_starts maps.
9122 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
9123 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
9124
9125 for (local_i, frame) in frames.iter().enumerate() {
9126 let commit = self.wal_horizon_floor + local_i as u64;
9127 let records: &[WalRecord] = match frame {
9128 WalRecord::Batch(inner) => inner.as_slice(),
9129 single => std::slice::from_ref(single),
9130 };
9131 for rec in records {
9132 match rec {
9133 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
9134 key_starts.entry(key.clone()).or_default().push(commit);
9135 }
9136 WalRecord::RenameNode { old_key, new_key } => {
9137 // new_key came into existence at this commit.
9138 key_starts.entry(new_key.clone()).or_default().push(commit);
9139 // Record the reverse rename: new_key was introduced by renaming old_key.
9140 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
9141 }
9142 _ => {}
9143 }
9144 }
9145 }
9146
9147 // Build alias intervals by following the reverse rename chain.
9148 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
9149 let mut current_key = queried_key.to_string();
9150 let mut current_valid_until: Option<u64> = None;
9151
9152 loop {
9153 // valid_from: the most recent commit where current_key was assigned to this
9154 // identity. For aliases (valid_until = Some(vu)), find the last start event
9155 // for the key strictly before vu — this is where the alias's occupancy by
9156 // this identity began, correctly excluding prior identities that reused the key.
9157 let valid_from = if let Some(vu) = current_valid_until {
9158 key_starts
9159 .get(¤t_key)
9160 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9161 .unwrap_or(self.wal_horizon_floor)
9162 } else {
9163 // Queried key — no upper bound; may have been introduced at any commit.
9164 self.wal_horizon_floor
9165 };
9166
9167 result.push((current_key.clone(), valid_from, current_valid_until));
9168
9169 match reverse_rename.get(¤t_key) {
9170 Some((old_key, rename_commit)) => {
9171 current_valid_until = Some(*rename_commit);
9172 current_key = old_key.clone();
9173 }
9174 None => break,
9175 }
9176 }
9177
9178 result
9179 }
9180
9181 /// Returns true if `record_key` matches any alias interval that covers `commit`.
9182 fn aliases_match(
9183 intervals: &[(String, u64, Option<u64>)],
9184 record_key: &str,
9185 commit: u64,
9186 ) -> bool {
9187 intervals
9188 .iter()
9189 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9190 }
9191
9192 /// Return the change history of node `key` by scanning the on-disk WAL.
9193 ///
9194 /// ## Horizon
9195 ///
9196 /// History reaches back only as far as the retained WAL. The returned
9197 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
9198 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
9199 /// oldest commit still reachable). When `horizon > 0`, older events were
9200 /// pruned and are not in `items`.
9201 pub fn node_history(
9202 &self,
9203 key: &str,
9204 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
9205 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
9206 use core_storage::wal::WalRecord;
9207
9208 let (frames, _) = self.all_frames()?;
9209 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9210
9211 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9212 let alias_intervals = self.build_key_alias_intervals(&frames, key);
9213
9214 let mut out: Vec<HistoryEntry> = Vec::new();
9215
9216 for (local_i, frame) in frames.iter().enumerate() {
9217 let commit = self.wal_horizon_floor + local_i as u64;
9218 // Collect the inner records to process — Batch is one commit, single records are one commit.
9219 let records: &[WalRecord] = match frame {
9220 WalRecord::Batch(inner) => inner.as_slice(),
9221 single => std::slice::from_ref(single),
9222 };
9223
9224 for rec in records {
9225 let change = match rec {
9226 WalRecord::InsertNode { label, key: k, .. }
9227 if Self::aliases_match(&alias_intervals, k, commit) =>
9228 {
9229 Some(HistoryChange::NodeInserted {
9230 label: label.clone(),
9231 })
9232 }
9233 WalRecord::InsertNodeId { label, key: k, .. }
9234 if Self::aliases_match(&alias_intervals, k, commit) =>
9235 {
9236 let label_str = match self.syms.resolve(*label) {
9237 Some(s) => s.to_string(),
9238 None => continue,
9239 };
9240 Some(HistoryChange::NodeInserted { label: label_str })
9241 }
9242 WalRecord::SetProp {
9243 key: k,
9244 field,
9245 value,
9246 } if Self::aliases_match(&alias_intervals, k, commit) => {
9247 Some(HistoryChange::PropSet {
9248 field: field.clone(),
9249 value: value.clone(),
9250 })
9251 }
9252 WalRecord::SetPropId { id, field, value } => {
9253 // Use key_of_historical (not key_of) so a node's prop_set
9254 // events remain visible after the node is later deleted:
9255 // key_of returns None for a tombstoned id, which would
9256 // silently drop every PropSet between insert and delete.
9257 // Mirrors the InsertEdgeId arm below and edge_history's
9258 // own id-keyed arms.
9259 match self.ids.key_of_historical(*id) {
9260 // key_of_historical returns the last-known (possibly
9261 // post-rename, possibly post-delete) key; compare to queried key.
9262 Some(resolved) if resolved == key => {
9263 let field_str = match self.syms.resolve(*field) {
9264 Some(s) => s.to_string(),
9265 None => continue,
9266 };
9267 Some(HistoryChange::PropSet {
9268 field: field_str,
9269 value: value.clone(),
9270 })
9271 }
9272 _ => None,
9273 }
9274 }
9275 WalRecord::RemoveProp { key: k, field }
9276 if Self::aliases_match(&alias_intervals, k, commit) =>
9277 {
9278 Some(HistoryChange::PropRemoved {
9279 field: field.clone(),
9280 })
9281 }
9282 WalRecord::InsertEdge {
9283 edge_type,
9284 src_key,
9285 dst_key,
9286 } => {
9287 if Self::aliases_match(&alias_intervals, src_key, commit) {
9288 Some(HistoryChange::EdgeAdded {
9289 edge_type: edge_type.clone(),
9290 other: dst_key.clone(),
9291 outgoing: true,
9292 })
9293 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9294 Some(HistoryChange::EdgeAdded {
9295 edge_type: edge_type.clone(),
9296 other: src_key.clone(),
9297 outgoing: false,
9298 })
9299 } else {
9300 None
9301 }
9302 }
9303 WalRecord::InsertEdgeId { etype, src, dst } => {
9304 let etype_str = match self.syms.resolve(*etype) {
9305 Some(s) => s.to_string(),
9306 None => continue,
9307 };
9308 // key_of_historical (not key_of): an edge added before
9309 // either endpoint was later deleted must still resolve —
9310 // see the SetPropId arm above and edge_history's
9311 // InsertEdgeId arm, which use the same lookup for the
9312 // same reason.
9313 let src_key = self.ids.key_of_historical(*src);
9314 let dst_key = self.ids.key_of_historical(*dst);
9315 if src_key == Some(key) {
9316 let other = match dst_key {
9317 Some(s) => s.to_string(),
9318 None => continue,
9319 };
9320 Some(HistoryChange::EdgeAdded {
9321 edge_type: etype_str,
9322 other,
9323 outgoing: true,
9324 })
9325 } else if dst_key == Some(key) {
9326 let other = match src_key {
9327 Some(s) => s.to_string(),
9328 None => continue,
9329 };
9330 Some(HistoryChange::EdgeAdded {
9331 edge_type: etype_str,
9332 other,
9333 outgoing: false,
9334 })
9335 } else {
9336 None
9337 }
9338 }
9339 WalRecord::DeleteEdge {
9340 edge_type,
9341 src_key,
9342 dst_key,
9343 } => {
9344 if Self::aliases_match(&alias_intervals, src_key, commit) {
9345 Some(HistoryChange::EdgeRemoved {
9346 edge_type: edge_type.clone(),
9347 other: dst_key.clone(),
9348 outgoing: true,
9349 })
9350 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9351 Some(HistoryChange::EdgeRemoved {
9352 edge_type: edge_type.clone(),
9353 other: src_key.clone(),
9354 outgoing: false,
9355 })
9356 } else {
9357 None
9358 }
9359 }
9360 WalRecord::DeleteNode { key: k }
9361 if Self::aliases_match(&alias_intervals, k, commit) =>
9362 {
9363 Some(HistoryChange::NodeDeleted)
9364 }
9365 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9366 _ => None,
9367 };
9368
9369 if let Some(change) = change {
9370 out.push(HistoryEntry { commit, change });
9371 }
9372 }
9373 }
9374
9375 Ok(HistoryResult {
9376 items: out,
9377 total_commits,
9378 horizon: self.wal_horizon_floor,
9379 })
9380 }
9381
9382 /// Return the per-edge change history between nodes `a` and `b` by scanning
9383 /// the on-disk WAL.
9384 ///
9385 /// ## Horizon
9386 ///
9387 /// History reaches back only to the last WAL-truncating snapshot, exactly
9388 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9389 /// `total_commits` (= number of WAL frames), which is the exclusive upper
9390 /// bound for valid commit indices.
9391 ///
9392 /// ## Derived edges
9393 ///
9394 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9395 /// WAL markers written by `log_then_apply_with` after each rule-firing
9396 /// mutation. The `rule` field of those events carries the rule name.
9397 ///
9398 /// ## DeleteNode
9399 ///
9400 /// When a node is deleted, its manual incident edges are swept inline without
9401 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9402 /// events for either endpoint and synthesises `Retracted(rule:None)` events
9403 /// for each manual edge that was active at that point. Derived edges active at
9404 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9405 /// the engine appends immediately after the `DeleteNode` record; those events
9406 /// carry correct rule attribution and are emitted by the marker arm, not the
9407 /// synthetic sweep.
9408 ///
9409 /// ## Masks
9410 ///
9411 /// Like `node_history`, this method has no mask parameter and returns WAL
9412 /// history regardless of any role mask. For masked history semantics, apply
9413 /// the mask at the caller level.
9414 pub fn edge_history(
9415 &self,
9416 a: &str,
9417 b: &str,
9418 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9419 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9420 use core_storage::wal::WalRecord;
9421
9422 let (frames, _) = self.all_frames()?;
9423 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9424
9425 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9426 // Intervals are commit-bounded so recycled keys don't contaminate histories.
9427 let alias_a = self.build_key_alias_intervals(&frames, a);
9428 let alias_b = self.build_key_alias_intervals(&frames, b);
9429
9430 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9431 // The is_derived flag is used by the DeleteNode sweep: manual edges are
9432 // swept with a synthetic Retracted(rule:None); derived edges are skipped
9433 // because the engine writes a DerivedEdgeRetracted marker immediately after
9434 // the DeleteNode record, which carries the correct rule attribution.
9435 let mut active: Vec<(String, String, String, bool)> = Vec::new();
9436 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9437
9438 for (local_i, frame) in frames.iter().enumerate() {
9439 let commit = self.wal_horizon_floor + local_i as u64;
9440 let records: &[WalRecord] = match frame {
9441 WalRecord::Batch(inner) => inner.as_slice(),
9442 single => std::slice::from_ref(single),
9443 };
9444
9445 for rec in records {
9446 match rec {
9447 WalRecord::InsertEdge {
9448 edge_type,
9449 src_key,
9450 dst_key,
9451 } => {
9452 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9453 && Self::aliases_match(&alias_b, dst_key, commit);
9454 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9455 && Self::aliases_match(&alias_a, dst_key, commit);
9456 if is_ab || is_ba {
9457 active.push((
9458 edge_type.clone(),
9459 src_key.clone(),
9460 dst_key.clone(),
9461 false,
9462 ));
9463 out.push(EdgeHistoryEvent {
9464 edge_type: edge_type.clone(),
9465 commit,
9466 event: EdgeEvent::Added,
9467 rule: None,
9468 });
9469 }
9470 }
9471 WalRecord::InsertEdgeId { etype, src, dst } => {
9472 let etype_str = match self.syms.resolve(*etype) {
9473 Some(s) => s.to_string(),
9474 None => continue,
9475 };
9476 // Use key_of_historical so tombstoned nodes (deleted
9477 // later in the WAL) still resolve during the scan.
9478 let src_key = self.ids.key_of_historical(*src);
9479 let dst_key = self.ids.key_of_historical(*dst);
9480 let is_ab = src_key == Some(a) && dst_key == Some(b);
9481 let is_ba = src_key == Some(b) && dst_key == Some(a);
9482 if is_ab || is_ba {
9483 let src_str = src_key.unwrap().to_string();
9484 let dst_str = dst_key.unwrap().to_string();
9485 active.push((etype_str.clone(), src_str, dst_str, false));
9486 out.push(EdgeHistoryEvent {
9487 edge_type: etype_str,
9488 commit,
9489 event: EdgeEvent::Added,
9490 rule: None,
9491 });
9492 }
9493 }
9494 WalRecord::DeleteEdge {
9495 edge_type,
9496 src_key,
9497 dst_key,
9498 } => {
9499 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9500 && Self::aliases_match(&alias_b, dst_key, commit);
9501 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9502 && Self::aliases_match(&alias_a, dst_key, commit);
9503 if is_ab || is_ba {
9504 // Remove the first matching active entry (flag ignored).
9505 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9506 et == edge_type && s == src_key && d == dst_key
9507 }) {
9508 active.remove(pos);
9509 }
9510 out.push(EdgeHistoryEvent {
9511 edge_type: edge_type.clone(),
9512 commit,
9513 event: EdgeEvent::Retracted,
9514 rule: None,
9515 });
9516 }
9517 }
9518 WalRecord::DeleteNode { key: k }
9519 if Self::aliases_match(&alias_a, k, commit)
9520 || Self::aliases_match(&alias_b, k, commit) =>
9521 {
9522 // Sweep: implicitly retract only MANUAL active edges.
9523 // Derived active edges are skipped here because the rule
9524 // engine appends a DerivedEdgeRetracted marker immediately
9525 // after this DeleteNode record; that marker produces the
9526 // single correctly-attributed Retracted event. Derived
9527 // entries are dropped from `active` (the marker arm's
9528 // idempotent retain finds nothing to remove).
9529 for (et, _, _, is_derived) in active.drain(..) {
9530 if !is_derived {
9531 out.push(EdgeHistoryEvent {
9532 edge_type: et,
9533 commit,
9534 event: EdgeEvent::Retracted,
9535 rule: None,
9536 });
9537 }
9538 // Derived: drop silently; marker carries the Retracted event.
9539 }
9540 }
9541 WalRecord::DerivedEdgeAdded {
9542 rule,
9543 edge_type: et,
9544 src_key,
9545 dst_key,
9546 } => {
9547 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9548 && Self::aliases_match(&alias_b, dst_key, commit);
9549 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9550 && Self::aliases_match(&alias_a, dst_key, commit);
9551 if is_ab || is_ba {
9552 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9553 out.push(EdgeHistoryEvent {
9554 edge_type: et.clone(),
9555 commit,
9556 event: EdgeEvent::Added,
9557 rule: Some(rule.clone()),
9558 });
9559 }
9560 }
9561 WalRecord::DerivedEdgeRetracted {
9562 rule,
9563 edge_type: et,
9564 src_key,
9565 dst_key,
9566 } => {
9567 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9568 && Self::aliases_match(&alias_b, dst_key, commit);
9569 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9570 && Self::aliases_match(&alias_a, dst_key, commit);
9571 if is_ab || is_ba {
9572 // Push unconditionally: a derived edge whose Added marker
9573 // predates the history horizon has no `active` entry, but
9574 // the retraction is still a real in-window event.
9575 // Remove from active idempotently if present.
9576 active.retain(|(aet, s, d, _)| {
9577 !(aet == et && s == src_key && d == dst_key)
9578 });
9579 out.push(EdgeHistoryEvent {
9580 edge_type: et.clone(),
9581 commit,
9582 event: EdgeEvent::Retracted,
9583 rule: Some(rule.clone()),
9584 });
9585 }
9586 }
9587 // All other records (InsertNode, SetProp, CreateRule, etc.)
9588 // do not affect edges between a and b.
9589 _ => {}
9590 }
9591 }
9592 }
9593
9594 Ok(HistoryResult {
9595 items: out,
9596 total_commits,
9597 horizon: self.wal_horizon_floor,
9598 })
9599 }
9600
9601 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9602 /// (in either direction) at the WAL commit `at_commit`.
9603 ///
9604 /// ## Horizon
9605 ///
9606 /// Valid commit indices are `0..total_commits` where `total_commits` is the
9607 /// number of WAL frames. An `at_commit >= total_commits` is outside the
9608 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9609 ///
9610 /// ## Derived edges
9611 ///
9612 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9613 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9614 /// and therefore includes derived edges in its point-in-time evaluation,
9615 /// matching `edge_history`'s fidelity.
9616 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9617 use core_storage::wal::WalRecord;
9618
9619 let (frames, _) = self.all_frames()?;
9620 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9621
9622 // Horizon floor: commits in pruned archives are unreachable.
9623 if at_commit < self.wal_horizon_floor {
9624 return Err(GraphError::CommitOutOfRange {
9625 commit: at_commit,
9626 total: total_commits,
9627 floor: self.wal_horizon_floor,
9628 });
9629 }
9630 if at_commit >= total_commits {
9631 return Err(GraphError::CommitOutOfRange {
9632 commit: at_commit,
9633 total: total_commits,
9634 floor: self.wal_horizon_floor,
9635 });
9636 }
9637
9638 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9639 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9640 let alias_a = self.build_key_alias_intervals(&frames, a);
9641 let alias_b = self.build_key_alias_intervals(&frames, b);
9642
9643 // Local index into surviving frames (0 = first frame of oldest archive).
9644 let local_commit = at_commit - self.wal_horizon_floor;
9645
9646 // Replay local frames 0..=local_commit, tracking active edges.
9647 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9648
9649 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9650 let commit = self.wal_horizon_floor + local_i as u64;
9651 let records: &[WalRecord] = match frame {
9652 WalRecord::Batch(inner) => inner.as_slice(),
9653 single => std::slice::from_ref(single),
9654 };
9655
9656 for rec in records {
9657 match rec {
9658 WalRecord::InsertEdge {
9659 edge_type: et,
9660 src_key,
9661 dst_key,
9662 } => {
9663 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9664 && Self::aliases_match(&alias_b, dst_key, commit);
9665 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9666 && Self::aliases_match(&alias_a, dst_key, commit);
9667 if is_ab || is_ba {
9668 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9669 }
9670 }
9671 WalRecord::InsertEdgeId { etype, src, dst } => {
9672 let etype_str = match self.syms.resolve(*etype) {
9673 Some(s) => s.to_string(),
9674 None => continue,
9675 };
9676 // Use key_of_historical so tombstoned nodes resolve.
9677 let src_key = self.ids.key_of_historical(*src);
9678 let dst_key = self.ids.key_of_historical(*dst);
9679 let is_ab = src_key == Some(a) && dst_key == Some(b);
9680 let is_ba = src_key == Some(b) && dst_key == Some(a);
9681 if is_ab || is_ba {
9682 active.insert((
9683 etype_str,
9684 src_key.unwrap().to_string(),
9685 dst_key.unwrap().to_string(),
9686 ));
9687 }
9688 }
9689 WalRecord::DeleteEdge {
9690 edge_type: et,
9691 src_key,
9692 dst_key,
9693 } => {
9694 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9695 && Self::aliases_match(&alias_b, dst_key, commit);
9696 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9697 && Self::aliases_match(&alias_a, dst_key, commit);
9698 if is_ab || is_ba {
9699 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9700 }
9701 }
9702 WalRecord::DeleteNode { key: k }
9703 if Self::aliases_match(&alias_a, k, commit)
9704 || Self::aliases_match(&alias_b, k, commit) =>
9705 {
9706 // All edges touching the deleted node are gone.
9707 active.retain(|(_, s, d)| s != k && d != k);
9708 }
9709 WalRecord::DerivedEdgeAdded {
9710 edge_type: et,
9711 src_key,
9712 dst_key,
9713 ..
9714 } => {
9715 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9716 && Self::aliases_match(&alias_b, dst_key, commit);
9717 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9718 && Self::aliases_match(&alias_a, dst_key, commit);
9719 if is_ab || is_ba {
9720 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9721 }
9722 }
9723 WalRecord::DerivedEdgeRetracted {
9724 edge_type: et,
9725 src_key,
9726 dst_key,
9727 ..
9728 } => {
9729 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9730 && Self::aliases_match(&alias_b, dst_key, commit);
9731 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9732 && Self::aliases_match(&alias_a, dst_key, commit);
9733 if is_ab || is_ba {
9734 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9735 }
9736 }
9737 _ => {}
9738 }
9739 }
9740 }
9741
9742 Ok(active.iter().any(|(et, _, _)| et == edge_type))
9743 }
9744
9745 /// Every edge incident to `key` — either endpoint — that existed at WAL
9746 /// commit `commit`, from ONE scan of the WAL.
9747 ///
9748 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
9749 /// "what did K's relationships look like at commit C" with one call instead
9750 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
9751 /// The two agree edge for edge.
9752 ///
9753 /// Results are sorted by `(edge_type, src_key, dst_key)`.
9754 ///
9755 /// ## Horizon
9756 ///
9757 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
9758 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
9759 /// `was_linked`. An unknown key is not an error — it simply had no edges.
9760 ///
9761 /// ## Derived edges
9762 ///
9763 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
9764 /// attribution, so a rule-owned edge comes back with `derived: true` and
9765 /// `rule: Some(name)`.
9766 ///
9767 /// ## Renames
9768 ///
9769 /// `key` is matched through the same commit-bounded alias intervals
9770 /// `edge_history` uses, so querying a node's *current* key surfaces edges
9771 /// written under an earlier name. Endpoint keys in the result are reported
9772 /// under the name the node carries today, so they can be fed straight back
9773 /// into `node_info`, `explain` or another `edges_at`.
9774 ///
9775 /// ## Masks
9776 ///
9777 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
9778 /// any role mask. Apply masking at the caller level.
9779 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
9780 use core_storage::wal::WalRecord;
9781
9782 let (frames, _) = self.all_frames()?;
9783 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9784
9785 // Horizon floor: commits in pruned archives are unreachable.
9786 if commit < self.wal_horizon_floor || commit >= total_commits {
9787 return Err(GraphError::CommitOutOfRange {
9788 commit,
9789 total: total_commits,
9790 floor: self.wal_horizon_floor,
9791 });
9792 }
9793
9794 // Commit-bounded historical names of `key` (handles RenameNode).
9795 let alias = self.build_key_alias_intervals(&frames, key);
9796
9797 // Forward rename chain, for reporting endpoints under their current
9798 // names: old key → [(commit, new key)] in ascending commit order.
9799 // Built over the whole WAL, not just the prefix up to `commit`, because
9800 // a rename after `commit` still changes what the node is called today.
9801 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
9802 for (local_i, frame) in frames.iter().enumerate() {
9803 let c = self.wal_horizon_floor + local_i as u64;
9804 let records: &[WalRecord] = match frame {
9805 WalRecord::Batch(inner) => inner.as_slice(),
9806 single => std::slice::from_ref(single),
9807 };
9808 for rec in records {
9809 if let WalRecord::RenameNode { old_key, new_key } = rec {
9810 renames
9811 .entry(old_key.clone())
9812 .or_default()
9813 .push((c, new_key.clone()));
9814 }
9815 }
9816 }
9817
9818 // The name a node written as `k` at commit `from` carries today.
9819 // Follows the first rename at or after `from`, then keeps going. The
9820 // iteration cap bounds a rename cycle inside a single batch.
9821 let canon = |k: &str, from: u64| -> String {
9822 if renames.is_empty() {
9823 return k.to_string();
9824 }
9825 let mut cur = k.to_string();
9826 let mut at = from;
9827 for _ in 0..64 {
9828 match renames
9829 .get(&cur)
9830 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
9831 {
9832 Some((c, new)) => {
9833 at = *c;
9834 cur = new.clone();
9835 }
9836 None => break,
9837 }
9838 }
9839 cur
9840 };
9841
9842 let local_commit = commit - self.wal_horizon_floor;
9843 // (edge_type, src_key, dst_key) → (derived, rule)
9844 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
9845 BTreeMap::new();
9846
9847 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9848 let c = self.wal_horizon_floor + local_i as u64;
9849 let records: &[WalRecord] = match frame {
9850 WalRecord::Batch(inner) => inner.as_slice(),
9851 single => std::slice::from_ref(single),
9852 };
9853
9854 for rec in records {
9855 match rec {
9856 WalRecord::InsertEdge {
9857 edge_type,
9858 src_key,
9859 dst_key,
9860 } => {
9861 if Self::aliases_match(&alias, src_key, c)
9862 || Self::aliases_match(&alias, dst_key, c)
9863 {
9864 active.insert(
9865 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9866 (false, None),
9867 );
9868 }
9869 }
9870 WalRecord::InsertEdgeId { etype, src, dst } => {
9871 let Some(etype_str) = self.syms.resolve(*etype) else {
9872 continue;
9873 };
9874 // `key_of_historical` resolves tombstoned ids too, and
9875 // already returns the node's current key — no rename
9876 // canonicalisation needed on this arm.
9877 let (Some(src_key), Some(dst_key)) = (
9878 self.ids.key_of_historical(*src),
9879 self.ids.key_of_historical(*dst),
9880 ) else {
9881 continue;
9882 };
9883 if src_key == key || dst_key == key {
9884 active.insert(
9885 (
9886 etype_str.to_string(),
9887 src_key.to_string(),
9888 dst_key.to_string(),
9889 ),
9890 (false, None),
9891 );
9892 }
9893 }
9894 WalRecord::DeleteEdge {
9895 edge_type,
9896 src_key,
9897 dst_key,
9898 } => {
9899 if Self::aliases_match(&alias, src_key, c)
9900 || Self::aliases_match(&alias, dst_key, c)
9901 {
9902 active.remove(&(
9903 edge_type.clone(),
9904 canon(src_key, c),
9905 canon(dst_key, c),
9906 ));
9907 }
9908 }
9909 WalRecord::DeleteNode { key: k } => {
9910 if active.is_empty() {
9911 continue;
9912 }
9913 if Self::aliases_match(&alias, k, c) {
9914 // Our node is gone; every incident edge goes with it.
9915 active.clear();
9916 } else {
9917 // A partner is gone; its edges to us go with it.
9918 let ck = canon(k, c);
9919 active.retain(|(_, s, d), _| *s != ck && *d != ck);
9920 }
9921 }
9922 WalRecord::DerivedEdgeAdded {
9923 rule,
9924 edge_type,
9925 src_key,
9926 dst_key,
9927 } => {
9928 if Self::aliases_match(&alias, src_key, c)
9929 || Self::aliases_match(&alias, dst_key, c)
9930 {
9931 active.insert(
9932 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9933 (true, Some(rule.clone())),
9934 );
9935 }
9936 }
9937 WalRecord::DerivedEdgeRetracted {
9938 edge_type,
9939 src_key,
9940 dst_key,
9941 ..
9942 } => {
9943 if Self::aliases_match(&alias, src_key, c)
9944 || Self::aliases_match(&alias, dst_key, c)
9945 {
9946 active.remove(&(
9947 edge_type.clone(),
9948 canon(src_key, c),
9949 canon(dst_key, c),
9950 ));
9951 }
9952 }
9953 // InsertNode, SetProp, CreateRule, … do not move edges.
9954 _ => {}
9955 }
9956 }
9957 }
9958
9959 // BTreeMap iteration is already (edge_type, src, dst) order.
9960 Ok(active
9961 .into_iter()
9962 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
9963 edge_type,
9964 src_key,
9965 dst_key,
9966 derived,
9967 rule,
9968 })
9969 .collect())
9970 }
9971
9972 /// The derived edges that would be retracted and derived if `key.field`
9973 /// were set to `value` — computed WITHOUT writing anything.
9974 ///
9975 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
9976 /// provenance, its candidate indexes, the topology and the property columns
9977 /// are all cloned first, the change is applied to the clone, and the real
9978 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
9979 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
9980 /// it emits are the answer, so rule semantics — predicates, top-k,
9981 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
9982 ///
9983 /// Works on a read-only handle.
9984 ///
9985 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
9986 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
9987 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
9988 /// (the node already holds `value`, or no rule watches `field`) returns
9989 /// empty lists.
9990 ///
9991 /// ## Cost
9992 ///
9993 /// One clone of the property columns, the topology overlay, the symbol
9994 /// interner, the edge properties and the provenance map, plus one candidate
9995 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
9996 /// directory, but it is not free — this is an interactive "what if", not a
9997 /// hot path.
9998 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
9999 // The engine's provenance, HNSW and IVF state live in the mmap'd base
10000 // until something asks for them. On a store opened cold from a snapshot
10001 // this is the first ask, and without it the clone below starts from an
10002 // empty provenance map: nothing to retract, so `lost` comes back empty.
10003 self.ensure_v8_base_sections_loaded();
10004
10005 let empty = WhatIf {
10006 lost: Vec::new(),
10007 gained: Vec::new(),
10008 };
10009
10010 if let Some(view_name) = self.view_store.view_for_prop(field) {
10011 return Err(GraphError::ViewPropReadOnly {
10012 view_name: view_name.to_string(),
10013 });
10014 }
10015 MutPreview::new(self).check_live_key(key)?;
10016 let id = self
10017 .ids
10018 .get(key)
10019 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10020
10021 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
10022 if rules.is_empty() {
10023 return Ok(empty);
10024 }
10025
10026 // No rule watches this field → no derivation can change.
10027 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
10028 return Ok(empty);
10029 }
10030
10031 let old_value = build_props_view(&self.props, &self.base)
10032 .get(id, field)
10033 .map(|vr| vr.into_value());
10034 if old_value.as_ref() == Some(&value) {
10035 return Ok(empty);
10036 }
10037
10038 // --- Clone every piece of state the re-derivation writes to. ---
10039 let mut props = self.props.clone();
10040 let mut topo = self.topo.clone();
10041 let mut syms = self.syms.clone();
10042 let mut edge_props = self.edge_props.clone();
10043
10044 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
10045 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
10046 for r in &rules {
10047 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
10048 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
10049 }
10050 // `provenance()` decodes retained snapshot bytes on first use; the
10051 // engine clone needs the real map, not an empty one.
10052 let provenance = self.engine.provenance().clone();
10053 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
10054
10055 // Build the candidate indexes from the state BEFORE the change, exactly
10056 // as apply() sees them: `on_node_changed` withdraws the node under its
10057 // old value and refiles it under the new one, so the index must not
10058 // already reflect the change.
10059 engine.reindex_all_load_state(
10060 &self.ids,
10061 &syms,
10062 &self.labels,
10063 build_props_view(&self.props, &self.base),
10064 self.engine.export_ivf_state(),
10065 self.engine.export_hnsw_state_passthrough(),
10066 );
10067 engine.set_emit_deltas(true);
10068
10069 // --- Apply the hypothetical change and re-derive. ---
10070 props.set(id, field, value);
10071 {
10072 let mut gm = make_graph_mut(
10073 &self.ids,
10074 &mut syms,
10075 &self.labels,
10076 build_props_view(&props, &self.base),
10077 &mut topo,
10078 &self.base,
10079 &mut edge_props,
10080 );
10081 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
10082 }
10083
10084 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
10085 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
10086 for d in engine.drain_deltas() {
10087 let edge = EdgeAt {
10088 edge_type: d.edge_type,
10089 src_key: d.src_key,
10090 dst_key: d.dst_key,
10091 derived: true,
10092 rule: Some(d.rule),
10093 };
10094 if d.fired {
10095 gained.insert(edge);
10096 } else {
10097 lost.insert(edge);
10098 }
10099 }
10100 // An edge retracted and re-derived within the same re-derivation (top-k
10101 // churn) is not a change the caller would see.
10102 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
10103 for e in churn {
10104 lost.remove(&e);
10105 gained.remove(&e);
10106 }
10107
10108 Ok(WhatIf {
10109 lost: lost.into_iter().collect(),
10110 gained: gained.into_iter().collect(),
10111 })
10112 }
10113
10114 pub fn edge_count(&self) -> u64 {
10115 self.topo_view().edge_count()
10116 }
10117
10118 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
10119 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
10120 pub fn stats(&self) -> Stats {
10121 self.ensure_v8_base_sections_loaded();
10122 let rules: Vec<RuleStats> = self
10123 .engine
10124 .rules()
10125 .map(|r| RuleStats {
10126 name: r.name.clone(),
10127 edges: self
10128 .engine
10129 .provenance()
10130 .get(&r.name)
10131 .map(|s| s.len() as u64)
10132 .unwrap_or(0),
10133 tripped: self.engine.is_tripped(&r.name),
10134 fires: self.engine.fire_count(&r.name),
10135 approximate: r.approximate,
10136 })
10137 .collect();
10138 Stats {
10139 nodes_live: self.ids.live_len(),
10140 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
10141 edges: self.topo_view().edge_count(),
10142 rules,
10143 chain_truncations: self.engine.chain_truncations(),
10144 history_floor: self.wal_horizon_floor,
10145 }
10146 }
10147
10148 /// On-disk size of the WAL file in bytes.
10149 ///
10150 /// Reads file metadata without loading WAL contents. Returns `Err` for
10151 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
10152 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
10153 let path = self.fs.wal_path().ok_or_else(|| {
10154 std::io::Error::new(
10155 std::io::ErrorKind::Unsupported,
10156 "wal_path not available for this Fs implementation",
10157 )
10158 })?;
10159 Ok(std::fs::metadata(path)?.len())
10160 }
10161
10162 /// Set the slow-query threshold. Queries whose execution time equals or
10163 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
10164 ///
10165 /// Use this setter in tests — the environment variable
10166 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
10167 /// threads.
10168 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
10169 self.slow_query_threshold_ms = ms;
10170 }
10171
10172 /// Snapshot of the slow-query ring buffer and lifetime counter.
10173 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
10174 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
10175 SlowQuerySnapshot {
10176 threshold_ms: self.slow_query_threshold_ms,
10177 count: log.total,
10178 last: log.entries.iter().cloned().collect(),
10179 }
10180 }
10181
10182 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
10183 /// to compute uptime.
10184 pub fn started_at(&self) -> std::time::Instant {
10185 self.started_at
10186 }
10187
10188 /// On-disk snapshot format version this binary writes and reads.
10189 pub fn format_version() -> u16 {
10190 core_storage::snapshot::VERSION
10191 }
10192
10193 /// Test-support: total bytes appended (SimFs only usage).
10194 pub fn fs_total_appended(&self) -> usize
10195 where
10196 F: FsIntrospect,
10197 {
10198 self.fs.total_appended()
10199 }
10200
10201 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10202 pub fn fs_sync_count(&self) -> usize
10203 where
10204 F: FsIntrospect,
10205 {
10206 self.fs.sync_count()
10207 }
10208
10209 /// Consume the db, returning its fs (for crash simulation).
10210 pub fn into_fs(self) -> F {
10211 self.fs
10212 }
10213
10214 pub fn snapshot(&mut self) -> Result<()> {
10215 self.snapshot_with(SnapshotOptions::default())
10216 }
10217
10218 /// Snapshot with explicit options.
10219 ///
10220 /// # `keep_wal`
10221 ///
10222 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10223 /// - The WAL is replaced with a minimal baseline containing one
10224 /// `EnableFulltext` record per active declaration. All pre-snapshot
10225 /// history is discarded; `open_at` can only reach post-snapshot commits.
10226 ///
10227 /// When `keep_wal` is `true`:
10228 /// - The WAL is left intact. All pre-snapshot commits remain reachable
10229 /// via `open_at`. The existing WAL already contains the original
10230 /// `EnableFulltext` records, so no baseline re-write is needed; the
10231 /// recovery guards in `apply()` silently skip any duplicate records on
10232 /// replay.
10233 /// - Crash window: a crash after the snapshot write but before the next
10234 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
10235 /// snapshot is loaded and the WAL replayed idempotently over it — safe
10236 /// because every `apply()` arm is idempotent when replayed over an
10237 /// already-current snapshot.
10238 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10239 if self.read_only {
10240 return Err(GraphError::ReadOnly);
10241 }
10242 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10243 // appending ends up holding a descriptor on an unlinked inode and loses
10244 // commits it believes durable. Snapshotting therefore requires the
10245 // cross-process write lock, exactly as appending does. Unlike the WAL
10246 // append path this does not go through `log_then_apply_with`, so both
10247 // guards are repeated here.
10248 if self.degraded {
10249 return Err(GraphError::Io(std::io::Error::other(
10250 "database degraded after group-commit fsync failure; reopen required",
10251 )));
10252 }
10253 if self.lock_denied {
10254 return Err(GraphError::Busy { holder: None });
10255 }
10256 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10257 // Used by the archive path's conservative genesis-chain check: if a prior
10258 // snapshot exists but wal.truncated does not, we cannot distinguish a
10259 // legacy store (may have been truncated in an older code version) from a
10260 // new store that only used keep_wal=true. Conservative: refuse genesis in
10261 // both cases. Must be sampled here, before the snapshot write below.
10262 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10263 self.ensure_v8_base_sections_loaded();
10264 // Ensure provenance is decoded before to_persist() clones it.
10265 self.engine.ensure_provenance_loaded_mut();
10266 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10267 let rule_defs = rule_defs_typed
10268 .iter()
10269 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10270 .collect();
10271 // Collect HNSW state and IVF state. When indexes are not yet
10272 // populated (clean open, no mutation since open), pass the retained
10273 // raw bytes through directly so that migrate/snapshot does not
10274 // silently discard fitted approximate-rule indexes.
10275 let hnsw_state = self.engine.export_hnsw_state_passthrough();
10276 let ivf_bytes = if !self.engine.indexes_populated() {
10277 // Pass retained IVF bytes through unchanged (no re-encode).
10278 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10279 } else {
10280 // Indexes live: encode from current state.
10281 let raw_ivf = self.engine.export_ivf_state();
10282 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10283 .into_iter()
10284 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10285 (
10286 name,
10287 core_storage::snapshot::PerRuleIvfState {
10288 src: core_storage::snapshot::SideIvfState {
10289 centroids: sc,
10290 clusters: sa,
10291 drift: sd,
10292 },
10293 dst: core_storage::snapshot::SideIvfState {
10294 centroids: dc,
10295 clusters: da,
10296 drift: dd,
10297 },
10298 },
10299 )
10300 })
10301 .collect();
10302 if ivf_state_map.is_empty() {
10303 Vec::new()
10304 } else {
10305 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
10306 }
10307 };
10308 let view_defs: Vec<Vec<u8>> = self
10309 .view_store
10310 .views()
10311 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
10312 .collect();
10313 if self.base.is_some() {
10314 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
10315 // write it atomically, remap it as the new base, then clear the overlay.
10316 let meta = V8Meta {
10317 labels: self.labels.clone(),
10318 edge_props: self.edge_props.clone(),
10319 rule_defs,
10320 provenance,
10321 rule_tripped,
10322 rule_fires,
10323 ivf_bytes,
10324 view_defs,
10325 wal_truncated: !opts.keep_wal,
10326 hnsw: hnsw_state,
10327 last_change: self.last_change.clone(),
10328 };
10329 let mut buf: Vec<u8> = Vec::new();
10330 {
10331 // Clone the Arc so the old base stays alive while we encode.
10332 // The borrow of archived_csr (into old_base's mmap) is released
10333 // at the end of this block, before we replace self.base.
10334 let old_base = self.base.clone().expect("is_some checked above");
10335 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
10336 detail: format!("v8 snapshot: topology section: {e:?}"),
10337 })?;
10338 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
10339 detail: format!("v8 snapshot: columns section: {e:?}"),
10340 })?;
10341 // `None` when the base predates V9 — the migration path: its
10342 // string columns still carry their own tables and this snapshot
10343 // is the rewrite that collapses them into section 12.
10344 let archived_strings =
10345 old_base
10346 .string_table()
10347 .transpose()
10348 .map_err(|e| GraphError::Corrupt {
10349 detail: format!("v8 snapshot: strings section: {e:?}"),
10350 })?;
10351 let archived_edge_props =
10352 old_base
10353 .edge_props_section()
10354 .map_err(|e| GraphError::Corrupt {
10355 detail: format!("v8 snapshot: edge_props section: {e:?}"),
10356 })?;
10357 let edge_props_raw =
10358 old_base
10359 .edge_props_raw_bytes()
10360 .map_err(|e| GraphError::Corrupt {
10361 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
10362 })?;
10363 let prov_raw =
10364 old_base
10365 .provenance_raw_bytes()
10366 .map_err(|e| GraphError::Corrupt {
10367 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
10368 })?;
10369 encode_v8(
10370 Some(archived_csr),
10371 Some(archived_cols),
10372 archived_strings,
10373 Some((archived_edge_props, edge_props_raw)),
10374 Some(prov_raw),
10375 &self.topo,
10376 &self.props,
10377 &self.ids,
10378 &self.syms,
10379 &meta,
10380 &mut buf,
10381 )?;
10382 }
10383 self.fs.write_atomic(FileId::Snapshot, &buf)?;
10384 // Remap the freshly-written snapshot as the new base.
10385 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
10386 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10387 core_storage::v8::MappedBase::map(&snap_path)
10388 } else {
10389 core_storage::v8::MappedBase::from_bytes(buf)
10390 }
10391 .map_err(|e| GraphError::Corrupt {
10392 detail: format!("v8 snapshot: remap new base: {e:?}"),
10393 })?;
10394 self.base = Some(Arc::new(new_base));
10395 // Clear the overlay and prop tombstones — all data is now in the new base.
10396 self.topo = Topology::new();
10397 self.props = core_storage::columns::ColumnStore::new();
10398 } else {
10399 // Legacy path (V5–V7 stores without a V8 base).
10400 //
10401 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
10402 // clone and no encode_v8_from_state intermediate clones. The big
10403 // structures (self.topo, self.props) are borrowed, not cloned.
10404 // self.edge_props is moved (not cloned) because we immediately clear it
10405 // when we remap the new V8 snapshot as self.base (see below).
10406 //
10407 // Eliminates from peak RSS vs. the old SnapshotState path:
10408 // • self.topo.clone() (~topology HashMap footprint)
10409 // • self.props.clone() (~column-store footprint)
10410 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
10411 let meta = V8Meta {
10412 labels: self.labels.clone(),
10413 wal_truncated: !opts.keep_wal,
10414 // Move edge_props out so the large overlay is freed when meta
10415 // drops at end of this block (self.edge_props is now empty; reads
10416 // after base assignment go through the mmap'd base section).
10417 edge_props: std::mem::take(&mut self.edge_props),
10418 rule_defs,
10419 provenance,
10420 rule_tripped,
10421 rule_fires,
10422 ivf_bytes,
10423 view_defs,
10424 hnsw: hnsw_state,
10425 last_change: self.last_change.clone(),
10426 };
10427 let mut buf = Vec::new();
10428 encode_v8(
10429 None,
10430 None,
10431 None,
10432 None,
10433 None,
10434 &self.topo,
10435 &self.props,
10436 &self.ids,
10437 &self.syms,
10438 &meta,
10439 &mut buf,
10440 )?;
10441 // meta (and the moved edge_props inside it) is no longer needed;
10442 // drop it before the write to keep the peak window narrow.
10443 drop(meta);
10444 self.fs.write_atomic(FileId::Snapshot, &buf)?;
10445 // Remap the freshly-written V8 snapshot as self.base.
10446 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
10447 // On SimFs (tests): pass buf to from_bytes.
10448 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10449 drop(buf);
10450 core_storage::v8::MappedBase::map(&snap_path)
10451 } else {
10452 core_storage::v8::MappedBase::from_bytes(buf)
10453 }
10454 .map_err(|e| GraphError::Corrupt {
10455 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
10456 })?;
10457 self.base = Some(Arc::new(new_base));
10458 // Free the large heap-allocated decoded state — all data is now in the
10459 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
10460 // self.edge_props was already moved into meta and is effectively empty.
10461 self.topo = Topology::new();
10462 self.props = core_storage::columns::ColumnStore::new();
10463 }
10464
10465 if opts.archive_wal {
10466 // History-preserving snapshot (Task 4):
10467 // 1. Snapshot already written above (write_atomic → fsynced).
10468 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
10469 // Crash window B: crash here leaves archive present, WAL
10470 // absent. Reopen: snapshot loaded (full state), no WAL
10471 // replay. Archive is NOT replayed into live state — it is
10472 // pre-snapshot by construction. Safe.
10473 // 3. Optionally write genesis marker (first archive only, no
10474 // prior WAL truncation).
10475 // 4. Prune old archives (retention), update horizon floor.
10476 // Pruning invalidates the genesis chain; delete marker.
10477 // 5. Write new minimal baseline WAL (write_atomic).
10478 // Crash window C: crash here leaves new archive plus no live
10479 // WAL. Same as window B — handled above.
10480 //
10481 // Sample existing archives BEFORE the rename so we can detect
10482 // whether this is the first archive.
10483 let existing_archives = self.fs.list_archives()?;
10484 let is_first_archive = existing_archives.is_empty();
10485
10486 // Compute a globally-monotonic archive name: the name equals the
10487 // cumulative end-frame index of the archive in global commit space.
10488 //
10489 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
10490 // commit_seq is seeded from max(last_change), which underestimates
10491 // the WAL depth when trailing commits (e.g. insert_edge) do not
10492 // update last_change. A session-2 archive could then receive a name
10493 // ≤ the session-1 archive, causing incorrect sort order or collision.
10494 //
10495 // Instead: read and decode the live WAL here (before the rename) to
10496 // get its exact frame count, then add it to the last known global
10497 // end-frame index (the name of the most recent existing archive, or
10498 // wal_horizon_floor if no archives exist). This is O(WAL size) but
10499 // snapshot is already serialising the full graph state, so the cost
10500 // is dominated.
10501 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
10502 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
10503 let archive_n = existing_archives
10504 .last()
10505 .copied()
10506 .unwrap_or(self.wal_horizon_floor)
10507 + live_frames_for_name.len() as u64;
10508 self.fs.archive_wal(archive_n)?;
10509
10510 // Genesis marker: written once when the first archive is taken
10511 // from a store that has never undergone a WAL-truncating snapshot.
10512 // When present, `open_at` may replay archive-resident commits from
10513 // empty state (the archive chain covers from global index 0).
10514 //
10515 // Two conditions must ALL hold:
10516 // 1. This is the first archive (existing_archives was empty).
10517 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
10518 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
10519 // before truncating the WAL, so if any prior truncating snapshot was taken
10520 // — even in a previous session — snapshot.bin is present and this condition
10521 // is false. This subsumes the cross-session truncation case without
10522 // requiring a separate wal.truncated sidecar file.
10523 // For legacy stores (snapshot.bin written by an older code version that
10524 // may have truncated the WAL), the same conservative refusal applies:
10525 // we cannot prove the chain is complete, so we refuse genesis (cost =
10526 // no as-of-through-archives; never silent wrong data).
10527 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
10528 // so SimFs always passes this check.
10529 if is_first_archive && !had_prior_snapshot {
10530 self.fs.write_genesis_marker()?;
10531 self.archive_genesis_chain = true;
10532 }
10533
10534 // Retention pruning: keep newest `keep` archives; delete oldest.
10535 // Pruning is the ONLY deletion site for archives.
10536 //
10537 // Crash-safety ordering (C1 fix):
10538 // 1. Count frames in surplus archives (reads only — no mutation).
10539 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
10540 // rename (atomic). A crash after this point leaves orphaned
10541 // archives on disk, but the floor is correct. The opening
10542 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
10543 // the next open, so the store is always safe to reopen.
10544 // 3. Delete the genesis marker (floor > 0 already blocks open_at
10545 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
10546 // 4. Delete surplus archives. A crash between any two deletes
10547 // leaves the floor committed and orphaned archives cleaned at
10548 // next open — never a stale floor with a missing archive prefix.
10549 if let Some(keep) = self.wal_archive_retention {
10550 if keep > 0 {
10551 let archives = self.fs.list_archives()?;
10552 // archives is sorted ascending (oldest first)
10553 if archives.len() as u32 > keep {
10554 let surplus = archives.len() - keep as usize;
10555 // Step 1: count pruned frames (reads, no mutation).
10556 let mut pruned_frames = 0u64;
10557 for &n in &archives[..surplus] {
10558 let bytes = self.fs.read_archive(n)?;
10559 let (frames, _) = decode_all(&bytes);
10560 pruned_frames += frames.len() as u64;
10561 }
10562 // Step 2: advance and persist floor FIRST.
10563 self.wal_horizon_floor += pruned_frames;
10564 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
10565 // Step 3: delete genesis marker (floor > 0 already
10566 // blocks open_at; this is belt-and-suspenders cleanup).
10567 if pruned_frames > 0 && self.archive_genesis_chain {
10568 self.fs.delete_genesis_marker()?;
10569 self.archive_genesis_chain = false;
10570 }
10571 // Step 4: delete surplus archives. Crash here →
10572 // orphaned archives; cleaned at next open.
10573 for &n in &archives[..surplus] {
10574 self.fs.delete_archive(n)?;
10575 }
10576 }
10577 }
10578 }
10579
10580 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
10581 let mut baseline_wal: Vec<u8> = Vec::new();
10582 for (label, field) in self.fulltext.enabled_pairs() {
10583 let rec = WalRecord::EnableFulltext {
10584 label: label.clone(),
10585 field: field.clone(),
10586 };
10587 baseline_wal.extend_from_slice(&encode_record(&rec));
10588 }
10589 for (label, field) in self.prop_index.enabled_pairs() {
10590 let rec = WalRecord::EnableIndex {
10591 label: label.clone(),
10592 field: field.clone(),
10593 };
10594 baseline_wal.extend_from_slice(&encode_record(&rec));
10595 }
10596 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10597 } else if opts.keep_wal {
10598 // keep_wal=true: WAL is left untouched. The existing WAL already
10599 // contains the EnableFulltext records from the original enable calls;
10600 // replay is idempotent (guards in apply() skip already-live entries).
10601 // No baseline re-write is needed or safe here — the full WAL history
10602 // must remain intact for open_at to reach pre-snapshot commits.
10603 } else {
10604 // keep_wal=false (default): truncate by replacing the WAL with a
10605 // minimal baseline of one EnableFulltext record per active pair.
10606 //
10607 // Crash-ordering: write_atomic is atomic.
10608 // • Crash before snapshot write → WAL unchanged. Safe.
10609 // • Crash after snapshot write but before this WAL write → full
10610 // pre-snapshot WAL still present; open_with replays idempotently.
10611 // • Crash after both writes → normal post-snapshot state.
10612 //
10613 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
10614 // for any archives taken AFTER this point (their WAL slices would
10615 // not start at genesis). Delete any existing genesis marker so that
10616 // open_at refuses archive-resident commits. Future sessions are
10617 // covered by had_prior_snapshot: snapshot.bin written here persists
10618 // across sessions and prevents a later archiving session from
10619 // incorrectly claiming a complete genesis chain.
10620 if self.archive_genesis_chain {
10621 self.fs.delete_genesis_marker()?;
10622 self.archive_genesis_chain = false;
10623 }
10624 let mut baseline_wal: Vec<u8> = Vec::new();
10625 for (label, field) in self.fulltext.enabled_pairs() {
10626 let rec = WalRecord::EnableFulltext {
10627 label: label.clone(),
10628 field: field.clone(),
10629 };
10630 baseline_wal.extend_from_slice(&encode_record(&rec));
10631 }
10632 for (label, field) in self.prop_index.enabled_pairs() {
10633 let rec = WalRecord::EnableIndex {
10634 label: label.clone(),
10635 field: field.clone(),
10636 };
10637 baseline_wal.extend_from_slice(&encode_record(&rec));
10638 }
10639 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10640 }
10641 // After snapshot the overlay may have changed (V8 merge path clears
10642 // self.topo and self.props). Refresh the MVCC fold so future readers
10643 // see the post-snapshot state rather than stale overlay data.
10644 self.fold_now();
10645 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
10646 // markers this handle uses to detect other processes' work must be
10647 // re-taken from disk. Skipping this would make our own snapshot look
10648 // like a peer's on the next staleness check and force a needless
10649 // reload.
10650 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
10651 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
10652 Ok(())
10653 }
10654}
10655
10656/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
10657///
10658/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
10659/// callers can build a set of mutations without holding `&mut GraphDb` and
10660/// hand them off to the group-committing writer for durable, batched I/O.
10661pub enum BatchOp {
10662 InsertNode {
10663 label: String,
10664 key: String,
10665 props: Vec<(String, Value)>,
10666 },
10667 InsertEdge {
10668 edge_type: String,
10669 src_key: String,
10670 dst_key: String,
10671 },
10672 SetProp {
10673 key: String,
10674 field: String,
10675 value: Value,
10676 },
10677 RemoveProp {
10678 key: String,
10679 field: String,
10680 },
10681 DeleteEdge {
10682 edge_type: String,
10683 src_key: String,
10684 dst_key: String,
10685 },
10686 DeleteNode {
10687 key: String,
10688 },
10689 CreateRule(RuleDef),
10690 DeleteRule {
10691 name: String,
10692 },
10693 /// Rename a node's key. Validated: old must exist, new must not.
10694 RenameNode {
10695 old_key: String,
10696 new_key: String,
10697 },
10698 /// Insert an edge, auto-creating any missing endpoint as a plain node with
10699 /// `placeholder_label` and no props. Rules fire and last-change is updated
10700 /// for each created endpoint (normal InsertNode semantics in the batch frame).
10701 InsertEdgeUpsert {
10702 edge_type: String,
10703 src_key: String,
10704 dst_key: String,
10705 placeholder_label: String,
10706 },
10707}
10708
10709/// Three-way node visibility status used by `check_single_op_authz`.
10710enum NodeAuthzStatus {
10711 /// Node exists in the store and is in the role's read mask.
10712 Visible(String), // carries the node's label
10713 /// Node exists in the store but is NOT in the role's read mask.
10714 Hidden,
10715 /// Node does not exist in the store.
10716 Absent,
10717}
10718
10719/// Overlay of ops already accepted earlier in the same batch. Never written
10720/// back to the database — validation only.
10721#[derive(Default)]
10722struct Overlay {
10723 extra_keys: BTreeSet<String>,
10724 deleted_keys: BTreeSet<String>,
10725 extra_props: BTreeMap<(String, String), Value>,
10726 removed_props: BTreeSet<(String, String)>,
10727 extra_edges: BTreeSet<(String, String, String)>,
10728 deleted_edges: BTreeSet<(String, String, String)>,
10729 extra_rules: BTreeSet<String>,
10730 deleted_rules: BTreeSet<String>,
10731 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10732 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10733 /// sees only the rules already committed to the engine. Keyed by name so a
10734 /// later `DeleteRule` in the same batch drops the arc with the rule.
10735 extra_rule_arcs: BTreeMap<String, (String, String)>,
10736}
10737
10738/// Read-only view of live db state plus a batch overlay. Shared by single-op
10739/// public methods (empty overlay) and `commit_batch`.
10740struct MutPreview<'a, F: Fs> {
10741 db: &'a GraphDb<F>,
10742 overlay: Overlay,
10743}
10744
10745/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10746/// `None` if `target` is unreachable.
10747///
10748/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10749/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10750/// reported path is stable for a given rule set, and iterative so a pathological
10751/// rule graph cannot overflow the stack.
10752fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10753 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10754 for (from, to) in arcs {
10755 adj.entry(from.as_str()).or_default().insert(to.as_str());
10756 }
10757 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10758 let mut visited: BTreeSet<&str> = BTreeSet::new();
10759 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10760 visited.insert(start);
10761 queue.push_back(start);
10762 while let Some(node) = queue.pop_front() {
10763 if node == target {
10764 let mut path = vec![node.to_string()];
10765 let mut cur = node;
10766 while let Some(&p) = parent.get(cur) {
10767 path.push(p.to_string());
10768 cur = p;
10769 }
10770 path.reverse();
10771 return Some(path);
10772 }
10773 for &next in adj.get(node).into_iter().flatten() {
10774 if visited.insert(next) {
10775 parent.insert(next, node);
10776 queue.push_back(next);
10777 }
10778 }
10779 }
10780 None
10781}
10782
10783impl<'a, F: Fs> MutPreview<'a, F> {
10784 fn new(db: &'a GraphDb<F>) -> Self {
10785 Self {
10786 db,
10787 overlay: Overlay::default(),
10788 }
10789 }
10790
10791 fn has_key(&self, key: &str) -> bool {
10792 if self.overlay.extra_keys.contains(key) {
10793 return true;
10794 }
10795 if self.overlay.deleted_keys.contains(key) {
10796 return false;
10797 }
10798 self.db.ids.get(key).is_some()
10799 }
10800
10801 fn has_prop(&self, key: &str, field: &str) -> bool {
10802 if !self.has_key(key) {
10803 return false;
10804 }
10805 let k = (key.to_string(), field.to_string());
10806 if self.overlay.removed_props.contains(&k) {
10807 return false;
10808 }
10809 if self.overlay.extra_props.contains_key(&k) {
10810 return true;
10811 }
10812 // Fresh identity (first insert in this batch, or delete+reinsert):
10813 // ignore props still sitting on the soon-to-be-tombstoned slot.
10814 if self.overlay.extra_keys.contains(key) {
10815 return false;
10816 }
10817 self.db.get_prop(key, field).is_some()
10818 }
10819
10820 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10821 let k = (
10822 edge_type.to_string(),
10823 src_key.to_string(),
10824 dst_key.to_string(),
10825 );
10826 if self.overlay.deleted_edges.contains(&k) {
10827 return false;
10828 }
10829 if self.overlay.extra_edges.contains(&k) {
10830 return true;
10831 }
10832 // A key created in this batch (including reinsert) has no db edges.
10833 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10834 return false;
10835 }
10836 if self.overlay.deleted_keys.contains(src_key)
10837 || self.overlay.deleted_keys.contains(dst_key)
10838 {
10839 return false;
10840 }
10841 let Some(src) = self.db.ids.get(src_key) else {
10842 return false;
10843 };
10844 let Some(dst) = self.db.ids.get(dst_key) else {
10845 return false;
10846 };
10847 let Some(sym) = self.db.syms.get(edge_type) else {
10848 return false;
10849 };
10850 self.db
10851 .topo_view()
10852 .neighbors(sym, Direction::Out, src)
10853 .binary_search(&dst)
10854 .is_ok()
10855 }
10856
10857 fn has_rule(&self, name: &str) -> bool {
10858 if self.overlay.extra_rules.contains(name) {
10859 return true;
10860 }
10861 if self.overlay.deleted_rules.contains(name) {
10862 return false;
10863 }
10864 self.db.engine.rules().any(|r| r.name == name)
10865 }
10866
10867 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10868 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10869 return false;
10870 }
10871 if self.overlay.deleted_keys.contains(src_key)
10872 || self.overlay.deleted_keys.contains(dst_key)
10873 {
10874 return false;
10875 }
10876 let Some(src) = self.db.ids.get(src_key) else {
10877 return false;
10878 };
10879 let Some(dst) = self.db.ids.get(dst_key) else {
10880 return false;
10881 };
10882 let Some(et) = self.db.syms.get(edge_type) else {
10883 return false;
10884 };
10885 // extra_rules is deliberately not consulted: a CreateRule earlier in
10886 // this batch has not fired, so it contributes no provenance. That is
10887 // the documented rule-window gap (see GraphDb::batch).
10888 if self.overlay.deleted_rules.is_empty() {
10889 return self.db.engine.is_owned(et, src, dst);
10890 }
10891 for (rule, triples) in self.db.engine.provenance() {
10892 if self.overlay.deleted_rules.contains(rule) {
10893 continue;
10894 }
10895 if triples.contains(&(et, src, dst)) {
10896 return true;
10897 }
10898 }
10899 false
10900 }
10901
10902 fn check_insert_node(&self, key: &str) -> Result<()> {
10903 if self.has_key(key) {
10904 Err(GraphError::DuplicateKey { key: key.into() })
10905 } else {
10906 Ok(())
10907 }
10908 }
10909
10910 fn check_live_key(&self, key: &str) -> Result<()> {
10911 if self.has_key(key) {
10912 Ok(())
10913 } else {
10914 Err(GraphError::KeyNotFound { key: key.into() })
10915 }
10916 }
10917
10918 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10919 for k in [src_key, dst_key] {
10920 if !self.has_key(k) {
10921 return Err(GraphError::KeyNotFound { key: k.into() });
10922 }
10923 }
10924 if self.is_rule_owned(edge_type, src_key, dst_key) {
10925 return Err(GraphError::RuleOwned {
10926 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10927 });
10928 }
10929 Ok(!self.has_edge(edge_type, src_key, dst_key))
10930 }
10931
10932 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10933 self.check_live_key(key)?;
10934 Ok(self.has_prop(key, field))
10935 }
10936
10937 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10938 for k in [src_key, dst_key] {
10939 if !self.has_key(k) {
10940 return Err(GraphError::KeyNotFound { key: k.into() });
10941 }
10942 }
10943 // Provenance-owned OR a live rule would derive this pair. User-first
10944 // edges that a later rule matches are not in `owned`, but deleting
10945 // them would leave a hole `rebuild_rule` immediately fills.
10946 if self.is_rule_owned(edge_type, src_key, dst_key) {
10947 return Err(GraphError::RuleOwned {
10948 detail: format!(
10949 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10950 delete or change the owning rule"
10951 ),
10952 });
10953 }
10954 if self.would_derive(edge_type, src_key, dst_key) {
10955 return Err(GraphError::RuleOwned {
10956 detail: format!(
10957 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10958 delete or change the owning rule, or a live rule would re-derive it"
10959 ),
10960 });
10961 }
10962 Ok(self.has_edge(edge_type, src_key, dst_key))
10963 }
10964
10965 /// True if any live rule (minus overlay-deleted names) would derive
10966 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10967 /// CreateRule names in `extra_rules` are ignored — same documented
10968 /// same-batch rule-window as [`Self::is_rule_owned`].
10969 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10970 if src_key == dst_key {
10971 return false;
10972 }
10973 let Some(src_label) = self.label_of(src_key) else {
10974 return false;
10975 };
10976 let Some(dst_label) = self.label_of(dst_key) else {
10977 return false;
10978 };
10979 for rule in self.db.engine.rules() {
10980 if self.overlay.deleted_rules.contains(&rule.name) {
10981 continue;
10982 }
10983 if rule.edge_type != edge_type {
10984 continue;
10985 }
10986 if rule.src_label != src_label || rule.dst_label != dst_label {
10987 continue;
10988 }
10989 let src_props = |f: &str| self.prop_value(src_key, f);
10990 let dst_props = |f: &str| self.prop_value(dst_key, f);
10991 let src_view = NodeView {
10992 key: src_key,
10993 props: &src_props,
10994 };
10995 let dst_view = NodeView {
10996 key: dst_key,
10997 props: &dst_props,
10998 };
10999 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
11000 return true;
11001 }
11002 }
11003 false
11004 }
11005
11006 fn label_of(&self, key: &str) -> Option<String> {
11007 if self.overlay.deleted_keys.contains(key) {
11008 return None;
11009 }
11010 // Fresh identities created in this batch have no stored label in the
11011 // overlay; they cannot be provenance-owned yet either.
11012 let id = self.db.ids.get(key)?;
11013 let sym = self.db.labels.get(id as usize).copied()?;
11014 if sym == u32::MAX {
11015 return None;
11016 }
11017 self.db.syms.resolve(sym).map(str::to_string)
11018 }
11019
11020 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
11021 if !self.has_key(key) {
11022 return None;
11023 }
11024 let k = (key.to_string(), field.to_string());
11025 if self.overlay.removed_props.contains(&k) {
11026 return None;
11027 }
11028 if let Some(v) = self.overlay.extra_props.get(&k) {
11029 return Some(v.clone());
11030 }
11031 if self.overlay.extra_keys.contains(key) {
11032 return None;
11033 }
11034 self.db.get_prop(key, field)
11035 }
11036
11037 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
11038 def.validate()
11039 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
11040 if self.has_rule(&def.name) {
11041 return Err(GraphError::RuleInvalid {
11042 detail: format!("rule {:?} already exists", def.name),
11043 });
11044 }
11045 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
11046 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
11047 // `edge_type`". A cycle in that graph is a rule set that would re-fire
11048 // itself forever; the engine's depth cap would silently truncate it
11049 // instead, leaving an arbitrary partial result. Reject it here, the one
11050 // place that sees the whole rule set.
11051 //
11052 // Rules accepted earlier in the same batch count too: the overlay
11053 // carries their arcs, so a cycle cannot be assembled one op at a time.
11054 if let Some(via) = def.via_edge.as_deref() {
11055 if via == def.edge_type {
11056 return Err(GraphError::RuleInvalid {
11057 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
11058 });
11059 }
11060 let mut arcs: Vec<(String, String)> = self
11061 .db
11062 .engine
11063 .rules()
11064 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
11065 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
11066 .collect();
11067 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
11068 arcs.push((via.to_string(), def.edge_type.clone()));
11069 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
11070 return Err(GraphError::RuleInvalid {
11071 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
11072 });
11073 }
11074 }
11075 Ok(())
11076 }
11077
11078 fn check_delete_rule(&self, name: &str) -> Result<()> {
11079 if self.has_rule(name) {
11080 Ok(())
11081 } else {
11082 Err(GraphError::RuleNotFound { name: name.into() })
11083 }
11084 }
11085
11086 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
11087 self.overlay.deleted_keys.remove(key);
11088 self.overlay.extra_keys.insert(key.to_string());
11089 self.overlay.extra_props.retain(|(k, _), _| k != key);
11090 self.overlay.removed_props.retain(|(k, _)| k != key);
11091 for (field, value) in props {
11092 self.overlay
11093 .extra_props
11094 .insert((key.to_string(), field.clone()), value.clone());
11095 }
11096 }
11097
11098 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11099 let k = (
11100 edge_type.to_string(),
11101 src_key.to_string(),
11102 dst_key.to_string(),
11103 );
11104 self.overlay.deleted_edges.remove(&k);
11105 self.overlay.extra_edges.insert(k);
11106 }
11107
11108 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
11109 let k = (key.to_string(), field.to_string());
11110 self.overlay.removed_props.remove(&k);
11111 self.overlay.extra_props.insert(k, value.clone());
11112 }
11113
11114 fn note_remove_prop(&mut self, key: &str, field: &str) {
11115 let k = (key.to_string(), field.to_string());
11116 self.overlay.extra_props.remove(&k);
11117 self.overlay.removed_props.insert(k);
11118 }
11119
11120 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11121 let k = (
11122 edge_type.to_string(),
11123 src_key.to_string(),
11124 dst_key.to_string(),
11125 );
11126 self.overlay.extra_edges.remove(&k);
11127 self.overlay.deleted_edges.insert(k);
11128 }
11129
11130 fn note_delete_node(&mut self, key: &str) {
11131 self.overlay.extra_keys.remove(key);
11132 self.overlay.deleted_keys.insert(key.to_string());
11133 self.overlay.extra_props.retain(|(k, _), _| k != key);
11134 self.overlay.removed_props.retain(|(k, _)| k != key);
11135 self.overlay
11136 .extra_edges
11137 .retain(|(_, s, d)| s != key && d != key);
11138 self.overlay
11139 .deleted_edges
11140 .retain(|(_, s, d)| s != key && d != key);
11141 }
11142
11143 fn note_create_rule(&mut self, def: &RuleDef) {
11144 self.overlay.deleted_rules.remove(&def.name);
11145 self.overlay.extra_rules.insert(def.name.clone());
11146 // Rules accepted earlier in this batch are not in the engine yet, so
11147 // the cycle check would not see their arcs. Keep the arc, not just the
11148 // name, so a batch cannot smuggle in a cycle one op at a time.
11149 if let Some(via) = def.via_edge.clone() {
11150 self.overlay
11151 .extra_rule_arcs
11152 .insert(def.name.clone(), (via, def.edge_type.clone()));
11153 }
11154 }
11155
11156 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
11157 if !self.has_key(old) {
11158 return Err(GraphError::KeyNotFound { key: old.into() });
11159 }
11160 if self.has_key(new) {
11161 return Err(GraphError::DuplicateKey { key: new.into() });
11162 }
11163 Ok(())
11164 }
11165
11166 fn note_rename_node(&mut self, old: &str, new: &str) {
11167 // Mark old as deleted so subsequent batch ops cannot reference it.
11168 self.overlay.extra_keys.remove(old);
11169 self.overlay.deleted_keys.insert(old.to_string());
11170 // Mark new as extra so subsequent batch ops can reference it.
11171 self.overlay.deleted_keys.remove(new);
11172 self.overlay.extra_keys.insert(new.to_string());
11173 // Migrate any overlay props from old key to new key.
11174 let new_str = new.to_string();
11175 let transferred: Vec<((String, String), Value)> = self
11176 .overlay
11177 .extra_props
11178 .iter()
11179 .filter(|((k, _), _)| k.as_str() == old)
11180 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
11181 .collect();
11182 self.overlay
11183 .extra_props
11184 .retain(|(k, _), _| k.as_str() != old);
11185 for (k, v) in transferred {
11186 self.overlay.extra_props.insert(k, v);
11187 }
11188 // Migrate removed_props.
11189 let transferred_removed: Vec<(String, String)> = self
11190 .overlay
11191 .removed_props
11192 .iter()
11193 .filter(|(k, _)| k.as_str() == old)
11194 .map(|(_, f)| (new_str.clone(), f.clone()))
11195 .collect();
11196 self.overlay
11197 .removed_props
11198 .retain(|(k, _)| k.as_str() != old);
11199 for k in transferred_removed {
11200 self.overlay.removed_props.insert(k);
11201 }
11202 }
11203
11204 fn note_delete_rule(&mut self, name: &str) {
11205 self.overlay.extra_rules.remove(name);
11206 // Drop its chain arc too: a rule created and then deleted in the same
11207 // batch must not make a later, legal rule look like a cycle.
11208 self.overlay.extra_rule_arcs.remove(name);
11209 self.overlay.deleted_rules.insert(name.to_string());
11210 // Treat the deleted rule's current provenance as gone so a later
11211 // delete_edge of those triples is a no-op (matches sequential).
11212 if let Some(triples) = self.db.engine.provenance().get(name) {
11213 for &(et, s, d) in triples {
11214 let Some(etype) = self.db.syms.resolve(et) else {
11215 continue;
11216 };
11217 let Some(src) = self.db.ids.key_of(s) else {
11218 continue;
11219 };
11220 let Some(dst) = self.db.ids.key_of(d) else {
11221 continue;
11222 };
11223 let k = (etype.to_string(), src.to_string(), dst.to_string());
11224 self.overlay.extra_edges.remove(&k);
11225 self.overlay.deleted_edges.insert(k);
11226 }
11227 }
11228 }
11229}
11230
11231/// Collects mutations and commits them as one WAL `Batch` frame.
11232///
11233/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11234/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11235/// See [`GraphDb::batch`] for validation and atomicity rules.
11236pub struct BatchBuilder<'a, F: Fs> {
11237 db: &'a mut GraphDb<F>,
11238 ops: Vec<BatchOp>,
11239}
11240
11241impl<'a, F: Fs> BatchBuilder<'a, F> {
11242 pub fn insert_node(
11243 &mut self,
11244 label: &str,
11245 key: &str,
11246 props: Vec<(String, Value)>,
11247 ) -> &mut Self {
11248 self.ops.push(BatchOp::InsertNode {
11249 label: label.into(),
11250 key: key.into(),
11251 props,
11252 });
11253 self
11254 }
11255
11256 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11257 self.ops.push(BatchOp::InsertEdge {
11258 edge_type: edge_type.into(),
11259 src_key: src_key.into(),
11260 dst_key: dst_key.into(),
11261 });
11262 self
11263 }
11264
11265 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
11266 self.ops.push(BatchOp::SetProp {
11267 key: key.into(),
11268 field: field.into(),
11269 value,
11270 });
11271 self
11272 }
11273
11274 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
11275 self.ops.push(BatchOp::RemoveProp {
11276 key: key.into(),
11277 field: field.into(),
11278 });
11279 self
11280 }
11281
11282 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11283 self.ops.push(BatchOp::DeleteEdge {
11284 edge_type: edge_type.into(),
11285 src_key: src_key.into(),
11286 dst_key: dst_key.into(),
11287 });
11288 self
11289 }
11290
11291 pub fn delete_node(&mut self, key: &str) -> &mut Self {
11292 self.ops.push(BatchOp::DeleteNode { key: key.into() });
11293 self
11294 }
11295
11296 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
11297 self.ops.push(BatchOp::CreateRule(def));
11298 self
11299 }
11300
11301 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
11302 self.ops.push(BatchOp::DeleteRule { name: name.into() });
11303 self
11304 }
11305
11306 /// Queue a node-rename in this batch.
11307 ///
11308 /// Validation (old exists, new not taken) runs at commit time.
11309 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
11310 self.ops.push(BatchOp::RenameNode {
11311 old_key: old_key.into(),
11312 new_key: new_key.into(),
11313 });
11314 self
11315 }
11316
11317 /// Queue an edge insert with endpoint auto-creation.
11318 ///
11319 /// Any missing endpoint is created as a plain node `{key, label:
11320 /// placeholder_label, no props}` inside this batch frame. Rules fire and
11321 /// last-change is updated for each auto-created node.
11322 pub fn insert_edge_upsert(
11323 &mut self,
11324 edge_type: &str,
11325 src_key: &str,
11326 dst_key: &str,
11327 placeholder_label: &str,
11328 ) -> &mut Self {
11329 self.ops.push(BatchOp::InsertEdgeUpsert {
11330 edge_type: edge_type.into(),
11331 src_key: src_key.into(),
11332 dst_key: dst_key.into(),
11333 placeholder_label: placeholder_label.into(),
11334 });
11335 self
11336 }
11337
11338 /// Validate every queued op, then log one `Batch` frame and apply.
11339 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
11340 /// A second `commit()` after a successful one is an empty-batch no-op
11341 /// (queued ops were taken).
11342 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
11343 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
11344 ///
11345 /// **Rule-window limitation:** batch validation cannot see edges that a
11346 /// rule created earlier in the *same* batch will derive at apply time, so
11347 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
11348 /// where sequential calls would return `Err(RuleOwned)`. State integrity
11349 /// is unaffected (idempotent apply, provenance intact). Create rules in
11350 /// their own batch, or sequentially, when later ops may touch derived
11351 /// edges.
11352 /// Validate every queued op and commit atomically.
11353 ///
11354 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
11355 /// WAL records actually written (duplicate edges are silent no-ops and are
11356 /// NOT counted). Both are 0 when the batch is empty or all-noop.
11357 pub fn commit(&mut self) -> Result<(usize, usize)> {
11358 let ops = std::mem::take(&mut self.ops);
11359 self.db.commit_batch(ops)
11360 }
11361
11362 /// Same as [`commit`](Self::commit) but tail the inner events with
11363 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
11364 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
11365 let ops = std::mem::take(&mut self.ops);
11366 self.db
11367 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
11368 }
11369}
11370
11371pub struct NodeRef<'a, F: Fs> {
11372 db: &'a GraphDb<F>,
11373 id: u32,
11374}
11375
11376impl<'a, F: Fs> NodeRef<'a, F> {
11377 pub fn key(&self) -> &str {
11378 self.db.ids.key_of(self.id).expect("dense ids")
11379 }
11380
11381 pub fn label(&self) -> &str {
11382 let sym = self
11383 .db
11384 .labels
11385 .get(self.id as usize)
11386 .copied()
11387 .filter(|&s| s != u32::MAX)
11388 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11389 self.db.syms.resolve(sym).expect("interned label symbol")
11390 }
11391
11392 pub fn prop(&self, field: &str) -> Option<Value> {
11393 self.db
11394 .props_view()
11395 .get(self.id, field)
11396 .map(|vr| vr.into_value())
11397 }
11398
11399 /// All stored fields for this node, sorted by field name.
11400 ///
11401 /// Reads from the full base+overlay view so that props stored only in the
11402 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
11403 pub fn props(&self) -> BTreeMap<String, Value> {
11404 let mut out = BTreeMap::new();
11405 let pv = self.db.props_view();
11406 for field in pv.field_names() {
11407 if let Some(vr) = pv.get(self.id, &field) {
11408 out.insert(field, vr.into_value());
11409 }
11410 }
11411 out
11412 }
11413
11414 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
11415 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
11416 let view = self.db.view();
11417 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
11418 names
11419 .iter()
11420 .filter_map(|name| view.syms.get(name))
11421 .collect()
11422 });
11423 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
11424 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
11425 for (nid, d) in nb.nodes {
11426 let key = view.key_of(nid);
11427 let label = view
11428 .label_of(nid)
11429 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11430 rs.push_row(vec![
11431 Some(Value::Str(key.to_string())),
11432 Some(Value::Str(label.to_string())),
11433 Some(Value::Int(d as i64)),
11434 ]);
11435 }
11436 rs
11437 }
11438
11439 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
11440 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
11441 let view = self.db.view();
11442 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11443 for e in expand(&view, self.id, None, Dir::Both) {
11444 // Skip edges with unknown etypes (only possible from corrupt large
11445 // TOPOLOGY section; function returns BTreeMap not Result).
11446 let Some(etype) = view.syms.resolve(e.etype) else {
11447 continue;
11448 };
11449 let etype = etype.to_string();
11450 let nbr = if e.src == self.id { e.dst } else { e.src };
11451 groups
11452 .entry(etype)
11453 .or_default()
11454 .insert(view.key_of(nbr).to_string());
11455 }
11456 groups
11457 .into_iter()
11458 .map(|(k, v)| (k, v.into_iter().collect()))
11459 .collect()
11460 }
11461}
11462
11463#[cfg(test)]
11464mod tests {
11465 use super::*;
11466 use core_rules::Predicate;
11467
11468 fn tmp_dir(name: &str) -> std::path::PathBuf {
11469 let d =
11470 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
11471 let _ = std::fs::remove_dir_all(&d);
11472 d
11473 }
11474
11475 fn fk_rule() -> RuleDef {
11476 RuleDef {
11477 name: "works_at".into(),
11478 src_label: "Person".into(),
11479 dst_label: "Org".into(),
11480 predicate: Predicate::KeyMatch {
11481 field: "org_id".into(),
11482 },
11483 edge_type: "WORKS_AT".into(),
11484 weight_prop: None,
11485 max_edges: None,
11486 approximate: false,
11487 via_label: None,
11488 via_edge: None,
11489 via_dir: None,
11490 }
11491 }
11492
11493 /// Regression guard for the no-views delta-copy fast path.
11494 ///
11495 /// When no views are defined, `pending_deltas_since().to_vec()` must never
11496 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
11497 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
11498 /// a count of 0 after the entire sequence proves the guard fires correctly.
11499 #[test]
11500 fn no_delta_copy_when_no_views() {
11501 DELTA_COPY_COUNT.with(|c| c.set(0));
11502 let dir = tmp_dir("no-delta-copy");
11503 {
11504 let mut db = GraphDb::open(&dir).unwrap();
11505 // Insert 50 Org + 50 Person nodes with FK links.
11506 for i in 0..50u32 {
11507 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11508 }
11509 for i in 0..50u32 {
11510 db.insert_node(
11511 "Person",
11512 &format!("p{i}"),
11513 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11514 )
11515 .unwrap();
11516 }
11517 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
11518 db.create_rule(fk_rule()).unwrap();
11519
11520 // Counter must stay 0 — no views, no copies.
11521 let copies = DELTA_COPY_COUNT.with(|c| c.get());
11522 assert_eq!(
11523 copies, 0,
11524 "pending_deltas_since().to_vec() called despite no views"
11525 );
11526
11527 // Derived edges must still be correct (the guard skips only the
11528 // empty delta propagation loop, not the rule application itself).
11529 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
11530 assert_eq!(
11531 nbrs,
11532 vec!["o0"],
11533 "rule must derive edges even with no views"
11534 );
11535 }
11536 let _ = std::fs::remove_dir_all(&dir);
11537 }
11538
11539 /// Gating regression: subscribe AFTER a backfill must see no stale events.
11540 /// subscribe BEFORE a backfill must see every edge-fire event.
11541 #[test]
11542 fn subscribe_after_backfill_no_stale_events() {
11543 let dir = tmp_dir("sub-after-backfill");
11544 {
11545 let mut db = GraphDb::open(&dir).unwrap();
11546 for i in 0..10u32 {
11547 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11548 db.insert_node(
11549 "Person",
11550 &format!("p{i}"),
11551 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11552 )
11553 .unwrap();
11554 }
11555 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
11556 db.create_rule(fk_rule()).unwrap();
11557
11558 // Subscribe AFTER the backfill — queue must be empty (no stale events).
11559 let sub = db.subscribe_all_rules().unwrap();
11560 // No events should have queued for the prior backfill.
11561 assert!(
11562 sub.try_recv().is_none(),
11563 "subscribe after backfill must see no stale events"
11564 );
11565
11566 // Inserting a new node now should fire an event (emit_deltas is now true).
11567 db.insert_node("Org", "o_new", vec![]).unwrap();
11568 db.insert_node(
11569 "Person",
11570 "p_new",
11571 vec![("org_id".into(), Value::Str("o_new".into()))],
11572 )
11573 .unwrap();
11574 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
11575 assert!(
11576 ev.is_some(),
11577 "edge-fire event must arrive after subscribe (emit_deltas=true)"
11578 );
11579 }
11580 let _ = std::fs::remove_dir_all(&dir);
11581 }
11582
11583 /// Gating regression: subscribe BEFORE a backfill → events flow.
11584 #[test]
11585 fn subscribe_before_backfill_events_flow() {
11586 let dir = tmp_dir("sub-before-backfill");
11587 {
11588 let mut db = GraphDb::open(&dir).unwrap();
11589 // Subscribe FIRST — emit_deltas becomes true.
11590 let sub = db.subscribe_all_rules().unwrap();
11591
11592 for i in 0..5u32 {
11593 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11594 db.insert_node(
11595 "Person",
11596 &format!("p{i}"),
11597 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11598 )
11599 .unwrap();
11600 }
11601 // Backfill fires with emit_deltas=true → events queued.
11602 db.create_rule(fk_rule()).unwrap();
11603
11604 // Should receive at least one edge-fired event from the backfill.
11605 let mut received = 0usize;
11606 while sub.try_recv().is_some() {
11607 received += 1;
11608 }
11609 assert!(
11610 received > 0,
11611 "subscribe before backfill must receive edge-fire events (got 0)"
11612 );
11613 }
11614 let _ = std::fs::remove_dir_all(&dir);
11615 }
11616
11617 /// Companion: when a view IS defined, the delta path fires and view values update.
11618 #[test]
11619 fn delta_copy_fires_when_view_exists() {
11620 use core_rules::ViewSource;
11621 DELTA_COPY_COUNT.with(|c| c.set(0));
11622 let dir = tmp_dir("delta-copy-with-view");
11623 {
11624 let mut db = GraphDb::open(&dir).unwrap();
11625 db.insert_node("Org", "o1", vec![]).unwrap();
11626 db.insert_node(
11627 "Person",
11628 "p1",
11629 vec![("org_id".into(), Value::Str("o1".into()))],
11630 )
11631 .unwrap();
11632 // Declare a Degree view so is_empty() returns false.
11633 db.create_view(ViewDef {
11634 name: "degree_out".into(),
11635 label: "Person".into(),
11636 view_prop: "degree_out".into(),
11637 source: ViewSource::Degree {
11638 edge_type: "WORKS_AT".into(),
11639 direction: Direction::Out,
11640 },
11641 })
11642 .unwrap();
11643 db.create_rule(fk_rule()).unwrap();
11644
11645 // At least one delta copy should have happened (CreateRule backfill).
11646 let copies = DELTA_COPY_COUNT.with(|c| c.get());
11647 assert!(
11648 copies > 0,
11649 "expected delta copy to fire when a view is defined"
11650 );
11651
11652 // View value should be computed: p1 has one WORKS_AT out-edge.
11653 let info = db.node_info("p1").unwrap();
11654 let degree = info.props.get("degree_out");
11655 assert!(
11656 degree.is_some(),
11657 "view prop should be written to node props"
11658 );
11659 }
11660 let _ = std::fs::remove_dir_all(&dir);
11661 }
11662
11663 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
11664 /// derived-edge-driven view values reflect the as-of state rather than just
11665 /// the initial backfill written at `CreateView` time.
11666 ///
11667 /// Base WAL frames (indices 0..=5 before history markers):
11668 /// 0: insert Org "o1"
11669 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
11670 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
11671 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
11672 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
11673 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
11674 ///
11675 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
11676 /// no-op), so the total commit count is higher than the base frame count.
11677 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
11678 ///
11679 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
11680 /// initial backfill value (0) instead of reflecting the replayed derived edges.
11681 #[test]
11682 fn open_at_derived_edge_view_values_correct() {
11683 use core_rules::ViewSource;
11684 let dir = tmp_dir("open-at-view-rebuild");
11685 {
11686 let mut db = GraphDb::open(&dir).unwrap();
11687 // frame 0
11688 db.insert_node("Org", "o1", vec![]).unwrap();
11689 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
11690 db.create_view(ViewDef {
11691 name: "employee_count".into(),
11692 label: "Org".into(),
11693 view_prop: "emp".into(),
11694 source: ViewSource::Degree {
11695 edge_type: "WORKS_AT".into(),
11696 direction: Direction::In,
11697 },
11698 })
11699 .unwrap();
11700 // frame 2: create rule — no Persons yet; backfill is a no-op
11701 db.create_rule(fk_rule()).unwrap();
11702 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
11703 db.insert_node(
11704 "Person",
11705 "p1",
11706 vec![("org_id".into(), Value::Str("o1".into()))],
11707 )
11708 .unwrap();
11709 // frame 4: p2 — degree = 2
11710 db.insert_node(
11711 "Person",
11712 "p2",
11713 vec![("org_id".into(), Value::Str("o1".into()))],
11714 )
11715 .unwrap();
11716 // frame 5: p3 — degree = 3
11717 db.insert_node(
11718 "Person",
11719 "p3",
11720 vec![("org_id".into(), Value::Str("o1".into()))],
11721 )
11722 .unwrap();
11723 // Sanity: normal open sees degree = 3.
11724 assert_eq!(
11725 db.get_view_prop("o1", "emp"),
11726 Some(Value::Int(3)),
11727 "normal db must show degree 3 after 3 derived edges"
11728 );
11729 } // WAL flushed
11730
11731 // Re-open normally to get the authoritative reference value.
11732 let normal_db = GraphDb::open(&dir).unwrap();
11733 let normal_emp = normal_db.get_view_prop("o1", "emp");
11734 assert_eq!(
11735 normal_emp,
11736 Some(Value::Int(3)),
11737 "re-opened normal db must show degree 3"
11738 );
11739
11740 // Latest as-of (last WAL commit): must match the normal open.
11741 // History-marker frames are appended after each rule-fire, so the total
11742 // commit count is computed dynamically rather than hardcoded.
11743 let total = crate::wal_commit_count_at(&dir).unwrap();
11744 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11745 assert_eq!(
11746 aof_latest.get_view_prop("o1", "emp"),
11747 normal_emp,
11748 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11749 );
11750
11751 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11752 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11753 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11754 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11755 assert_eq!(
11756 aof_mid.get_view_prop("o1", "emp"),
11757 Some(Value::Int(1)),
11758 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11759 );
11760
11761 let _ = std::fs::remove_dir_all(&dir);
11762 }
11763
11764 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11765 /// as-of instances never commit, so distribute_events never runs and any
11766 /// subscription would wait forever.
11767 #[test]
11768 fn subscribe_on_as_of_returns_read_only_error() {
11769 let dir = tmp_dir("sub-as-of-read-only");
11770 {
11771 let mut db = GraphDb::open(&dir).unwrap();
11772 db.insert_node("Org", "o1", vec![]).unwrap();
11773 db.create_rule(fk_rule()).unwrap();
11774 }
11775 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11776
11777 assert!(
11778 matches!(
11779 aof.subscribe_all_rules(),
11780 Err(core_storage::GraphError::ReadOnly)
11781 ),
11782 "subscribe_all_rules on as-of must return ReadOnly"
11783 );
11784 assert!(
11785 matches!(
11786 aof.subscribe_writes(),
11787 Err(core_storage::GraphError::ReadOnly)
11788 ),
11789 "subscribe_writes on as-of must return ReadOnly"
11790 );
11791 assert!(
11792 matches!(
11793 aof.subscribe_rule("works_at"),
11794 Err(core_storage::GraphError::ReadOnly)
11795 ),
11796 "subscribe_rule on as-of must return ReadOnly"
11797 );
11798 let _ = std::fs::remove_dir_all(&dir);
11799 }
11800
11801 /// Regression: a failed dense WAL rewrite must not leave speculative
11802 /// interns in `syms`. If it does, the next successful mutation logs an
11803 /// `Intern` record with an inflated id; replay (which never saw the
11804 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11805 #[test]
11806 fn dense_rewrite_error_rolls_back_speculative_interns() {
11807 let dir = tmp_dir("dense-rewrite-rollback");
11808 {
11809 let mut db = GraphDb::open(&dir).unwrap();
11810 db.insert_node("Person", "a", vec![]).unwrap();
11811
11812 // Bypass MutPreview validation to hit the rewrite's own error path
11813 // (same shape as an id-exhaustion failure mid-rewrite). The
11814 // InsertEdge arm interns the edge type before it resolves keys.
11815 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11816 edge_type: "ORPHAN_TYPE".into(),
11817 src_key: "missing".into(),
11818 dst_key: "a".into(),
11819 }]);
11820 assert!(err.is_err(), "rewrite of a missing src key must fail");
11821 assert_eq!(
11822 db.syms.get("ORPHAN_TYPE"),
11823 None,
11824 "failed rewrite must roll back speculative interns"
11825 );
11826
11827 // A later successful mutation must produce a replayable WAL.
11828 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11829 }
11830 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11831 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11832 let _ = std::fs::remove_dir_all(&dir);
11833 }
11834}