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}
279
280/// One rule's provenance size, trip latch, and fire counter.
281///
282/// `tripped` is a one-way latch: once set, the engine adds no new edges for
283/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
284/// set then fits). `fires` counts `on_node_changed` evaluations plus
285/// backfill/rebuild participant ticks (rebuild counts even when it is a
286/// provenance no-op).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct RuleStats {
289 pub name: String,
290 pub edges: u64,
291 pub tripped: bool,
292 pub fires: u64,
293 /// Whether this rule uses the approximate IVF-Flat candidate path.
294 pub approximate: bool,
295}
296
297/// One entry in the slow-query ring buffer.
298#[derive(Debug, Clone, Serialize)]
299pub struct SlowQueryEntry {
300 /// Execution time in whole milliseconds.
301 pub ms: u64,
302 /// The Cypher query string that was slow.
303 pub query: String,
304 /// The commit sequence number at the time the query ran.
305 pub at_commit: u64,
306}
307
308/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
309#[derive(Debug, Clone, Serialize)]
310pub struct SlowQuerySnapshot {
311 /// Current threshold in milliseconds (0 = disabled).
312 pub threshold_ms: u64,
313 /// Total number of slow queries ever recorded (not capped by ring size).
314 pub count: u64,
315 /// Most-recent slow queries (up to 16), oldest first.
316 pub last: Vec<SlowQueryEntry>,
317}
318
319/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
320/// write to it without a mutable borrow.
321struct SlowQueryLog {
322 entries: std::collections::VecDeque<SlowQueryEntry>,
323 total: u64,
324}
325
326/// Maximum number of entries kept in the slow-query ring buffer.
327const SLOW_QUERY_RING_CAP: usize = 16;
328
329/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
330/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct PredicateSummary {
333 pub kind: String,
334 pub fields: Vec<String>,
335 pub min: Option<f64>,
336 pub tolerance: Option<f64>,
337 pub km: Option<f64>,
338 pub parts: Option<Vec<PredicateSummary>>,
339 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
340 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
341 #[serde(default)]
342 pub approximate: bool,
343}
344
345impl From<&Predicate> for PredicateSummary {
346 fn from(p: &Predicate) -> Self {
347 match p {
348 Predicate::KeyMatch { field } => PredicateSummary {
349 kind: "key_match".into(),
350 fields: vec![field.clone()],
351 min: None,
352 tolerance: None,
353 km: None,
354 parts: None,
355 approximate: false,
356 },
357 Predicate::FieldEqual { field } => PredicateSummary {
358 kind: "field_equal".into(),
359 fields: vec![field.clone()],
360 min: None,
361 tolerance: None,
362 km: None,
363 parts: None,
364 approximate: false,
365 },
366 Predicate::Overlap { field, min } => PredicateSummary {
367 kind: "overlap".into(),
368 fields: vec![field.clone()],
369 min: Some(*min),
370 tolerance: None,
371 km: None,
372 parts: None,
373 approximate: false,
374 },
375 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
376 kind: "numeric_within".into(),
377 fields: vec![field.clone()],
378 min: None,
379 tolerance: Some(*tolerance),
380 km: None,
381 parts: None,
382 approximate: false,
383 },
384 Predicate::GeoRadius { field, km } => PredicateSummary {
385 kind: "geo_radius".into(),
386 fields: vec![field.clone()],
387 min: None,
388 tolerance: None,
389 km: Some(*km),
390 parts: None,
391 approximate: false,
392 },
393 Predicate::VectorSimilar { field, min } => PredicateSummary {
394 kind: "vector_similar".into(),
395 fields: vec![field.clone()],
396 min: Some(*min),
397 tolerance: None,
398 km: None,
399 parts: None,
400 approximate: false,
401 },
402 Predicate::All(inner) => {
403 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
404 let mut fields = Vec::new();
405 for part in &parts {
406 for f in &part.fields {
407 if !fields.contains(f) {
408 fields.push(f.clone());
409 }
410 }
411 }
412 PredicateSummary {
413 kind: "all".into(),
414 fields,
415 min: None,
416 tolerance: None,
417 km: None,
418 parts: Some(parts),
419 approximate: false,
420 }
421 }
422 Predicate::Any(inner) => {
423 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
424 let mut fields = Vec::new();
425 for part in &parts {
426 for f in &part.fields {
427 if !fields.contains(f) {
428 fields.push(f.clone());
429 }
430 }
431 }
432 PredicateSummary {
433 kind: "any".into(),
434 fields,
435 min: None,
436 tolerance: None,
437 km: None,
438 parts: Some(parts),
439 approximate: false,
440 }
441 }
442 }
443 }
444}
445
446/// Snapshot of a live node's key, label, and columnar properties.
447///
448/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
449/// regardless of insert order or the columnar store's `HashMap` iteration.
450///
451/// Deliberately does not derive `Serialize`: `Value`'s serde form is
452/// internally tagged. Wire JSON is built by `value_to_json` in the server.
453#[derive(Debug, Clone, PartialEq)]
454pub struct NodeInfo {
455 pub key: String,
456 pub label: String,
457 pub props: BTreeMap<String, Value>,
458}
459
460/// Counts returned by [`GraphDb::delete_node`].
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct DeleteReport {
463 /// Number of manual (user-inserted) edges removed.
464 pub manual_edges: u64,
465 /// Number of derived (rule-owned) edges retracted.
466 pub derived_edges: u64,
467}
468
469/// One directed edge incident on a node, with provenance membership.
470///
471/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
472/// Plan-8 `by_node` provenance index.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct EdgeInfo {
475 pub edge_type: String,
476 pub src_key: String,
477 pub dst_key: String,
478 pub derived: bool,
479}
480
481/// One directed edge incident on a node at a point in WAL history, with the
482/// rule that derived it when it is rule-owned.
483///
484/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
485/// and by [`GraphDb::what_if_set_prop`].
486#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
487pub struct EdgeAt {
488 pub edge_type: String,
489 pub src_key: String,
490 pub dst_key: String,
491 /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
492 /// live provenance entry).
493 pub derived: bool,
494 /// The rule that derived the edge. `None` for a manual edge.
495 pub rule: Option<String>,
496}
497
498/// The derived edges a hypothetical property change would retract and derive.
499///
500/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
501/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503pub struct WhatIf {
504 /// Derived edges that exist now and would be retracted.
505 pub lost: Vec<EdgeAt>,
506 /// Derived edges that do not exist now and would be derived.
507 pub gained: Vec<EdgeAt>,
508}
509
510/// An edge with mask-aware endpoint visibility.
511///
512/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
513/// mode — hidden endpoints carry `*_restricted: true`.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct MaskedEdge {
516 pub edge_type: String,
517 pub src_key: String,
518 /// `true` when `src_key` is in the DB but hidden from the mask.
519 pub src_restricted: bool,
520 pub dst_key: String,
521 /// `true` when `dst_key` is in the DB but hidden from the mask.
522 pub dst_restricted: bool,
523 pub derived: bool,
524}
525
526/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
527///
528/// `None` from that method means the key does not exist (→ 404).
529/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
530#[derive(Debug, PartialEq)]
531pub enum MaskedNodeResult {
532 Visible(NodeInfo),
533 /// Node exists in the DB but is hidden from this mask.
534 Restricted,
535}
536
537/// One rule-owned edge between two nodes, with the rule name, edge type,
538/// direction (src_key → dst_key), and weight if the rule stores one.
539#[derive(Debug, Clone, PartialEq, Serialize)]
540pub struct Explanation {
541 pub rule: String,
542 pub edge_type: String,
543 pub src_key: String,
544 pub dst_key: String,
545 pub weight: Option<f64>,
546 pub predicate: PredicateSummary,
547 /// For a via-hop rule, the edge type the rule hops over to reach its
548 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
549 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
550 /// was written by another rule in the same commit.
551 #[serde(default)]
552 pub via_edge: Option<String>,
553}
554
555/// Report returned by [`GraphDb::backup_to`].
556#[derive(Debug, Clone)]
557pub struct BackupReport {
558 /// Filenames copied into the destination directory (sorted ascending).
559 pub files: Vec<String>,
560 /// Total bytes written across all copied files.
561 pub bytes: u64,
562 /// `true` when the destination opened cleanly and passed post-copy checks.
563 ///
564 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
565 /// matched **and** the destination opened without error.
566 ///
567 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
568 /// CRC-check; `verified` is `true` when the destination opened and
569 /// replayed the WAL without error (record-level checksums in the WAL
570 /// provide the integrity signal, not section CRCs).
571 pub verified: bool,
572}
573
574/// One directed edge in export form, with optional rule attribution for derived edges.
575///
576/// Returned by [`GraphDb::all_edges_for_export`].
577///
578/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
579/// order. Callers that need a stable edge ordering already sort by
580/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
581#[derive(Debug, Clone, PartialEq, PartialOrd)]
582pub struct ExportEdge {
583 pub edge_type: String,
584 pub src: String,
585 pub dst: String,
586 pub derived: bool,
587 /// Rule name that created this edge, if derived. `None` for manual edges.
588 pub rule: Option<String>,
589 /// The creating rule's declared `weight_prop`, read off this edge, when
590 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
591 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
592 pub weight: Option<f64>,
593}
594
595/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
596///
597/// Deliberately per *type* and not per edge: everything here is a summary a
598/// caller can print in one line, and none of it costs a record per edge.
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct EdgeTypeCensus {
601 pub edge_type: String,
602 /// Directed edges of this type. Counted the way
603 /// [`GraphDb::edge_count`] counts: each edge once, from its source.
604 pub edges: u64,
605 /// Every label seen on a source of this type, sorted.
606 pub src_labels: Vec<String>,
607 /// Every label seen on a destination of this type, sorted.
608 pub dst_labels: Vec<String>,
609 /// The rules that declare this `edge_type`, sorted. Empty for a type
610 /// written by hand.
611 pub rules: Vec<String>,
612 /// `(src key, dst key)` of the first edge of this type in the store's own
613 /// id order — a real pair to quote in an example.
614 pub sample: Option<(String, String)>,
615}
616
617/// Construct the standard write-query result set (columns: created, properties_set, deleted).
618fn write_result_set() -> ResultSet {
619 ResultSet::new(vec![
620 "created".into(),
621 "properties_set".into(),
622 "deleted".into(),
623 ])
624}
625
626fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
627 match op {
628 Operand::Lit(v) => Ok(v.clone()),
629 Operand::Param(name) => params
630 .get(name)
631 .cloned()
632 .ok_or_else(|| GraphError::QueryError {
633 detail: format!("missing parameter `{name}`"),
634 }),
635 _ => Err(GraphError::QueryError {
636 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
637 }),
638 }
639}
640
641fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
642 match op {
643 Operand::Prop { var, .. } | Operand::Var(var) => {
644 if !out.contains(var) {
645 out.push(var.clone());
646 }
647 }
648 Operand::FuncCall { args, .. } => {
649 for arg in args {
650 operand_node_vars(arg, out);
651 }
652 }
653 Operand::BinArith { left, right, .. } => {
654 operand_node_vars(left, out);
655 operand_node_vars(right, out);
656 }
657 Operand::Case { branches, default } => {
658 // Branch conditions reference vars already bound (and mask-filtered)
659 // by the MATCH phase, so collecting from the value operands + ELSE
660 // is sufficient for RETURN-projection var discovery.
661 for (_, value) in branches {
662 operand_node_vars(value, out);
663 }
664 if let Some(d) = default {
665 operand_node_vars(d, out);
666 }
667 }
668 Operand::Index { base, index } => {
669 operand_node_vars(base, out);
670 operand_node_vars(index, out);
671 }
672 Operand::Lit(_) | Operand::Param(_) => {}
673 }
674}
675
676fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
677 let mut out = Vec::new();
678 for item in items {
679 match &item.value {
680 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
681 if !out.contains(v) {
682 out.push(v.clone());
683 }
684 }
685 RetVal::FuncCall { args, .. } => {
686 for arg in args {
687 operand_node_vars(arg, &mut out);
688 }
689 }
690 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
691 RetVal::Agg { .. } => {}
692 }
693 }
694 out
695}
696
697fn add_var(out: &mut Vec<String>, v: &str) {
698 if !out.iter().any(|x| x == v) {
699 out.push(v.to_string());
700 }
701}
702
703fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
704 let mut out = Vec::new();
705 for p in pats {
706 if let Some(v) = &p.start.var {
707 add_var(&mut out, v);
708 }
709 for (_, dest) in &p.chain {
710 if let Some(v) = &dest.var {
711 add_var(&mut out, v);
712 }
713 }
714 }
715 out
716}
717
718fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
719 let mut out = Vec::new();
720 for p in pats {
721 for (rel, _) in &p.chain {
722 if rel.hops.is_none() {
723 if let Some(v) = &rel.var {
724 add_var(&mut out, v);
725 }
726 }
727 }
728 }
729 out
730}
731
732fn rel_type_alias(var: &str) -> String {
733 format!("__rt_{var}")
734}
735
736fn ret_column_name(item: &RetItem) -> String {
737 if let Some(alias) = &item.alias {
738 return alias.clone();
739 }
740 // The same naming rule the planner and the executor use, so a
741 // write-statement RETURN names its columns exactly as a read query does.
742 // An aggregate is not legal in a write-statement RETURN; it keeps the
743 // placeholder it always had.
744 ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
745}
746
747fn eval_set_return_operand<F: Fs>(
748 db: &GraphDb<F>,
749 match_rs: &ResultSet,
750 row: usize,
751 rel_vars: &[String],
752 op: &Operand,
753 params: &BTreeMap<String, Value>,
754) -> Result<Option<Value>> {
755 match op {
756 Operand::Lit(v) => Ok(Some(v.clone())),
757 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
758 detail: format!("missing parameter `{name}`"),
759 }).map(Some),
760 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
761 detail: format!(
762 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
763 ),
764 }),
765 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
766 Operand::Prop { var, field } => {
767 if rel_vars.iter().any(|r| r == var) {
768 return Ok(None);
769 }
770 let Some(Value::Str(key)) = match_rs.get(row, var) else {
771 return Ok(None);
772 };
773 Ok(db.get_prop(key, field))
774 }
775 Operand::FuncCall { name, args } => {
776 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
777 }
778 Operand::BinArith { op, left, right } => {
779 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
780 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
781 eval_set_return_arith(op, lv, rv)
782 }
783 // CASE is supported in read-query RETURN; in a write-statement RETURN
784 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
785 Operand::Case { .. } => Err(GraphError::QueryError {
786 detail: "CASE is not supported in a write-statement RETURN projection; \
787 use a read query"
788 .into(),
789 }),
790 // Same as CASE: a list subscript is supported in a read-query RETURN
791 // but not yet in a write-statement RETURN projection.
792 Operand::Index { .. } => Err(GraphError::QueryError {
793 detail: "a list subscript is not supported in a write-statement RETURN \
794 projection; use a read query"
795 .into(),
796 }),
797 }
798}
799
800fn eval_set_return_arith(
801 op: &ArithOp,
802 lv: Option<Value>,
803 rv: Option<Value>,
804) -> Result<Option<Value>> {
805 match (lv, rv) {
806 (None, _) | (_, None) => Ok(None),
807 (Some(Value::Int(a)), Some(Value::Int(b))) => {
808 let result = match op {
809 ArithOp::Sub => a.saturating_sub(b),
810 ArithOp::Mul => a.saturating_mul(b),
811 ArithOp::Add => a.saturating_add(b),
812 ArithOp::Div => {
813 if b == 0 {
814 return Err(GraphError::QueryError {
815 detail: "division by zero".into(),
816 });
817 }
818 a.checked_div(b).unwrap_or(i64::MAX)
819 }
820 };
821 Ok(Some(Value::Int(result)))
822 }
823 (Some(lv), Some(rv)) => {
824 let a = match &lv {
825 Value::Float(f) => *f,
826 Value::Int(i) => *i as f64,
827 _ => {
828 return Err(GraphError::QueryError {
829 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
830 })
831 }
832 };
833 let b = match &rv {
834 Value::Float(f) => *f,
835 Value::Int(i) => *i as f64,
836 _ => {
837 return Err(GraphError::QueryError {
838 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
839 })
840 }
841 };
842 let result = match op {
843 ArithOp::Sub => a - b,
844 ArithOp::Mul => a * b,
845 ArithOp::Add => a + b,
846 ArithOp::Div => {
847 if b == 0.0 {
848 return Err(GraphError::QueryError {
849 detail: "division by zero".into(),
850 });
851 }
852 a / b
853 }
854 };
855 Ok(Some(Value::Float(result)))
856 }
857 }
858}
859
860fn eval_set_return_func<F: Fs>(
861 db: &GraphDb<F>,
862 match_rs: &ResultSet,
863 row: usize,
864 rel_vars: &[String],
865 name: &str,
866 args: &[Operand],
867 params: &BTreeMap<String, Value>,
868) -> Result<Option<Value>> {
869 let norm = name.to_ascii_lowercase();
870 if norm == "type" {
871 if args.len() != 1 {
872 return Err(GraphError::QueryError {
873 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
874 });
875 }
876 let Operand::Var(rel) = &args[0] else {
877 return Err(GraphError::QueryError {
878 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
879 });
880 };
881 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
882 }
883 if norm == "key" {
884 if args.len() != 1 {
885 return Err(GraphError::QueryError {
886 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
887 });
888 }
889 let Operand::Var(var) = &args[0] else {
890 return Err(GraphError::QueryError {
891 detail: "key() argument must be a node variable (e.g. key(n))".into(),
892 });
893 };
894 if rel_vars.iter().any(|r| r == var) {
895 return Err(GraphError::QueryError {
896 detail: format!("key() argument `{var}` is a relationship, not a node"),
897 });
898 }
899 // MATCH rows bind node variables to their key string, so the column
900 // value *is* the key.
901 return Ok(match_rs.get(row, var).cloned());
902 }
903 let mut vals = Vec::with_capacity(args.len());
904 for arg in args {
905 vals.push(eval_set_return_operand(
906 db, match_rs, row, rel_vars, arg, params,
907 )?);
908 }
909 match norm.as_str() {
910 "tolower" => {
911 if vals.len() != 1 {
912 return Err(GraphError::QueryError {
913 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
914 });
915 }
916 Ok(vals[0].clone().map(|val| match val {
917 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
918 other => other,
919 }))
920 }
921 "toupper" => {
922 if vals.len() != 1 {
923 return Err(GraphError::QueryError {
924 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
925 });
926 }
927 Ok(vals[0].clone().map(|val| match val {
928 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
929 other => other,
930 }))
931 }
932 "size" => match vals.first().cloned().flatten() {
933 None => Ok(None),
934 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
935 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
936 Some(_) => Ok(None),
937 },
938 "coalesce" => Ok(vals.into_iter().flatten().next()),
939 "abs" => match vals.first().cloned().flatten() {
940 None => Ok(None),
941 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
942 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
943 Some(_) => Ok(None),
944 },
945 "round" => match vals.first().cloned().flatten() {
946 None => Ok(None),
947 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
948 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
949 Some(_) => Ok(None),
950 },
951 "decay" => {
952 if vals.len() != 3 {
953 return Err(GraphError::QueryError {
954 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
955 });
956 }
957 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
958 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
959 (Some(b), Some(a), Some(h)) => {
960 let numeric = |v: Value| -> Result<f64> {
961 match v {
962 Value::Int(n) => Ok(n as f64),
963 Value::Float(f) => Ok(f),
964 other => Err(GraphError::QueryError {
965 detail: format!(
966 "decay() requires numeric arguments, got {other:?}"
967 ),
968 }),
969 }
970 };
971 let b = numeric(b)?;
972 let a = numeric(a)?;
973 let h = numeric(h)?;
974 if h <= 0.0 {
975 return Err(GraphError::QueryError {
976 detail: "decay() requires halflife > 0".into(),
977 });
978 }
979 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
980 }
981 }
982 }
983 _ => Err(GraphError::QueryError {
984 detail: format!(
985 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
986 ),
987 }),
988 }
989}
990
991fn eval_set_return_item<F: Fs>(
992 db: &GraphDb<F>,
993 match_rs: &ResultSet,
994 row: usize,
995 rel_vars: &[String],
996 item: &RetItem,
997 params: &BTreeMap<String, Value>,
998) -> Result<Option<Value>> {
999 match &item.value {
1000 RetVal::Var(v) => eval_set_return_operand(
1001 db,
1002 match_rs,
1003 row,
1004 rel_vars,
1005 &Operand::Var(v.clone()),
1006 params,
1007 ),
1008 RetVal::Prop { var, field } => eval_set_return_operand(
1009 db,
1010 match_rs,
1011 row,
1012 rel_vars,
1013 &Operand::Prop {
1014 var: var.clone(),
1015 field: field.clone(),
1016 },
1017 params,
1018 ),
1019 RetVal::FuncCall { name, args } => {
1020 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1021 }
1022 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1023 RetVal::Agg { .. } => Err(GraphError::QueryError {
1024 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1025 }),
1026 }
1027}
1028
1029/// Project user RETURN from original MATCH rows after SET. No rematch.
1030fn project_set_return_rows<F: Fs>(
1031 db: &GraphDb<F>,
1032 rel_vars: &[String],
1033 match_rs: &ResultSet,
1034 returns: &[RetItem],
1035 params: &BTreeMap<String, Value>,
1036) -> Result<ResultSet> {
1037 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1038 let mut out = ResultSet::new(columns);
1039 for row in 0..match_rs.len() {
1040 let mut cells = Vec::with_capacity(returns.len());
1041 for item in returns {
1042 cells.push(eval_set_return_item(
1043 db, match_rs, row, rel_vars, item, params,
1044 )?);
1045 }
1046 out.push_row(cells);
1047 }
1048 Ok(out)
1049}
1050
1051/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1052/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1053/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1054/// Returns `None` for non-list values or lists with non-numeric elements.
1055fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1056 match v {
1057 Value::List(items) => items
1058 .iter()
1059 .map(|item| match item {
1060 Value::Float(f) => Some(*f),
1061 Value::Int(i) => Some(*i as f64),
1062 _ => None,
1063 })
1064 .collect(),
1065 _ => None,
1066 }
1067}
1068
1069fn make_graph_mut<'a>(
1070 ids: &'a IdMap,
1071 syms: &'a mut Interner,
1072 labels: &'a [u32],
1073 props: core_storage::v8::seam::ColumnsView<'a>,
1074 topo: &'a mut Topology,
1075 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1076 edge_props: &'a mut EdgeProps,
1077) -> GraphMut<'a> {
1078 GraphMut {
1079 ids,
1080 syms,
1081 labels,
1082 props,
1083 topo,
1084 base_topo: base_csr(base),
1085 edge_props,
1086 }
1087}
1088
1089/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1090///
1091/// A store opened from a snapshot keeps its edges in the mapping and its
1092/// overlay empty, so a rule that reads the graph's shape has to see both.
1093fn base_csr(
1094 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1095) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1096 base.as_ref().map(|b| {
1097 b.topology()
1098 .expect("base topology section bounds validated at open")
1099 })
1100}
1101
1102/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1103///
1104/// Takes explicit field references rather than `&self` so the caller can hold
1105/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1106fn build_props_view<'a>(
1107 props: &'a ColumnStore,
1108 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1109) -> core_storage::v8::seam::ColumnsView<'a> {
1110 match base {
1111 None => core_storage::v8::seam::ColumnsView::owned(props),
1112 Some(b) => {
1113 let archived = b
1114 .columns()
1115 .expect("base columns section bounds validated at open");
1116 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1117 }
1118 }
1119}
1120
1121fn build_topo_view<'a>(
1122 overlay: &'a Topology,
1123 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1124) -> core_storage::v8::seam::TopologyView<'a> {
1125 match base {
1126 None => core_storage::v8::seam::TopologyView::owned(overlay),
1127 Some(b) => {
1128 let archived_csr = b
1129 .topology()
1130 .expect("base topology section bounds validated at open");
1131 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1132 }
1133 }
1134}
1135
1136/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1137///
1138/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1139/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1140/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1141/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1142/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1143#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1144pub enum FsyncPolicy {
1145 /// Every WAL commit calls `fs.sync` (today's behavior).
1146 #[default]
1147 Strict,
1148 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1149 /// this policy is set on the database.
1150 Batched,
1151 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1152 Relaxed,
1153}
1154
1155/// A precondition for a compare-and-set batch write.
1156///
1157/// All preconditions in a [`GraphDb::write_batch_cas`] or
1158/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1159/// any operation in the batch is applied. If any precondition fails, the
1160/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1161/// is written.
1162///
1163/// # Touch definition
1164///
1165/// A node's last-change commit (`last_changed`) is updated when any of the
1166/// following state-changing WAL records touch it:
1167///
1168/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1169/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1170/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1171/// endpoints (an edge change touches both sides).
1172/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1173/// for deleted keys so the pre-deletion entry is never observed.
1174///
1175/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1176/// state no-ops. The underlying mutation that triggered rule firing already
1177/// updated the relevant nodes' last-change entries. Rule-management records
1178/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1179/// do not touch any node's last-change.
1180#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum Precondition {
1182 /// The node's last-change commit must equal `expected`.
1183 ///
1184 /// Fails with [`GraphError::CasConflict`] when:
1185 /// - The node does not exist (`last_changed` returns `None`), or
1186 /// - The recorded commit seq does not match `expected`.
1187 NodeUnchangedSince { key: String, expected: u64 },
1188 /// The node must not exist (not inserted, or already deleted).
1189 ///
1190 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1191 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1192 NodeAbsent { key: String },
1193}
1194
1195pub struct GraphDb<F: Fs> {
1196 fs: F,
1197 ids: IdMap,
1198 syms: Interner,
1199 topo: Topology,
1200 props: ColumnStore,
1201 labels: Vec<u32>, // node id -> label symbol
1202 edge_props: EdgeProps,
1203 engine: RuleEngine,
1204 view_store: ViewStore,
1205 /// Incremental inverted index for full-text-lite search.
1206 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1207 fulltext: FulltextIndex,
1208 /// Opt-in equality index over scalar node properties.
1209 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1210 /// open end (mirrors `fulltext`).
1211 prop_index: PropertyIndex,
1212 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1213 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1214 fsync: FsyncPolicy,
1215 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1216 /// call increments this once; all events emitted from that call share the same
1217 /// `commit_seq` value.
1218 commit_seq: u64,
1219 /// RBAC role definitions loaded from `roles.json` at open.
1220 ///
1221 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1222 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1223 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1224 roles: Option<Vec<RoleDef>>,
1225 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1226 /// distribute_events call.
1227 subscriptions: Vec<SubEntry>,
1228 /// Live query subscriptions. Re-executed on every commit when non-empty.
1229 /// Dead `Weak` entries are pruned inside `distribute_events`.
1230 query_subscriptions: Vec<QuerySubEntry>,
1231 /// Queue capacity for new subscriptions created by this db. Default is
1232 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1233 /// to test Lagged behaviour with small queues.
1234 sub_capacity: usize,
1235 /// True for as-of instances opened via [`GraphDb::open_at`].
1236 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1237 /// when this flag is set.
1238 read_only: bool,
1239 /// Total WAL commit count at the time [`open_at`] was called.
1240 /// 0 for normal (non-as-of) instances.
1241 total_wal_commits: u64,
1242 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1243 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1244 /// reads go through a merged `TopologyView`. `self.props` is always
1245 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1246 base: Option<Arc<core_storage::v8::MappedBase>>,
1247 // ── MVCC epoch reader state ───────────────────────────────────────────────
1248 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1249 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1250 /// `None` only between struct creation and the first fold.
1251 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1252 /// Per-commit deltas accumulated since the last fold.
1253 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1254 /// How many commits have occurred since the last fold.
1255 commits_since_fold: usize,
1256 /// When true, `log_then_apply_with` buffers event notifications instead of
1257 /// firing them immediately. Used by the group-commit drain thread to defer
1258 /// events until after the group fsync (R2: durability before notification).
1259 /// Cleared to false once the drain thread flushes or discards the buffer.
1260 defer_events: bool,
1261 /// Buffered events accumulated while `defer_events` is true.
1262 deferred_events: Vec<DeferredEvent>,
1263 /// Set to true by the group-commit drain thread when a group fsync fails
1264 /// after WAL truncation. All subsequent mutation attempts return an IO
1265 /// error until the database is reopened.
1266 degraded: bool,
1267 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1268 /// HNSW, and IVF sections from the mmap base into the engine's retained
1269 /// fields. `false` on all opens until first use; always `true` for non-V8
1270 /// opens (base is None, fast-path sets flag immediately).
1271 v8_sections_loaded: std::sync::atomic::AtomicBool,
1272 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1273 v8_sections_mutex: std::sync::Mutex<()>,
1274 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1275 /// the node was last modified by commit `seq`.
1276 ///
1277 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1278 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1279 /// nodes return `None` from `last_changed` until they are next mutated.
1280 ///
1281 /// See [`Precondition`] for the full touch definition.
1282 last_change: HashMap<u32, u64>,
1283 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1284 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1285 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1286 wal_archive_retention: Option<u32>,
1287 /// Global frame index of the first commit that is still reachable through
1288 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1289 /// Default 0 = all history reachable.
1290 wal_horizon_floor: u64,
1291 /// True when the surviving archive chain forms a continuous WAL history
1292 /// starting from the store's first commit (the genesis chain).
1293 ///
1294 /// `open_at` may replay archive-resident commits from empty state only when
1295 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1296 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1297 /// already exist (breaks the chain for subsequent archives), or
1298 /// - any archive is pruned (floor advances past zero).
1299 ///
1300 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1301 archive_genesis_chain: bool,
1302 /// Transient write-authz context set by `write_batch_authz` /
1303 /// `query_write_authz` for the duration of ONE mutation call.
1304 /// Always `None` at rest. Never serialized, never WAL-replayed.
1305 pending_write_authz: Option<WriteAuthz>,
1306 /// Slow-query threshold in milliseconds. 0 = disabled.
1307 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1308 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1309 /// — env vars are process-global and race parallel test threads).
1310 slow_query_threshold_ms: u64,
1311 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1312 /// can record entries without requiring `&mut self`).
1313 slow_queries: std::sync::Mutex<SlowQueryLog>,
1314 /// Instant at which the database was opened (used by `/metrics` uptime).
1315 started_at: std::time::Instant,
1316 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1317 /// Byte offset of the WAL prefix already applied to in-memory state.
1318 ///
1319 /// Advanced by exactly the encoded length of every frame this handle
1320 /// appends, and by the decoded byte count of every tail
1321 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1322 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1323 /// drain thread truncates a failed group. Compared against the WAL's
1324 /// on-disk length to decide staleness.
1325 wal_consumed: u64,
1326 /// Identity of the snapshot this handle's base state came from, as
1327 /// `(len, mtime_nanos)`. A different value means another process replaced
1328 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1329 snapshot_ident: Option<(u64, u64)>,
1330 /// The options this handle was opened with. Replayed verbatim when
1331 /// `refresh` has to rebuild from disk.
1332 open_opts: OpenOptions,
1333 /// True when this handle holds the cross-process write lock for its whole
1334 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1335 /// no-op on such a handle, and never releases the lock.
1336 holds_lifetime_lock: bool,
1337 /// True between a failed lock acquisition and the end of the write scope
1338 /// that failed. Makes every WAL-appending mutation in that scope return
1339 /// [`GraphError::Busy`] instead of writing.
1340 lock_denied: bool,
1341 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1342 /// pinned to one commit, so it is never stale and never refreshes — later
1343 /// commits by any process are deliberately invisible to it.
1344 pinned: bool,
1345}
1346
1347/// One group of deferred event notifications, held until the group fsync
1348/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1349struct DeferredEvent {
1350 rec: core_storage::WalRecord,
1351 engine_deltas: Vec<EngineEdgeDelta>,
1352 seq: u64,
1353 ingest: Option<(String, usize)>,
1354}
1355
1356/// Options for [`GraphDb::open_with_options`].
1357#[derive(Clone, Copy, Debug)]
1358pub struct OpenOptions {
1359 /// Rewrite an old-format snapshot to the current VERSION after a
1360 /// successful load (default `true`). The old snapshot is kept as
1361 /// `snapshot.bin.bak` until the next clean open at the current version,
1362 /// at which point the `.bak` is deleted.
1363 ///
1364 /// Set to `false` to open a store without touching any on-disk files
1365 /// (useful for read-only inspection of a store at an older format).
1366 pub auto_migrate: bool,
1367
1368 /// Write the valid WAL prefix back over a torn tail on open (default
1369 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1370 ///
1371 /// Set to `false` for an unattended reader. The valid prefix is still
1372 /// decoded and replayed in memory, but nothing is written: a reader that
1373 /// opens while another process is mid-append would otherwise discard a
1374 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1375 /// every prompt, passes `false` for exactly this reason.
1376 pub repair_wal: bool,
1377
1378 /// Open without ever writing to the store (default `false`).
1379 ///
1380 /// A read-only handle:
1381 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1382 /// `snapshot()`;
1383 /// - performs no disk write at open — no WAL repair write-back and no
1384 /// auto-migration rewrite, whatever the other two flags say;
1385 /// - never takes the cross-process write lock, so it opens immediately even
1386 /// while another process is writing, and never makes a writer wait.
1387 ///
1388 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1389 /// normally, so a read-only handle can follow another process's commits.
1390 pub read_only: bool,
1391}
1392
1393impl Default for OpenOptions {
1394 fn default() -> Self {
1395 Self {
1396 auto_migrate: true,
1397 repair_wal: true,
1398 read_only: false,
1399 }
1400 }
1401}
1402
1403/// How long a writer polls for the cross-process write lock before giving up
1404/// with [`GraphError::Busy`].
1405///
1406/// Long enough to ride out another process's commit (a batch apply plus one
1407/// fsync), short enough that a stuck peer surfaces as an error rather than a
1408/// hang.
1409pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1410
1411/// Interval between poll attempts while waiting for the cross-process lock.
1412pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1413
1414/// Why `load_from_disk` is running, which decides whether it may repair.
1415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1416enum LoadOrigin {
1417 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1418 /// the signature of a crash and truncating it is correct, and archives
1419 /// orphaned by an interrupted prune can be swept.
1420 Open,
1421 /// A reload driven by [`GraphDb::refresh`], because another process
1422 /// replaced the snapshot. Nothing here is crash recovery — the store is
1423 /// live and someone else is writing it — so this origin writes nothing.
1424 Reload,
1425}
1426
1427/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1428///
1429/// `None` at the call site = full authority (today's zero-cost behavior).
1430/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1431/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1432/// record is built. A denial returns an error with no WAL frame written.
1433///
1434/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1435/// hidden-node existence to callers.
1436#[derive(Clone, Debug)]
1437pub struct WriteAuthz {
1438 pub role: String,
1439 pub scope: WriteScope,
1440 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1441 /// Always `Omit`-mode — never `Stub`.
1442 pub mask: crate::mask::NodeMask,
1443}
1444
1445/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1446///
1447/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1448/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1449/// syncs the directory entry. This is the only correct path for writing the
1450/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1451/// the directory sync.
1452pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1453 use core_storage::fs::{FileId, Fs as _};
1454 RealFs::new(dir)
1455 .map_err(core_storage::GraphError::Io)?
1456 .write_atomic(FileId::SnapshotBak, bytes)
1457 .map_err(core_storage::GraphError::Io)
1458}
1459
1460/// Return the on-disk snapshot format version without decoding the full snapshot.
1461///
1462/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1463/// snapshot file exists (WAL-only store). Returns an error if the header is
1464/// malformed.
1465pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1466 use std::io::Read as _;
1467 let path = dir.join("snapshot.bin");
1468 let mut header = [0u8; 6];
1469 let n = match std::fs::File::open(&path) {
1470 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1471 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1472 Err(e) => return Err(core_storage::GraphError::Io(e)),
1473 };
1474 core_storage::snapshot::peek_version(&header[..n])
1475}
1476
1477/// Options for [`GraphDb::snapshot_with`].
1478#[derive(Debug, Clone, Default)]
1479pub struct SnapshotOptions {
1480 /// When `true`, the WAL is preserved after the snapshot write.
1481 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1482 /// When `false` (the default), the WAL is truncated to a minimal
1483 /// baseline so cold-start replay stays fast.
1484 pub keep_wal: bool,
1485 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1486 /// before a fresh WAL baseline is written (history-preserving snapshot).
1487 ///
1488 /// This is the feature opt-in: `false` (the default) leaves the existing
1489 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1490 /// precedence over `keep_wal` when both are set.
1491 ///
1492 /// Archives can be scanned by [`GraphDb::node_history`],
1493 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1494 /// [`GraphDb::open_at`], extending the reachable history horizon across
1495 /// snapshot boundaries.
1496 pub archive_wal: bool,
1497}
1498
1499/// Derive the scan-label sym for the commit-skip fast-path.
1500///
1501/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1502/// or `IndexIntersect`) with a concrete label string, then interns it.
1503///
1504/// Returns `None` in all cases where skipping is unsafe:
1505/// - Any `Expand` op is present (edge traversal; edges change results regardless
1506/// of node labels).
1507/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1508/// - No recognizable leading scan op is found.
1509///
1510/// This is the conservative v0.4.3 boundary. The caller stores the result in
1511/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1512fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1513 // Any Expand → must always re-execute (edges can change join results).
1514 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1515 return None;
1516 }
1517 for op in ops {
1518 match op {
1519 PlanOp::ScanLabel {
1520 label: Some(label), ..
1521 } => return Some(syms.intern(label)),
1522 PlanOp::IndexScan {
1523 label: Some(label), ..
1524 } => return Some(syms.intern(label)),
1525 PlanOp::IndexIntersect {
1526 label: Some(label), ..
1527 } => return Some(syms.intern(label)),
1528 _ => {}
1529 }
1530 }
1531 None
1532}
1533
1534impl GraphDb<RealFs> {
1535 /// Open the database at `dir` with default options.
1536 ///
1537 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1538 /// Old-format snapshots (V5, V6) are automatically migrated to the
1539 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1540 pub fn open(dir: &std::path::Path) -> Result<Self> {
1541 Self::open_with_options(dir, OpenOptions::default())
1542 }
1543
1544 /// Open the database at `dir` with explicit options.
1545 ///
1546 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1547 /// snapshot is an older format version, this function:
1548 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1549 /// + fsynced) before any modification.
1550 /// 2. Rewrites `snapshot.bin` at the current format version via
1551 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1552 ///
1553 /// If migration fails the error is returned and the original files are
1554 /// intact (the `.bak` was written before the new snapshot was attempted).
1555 ///
1556 /// A clean open that finds the snapshot already at the current version
1557 /// deletes any leftover `.bak` file.
1558 ///
1559 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1560 ///
1561 /// `opts.repair_wal` controls the other write this function can make; see
1562 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1563 /// no file on disk.
1564 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1565 Self::open_dir(dir, opts, true)
1566 }
1567
1568 /// Open without taking the cross-process write lock for the handle's
1569 /// lifetime.
1570 ///
1571 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1572 /// its handle open indefinitely, so it takes the lock per write instead of
1573 /// keeping every other process out of the store for as long as it runs.
1574 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1575 Self::open_dir(dir, OpenOptions::default(), false)
1576 }
1577
1578 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1579 // Header-only peek — 6 bytes, no full decode.
1580 let snap_version = snapshot_version_at(dir)?;
1581
1582 // Full load: decode snapshot + replay WAL + rebuild indexes.
1583 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1584
1585 // A read-only handle writes nothing at open, so it never migrates —
1586 // the old-format snapshot is loaded and left exactly as it is.
1587 if opts.auto_migrate && !opts.read_only {
1588 match snap_version {
1589 Some(ver) if ver < core_storage::snapshot::VERSION => {
1590 let _tm = std::time::Instant::now();
1591 // Copy the original snapshot to .bak at OS level — no in-memory
1592 // buffer required for a 2+ GiB file.
1593 //
1594 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1595 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1596 // A torn .bak on crash is acceptable because the original
1597 // snapshot.bin is the authoritative source until after the rename.
1598 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1599 .map_err(core_storage::GraphError::Io)?;
1600 trace_migrate!("bak copy done", _tm);
1601 // Rewrite snapshot at current version; keep WAL intact.
1602 db.snapshot_with(SnapshotOptions {
1603 keep_wal: true,
1604 ..SnapshotOptions::default()
1605 })?;
1606 trace_migrate!("snapshot_with done", _tm);
1607 }
1608 Some(_) => {
1609 // Already current version: remove any leftover .bak.
1610 let bak = dir.join("snapshot.bin.bak");
1611 if bak.exists() {
1612 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1613 }
1614 }
1615 None => {
1616 // WAL-only store — nothing to migrate on open.
1617 }
1618 }
1619 }
1620
1621 Ok(db)
1622 }
1623
1624 /// Open a read-only view of the database as it existed after `commit`.
1625 ///
1626 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1627 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1628 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1629 ///
1630 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1631 /// so as-of can only reach commits recorded in the current WAL (those
1632 /// written after the most recent snapshot, or all commits if no snapshot
1633 /// was ever taken). Commit 0 in `open_at` always refers to the first
1634 /// frame in the WAL that exists on disk, not the first ever write to the
1635 /// database. When the on-disk snapshot recorded that it truncated the
1636 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1637 /// before frame replay, so the as-of view includes all pre-snapshot data.
1638 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1639 /// are ignored and replay is WAL-only, as before.
1640 ///
1641 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1642 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1643 /// `stats()` work normally.
1644 ///
1645 /// # Errors
1646 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1647 /// when the WAL is empty after a snapshot).
1648 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1649 Self::open_at_with(RealFs::new(dir)?, commit)
1650 }
1651
1652 /// Run a **read-only** Cypher query against the graph as it existed at
1653 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1654 /// of this store's directory at that commit and executes the read there.
1655 ///
1656 /// The current instance is unaffected. Write statements are rejected (the
1657 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1658 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1659 /// state. Prefer this over holding many historical instances open.
1660 ///
1661 /// # Errors
1662 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1663 /// - A query error for a malformed or write query.
1664 pub fn query_at(
1665 &self,
1666 commit: u64,
1667 cypher: &str,
1668 params: &std::collections::BTreeMap<String, Value>,
1669 ) -> Result<ResultSet> {
1670 let dir = self.fs.dir().to_path_buf();
1671 let temporal = Self::open_at(&dir, commit)?;
1672 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1673 detail: format!("lex: {e}"),
1674 })?) {
1675 return Err(GraphError::QueryError {
1676 detail: "query_at is read-only: write statements are not permitted in a \
1677 time-travel query"
1678 .into(),
1679 });
1680 }
1681 temporal.query(cypher, params)
1682 }
1683}
1684
1685impl<F: Fs> GraphDb<F> {
1686 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1687 pub fn open_with(fs: F) -> Result<Self> {
1688 Self::open_with_repair(fs, true)
1689 }
1690
1691 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1692 /// prefix without writing the truncation back. See
1693 /// [`OpenOptions::repair_wal`].
1694 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1695 Self::open_generic(
1696 fs,
1697 OpenOptions {
1698 repair_wal,
1699 ..OpenOptions::default()
1700 },
1701 true,
1702 )
1703 }
1704
1705 /// Shared open path.
1706 ///
1707 /// `hold_lock` requests the cross-process write lock for the whole handle
1708 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1709 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1710 /// `false` and takes the lock per write instead, so that a long-lived
1711 /// server does not keep every other process out of the store.
1712 ///
1713 /// A read-only open never takes the lock regardless of `hold_lock`.
1714 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1715 let mut db = Self::new_empty(fs, opts);
1716 db.read_only = opts.read_only;
1717 if hold_lock && !opts.read_only {
1718 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1719 return Err(GraphError::Busy { holder: None });
1720 }
1721 db.holds_lifetime_lock = true;
1722 }
1723 db.load_from_disk(LoadOrigin::Open)?;
1724 Ok(db)
1725 }
1726
1727 /// A handle with no state loaded: every field at its empty value, the
1728 /// filesystem and options in place. Only [`load_from_disk`] makes it
1729 /// usable.
1730 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1731 Self {
1732 fs,
1733 ids: IdMap::new(),
1734 syms: Interner::new(),
1735 topo: Topology::new(),
1736 props: ColumnStore::new(),
1737 labels: Vec::new(),
1738 edge_props: EdgeProps::new(),
1739 engine: RuleEngine::new(),
1740 view_store: ViewStore::new(),
1741 fulltext: FulltextIndex::new(),
1742 prop_index: PropertyIndex::new(),
1743 event_sink: None,
1744 fsync: FsyncPolicy::Strict,
1745 commit_seq: 0,
1746 roles: Some(vec![]),
1747 subscriptions: Vec::new(),
1748 query_subscriptions: Vec::new(),
1749 sub_capacity: DEFAULT_SUB_CAPACITY,
1750 read_only: false,
1751 total_wal_commits: 0,
1752 base: None,
1753 fold_overlay: None,
1754 delta_tail: Vec::new(),
1755 commits_since_fold: 0,
1756 defer_events: false,
1757 deferred_events: Vec::new(),
1758 degraded: false,
1759 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1760 v8_sections_mutex: std::sync::Mutex::new(()),
1761 last_change: HashMap::new(),
1762 wal_archive_retention: None,
1763 wal_horizon_floor: 0,
1764 archive_genesis_chain: false,
1765 pending_write_authz: None,
1766 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1767 .ok()
1768 .and_then(|v| v.parse().ok())
1769 .unwrap_or(100),
1770 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1771 entries: std::collections::VecDeque::new(),
1772 total: 0,
1773 }),
1774 started_at: std::time::Instant::now(),
1775 wal_consumed: 0,
1776 snapshot_ident: None,
1777 open_opts: opts,
1778 holds_lifetime_lock: false,
1779 lock_denied: false,
1780 pinned: false,
1781 }
1782 }
1783
1784 /// Return every field describing stored graph state to its empty value,
1785 /// leaving this handle's own identity alone.
1786 ///
1787 /// Preserved on purpose: the filesystem, open options, lock ownership, the
1788 /// event sink and subscriptions, fsync policy, degraded flag, and the
1789 /// slow-query configuration and log. A caller that registered a sink or a
1790 /// subscription keeps it across a reload.
1791 fn reset_for_reload(&mut self) {
1792 self.ids = IdMap::new();
1793 self.syms = Interner::new();
1794 self.topo = Topology::new();
1795 self.props = ColumnStore::new();
1796 self.labels = Vec::new();
1797 self.edge_props = EdgeProps::new();
1798 self.engine = RuleEngine::new();
1799 self.view_store = ViewStore::new();
1800 self.fulltext = FulltextIndex::new();
1801 self.prop_index = PropertyIndex::new();
1802 self.commit_seq = 0;
1803 self.roles = Some(vec![]);
1804 self.total_wal_commits = 0;
1805 self.base = None;
1806 self.fold_overlay = None;
1807 self.delta_tail = Vec::new();
1808 self.commits_since_fold = 0;
1809 self.deferred_events = Vec::new();
1810 self.v8_sections_loaded
1811 .store(false, std::sync::atomic::Ordering::Release);
1812 self.last_change = HashMap::new();
1813 self.wal_horizon_floor = 0;
1814 self.archive_genesis_chain = false;
1815 self.pending_write_authz = None;
1816 self.wal_consumed = 0;
1817 self.snapshot_ident = None;
1818 }
1819
1820 /// Load the snapshot base and replay the WAL into an empty handle — the
1821 /// whole of what opening a store does after the struct exists.
1822 ///
1823 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1824 /// rebuild a handle in place, without ownership of `F`, when another
1825 /// process replaces the snapshot underneath it.
1826 ///
1827 /// `origin` decides whether the two repair writes this function can make
1828 /// are appropriate; see [`LoadOrigin`].
1829 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1830 // Both writes below are crash recovery, and only an open is entitled to
1831 // perform them. A read-only handle promises to touch nothing, and a
1832 // reload driven by `refresh` is looking at a store another process is
1833 // actively writing: what looks like a torn tail there is a peer
1834 // mid-append, and what looks like an orphaned archive may be one that
1835 // peer is about to reference.
1836 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1837 let repair_wal = self.open_opts.repair_wal && may_repair;
1838 let db = self;
1839 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1840 db.archive_genesis_chain = db.fs.has_genesis_marker();
1841 // Opening cleanup: remove orphaned archives — archives whose frames all
1842 // fall below the horizon floor. Orphans arise when a crash interrupted
1843 // the retention-prune sequence after the floor was written but before
1844 // all surplus archives were deleted. Safe to delete: floor already
1845 // accounts for their frames.
1846 if may_repair {
1847 db.cleanup_orphaned_archives()?;
1848 }
1849 let _t0 = std::time::Instant::now();
1850 // Peek 6 bytes to determine snapshot version without reading the full
1851 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1852 // default impl reads all bytes and truncates (still correct).
1853 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1854 let is_v8 = snap_header.len() >= 6
1855 && &snap_header[0..4] == b"GDB1"
1856 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1857 == core_storage::snapshot::VERSION_8;
1858 if is_v8 {
1859 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1860 // No 2.4GB heap Vec is allocated on RealFs.
1861 let mapped = Arc::new(
1862 if let Some(snap_path) = db.fs.snapshot_path() {
1863 core_storage::v8::MappedBase::map(&snap_path)
1864 } else {
1865 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1866 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1867 }
1868 .map_err(|e| GraphError::Corrupt {
1869 detail: format!("v8: mmap open: {e:?}"),
1870 })?,
1871 );
1872 db.restore_v8_base(Arc::clone(&mapped))?;
1873 trace_open!("restore_v8_base", _t0);
1874 db.base = Some(mapped);
1875 trace_open!("base assigned", _t0);
1876 } else if !snap_header.is_empty() {
1877 // Legacy V5-V7: full read required for decode.
1878 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1879 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1880 db.restore_snapshot_state(state)?;
1881 }
1882 }
1883 // else: snap_header is empty = no snapshot file, fresh store.
1884 //
1885 // Seed commit_seq from the highest seq persisted in last_change so that
1886 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1887 // already stored in the snapshot. Without this, a db with one snapshot
1888 // commit would save last_change["a"]=1, then on reopen the first WAL
1889 // frame would replay at seq=1 again — colliding and making WAL-tail
1890 // mutations indistinguishable from the snapshot baseline.
1891 //
1892 // Safety invariant (seq-recycling):
1893 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1894 // last_change because they belonged to a previous db lifetime — a new
1895 // db starts at commit_seq=0 with an empty last_change. Therefore no
1896 // CAS precondition can carry a recycled seq as its `expected` value
1897 // and accidentally match a live node's last_change entry.
1898 //
1899 // `expected:0` on a deleted-then-reinserted node:
1900 // After deletion, last_changed() returns None; callers that call
1901 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1902 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1903 // expected=0 correctly conflicts. The only way to observe actual=0 in
1904 // a CasConflict would be a caller that invented expected=0 without ever
1905 // calling last_changed() — unreachable via the documented API contract.
1906 if let Some(&max_seq) = db.last_change.values().max() {
1907 db.commit_seq = db.commit_seq.max(max_seq);
1908 }
1909 let bytes = db.fs.read(FileId::Wal)?;
1910 let (records, valid_len) = decode_all(&bytes);
1911 // The valid prefix is replayed either way; `repair_wal` only decides
1912 // whether the truncation is written back. A reader that races a live
1913 // appender must not persist a truncation the writer never asked for.
1914 if valid_len < bytes.len() && repair_wal {
1915 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1916 }
1917 // WAL-present path: build indexes eagerly BEFORE replay so that the
1918 // first replayed record does not trigger the lazy-init guard (which
1919 // would call reindex_all_load_ivf on an empty graph, defeating the
1920 // point of restoring IVF/HNSW blobs from the snapshot).
1921 if !records.is_empty() {
1922 db.ensure_v8_base_sections_loaded();
1923 trace_open!("lazy sections loaded (WAL path)", _t0);
1924 }
1925 let replayed = db.apply_frames(records)?;
1926 // The cursor sits at the end of the valid prefix, not the end of the
1927 // file: a torn or still-being-written tail is unconsumed by definition
1928 // and stays visible to `is_stale` until it decodes.
1929 db.wal_consumed = valid_len as u64;
1930 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
1931 trace_open!("wal replay done", _t0);
1932 // Rebuild view values after WAL replay only when there is no V8 base.
1933 // With a V8 base, view values are correct in the snapshot and are updated
1934 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1935 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1936 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1937 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1938 // base value).
1939 if db.base.is_none() {
1940 let topo_view = TopologyView::owned(&db.topo);
1941 db.view_store
1942 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1943 }
1944 // Rebuild full-text index after WAL replay. Corrects drift from
1945 // per-record incremental apply during replay.
1946 db.fulltext.rebuild_all(
1947 &db.ids,
1948 &db.labels,
1949 &db.syms,
1950 build_props_view(&db.props, &db.base),
1951 );
1952 db.prop_index.rebuild_all(
1953 &db.ids,
1954 &db.labels,
1955 &db.syms,
1956 build_props_view(&db.props, &db.base),
1957 );
1958 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1959 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1960 db.roles = Self::load_roles_from_fs(&db.fs)?;
1961 // Capture the initial MVCC fold so reader() is ready immediately.
1962 db.fold_now();
1963 trace_open!("open_with complete", _t0);
1964 Ok(replayed)
1965 }
1966
1967 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
1968 /// replay does — same `apply` calls, same per-frame delta drain, same
1969 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
1970 /// edges appear identically whether a frame arrives at open, from a local
1971 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
1972 ///
1973 /// Returns the number of frames applied.
1974 ///
1975 /// Deltas are drained and discarded per frame: replayed frames are already
1976 /// reflected on disk, so they are not news to a subscriber, and draining
1977 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
1978 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
1979 if records.is_empty() {
1980 return Ok(0);
1981 }
1982 // Materialize any state retained in the mmap base before the first
1983 // frame lands, so a replayed record cannot trip the lazy-init guard and
1984 // rebuild indexes from an empty graph. Both calls are idempotent.
1985 self.ensure_v8_base_sections_loaded();
1986 self.engine.consume_retained_state_eager(
1987 &self.ids,
1988 &self.syms,
1989 &self.labels,
1990 build_props_view(&self.props, &self.base),
1991 );
1992 let applied = records.len();
1993 for rec in records {
1994 self.apply(&rec)?;
1995 let _ = self.engine.drain_deltas();
1996 // Track commit_seq during replay so last_change entries are
1997 // consistent with the seqs assigned by log_then_apply_with on
1998 // subsequent live commits. After N replayed frames, commit_seq=N;
1999 // live commits begin at N+1.
2000 self.commit_seq += 1;
2001 let replay_seq = self.commit_seq;
2002 self.update_last_change_from_rec(&rec, replay_seq);
2003 }
2004 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2005 // this assert catches the regression in debug builds immediately.
2006 debug_assert_eq!(
2007 self.engine.pending_delta_count(),
2008 0,
2009 "pending_deltas non-empty after replay — \
2010 per-frame drain must run inside the loop to keep memory O(1)"
2011 );
2012 // T2 note: the per-frame drain IS the suppression seam for replay.
2013 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2014 // replaying subscribers; the mechanism is already in place.
2015 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2016 Ok(applied)
2017 }
2018
2019 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2020 //
2021 // mushroomdb is many-readers / one-writer across processes. Writers take an
2022 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2023 // Every handle tracks how much of the WAL it has consumed, so it can pick
2024 // up another process's commits by decoding only the new tail rather than
2025 // reopening. See `docs/site/concurrency.md`.
2026
2027 /// Whether the store on disk has moved ahead of (or out from under) this
2028 /// handle's in-memory state.
2029 ///
2030 /// True when the WAL's length differs from this handle's cursor — another
2031 /// process committed, or is mid-append — or when the snapshot file's
2032 /// identity changed. Costs two metadata lookups and reads no file contents,
2033 /// so it is cheap enough for a read path to call.
2034 ///
2035 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2036 /// pinned to one commit and later commits are deliberately invisible to it.
2037 pub fn is_stale(&self) -> Result<bool> {
2038 if self.pinned {
2039 return Ok(false);
2040 }
2041 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2042 return Ok(true);
2043 }
2044 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2045 }
2046
2047 /// Bring this handle up to date with every commit other processes have made,
2048 /// and return how many frames were applied.
2049 ///
2050 /// The WAL tail is decoded from this handle's cursor and applied through the
2051 /// same path the open replay uses, so rules fire and derived edges appear
2052 /// exactly as they would on a fresh open. Interners, id maps and indexes
2053 /// stay valid for the same reason.
2054 ///
2055 /// A frame another process is still writing is left alone: a trailing
2056 /// partial frame is a wait, not a corruption, and the handle stays stale
2057 /// until that frame is complete. Nothing is written to disk, so a read-only
2058 /// handle can refresh freely.
2059 ///
2060 /// When the snapshot file's identity changed, or the WAL is shorter than
2061 /// this handle's cursor, the WAL no longer continues our state — another
2062 /// process snapshotted or archived. The handle is then rebuilt from disk
2063 /// with the options it was opened with, and the return value is the number
2064 /// of frames in the new WAL.
2065 ///
2066 /// Returns 0 for an as-of view, which never follows later commits.
2067 ///
2068 /// # Errors
2069 ///
2070 /// An error here leaves the handle **degraded**: it got partway through
2071 /// applying the tail, or partway through a reload, so its in-memory state
2072 /// no longer matches any point on disk. Further mutations are refused and
2073 /// the handle must be reopened. Nothing on disk was damaged — the store
2074 /// itself is fine, and a fresh open recovers it.
2075 pub fn refresh(&mut self) -> Result<u64> {
2076 if self.pinned {
2077 return Ok(0);
2078 }
2079 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2080 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2081 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2082 // The WAL no longer continues our state: rebuild from disk. State
2083 // is cleared first, so a failed load leaves an empty handle — mark
2084 // it degraded rather than let a caller read an empty graph as if
2085 // it were the store's contents.
2086 self.reset_for_reload();
2087 return match self.load_from_disk(LoadOrigin::Reload) {
2088 Ok(frames) => Ok(frames as u64),
2089 Err(e) => {
2090 self.degraded = true;
2091 Err(e)
2092 }
2093 };
2094 }
2095 if wal_len == self.wal_consumed {
2096 return Ok(0);
2097 }
2098 let tail = self
2099 .fs
2100 .read_range(FileId::Wal, self.wal_consumed)
2101 .map_err(GraphError::Io)?;
2102 let (records, valid_len) = decode_all(&tail);
2103 let applied = match self.apply_frames(records) {
2104 Ok(n) => n,
2105 Err(e) => {
2106 // Some frames landed and some did not, and the cursor cannot
2107 // say how many. Advancing it would skip the rest; leaving it
2108 // would replay what already applied. Neither is recoverable in
2109 // place, so refuse further writes and require a reopen.
2110 self.degraded = true;
2111 return Err(e);
2112 }
2113 };
2114 // Advance by the bytes actually decoded, never by the file length: an
2115 // incomplete trailing frame stays unconsumed for the next refresh.
2116 self.wal_consumed += valid_len as u64;
2117 if applied > 0 {
2118 // Peer commits must reach `reader()` snapshots taken from here on.
2119 // A full fold is what open does; refresh does not build per-commit
2120 // deltas, so there is nothing cheaper that stays correct.
2121 self.fold_now();
2122 }
2123 Ok(applied as u64)
2124 }
2125
2126 /// Byte offset of the WAL prefix this handle has applied.
2127 ///
2128 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2129 #[doc(hidden)]
2130 pub fn wal_consumed(&self) -> u64 {
2131 self.wal_consumed
2132 }
2133
2134 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2135 /// failed group off the tail, so the cursor still describes the file.
2136 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2137 self.wal_consumed = len;
2138 }
2139
2140 /// One non-blocking attempt at the cross-process write lock.
2141 ///
2142 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2143 /// in-process write guard. That ordering is what keeps a busy peer in
2144 /// another process from stalling this process's readers.
2145 ///
2146 /// A handle that owns the lock for its lifetime always succeeds.
2147 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2148 if self.holds_lifetime_lock {
2149 return Ok(true);
2150 }
2151 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2152 }
2153
2154 /// Poll for the cross-process write lock until `wait` elapses.
2155 ///
2156 /// One attempt is always made, so a zero wait is a single try. Returns
2157 /// `false` when the lock is still held elsewhere at the deadline; nothing
2158 /// has been written and retrying later is safe.
2159 ///
2160 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2161 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2162 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2163 /// that it holds no in-process guard while it waits.
2164 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2165 let deadline = std::time::Instant::now() + wait;
2166 loop {
2167 if self.try_cross_process_lock()? {
2168 return Ok(true);
2169 }
2170 let now = std::time::Instant::now();
2171 if now >= deadline {
2172 return Ok(false);
2173 }
2174 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2175 }
2176 }
2177
2178 /// Open a cross-process write scope, given the outcome of an already-made
2179 /// lock attempt.
2180 ///
2181 /// The caller polls for the lock first — outside any in-process guard — and
2182 /// passes what it got. On success this refreshes, so the writes about to
2183 /// happen land on top of every other process's commits. On failure the
2184 /// handle refuses WAL-appending mutations and `snapshot()` with
2185 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2186 /// closes the scope, so a caller holding a guard cannot write behind
2187 /// another process's back.
2188 ///
2189 /// A handle that already owns the lock for its lifetime skips the refresh:
2190 /// no other process can have written, so there is nothing to pick up.
2191 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2192 self.lock_denied = !acquired;
2193 if !acquired || self.holds_lifetime_lock {
2194 return Ok(());
2195 }
2196 if let Err(e) = self.refresh() {
2197 // Do not hold a lock we cannot use: release it and let the caller
2198 // see the underlying failure.
2199 let _ = self.fs.unlock();
2200 self.lock_denied = true;
2201 return Err(e);
2202 }
2203 Ok(())
2204 }
2205
2206 /// Close a cross-process write scope opened by
2207 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2208 /// clear the Busy latch. Safe to call when the lock was never taken.
2209 pub(crate) fn end_write_lock(&mut self) {
2210 self.lock_denied = false;
2211 if !self.holds_lifetime_lock {
2212 // Releasing a lock we do not hold is a no-op; a failure to release
2213 // is reported by the OS closing the descriptor at handle drop.
2214 let _ = self.fs.unlock();
2215 }
2216 }
2217
2218 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2219 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2220 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2221 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2222 /// Restore all persisted state from a decoded snapshot. Shared by
2223 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2224 fn restore_snapshot_state(
2225 &mut self,
2226 state: core_storage::snapshot::SnapshotState,
2227 ) -> Result<()> {
2228 self.ids = state.ids;
2229 self.syms = state.syms;
2230 self.topo = state.topo;
2231 self.props = state.props;
2232 self.labels = state.labels;
2233 self.edge_props = state.edge_props;
2234 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2235 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2236 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2237 // open successfully and panic later in `NodeRef::label()` or
2238 // `neighborhood_masked()`. Catching it here turns those into typed
2239 // `GraphError::Corrupt` at open time.
2240 {
2241 let ids_len = self.ids.len();
2242 if self.labels.len() != ids_len {
2243 return Err(GraphError::Corrupt {
2244 detail: format!(
2245 "snapshot: labels vec has {} entries but id table has {} total slots",
2246 self.labels.len(),
2247 ids_len,
2248 ),
2249 });
2250 }
2251 let syms_len = self.syms.len() as u32;
2252 for (i, &sym) in self.labels.iter().enumerate() {
2253 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2254 if sym == u32::MAX {
2255 if !is_tombstoned {
2256 return Err(GraphError::Corrupt {
2257 detail: format!(
2258 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2259 ),
2260 });
2261 }
2262 } else if sym >= syms_len {
2263 return Err(GraphError::Corrupt {
2264 detail: format!(
2265 "snapshot: label at id slot {i} references sym {sym} \
2266 which is out of interner range ({syms_len})"
2267 ),
2268 });
2269 }
2270 }
2271 }
2272 let defs: Vec<RuleDef> = state
2273 .rule_defs
2274 .iter()
2275 .map(|b| {
2276 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2277 detail: format!("snapshot rule_def deserialize: {e}"),
2278 })
2279 })
2280 .collect::<Result<Vec<_>>>()?;
2281 self.engine =
2282 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2283 // Candidate indexes are rebuilt lazily on the first mutation (see
2284 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2285 // snapshot are retained without deserializing so that:
2286 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2287 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2288 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
2289 // - WAL-present: open_with calls consume_retained_state_eager before
2290 // replay so HNSW/IVF are live before any record fires the hooks.
2291 let ivf_bytes = if state.ivf_state.is_empty() {
2292 Vec::new()
2293 } else {
2294 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2295 };
2296 // Store blobs without eagerly deserializing them.
2297 self.engine
2298 .store_snapshot_state(state.hnsw_state, ivf_bytes);
2299 // Restore view defs from snapshot (V5).
2300 // The ColumnStore already contains view values from the snapshot;
2301 // use restore_view (no collision check, no backfill) so the store
2302 // is aware of the definitions. rebuild_all runs after WAL replay.
2303 for def_bytes in &state.view_defs {
2304 let def: ViewDef =
2305 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2306 detail: format!("snapshot view_def deserialize: {e}"),
2307 })?;
2308 self.view_store
2309 .restore_view(def)
2310 .map_err(|e| GraphError::Corrupt {
2311 detail: format!("snapshot view restore: {e}"),
2312 })?;
2313 }
2314 Ok(())
2315 }
2316
2317 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2318 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2319 ///
2320 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2321 /// deserialization and view rebuild have access to all column data.
2322 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2323 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2324 detail: format!("v8: ids section: {e:?}"),
2325 })?);
2326 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2327 detail: format!("v8: syms section: {e:?}"),
2328 })?);
2329
2330 // C1: self.props is left as an empty overlay. Column reads go through
2331 // props_view() (ColumnsView::with_base), which consults the archived base
2332 // section zero-copy. This avoids the O(columns) heap copy at every open.
2333
2334 // self.topo deliberately left as Topology::new() — overlay path.
2335
2336 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2337 detail: format!("v8: meta section: {e:?}"),
2338 })?)
2339 .map_err(|e| GraphError::Corrupt {
2340 detail: format!("v8: meta decode: {e:?}"),
2341 })?;
2342 self.labels = meta.labels;
2343 // Cross-section label integrity: labels must cover every id slot (live
2344 // and tombstoned), every non-sentinel sym must be within the interner's
2345 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2346 // sentinel label. Without this check, a crafted snapshot where the META
2347 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2348 // sym ids, or a sentinel label on a live node, would open successfully
2349 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2350 // related read paths. Catching the inconsistency here converts those
2351 // panics into typed `GraphError::Corrupt` at open time.
2352 {
2353 let ids_len = self.ids.len();
2354 if self.labels.len() != ids_len {
2355 return Err(GraphError::Corrupt {
2356 detail: format!(
2357 "v8: labels section has {} entries but id table has {} total slots",
2358 self.labels.len(),
2359 ids_len,
2360 ),
2361 });
2362 }
2363 let syms_len = self.syms.len() as u32;
2364 for (i, &sym) in self.labels.iter().enumerate() {
2365 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2366 if sym == u32::MAX {
2367 // Sentinel is only valid for tombstoned slots.
2368 if !is_tombstoned {
2369 return Err(GraphError::Corrupt {
2370 detail: format!(
2371 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2372 ),
2373 });
2374 }
2375 } else if sym >= syms_len {
2376 return Err(GraphError::Corrupt {
2377 detail: format!(
2378 "v8: label at id slot {i} references sym {sym} \
2379 which is out of interner range ({syms_len})"
2380 ),
2381 });
2382 }
2383 }
2384 }
2385 // C3: self.edge_props stays as an empty overlay. Reads go through
2386 // edge_props_view() which consults the mmap'd base section zero-copy
2387 // via EdgePropsView::with_base. No heap decode at open time.
2388
2389 // Restore rule engine.
2390 let (rule_def_bytes, rule_tripped, rule_fires) =
2391 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2392 GraphError::Corrupt {
2393 detail: format!("v8: rules_meta section: {e:?}"),
2394 }
2395 })?);
2396 let defs: Vec<RuleDef> = rule_def_bytes
2397 .iter()
2398 .map(|b| {
2399 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2400 detail: format!("v8: rule_def deserialize: {e}"),
2401 })
2402 })
2403 .collect::<Result<Vec<_>>>()?;
2404 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2405 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2406 // `ensure_v8_base_sections_loaded` reads them on first use from
2407 // `self.base` (set by the caller immediately after this returns).
2408 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2409
2410 // Restore view definitions.
2411 let view_defs =
2412 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2413 detail: format!("v8: views section: {e:?}"),
2414 })?);
2415 for def_bytes in &view_defs {
2416 let def: ViewDef =
2417 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2418 detail: format!("v8: view_def deserialize: {e}"),
2419 })?;
2420 self.view_store
2421 .restore_view(def)
2422 .map_err(|e| GraphError::Corrupt {
2423 detail: format!("v8: view restore: {e}"),
2424 })?;
2425 }
2426 // Load the last-change map from section 11 (small section; load eagerly).
2427 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2428 // in that case and `decode_last_change_bytes` returns an empty map.
2429 let last_change_raw = mapped
2430 .last_change_bytes()
2431 .map_err(|e| GraphError::Corrupt {
2432 detail: format!("v8: last_change section: {e:?}"),
2433 })?;
2434 self.last_change = decode_last_change_bytes(last_change_raw);
2435
2436 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2437 // the file. Pure bounds check — no bytes read, no page faults triggered.
2438 // Catches truncated snapshots at open time before the lazy deferred reads.
2439 mapped.validate_section_bounds().map_err(|e| match e {
2440 GraphError::Corrupt { detail } => GraphError::Corrupt {
2441 detail: format!("v8: section bounds: {detail}"),
2442 },
2443 other => other,
2444 })?;
2445 Ok(())
2446 }
2447
2448 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2449 /// engine's retained fields on first call. Subsequent calls are a no-op
2450 /// (AtomicBool fast-path).
2451 ///
2452 /// Must be called before any code path that reads or mutates engine
2453 /// provenance, HNSW, or IVF state:
2454 /// - WAL replay (before `consume_retained_state_eager`)
2455 /// - First mutation (`log_then_apply_with`)
2456 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2457 /// - Snapshot (`snapshot_with`)
2458 ///
2459 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2460 fn ensure_v8_base_sections_loaded(&self) {
2461 use std::sync::atomic::Ordering;
2462 if self.v8_sections_loaded.load(Ordering::Acquire) {
2463 return;
2464 }
2465 let _guard = self
2466 .v8_sections_mutex
2467 .lock()
2468 .expect("v8 sections mutex poisoned");
2469 if self.v8_sections_loaded.load(Ordering::Acquire) {
2470 return; // another caller populated while we waited
2471 }
2472 let _t = std::time::Instant::now();
2473 if let Some(base) = &self.base {
2474 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2475 // Bounds are already validated at open time (restore_v8_base →
2476 // validate_section_bounds) — unreachable post-validate_section_bounds;
2477 // unwrap_or_default is a safety belt against impossible errors.
2478 let prov_bytes = base
2479 .provenance_raw_bytes()
2480 .map(|b| b.to_vec())
2481 .unwrap_or_default();
2482 self.engine.store_provenance_bytes(prov_bytes);
2483 // HNSW: decode rkyv blobs into owned map.
2484 let hnsw_state = base
2485 .hnsw_section()
2486 .map(archived_hnsw_to_owned)
2487 .unwrap_or_default();
2488 // IVF: raw bincode bytes; deserialized on first mutation/query.
2489 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2490 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2491 }
2492 self.v8_sections_loaded.store(true, Ordering::Release);
2493 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2494 eprintln!(
2495 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2496 _t.elapsed()
2497 );
2498 }
2499 }
2500
2501 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2502 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2503 /// the full merged topology without going through `self.view()`.
2504 fn topo_view(&self) -> TopologyView<'_> {
2505 match self.base {
2506 None => TopologyView::owned(&self.topo),
2507 Some(ref base) => {
2508 // SAFETY: base lives as long as self; section bounds validated at open.
2509 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2510 let archived = base
2511 .topology()
2512 .expect("base topology section bounds validated at open");
2513 TopologyView::with_base(&self.topo, archived)
2514 }
2515 }
2516 }
2517
2518 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2519 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2520 /// overlay first, then fall through to the archived base section zero-copy.
2521 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2522 match self.base {
2523 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2524 Some(ref base) => {
2525 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2526 let archived = base
2527 .columns()
2528 .expect("base columns section bounds validated at open");
2529 core_storage::v8::seam::ColumnsView::with_base_cached(
2530 &self.props,
2531 archived,
2532 base.mixed_cache(),
2533 )
2534 }
2535 }
2536 }
2537
2538 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2539 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2540 ///
2541 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2542 /// through to the archived base section zero-copy. Tombstones in the
2543 /// overlay mask deleted-from-base entries.
2544 fn edge_props_view(&self) -> EdgePropsView<'_> {
2545 match self.base {
2546 None => EdgePropsView::owned(&self.edge_props),
2547 Some(ref base) => {
2548 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2549 let archived = base
2550 .edge_props_section()
2551 .expect("base edge_props section bounds validated at open");
2552 EdgePropsView::with_base(&self.edge_props, archived)
2553 }
2554 }
2555 }
2556
2557 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2558 // An as-of view never writes and is pinned to one commit: it takes no
2559 // cross-process lock and does not follow later commits.
2560 let mut db = Self::new_empty(
2561 fs,
2562 OpenOptions {
2563 repair_wal: false,
2564 auto_migrate: false,
2565 read_only: true,
2566 },
2567 );
2568 db.pinned = true; // read_only is set after replay, but pinning is immediate
2569 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2570 db.archive_genesis_chain = db.fs.has_genesis_marker();
2571 // Same orphaned-archive cleanup as open_with: floor was written first
2572 // during pruning, so a crash may have left stale archives below floor.
2573 db.cleanup_orphaned_archives()?;
2574 // Collect archive frames (oldest-first) and live WAL frames.
2575 // Archives represent pre-snapshot history; the snapshot captures the
2576 // cumulative state at the time of archiving. Crash-window guarantee:
2577 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2578 // B: crash after rename, before new WAL → archive present, WAL
2579 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2580 // C: crash after new baseline WAL written → normal post-archive.
2581 let archive_ns = db.fs.list_archives()?;
2582 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2583 for n in &archive_ns {
2584 let arc_bytes = db.fs.read_archive(*n)?;
2585 let (arc_frames, _) = decode_all(&arc_bytes);
2586 archive_frames_all.extend(arc_frames);
2587 }
2588 let total_archive_frames = archive_frames_all.len() as u64;
2589
2590 let live_bytes = db.fs.read(FileId::Wal)?;
2591 let (live_records, _valid_len) = decode_all(&live_bytes);
2592 let total_surviving = total_archive_frames + live_records.len() as u64;
2593 // Global total including any pruned history below the horizon floor.
2594 let total = db.wal_horizon_floor + total_surviving;
2595
2596 // Horizon and range check.
2597 if commit < db.wal_horizon_floor {
2598 return Err(GraphError::CommitOutOfRange { commit, total });
2599 }
2600 if commit >= total {
2601 return Err(GraphError::CommitOutOfRange { commit, total });
2602 }
2603
2604 // Local index into surviving frames (0 = first frame of oldest archive).
2605 let local = commit - db.wal_horizon_floor;
2606
2607 if local < total_archive_frames {
2608 // Target commit is in an archive. Correct replay from empty state
2609 // is only possible when the archive chain is an uninterrupted
2610 // genesis chain (first archive taken from a fresh store, no prior
2611 // WAL truncation) and no archives have been pruned (floor == 0).
2612 //
2613 // If either condition is violated the prefix needed to reconstruct
2614 // the requested state is gone; refuse rather than return wrong data.
2615 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2616 return Err(GraphError::CommitOutOfRange { commit, total });
2617 }
2618 // Replay all archive frames up to and including the target commit
2619 // from an empty database state. Archives must be replayed in order
2620 // so that dense-id intern tables are built up correctly.
2621 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2622 db.apply(&rec)?;
2623 let _ = db.engine.drain_deltas();
2624 }
2625 } else {
2626 // Target commit is in the live WAL: load snapshot as base, then
2627 // replay the needed live WAL prefix.
2628 //
2629 // Base state: a truncating snapshot (wal_truncated=true) compacts
2630 // all pre-truncation / pre-archive commits. Dense-id records in
2631 // the live WAL reference ids/interns that the snapshot provides.
2632 // Peek 6 bytes (same pattern as open_with).
2633 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2634 let is_v8 = snap_header.len() >= 6
2635 && &snap_header[0..4] == b"GDB1"
2636 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2637 == core_storage::snapshot::VERSION_8;
2638 if is_v8 {
2639 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2640 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2641 GraphError::Corrupt {
2642 detail: format!("v8: open_at mmap: {e:?}"),
2643 }
2644 })?;
2645 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2646 } else {
2647 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2648 core_storage::snapshot::decode(&snap_bytes)?
2649 };
2650 if let Some(state) = state {
2651 if state.wal_truncated {
2652 db.restore_snapshot_state(state)?;
2653 }
2654 }
2655 } else if !snap_header.is_empty() {
2656 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2657 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2658 if state.wal_truncated {
2659 db.restore_snapshot_state(state)?;
2660 }
2661 }
2662 }
2663 // else: snap_header empty = no snapshot file.
2664 let live_local = local - total_archive_frames;
2665 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2666 db.apply(&rec)?;
2667 let _ = db.engine.drain_deltas();
2668 }
2669 }
2670 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2671 // post-loop assert in open_with.
2672 debug_assert_eq!(
2673 db.engine.pending_delta_count(),
2674 0,
2675 "pending_deltas non-empty after open_at replay — \
2676 per-frame drain must run inside the loop to keep memory O(1)"
2677 );
2678 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2679 // Rebuild view values after WAL replay so derived-edge-driven views
2680 // reflect the as-of state. open_at always uses the legacy path (no V8
2681 // base), so topo_view is always owned.
2682 {
2683 let topo_view = TopologyView::owned(&db.topo);
2684 db.view_store
2685 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2686 }
2687 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2688 db.fulltext.rebuild_all(
2689 &db.ids,
2690 &db.labels,
2691 &db.syms,
2692 build_props_view(&db.props, &db.base),
2693 );
2694 db.prop_index.rebuild_all(
2695 &db.ids,
2696 &db.labels,
2697 &db.syms,
2698 build_props_view(&db.props, &db.base),
2699 );
2700 // Load roles sidecar (current roles, not point-in-time).
2701 db.roles = Self::load_roles_from_fs(&db.fs)?;
2702 db.read_only = true;
2703 db.total_wal_commits = total;
2704 // Capture initial fold so reader() is immediately usable.
2705 db.fold_now();
2706 Ok(db)
2707 }
2708
2709 /// Whether this instance is a read-only as-of view.
2710 pub fn is_read_only(&self) -> bool {
2711 self.read_only
2712 }
2713
2714 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2715
2716 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2717 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2718 /// the end of `open_with` / `open_at_with` to prime the reader.
2719 fn fold_now(&mut self) {
2720 let frozen = crate::reader::FrozenOverlay {
2721 ids: self.ids.clone(),
2722 syms: self.syms.clone(),
2723 topo: self.topo.clone(),
2724 props: self.props.clone(),
2725 labels: self.labels.clone(),
2726 edge_props: self.edge_props.clone(),
2727 roles: self.roles.clone(),
2728 fulltext: self.fulltext.clone(),
2729 };
2730 self.fold_overlay = Some(Arc::new(frozen));
2731 self.delta_tail.clear();
2732 self.commits_since_fold = 0;
2733 }
2734
2735 /// Capture a lock-free reader snapshot of the current db state.
2736 ///
2737 /// The read lock is held only for the duration of this call (to clone a
2738 /// handful of `Arc` handles). Subsequent query operations run without any
2739 /// lock.
2740 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2741 crate::reader::ReaderSnapshot::new(
2742 self.fold_overlay
2743 .clone()
2744 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2745 self.base.clone(),
2746 self.delta_tail.clone(),
2747 )
2748 }
2749
2750 /// Total number of WAL commits at the time [`open_at`] was called.
2751 /// Returns 0 for normal (non-as-of) instances.
2752 pub fn total_wal_commits(&self) -> u64 {
2753 self.total_wal_commits
2754 }
2755
2756 /// Apply a record to in-memory state. Used by both live writes and replay,
2757 /// so replay is definitionally identical to the original execution.
2758 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2759 match rec {
2760 WalRecord::InsertNode { label, key, props } => {
2761 let id = self.ids.try_insert(key)?;
2762 let sym = self.syms.intern(label);
2763 if self.labels.len() <= id as usize {
2764 // gap slots are sentinels, never valid label symbols
2765 self.labels.resize(id as usize + 1, u32::MAX);
2766 }
2767 self.labels[id as usize] = sym;
2768 for (field, value) in props {
2769 self.props.set(id, field, value.clone());
2770 }
2771 // Initialize view values for the new node before the engine runs so
2772 // delta-based increments start from a known zero baseline.
2773 self.view_store
2774 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2775 // Fire rules for the newly inserted node.
2776 let cursor = self.engine.pending_delta_count();
2777 let mut eng = std::mem::take(&mut self.engine);
2778 {
2779 let mut gm = make_graph_mut(
2780 &self.ids,
2781 &mut self.syms,
2782 &self.labels,
2783 build_props_view(&self.props, &self.base),
2784 &mut self.topo,
2785 &self.base,
2786 &mut self.edge_props,
2787 );
2788 eng.on_node_changed(id, None, &mut gm);
2789 }
2790 self.engine = eng;
2791 // Process derived-edge deltas for view maintenance.
2792 // Fast path: skip the O(delta_count) allocation when no views exist.
2793 if !self.view_store.is_empty() {
2794 #[cfg(test)]
2795 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2796 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2797 for d in &new_deltas {
2798 self.view_store.on_edge_changed(
2799 d.etype_sym,
2800 d.src_id,
2801 d.dst_id,
2802 d.fired,
2803 &mut self.props,
2804 &build_topo_view(&self.topo, &self.base),
2805 &self.ids,
2806 &self.syms,
2807 &self.labels,
2808 self.base.as_ref().map(|b| {
2809 b.columns()
2810 .expect("base columns section bounds validated at open")
2811 }),
2812 );
2813 }
2814 }
2815 // Full-text index maintenance: index enabled fields for this label.
2816 if self.fulltext.has_label(label) {
2817 for (field, value) in props {
2818 if self.fulltext.is_enabled(label, field) {
2819 self.fulltext.add_tokens(id, field, value);
2820 }
2821 }
2822 }
2823 // Property (equality) index maintenance.
2824 if self.prop_index.has_label(label) {
2825 for (field, value) in props {
2826 self.prop_index.set(label, field, id, value);
2827 }
2828 }
2829 }
2830 WalRecord::InsertEdge {
2831 edge_type,
2832 src_key,
2833 dst_key,
2834 } => {
2835 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2836 detail: format!("wal replay references unknown key {src_key}"),
2837 })?;
2838 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2839 detail: format!("wal replay references unknown key {dst_key}"),
2840 })?;
2841 let etype = self.syms.intern(edge_type);
2842 // Skip if the edge is already visible in the merged base+overlay
2843 // view. This keeps WAL replay idempotent when the WAL contains
2844 // pre-snapshot records that are already encoded in a V8 base
2845 // (keep_wal=true opens and crash-before-truncation scenarios).
2846 if self.base.is_some()
2847 && self
2848 .topo_view()
2849 .neighbors(etype, Direction::Out, src)
2850 .contains(&dst)
2851 {
2852 return Ok(());
2853 }
2854 self.topo.add_edge(etype, src, dst);
2855 // View maintenance for manual edge insert.
2856 self.view_store.on_edge_changed(
2857 etype,
2858 src,
2859 dst,
2860 true,
2861 &mut self.props,
2862 &build_topo_view(&self.topo, &self.base),
2863 &self.ids,
2864 &self.syms,
2865 &self.labels,
2866 self.base.as_ref().map(|b| {
2867 b.columns()
2868 .expect("base columns section bounds validated at open")
2869 }),
2870 );
2871 // Rule engine: via-hop rules must update when user edges change.
2872 let cursor = self.engine.pending_delta_count();
2873 let mut eng = std::mem::take(&mut self.engine);
2874 {
2875 let mut gm = make_graph_mut(
2876 &self.ids,
2877 &mut self.syms,
2878 &self.labels,
2879 build_props_view(&self.props, &self.base),
2880 &mut self.topo,
2881 &self.base,
2882 &mut self.edge_props,
2883 );
2884 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2885 }
2886 self.engine = eng;
2887 if !self.view_store.is_empty() {
2888 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2889 for d in &new_deltas {
2890 self.view_store.on_edge_changed(
2891 d.etype_sym,
2892 d.src_id,
2893 d.dst_id,
2894 d.fired,
2895 &mut self.props,
2896 &build_topo_view(&self.topo, &self.base),
2897 &self.ids,
2898 &self.syms,
2899 &self.labels,
2900 self.base.as_ref().map(|b| {
2901 b.columns()
2902 .expect("base columns section bounds validated at open")
2903 }),
2904 );
2905 }
2906 }
2907 }
2908 WalRecord::SetProp { key, field, value } => {
2909 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2910 detail: format!("wal replay references unknown key {key}"),
2911 })?;
2912 let old_value = build_props_view(&self.props, &self.base)
2913 .get(id, field)
2914 .map(|vr| vr.into_value());
2915 self.props.set(id, field, value.clone());
2916 // Fire rules for the changed field.
2917 let cursor = self.engine.pending_delta_count();
2918 let mut eng = std::mem::take(&mut self.engine);
2919 {
2920 let mut gm = make_graph_mut(
2921 &self.ids,
2922 &mut self.syms,
2923 &self.labels,
2924 build_props_view(&self.props, &self.base),
2925 &mut self.topo,
2926 &self.base,
2927 &mut self.edge_props,
2928 );
2929 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2930 }
2931 self.engine = eng;
2932 // Derived-edge deltas → view updates.
2933 if !self.view_store.is_empty() {
2934 #[cfg(test)]
2935 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2936 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2937 for d in &new_deltas {
2938 self.view_store.on_edge_changed(
2939 d.etype_sym,
2940 d.src_id,
2941 d.dst_id,
2942 d.fired,
2943 &mut self.props,
2944 &build_topo_view(&self.topo, &self.base),
2945 &self.ids,
2946 &self.syms,
2947 &self.labels,
2948 self.base.as_ref().map(|b| {
2949 b.columns()
2950 .expect("base columns section bounds validated at open")
2951 }),
2952 );
2953 }
2954 }
2955 // Neighbor-aggregate views that read `field` must also update.
2956 self.view_store.on_prop_changed(
2957 id,
2958 field,
2959 &mut self.props,
2960 &build_topo_view(&self.topo, &self.base),
2961 &self.ids,
2962 &self.syms,
2963 &self.labels,
2964 self.base.as_ref().map(|b| {
2965 b.columns()
2966 .expect("base columns section bounds validated at open")
2967 }),
2968 );
2969 // Full-text index maintenance: update tokens for this field if indexed.
2970 if self.fulltext.field_indexed(field) {
2971 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2972 if sym == u32::MAX {
2973 None
2974 } else {
2975 self.syms.resolve(sym)
2976 }
2977 });
2978 if let Some(label) = label_opt {
2979 if self.fulltext.is_enabled(label, field) {
2980 self.fulltext.remove_node_field(id, field);
2981 self.fulltext.add_tokens(id, field, value);
2982 }
2983 }
2984 }
2985 // Property (equality) index maintenance: re-key this node's value.
2986 if self.prop_index.field_indexed(field) {
2987 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2988 if sym == u32::MAX {
2989 None
2990 } else {
2991 self.syms.resolve(sym)
2992 }
2993 });
2994 if let Some(label) = label_opt {
2995 self.prop_index.set(label, field, id, value);
2996 }
2997 }
2998 }
2999 WalRecord::Intern { id, text } => {
3000 if let Some(existing) = self.syms.get(text) {
3001 if existing != *id {
3002 return Err(GraphError::Corrupt {
3003 detail: format!(
3004 "wal intern mismatch for {text:?}: have {existing}, record {id}"
3005 ),
3006 });
3007 }
3008 } else {
3009 let got = self.syms.intern(text);
3010 if got != *id {
3011 return Err(GraphError::Corrupt {
3012 detail: format!(
3013 "wal intern assigned {got} for {text:?}, record wanted {id}"
3014 ),
3015 });
3016 }
3017 }
3018 }
3019 WalRecord::InsertNodeId { label, key, props } => {
3020 let id = self.ids.try_insert(key)?;
3021 if self.labels.len() <= id as usize {
3022 self.labels.resize(id as usize + 1, u32::MAX);
3023 }
3024 self.labels[id as usize] = *label;
3025 let label_str = self
3026 .syms
3027 .resolve(*label)
3028 .ok_or_else(|| GraphError::Corrupt {
3029 detail: format!("wal InsertNodeId unknown label intern {label}"),
3030 })?
3031 .to_string();
3032 for (field_sym, value) in props {
3033 let field =
3034 self.syms
3035 .resolve(*field_sym)
3036 .ok_or_else(|| GraphError::Corrupt {
3037 detail: format!(
3038 "wal InsertNodeId unknown field intern {field_sym}"
3039 ),
3040 })?;
3041 self.props.set(id, field, value.clone());
3042 }
3043 self.view_store
3044 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3045 let cursor = self.engine.pending_delta_count();
3046 let mut eng = std::mem::take(&mut self.engine);
3047 {
3048 let mut gm = make_graph_mut(
3049 &self.ids,
3050 &mut self.syms,
3051 &self.labels,
3052 build_props_view(&self.props, &self.base),
3053 &mut self.topo,
3054 &self.base,
3055 &mut self.edge_props,
3056 );
3057 eng.on_node_changed(id, None, &mut gm);
3058 }
3059 self.engine = eng;
3060 if !self.view_store.is_empty() {
3061 #[cfg(test)]
3062 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3063 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3064 for d in &new_deltas {
3065 self.view_store.on_edge_changed(
3066 d.etype_sym,
3067 d.src_id,
3068 d.dst_id,
3069 d.fired,
3070 &mut self.props,
3071 &build_topo_view(&self.topo, &self.base),
3072 &self.ids,
3073 &self.syms,
3074 &self.labels,
3075 self.base.as_ref().map(|b| {
3076 b.columns()
3077 .expect("base columns section bounds validated at open")
3078 }),
3079 );
3080 }
3081 }
3082 if self.fulltext.has_label(&label_str) {
3083 for (field_sym, value) in props {
3084 let Some(field) = self.syms.resolve(*field_sym) else {
3085 continue;
3086 };
3087 if self.fulltext.is_enabled(&label_str, field) {
3088 self.fulltext.add_tokens(id, field, value);
3089 }
3090 }
3091 }
3092 if self.prop_index.has_label(&label_str) {
3093 for (field_sym, value) in props {
3094 let Some(field) = self.syms.resolve(*field_sym) else {
3095 continue;
3096 };
3097 self.prop_index.set(&label_str, field, id, value);
3098 }
3099 }
3100 }
3101 WalRecord::InsertEdgeId { etype, src, dst } => {
3102 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3103 // already be tombstoned. Skip rather than attaching edges to
3104 // dead ids (DeleteNode keys the live re-insert, not the old id).
3105 if self.ids.is_tombstoned(*src)
3106 || self.ids.is_tombstoned(*dst)
3107 || self.ids.key_of(*src).is_none()
3108 || self.ids.key_of(*dst).is_none()
3109 {
3110 return Ok(());
3111 }
3112 // Skip if already visible in the merged view (same idempotency
3113 // guard as InsertEdge above: prevents double-counting when
3114 // pre-snapshot WAL records are replayed over a V8 base).
3115 if self.base.is_some()
3116 && self
3117 .topo_view()
3118 .neighbors(*etype, Direction::Out, *src)
3119 .contains(dst)
3120 {
3121 return Ok(());
3122 }
3123 self.topo.add_edge(*etype, *src, *dst);
3124 self.view_store.on_edge_changed(
3125 *etype,
3126 *src,
3127 *dst,
3128 true,
3129 &mut self.props,
3130 &build_topo_view(&self.topo, &self.base),
3131 &self.ids,
3132 &self.syms,
3133 &self.labels,
3134 self.base.as_ref().map(|b| {
3135 b.columns()
3136 .expect("base columns section bounds validated at open")
3137 }),
3138 );
3139 // Rule engine: via-hop rules fire when user via-edges are inserted.
3140 // Resolve etype back to string so on_edge_changed can match rules by name.
3141 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3142 let cursor = self.engine.pending_delta_count();
3143 let mut eng = std::mem::take(&mut self.engine);
3144 {
3145 let mut gm = make_graph_mut(
3146 &self.ids,
3147 &mut self.syms,
3148 &self.labels,
3149 build_props_view(&self.props, &self.base),
3150 &mut self.topo,
3151 &self.base,
3152 &mut self.edge_props,
3153 );
3154 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3155 }
3156 self.engine = eng;
3157 if !self.view_store.is_empty() {
3158 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3159 for d in &new_deltas {
3160 self.view_store.on_edge_changed(
3161 d.etype_sym,
3162 d.src_id,
3163 d.dst_id,
3164 d.fired,
3165 &mut self.props,
3166 &build_topo_view(&self.topo, &self.base),
3167 &self.ids,
3168 &self.syms,
3169 &self.labels,
3170 self.base.as_ref().map(|b| {
3171 b.columns()
3172 .expect("base columns section bounds validated at open")
3173 }),
3174 );
3175 }
3176 }
3177 }
3178 }
3179 WalRecord::SetPropId { id, field, value } => {
3180 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3181 return Ok(());
3182 }
3183 let field_str = self
3184 .syms
3185 .resolve(*field)
3186 .ok_or_else(|| GraphError::Corrupt {
3187 detail: format!("wal SetPropId unknown field intern {field}"),
3188 })?
3189 .to_string();
3190 let old_value = build_props_view(&self.props, &self.base)
3191 .get(*id, &field_str)
3192 .map(|vr| vr.into_value());
3193 self.props.set(*id, &field_str, value.clone());
3194 let cursor = self.engine.pending_delta_count();
3195 let mut eng = std::mem::take(&mut self.engine);
3196 {
3197 let mut gm = make_graph_mut(
3198 &self.ids,
3199 &mut self.syms,
3200 &self.labels,
3201 build_props_view(&self.props, &self.base),
3202 &mut self.topo,
3203 &self.base,
3204 &mut self.edge_props,
3205 );
3206 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3207 }
3208 self.engine = eng;
3209 if !self.view_store.is_empty() {
3210 #[cfg(test)]
3211 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3212 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3213 for d in &new_deltas {
3214 self.view_store.on_edge_changed(
3215 d.etype_sym,
3216 d.src_id,
3217 d.dst_id,
3218 d.fired,
3219 &mut self.props,
3220 &build_topo_view(&self.topo, &self.base),
3221 &self.ids,
3222 &self.syms,
3223 &self.labels,
3224 self.base.as_ref().map(|b| {
3225 b.columns()
3226 .expect("base columns section bounds validated at open")
3227 }),
3228 );
3229 }
3230 }
3231 self.view_store.on_prop_changed(
3232 *id,
3233 &field_str,
3234 &mut self.props,
3235 &build_topo_view(&self.topo, &self.base),
3236 &self.ids,
3237 &self.syms,
3238 &self.labels,
3239 self.base.as_ref().map(|b| {
3240 b.columns()
3241 .expect("base columns section bounds validated at open")
3242 }),
3243 );
3244 if self.fulltext.field_indexed(&field_str) {
3245 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3246 if sym == u32::MAX {
3247 None
3248 } else {
3249 self.syms.resolve(sym)
3250 }
3251 });
3252 if let Some(label) = label_opt {
3253 if self.fulltext.is_enabled(label, &field_str) {
3254 self.fulltext.remove_node_field(*id, &field_str);
3255 self.fulltext.add_tokens(*id, &field_str, value);
3256 }
3257 }
3258 }
3259 if self.prop_index.field_indexed(&field_str) {
3260 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3261 if sym == u32::MAX {
3262 None
3263 } else {
3264 self.syms.resolve(sym)
3265 }
3266 });
3267 if let Some(label) = label_opt {
3268 self.prop_index.set(label, &field_str, *id, value);
3269 }
3270 }
3271 }
3272 WalRecord::CreateRule { def_bytes } => {
3273 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3274 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3275 })?;
3276 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3277 // so the engine already has it; silently skip to avoid a spurious
3278 // RuleInvalid error in the crash window between snapshot write and WAL
3279 // truncation.
3280 if self.engine.rules().any(|r| r.name == def.name) {
3281 return Ok(());
3282 }
3283 let cursor = self.engine.pending_delta_count();
3284 let mut eng = std::mem::take(&mut self.engine);
3285 let result = {
3286 let mut gm = make_graph_mut(
3287 &self.ids,
3288 &mut self.syms,
3289 &self.labels,
3290 build_props_view(&self.props, &self.base),
3291 &mut self.topo,
3292 &self.base,
3293 &mut self.edge_props,
3294 );
3295 eng.create_rule(def, &mut gm)
3296 };
3297 self.engine = eng;
3298 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3299 // Derived-edge fires from backfill → view updates.
3300 // Fast path: skip O(edge_count) allocation when no views exist.
3301 if !self.view_store.is_empty() {
3302 #[cfg(test)]
3303 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3304 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3305 for d in &new_deltas {
3306 self.view_store.on_edge_changed(
3307 d.etype_sym,
3308 d.src_id,
3309 d.dst_id,
3310 d.fired,
3311 &mut self.props,
3312 &build_topo_view(&self.topo, &self.base),
3313 &self.ids,
3314 &self.syms,
3315 &self.labels,
3316 self.base.as_ref().map(|b| {
3317 b.columns()
3318 .expect("base columns section bounds validated at open")
3319 }),
3320 );
3321 }
3322 }
3323 }
3324 WalRecord::DeleteRule { name } => {
3325 // Replay-over-snapshot idempotency: the snapshot already captured the
3326 // post-delete state so the rule is absent; silently skip to avoid a
3327 // spurious RuleNotFound error in the crash window between snapshot write
3328 // and WAL truncation.
3329 if !self.engine.rules().any(|r| r.name == *name) {
3330 return Ok(());
3331 }
3332 let cursor = self.engine.pending_delta_count();
3333 let mut eng = std::mem::take(&mut self.engine);
3334 let result = {
3335 let mut gm = make_graph_mut(
3336 &self.ids,
3337 &mut self.syms,
3338 &self.labels,
3339 build_props_view(&self.props, &self.base),
3340 &mut self.topo,
3341 &self.base,
3342 &mut self.edge_props,
3343 );
3344 eng.delete_rule(name, &mut gm)
3345 };
3346 self.engine = eng;
3347 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3348 // Derived-edge retractions → view updates.
3349 if !self.view_store.is_empty() {
3350 #[cfg(test)]
3351 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3352 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3353 for d in &new_deltas {
3354 self.view_store.on_edge_changed(
3355 d.etype_sym,
3356 d.src_id,
3357 d.dst_id,
3358 d.fired,
3359 &mut self.props,
3360 &build_topo_view(&self.topo, &self.base),
3361 &self.ids,
3362 &self.syms,
3363 &self.labels,
3364 self.base.as_ref().map(|b| {
3365 b.columns()
3366 .expect("base columns section bounds validated at open")
3367 }),
3368 );
3369 }
3370 }
3371 }
3372 WalRecord::RemoveProp { key, field } => {
3373 // Recovery-safe: unknown key or already-absent field is a
3374 // clean no-op. Crash-window replay over a snapshot that
3375 // already applied this record must not Err.
3376 let Some(id) = self.ids.get(key) else {
3377 return Ok(());
3378 };
3379 // Read old value through the seam for rule retraction.
3380 let old = build_props_view(&self.props, &self.base)
3381 .get(id, field)
3382 .map(|vr| vr.into_value());
3383 self.props.remove(id, field);
3384 // If the base still supplies the value after the overlay removal,
3385 // record a tombstone so ColumnsView::get does not resurrect it.
3386 // This covers both the base-only case AND the both-resident case:
3387 // base-only (in_overlay=false): old prop was only in base, remove
3388 // is a no-op on overlay, base still visible → tombstone needed.
3389 // both-resident (in_overlay=true): overlay had v2, base has v1;
3390 // removing overlay uncovers v1 → tombstone needed.
3391 // Idempotent on double-replay: second pass sees the tombstone →
3392 // get() returns None → condition is false → no duplicate tombstone.
3393 if build_props_view(&self.props, &self.base)
3394 .get(id, field)
3395 .is_some()
3396 {
3397 self.props.record_prop_tombstone(id, field);
3398 }
3399 let cursor = self.engine.pending_delta_count();
3400 let mut eng = std::mem::take(&mut self.engine);
3401 {
3402 let mut gm = make_graph_mut(
3403 &self.ids,
3404 &mut self.syms,
3405 &self.labels,
3406 build_props_view(&self.props, &self.base),
3407 &mut self.topo,
3408 &self.base,
3409 &mut self.edge_props,
3410 );
3411 eng.on_node_changed(id, Some((field, old)), &mut gm);
3412 }
3413 self.engine = eng;
3414 // Derived-edge deltas → view updates.
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 self.base.as_ref().map(|b| {
3431 b.columns()
3432 .expect("base columns section bounds validated at open")
3433 }),
3434 );
3435 }
3436 }
3437 // Neighbor-aggregate views that read `field` must also update.
3438 self.view_store.on_prop_changed(
3439 id,
3440 field,
3441 &mut self.props,
3442 &build_topo_view(&self.topo, &self.base),
3443 &self.ids,
3444 &self.syms,
3445 &self.labels,
3446 self.base.as_ref().map(|b| {
3447 b.columns()
3448 .expect("base columns section bounds validated at open")
3449 }),
3450 );
3451 // Full-text index maintenance: remove tokens for this field.
3452 if self.fulltext.field_indexed(field) {
3453 self.fulltext.remove_node_field(id, field);
3454 }
3455 // Property (equality) index maintenance: drop this node's entry.
3456 if self.prop_index.field_indexed(field) {
3457 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3458 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3459 }) {
3460 self.prop_index.remove_node(label, field, id);
3461 }
3462 }
3463 }
3464 WalRecord::DeleteEdge {
3465 edge_type,
3466 src_key,
3467 dst_key,
3468 } => {
3469 // Recovery-safe: unknown keys, unknown etype, or already-
3470 // absent edge is a clean no-op (remove_edge returns false).
3471 let Some(src) = self.ids.get(src_key) else {
3472 return Ok(());
3473 };
3474 let Some(dst) = self.ids.get(dst_key) else {
3475 return Ok(());
3476 };
3477 let Some(etype) = self.syms.get(edge_type) else {
3478 return Ok(());
3479 };
3480 // I3: phantom-tombstone guard. When a V8 base is present, a
3481 // DeleteEdge WAL record for an edge that was already absorbed into
3482 // the new base (i.e. neither in overlay nor in base) must be skipped.
3483 // Without this guard, remove_edge records a tombstone for an edge
3484 // that no longer exists, incorrectly understating edge_count.
3485 if self.base.is_some()
3486 && !self
3487 .topo_view()
3488 .neighbors(etype, core_storage::topology::Direction::Out, src)
3489 .contains(&dst)
3490 {
3491 return Ok(());
3492 }
3493 self.topo.remove_edge(etype, src, dst);
3494 self.edge_props.remove_edge(etype, src, dst);
3495 // View maintenance for manual edge delete (topo already updated above).
3496 self.view_store.on_edge_changed(
3497 etype,
3498 src,
3499 dst,
3500 false,
3501 &mut self.props,
3502 &build_topo_view(&self.topo, &self.base),
3503 &self.ids,
3504 &self.syms,
3505 &self.labels,
3506 self.base.as_ref().map(|b| {
3507 b.columns()
3508 .expect("base columns section bounds validated at open")
3509 }),
3510 );
3511 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3512 let cursor = self.engine.pending_delta_count();
3513 let mut eng = std::mem::take(&mut self.engine);
3514 {
3515 let mut gm = make_graph_mut(
3516 &self.ids,
3517 &mut self.syms,
3518 &self.labels,
3519 build_props_view(&self.props, &self.base),
3520 &mut self.topo,
3521 &self.base,
3522 &mut self.edge_props,
3523 );
3524 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3525 }
3526 self.engine = eng;
3527 if !self.view_store.is_empty() {
3528 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3529 for d in &new_deltas {
3530 self.view_store.on_edge_changed(
3531 d.etype_sym,
3532 d.src_id,
3533 d.dst_id,
3534 d.fired,
3535 &mut self.props,
3536 &build_topo_view(&self.topo, &self.base),
3537 &self.ids,
3538 &self.syms,
3539 &self.labels,
3540 self.base.as_ref().map(|b| {
3541 b.columns()
3542 .expect("base columns section bounds validated at open")
3543 }),
3544 );
3545 }
3546 }
3547 }
3548 WalRecord::DeleteNode { key } => {
3549 // Recovery-safe: already-tombstoned / unknown key is a clean
3550 // no-op. Crash-window replay over a snapshot that already
3551 // applied this record cannot recover the retired id from the
3552 // key (`IdMap::get` is None), so every subsequent step is
3553 // skipped. Each step is independently idempotent if invoked
3554 // twice on a still-live id: retraction is a no-op on empty
3555 // provenance, `remove_edge` returns false, `remove_all` is a
3556 // no-op, `ids.delete` returns None, label sentinel is sticky.
3557 let Some(n) = self.ids.get(key) else {
3558 return Ok(());
3559 };
3560
3561 // (1) Retract derived edges + de-index while props/labels live.
3562 let cursor = self.engine.pending_delta_count();
3563 let mut eng = std::mem::take(&mut self.engine);
3564 {
3565 let mut gm = make_graph_mut(
3566 &self.ids,
3567 &mut self.syms,
3568 &self.labels,
3569 build_props_view(&self.props, &self.base),
3570 &mut self.topo,
3571 &self.base,
3572 &mut self.edge_props,
3573 );
3574 eng.on_node_removed(n, &mut gm);
3575 }
3576 self.engine = eng;
3577 // Derived-edge retractions → view updates for neighbors.
3578 if !self.view_store.is_empty() {
3579 #[cfg(test)]
3580 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3581 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3582 for d in &new_deltas {
3583 self.view_store.on_edge_changed(
3584 d.etype_sym,
3585 d.src_id,
3586 d.dst_id,
3587 d.fired,
3588 &mut self.props,
3589 &build_topo_view(&self.topo, &self.base),
3590 &self.ids,
3591 &self.syms,
3592 &self.labels,
3593 self.base.as_ref().map(|b| {
3594 b.columns()
3595 .expect("base columns section bounds validated at open")
3596 }),
3597 );
3598 }
3599 }
3600
3601 // (2) Sweep ALL remaining edges incident to n, both directions,
3602 // every etype. This cascade is intentionally mask-independent:
3603 // topology integrity requires removing every edge touching the
3604 // deleted node regardless of the caller's visibility scope.
3605 // (The mask limits which nodes a role's read phase can return;
3606 // the WAL delete always executes with full storage authority.)
3607 // Collect then remove so neighbor slices stay valid during
3608 // iteration. Remove from topo first, then call view maintenance
3609 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3610 let etypes: Vec<u32> = self.topo.etypes().collect();
3611 let mut doomed = Vec::new();
3612 for et in &etypes {
3613 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3614 doomed.push((*et, n, dst));
3615 }
3616 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3617 doomed.push((*et, src, n));
3618 }
3619 }
3620 for (et, s, d) in doomed {
3621 self.topo.remove_edge(et, s, d);
3622 self.edge_props.remove_edge(et, s, d);
3623 // View maintenance: n's own view values will be cleared by
3624 // remove_all below; only update surviving neighbors.
3625 self.view_store.on_edge_changed(
3626 et,
3627 s,
3628 d,
3629 false,
3630 &mut self.props,
3631 &build_topo_view(&self.topo, &self.base),
3632 &self.ids,
3633 &self.syms,
3634 &self.labels,
3635 self.base.as_ref().map(|b| {
3636 b.columns()
3637 .expect("base columns section bounds validated at open")
3638 }),
3639 );
3640 }
3641
3642 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3643 self.props.remove_all(n);
3644 // Full-text index maintenance: remove all tokens for this node.
3645 self.fulltext.remove_node(n);
3646 // Property (equality) index maintenance: drop all entries for n.
3647 self.prop_index.remove_node_all(n);
3648
3649 // (4) Retire the dense id and stamp the label sentinel.
3650 self.ids.delete(key);
3651 if let Some(slot) = self.labels.get_mut(n as usize) {
3652 *slot = u32::MAX;
3653 }
3654 }
3655 WalRecord::Batch(inner) => {
3656 // Apply each inner record in order through the same apply path.
3657 // Inner records are validated free of nested Batch by encode_record.
3658 for rec in inner {
3659 self.apply(rec)?;
3660 }
3661 }
3662 WalRecord::RebuildRule { name } => {
3663 // Replay-over-snapshot idempotency: the snapshot may already
3664 // reflect a later delete_rule, so the rule is absent; skip.
3665 if !self.engine.rules().any(|r| r.name == *name) {
3666 return Ok(());
3667 }
3668 let cursor = self.engine.pending_delta_count();
3669 let mut eng = std::mem::take(&mut self.engine);
3670 let result = {
3671 let mut gm = make_graph_mut(
3672 &self.ids,
3673 &mut self.syms,
3674 &self.labels,
3675 build_props_view(&self.props, &self.base),
3676 &mut self.topo,
3677 &self.base,
3678 &mut self.edge_props,
3679 );
3680 eng.rebuild(name, &mut gm)
3681 };
3682 self.engine = eng;
3683 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3684 // Derived-edge delta changes → view updates.
3685 if !self.view_store.is_empty() {
3686 #[cfg(test)]
3687 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3688 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3689 for d in &new_deltas {
3690 self.view_store.on_edge_changed(
3691 d.etype_sym,
3692 d.src_id,
3693 d.dst_id,
3694 d.fired,
3695 &mut self.props,
3696 &build_topo_view(&self.topo, &self.base),
3697 &self.ids,
3698 &self.syms,
3699 &self.labels,
3700 self.base.as_ref().map(|b| {
3701 b.columns()
3702 .expect("base columns section bounds validated at open")
3703 }),
3704 );
3705 }
3706 }
3707 }
3708 WalRecord::CreateView { def_bytes } => {
3709 let def: ViewDef =
3710 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3711 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3712 })?;
3713 // Replay-over-snapshot idempotency: view already present → skip.
3714 if self.view_store.has_view(&def.name) {
3715 return Ok(());
3716 }
3717 self.view_store
3718 .create_view(
3719 def,
3720 &mut self.props,
3721 &build_topo_view(&self.topo, &self.base),
3722 &self.ids,
3723 &self.syms,
3724 &self.labels,
3725 )
3726 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3727 }
3728 WalRecord::DeleteView { name } => {
3729 // Replay-over-snapshot idempotency: view already absent → skip.
3730 if !self.view_store.has_view(name) {
3731 return Ok(());
3732 }
3733 self.view_store
3734 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3735 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3736 }
3737 WalRecord::EnableFulltext { label, field } => {
3738 // Replay-over-snapshot idempotency: already enabled → skip.
3739 if self.fulltext.is_enabled(label, field) {
3740 return Ok(());
3741 }
3742 self.fulltext.enable(label, field);
3743 // Backfill: index all live nodes of this label that have the field.
3744 let n = self.ids.len() as u32;
3745 for id in 0..n {
3746 let Some(&sym) = self.labels.get(id as usize) else {
3747 continue;
3748 };
3749 if sym == u32::MAX {
3750 continue; // tombstoned
3751 }
3752 let Some(lbl) = self.syms.resolve(sym) else {
3753 continue;
3754 };
3755 if lbl != label {
3756 continue;
3757 }
3758 if let Some(value) = build_props_view(&self.props, &self.base)
3759 .get(id, field)
3760 .map(|vr| vr.into_value())
3761 {
3762 self.fulltext.add_tokens(id, field, &value);
3763 }
3764 }
3765 }
3766 WalRecord::DisableFulltext { label, field } => {
3767 // Replay-over-snapshot idempotency: already disabled → skip.
3768 if !self.fulltext.is_enabled(label, field) {
3769 return Ok(());
3770 }
3771 // If another label still indexes this field, the postings column
3772 // is kept — but it must not contain node_ids from the now-disabled
3773 // label. Remove them before calling disable() so the field_indexed
3774 // guard inside disable() sees the correct post-removal state.
3775 if self.fulltext.field_indexed_by_other(label, field) {
3776 if let Some(label_sym) = self.syms.get(label) {
3777 for (node_id, &lsym) in self.labels.iter().enumerate() {
3778 if lsym == label_sym {
3779 self.fulltext.remove_node_field(node_id as u32, field);
3780 }
3781 }
3782 }
3783 }
3784 self.fulltext.disable(label, field);
3785 }
3786 WalRecord::EnableIndex { label, field } => {
3787 // Replay-over-snapshot idempotency: already enabled → skip.
3788 if self.prop_index.is_enabled(label, field) {
3789 return Ok(());
3790 }
3791 self.prop_index.enable(label, field);
3792 // Backfill: index all live nodes of this label that have the field.
3793 let n = self.ids.len() as u32;
3794 for id in 0..n {
3795 let Some(&sym) = self.labels.get(id as usize) else {
3796 continue;
3797 };
3798 if sym == u32::MAX {
3799 continue; // tombstoned
3800 }
3801 let Some(lbl) = self.syms.resolve(sym) else {
3802 continue;
3803 };
3804 if lbl != label {
3805 continue;
3806 }
3807 if let Some(value) = build_props_view(&self.props, &self.base)
3808 .get(id, field)
3809 .map(|vr| vr.into_value())
3810 {
3811 self.prop_index.set(label, field, id, &value);
3812 }
3813 }
3814 }
3815 WalRecord::DisableIndex { label, field } => {
3816 self.prop_index.disable(label, field);
3817 }
3818 // History markers carry no replay state — rules re-derive edges
3819 // deterministically on open/replay. Skip unconditionally.
3820 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3821 // ── rename_node ──────────────────────────────────────────────────
3822 WalRecord::RenameNode { old_key, new_key } => {
3823 // Recovery-safe: if old_key is already gone (key was renamed
3824 // by a snapshot or a prior replay frame), skip cleanly.
3825 if self.ids.get(old_key).is_none() {
3826 return Ok(());
3827 }
3828 // The rename only updates the key-table; the dense id, all
3829 // topo edges, props, labels, and rule state are id-indexed and
3830 // require no change.
3831 self.ids
3832 .rename(old_key, new_key)
3833 .map_err(|e| GraphError::Corrupt {
3834 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3835 })?;
3836 }
3837 }
3838 Ok(())
3839 }
3840
3841 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3842 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3843 /// idempotent when the string is already bound. Always emit: after
3844 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3845 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3846 let id = if let Some(id) = self.syms.get(s) {
3847 id
3848 } else {
3849 self.syms.intern(s)
3850 };
3851 (
3852 id,
3853 WalRecord::Intern {
3854 id,
3855 text: s.to_string(),
3856 },
3857 )
3858 }
3859
3860 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3861 /// state is left mutated: speculative interns made while building the
3862 /// output are rolled back, so a later successful mutation cannot log an
3863 /// `Intern` record whose id replay would never reproduce.
3864 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3865 let syms_checkpoint = self.syms.len();
3866 let result = self.rewrite_wal_dense_inner(recs);
3867 if result.is_err() {
3868 self.syms.truncate(syms_checkpoint);
3869 }
3870 result
3871 }
3872
3873 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3874 let mut out = Vec::with_capacity(recs.len());
3875 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3876 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3877 let mut interned = std::collections::HashSet::<u32>::new();
3878 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3879 detail: "id space exhausted".into(),
3880 })?;
3881 let lookup = |ids: &IdMap,
3882 pending: &std::collections::HashMap<String, u32>,
3883 key: &str|
3884 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3885 for rec in recs {
3886 match rec {
3887 WalRecord::InsertNode { label, key, props } => {
3888 let (label_id, intern) = self.intern_wal(&label);
3889 if interned.insert(label_id) {
3890 out.push(intern);
3891 }
3892 let mut props_id = Vec::with_capacity(props.len());
3893 for (field, value) in props {
3894 let (field_id, intern) = self.intern_wal(&field);
3895 if interned.insert(field_id) {
3896 out.push(intern);
3897 }
3898 props_id.push((field_id, value));
3899 }
3900 if lookup(&self.ids, &pending, &key).is_none() {
3901 pending.insert(key.clone(), next);
3902 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3903 detail: "id space exhausted".into(),
3904 })?;
3905 }
3906 out.push(WalRecord::InsertNodeId {
3907 label: label_id,
3908 key,
3909 props: props_id,
3910 });
3911 }
3912 WalRecord::SetProp { key, field, value } => {
3913 let id =
3914 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3915 detail: format!("dense WAL rewrite missing key {key}"),
3916 })?;
3917 let (field_id, intern) = self.intern_wal(&field);
3918 if interned.insert(field_id) {
3919 out.push(intern);
3920 }
3921 out.push(WalRecord::SetPropId {
3922 id,
3923 field: field_id,
3924 value,
3925 });
3926 }
3927 WalRecord::InsertEdge {
3928 edge_type,
3929 src_key,
3930 dst_key,
3931 } => {
3932 let (etype, intern) = self.intern_wal(&edge_type);
3933 if interned.insert(etype) {
3934 out.push(intern);
3935 }
3936 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3937 GraphError::Corrupt {
3938 detail: format!("dense WAL rewrite missing src {src_key}"),
3939 }
3940 })?;
3941 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3942 GraphError::Corrupt {
3943 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3944 }
3945 })?;
3946 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3947 }
3948 WalRecord::RenameNode {
3949 ref old_key,
3950 ref new_key,
3951 } => {
3952 // Track the rename in `pending` so subsequent InsertEdge /
3953 // SetProp records in this batch can resolve the new key.
3954 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3955 GraphError::Corrupt {
3956 detail: format!(
3957 "dense WAL rewrite: RenameNode old key {old_key} not found"
3958 ),
3959 }
3960 })?;
3961 pending.remove(old_key.as_str());
3962 pending.insert(new_key.clone(), id);
3963 out.push(rec);
3964 }
3965 // # Symbol-order invariant (load-bearing)
3966 //
3967 // Write-time and replay-time symbol assignment must agree: every
3968 // symbol in a `Batch` frame has to receive the same dense id when
3969 // the frame's records are replayed in order as it received when
3970 // the frame was written.
3971 //
3972 // A rule's backfill interns its `edge_type` lazily
3973 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3974 // site), and that backfill runs from `apply` — during the
3975 // `CreateRule` record itself, and again from any later
3976 // `InsertNodeId` in the same frame that makes the rule fire. At
3977 // write time the whole batch is rewritten before any of it is
3978 // applied, so a later `InsertEdge` in the same batch would win the
3979 // lower id for its edge type; on replay the rule's lazy intern
3980 // gets there first and steals it, and the `Intern` record fails at
3981 // the `wal intern assigned …` check in `apply`.
3982 //
3983 // Pre-interning the rule's `edge_type` here, and emitting its
3984 // `Intern` record ahead of the `CreateRule` record, makes both
3985 // orders identical. `weight_prop` needs no pre-intern:
3986 // `EdgeProps::set` keys props by `String`, never through the
3987 // interner. `via_edge` needs none either: via-hop rules resolve it
3988 // with `syms.get` and skip when it is absent.
3989 //
3990 // `RebuildRule` and `DeleteRule` need no such handling here:
3991 // `RebuildRule` has no `BatchOp` variant, so it never appears
3992 // inside a `Batch` today — it is only ever issued as its own
3993 // standalone commit (`rebuild_rule`, or the auto-rebuild path
3994 // that logs it as a second commit after the triggering op).
3995 // `DeleteRule` does have a `BatchOp` variant and can appear
3996 // inside a `Batch`, but it carries only a rule `name` — no
3997 // `edge_type` or other symbol that needs pre-interning — so
3998 // only `CreateRule` needs this arm.
3999 WalRecord::CreateRule { ref def_bytes } => {
4000 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4001 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4002 })?;
4003 let (etype, intern) = self.intern_wal(&def.edge_type);
4004 if interned.insert(etype) {
4005 out.push(intern);
4006 }
4007 out.push(rec);
4008 }
4009 other => out.push(other),
4010 }
4011 }
4012 Ok(out)
4013 }
4014
4015 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4016 let recs = self.rewrite_wal_dense(recs)?;
4017 match recs.len() {
4018 0 => Ok(()),
4019 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4020 _ => self.log_then_apply(WalRecord::Batch(recs)),
4021 }
4022 }
4023
4024 /// Durable write, then notify the event sink. Replay (`apply` during
4025 /// `open`) never enters this function, so it is the replay-silent seam.
4026 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4027 self.log_then_apply_with(rec, None, self.fsync)
4028 }
4029
4030 /// Whether this frame must fsync under `policy`.
4031 ///
4032 /// Batched contract: user-visible batches (>1 mutation) fsync; single
4033 /// mutations do not. The dense rewrite wraps a single mutation in a
4034 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4035 /// from the count — removing that filter would make every single-op write
4036 /// fsync under Batched (or, if the threshold were raised instead, skip a
4037 /// needed fsync for real two-op batches).
4038 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4039 match policy {
4040 FsyncPolicy::Relaxed => false,
4041 FsyncPolicy::Strict => true,
4042 FsyncPolicy::Batched => match rec {
4043 // Intern + one mutation is the single-op rewrite, not a user batch.
4044 WalRecord::Batch(inner) => {
4045 inner
4046 .iter()
4047 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4048 .count()
4049 > 1
4050 }
4051 _ => false,
4052 },
4053 }
4054 }
4055
4056 /// # Apply-infallibility invariant (load-bearing)
4057 ///
4058 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4059 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4060 /// the full frame while in-memory state would reflect only the ops before
4061 /// the failure. On reopen, WAL replay would then apply the entire batch —
4062 /// diverging permanently from what the pre-crash process had in memory.
4063 ///
4064 /// For `Batch` frames this situation cannot arise because:
4065 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4066 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4067 /// use, with no concurrent mutation between validation exit and apply entry.
4068 /// - Every `apply` arm for a validated op is either infallible by construction
4069 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4070 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4071 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4072 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4073 ///
4074 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4075 /// `Err` for a `Batch` frame, making any future regression immediately visible
4076 /// in tests rather than silently diverging crash-recovery behaviour.
4077 fn log_then_apply_with(
4078 &mut self,
4079 rec: WalRecord,
4080 ingest: Option<(String, usize)>,
4081 policy: FsyncPolicy,
4082 ) -> Result<()> {
4083 // Read-only guard: as-of instances must never write the WAL.
4084 if self.read_only {
4085 return Err(GraphError::ReadOnly);
4086 }
4087 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4088 // partway; in-memory state is ahead of (or out of step with) the
4089 // on-disk WAL, so further mutations would deepen the divergence.
4090 // Reopen the database to recover. Checked before the lock guard: this
4091 // is the more serious condition and the more useful error.
4092 if self.degraded {
4093 return Err(GraphError::Io(std::io::Error::other(
4094 "database degraded after group-commit fsync failure; reopen required",
4095 )));
4096 }
4097 // Cross-process guard: this write scope asked for the store's write
4098 // lock and did not get it. Writing anyway would append frames on top of
4099 // a WAL another process is extending, so refuse instead.
4100 if self.lock_denied {
4101 return Err(GraphError::Busy { holder: None });
4102 }
4103 // Ensure retained provenance bytes are decoded into the live mutable
4104 // fields before any mutation touches self.engine.provenance. This is a
4105 // no-op if provenance was never stored (fresh store) or has already been
4106 // consumed (subsequent mutations). WAL replay calls apply() directly
4107 // and is covered by consume_retained_state_eager before replay.
4108 self.ensure_v8_base_sections_loaded();
4109 self.engine.ensure_provenance_loaded_mut();
4110 // Invariant (I-1): no stale deltas may enter from a previous apply.
4111 // If any engine method ever accumulates deltas before erroring, they would
4112 // contaminate the *next* commit's event stream. This assert fires in debug
4113 // builds, making any future regression visible at the earliest point.
4114 debug_assert_eq!(
4115 self.engine.pending_delta_count(),
4116 0,
4117 "stale engine deltas at log_then_apply_with entry — \
4118 a previous apply arm may have accumulated deltas before erroring; \
4119 the caller must drain_deltas() on any error path before returning"
4120 );
4121 let frame = encode_record(&rec);
4122 self.fs.append(FileId::Wal, &frame)?;
4123 // The cursor advances by exactly the bytes appended: these frames are
4124 // ours and already applied, so a later refresh must not replay them.
4125 self.wal_consumed += frame.len() as u64;
4126 if Self::wal_needs_sync(policy, &rec) {
4127 self.fs.sync(FileId::Wal)?;
4128 }
4129 // Marker writing always needs the engine deltas, but the engine only
4130 // accumulates them when emit_deltas is true (normally gated on subscribers
4131 // or views being present). Enable emission for this apply if it is
4132 // currently off, then restore the original state unconditionally via an
4133 // RAII guard — this prevents a panic in apply() from leaking the flag.
4134 // The same guard resets the engine's transient chaining state. A panic
4135 // unwinding out of a rule hook would otherwise leave `chain_depth`
4136 // non-zero, which makes every later `begin_chain` decide chaining is
4137 // already running and silently switch it off for good.
4138 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4139 impl Drop for RestoreEmitDeltas {
4140 fn drop(&mut self) {
4141 // SAFETY: pointer into self (GraphDb); guard is dropped within
4142 // this frame before log_then_apply_with returns.
4143 unsafe {
4144 (*self.0).set_emit_deltas(self.1);
4145 (*self.0).reset_chain_state();
4146 }
4147 }
4148 }
4149 let original_emit = self.engine.emit_deltas();
4150 if !original_emit {
4151 self.engine.set_emit_deltas(true);
4152 }
4153 // SAFETY: raw pointer into self; guard dropped within this frame.
4154 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4155
4156 let apply_result = self.apply(&rec);
4157 // For Batch frames, post-validation apply must be infallible (see above).
4158 // A debug_assert here catches any future change that makes apply fallible
4159 // before the caller notices via silent WAL/memory divergence.
4160 if matches!(&rec, WalRecord::Batch(_)) {
4161 debug_assert!(
4162 apply_result.is_ok(),
4163 "Batch apply returned Err after successful WAL write — \
4164 the validate-then-apply invariant has been violated; \
4165 see log_then_apply_with invariant doc"
4166 );
4167 }
4168 if apply_result.is_err() {
4169 // Discard any partial deltas accumulated by the failed apply.
4170 // They must not ride the next commit's event stream (I-1).
4171 // _emit_guard restores emit_deltas on drop automatically.
4172 let _ = self.engine.drain_deltas();
4173 let _ = self.engine.take_rebuild_needed();
4174 apply_result?;
4175 }
4176 self.commit_seq += 1;
4177 let seq = self.commit_seq;
4178 // Update per-node last-change map for the committed record.
4179 // Must happen after commit_seq is incremented so the seq is correct.
4180 self.update_last_change_from_rec(&rec, seq);
4181 // Drain engine deltas and distribute to subscribers before the existing
4182 // MutationEvent sink fires — both happen post-fsync, post-apply.
4183 // _emit_guard restores emit_deltas after this line when it drops.
4184 let engine_deltas = self.engine.drain_deltas();
4185
4186 // Append history-marker WAL records for any derived-edge changes so
4187 // that `edge_history` and `was_linked` can surface rule-attributed
4188 // events. Markers are STATE NO-OPS during replay; they are written
4189 // without an additional fsync (the triggering commit's sync already
4190 // happened; the next commit's sync covers these lazily).
4191 if !engine_deltas.is_empty() {
4192 let markers: Vec<WalRecord> = engine_deltas
4193 .iter()
4194 .map(|d| {
4195 if d.fired {
4196 WalRecord::DerivedEdgeAdded {
4197 rule: d.rule.clone(),
4198 edge_type: d.edge_type.clone(),
4199 src_key: d.src_key.clone(),
4200 dst_key: d.dst_key.clone(),
4201 }
4202 } else {
4203 WalRecord::DerivedEdgeRetracted {
4204 rule: d.rule.clone(),
4205 edge_type: d.edge_type.clone(),
4206 src_key: d.src_key.clone(),
4207 dst_key: d.dst_key.clone(),
4208 }
4209 }
4210 })
4211 .collect();
4212 let marker_frame = if markers.len() == 1 {
4213 markers.into_iter().next().unwrap()
4214 } else {
4215 WalRecord::Batch(markers)
4216 };
4217 // Ignore append errors: markers are best-effort history
4218 // annotations. Losing them does not affect state correctness.
4219 // The cursor only advances when the bytes actually landed.
4220 let marker_bytes = encode_record(&marker_frame);
4221 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4222 self.wal_consumed += marker_bytes.len() as u64;
4223 }
4224 }
4225
4226 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4227 // stored as-is (including any nested Batch / Intern records); the
4228 // ReaderSnapshot's apply_one function handles all variants.
4229 {
4230 let derived_inserts = engine_deltas
4231 .iter()
4232 .filter(|d| d.fired)
4233 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4234 .collect();
4235 let derived_deletes = engine_deltas
4236 .iter()
4237 .filter(|d| !d.fired)
4238 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4239 .collect();
4240 let delta = Arc::new(crate::reader::CommitDelta {
4241 records: vec![rec.clone()],
4242 derived_inserts,
4243 derived_deletes,
4244 });
4245 self.delta_tail.push(delta);
4246 self.commits_since_fold += 1;
4247 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4248 self.fold_now();
4249 }
4250 }
4251
4252 if self.defer_events {
4253 // Group-commit drain thread: hold events until after the group
4254 // fsync so subscribers only observe durable data (R2).
4255 self.deferred_events.push(DeferredEvent {
4256 rec: rec.clone(),
4257 engine_deltas,
4258 seq,
4259 ingest,
4260 });
4261 } else {
4262 self.distribute_events(&rec, &engine_deltas, seq);
4263 self.emit_committed(&rec, ingest);
4264 }
4265 // Drift is only known after apply, so auto-rebuild cannot join the
4266 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4267 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4268 // retrigger loop is impossible if the fit succeeded, but we still
4269 // drain the flag so a leftover cannot re-enter.
4270 let rebuilds = self.engine.take_rebuild_needed();
4271 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4272 let mut failed = Vec::new();
4273 for name in rebuilds {
4274 if self.engine.rules().any(|r| r.name == name) {
4275 // User op is already durable. A failed second commit must
4276 // not surface as the caller's error.
4277 if let Err(e) =
4278 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4279 {
4280 eprintln!(
4281 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4282 );
4283 failed.push(name);
4284 }
4285 }
4286 }
4287 for name in failed {
4288 self.engine.queue_rebuild_needed(name);
4289 }
4290 }
4291 Ok(())
4292 }
4293
4294 /// Install a post-commit hook. Replaces any previous sink.
4295 ///
4296 /// The sink runs inside `log_then_apply` after a successful
4297 /// durable commit, while the caller still holds `&mut self`. When this
4298 /// database is behind a [`crate::SharedDb`], that means the **write
4299 /// guard is held**. The sink must never call `read` / `write` (or any
4300 /// other method) on the same `SharedDb` — the `RwLock` is not
4301 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4302 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4303 /// Intended examples: `std::sync::mpsc::SyncSender`,
4304 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4305 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4306 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4307 self.event_sink = Some(sink);
4308 }
4309
4310 /// Whether a post-commit event sink is currently installed.
4311 pub fn has_event_sink(&self) -> bool {
4312 self.event_sink.is_some()
4313 }
4314
4315 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4316 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4317 self.fsync = p;
4318 }
4319
4320 /// Return the current WAL fsync cadence.
4321 pub fn fsync_policy(&self) -> FsyncPolicy {
4322 self.fsync
4323 }
4324
4325 // ── Group-commit event deferral ───────────────────────────────────────────
4326
4327 /// Enable or disable deferred event mode.
4328 ///
4329 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4330 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4331 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4332 /// or [`discard_deferred_events`] if the fsync failed and the group must
4333 /// be treated as lost.
4334 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4335 self.defer_events = defer;
4336 }
4337
4338 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4339 /// was set to true. Clears the buffer.
4340 ///
4341 /// Called by the drain thread AFTER a successful group fsync, so
4342 /// subscribers observe only data that is durably on disk.
4343 pub fn flush_deferred_events(&mut self) {
4344 let events = std::mem::take(&mut self.deferred_events);
4345 for de in events {
4346 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4347 self.emit_committed(&de.rec, de.ingest);
4348 }
4349 }
4350
4351 /// Discard all buffered events without firing them.
4352 ///
4353 /// Called by the drain thread when a group fsync fails: the WAL has been
4354 /// truncated back to the pre-group offset, so the committed-but-unsynced
4355 /// ops must not be observable to subscribers.
4356 pub fn discard_deferred_events(&mut self) {
4357 self.deferred_events.clear();
4358 }
4359
4360 // ── Degraded state ────────────────────────────────────────────────────────
4361
4362 /// Mark this database as degraded.
4363 ///
4364 /// Called by the group-commit drain thread after a group fsync failure and
4365 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4366 /// further mutations would deepen the divergence. All subsequent calls to
4367 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4368 pub fn set_degraded(&mut self) {
4369 self.degraded = true;
4370 }
4371
4372 fn emit(&self, ev: MutationEvent) {
4373 if let Some(sink) = &self.event_sink {
4374 sink(ev);
4375 }
4376 }
4377
4378 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4379 match rec {
4380 WalRecord::Batch(inner) => {
4381 for r in inner {
4382 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4383 self.emit(ev);
4384 }
4385 }
4386 match ingest {
4387 Some((label, inserted)) => {
4388 self.emit(MutationEvent::Ingested { label, inserted })
4389 }
4390 None => {
4391 let ops = inner
4392 .iter()
4393 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4394 .count();
4395 if ops > 1 {
4396 self.emit(MutationEvent::BatchApplied { ops });
4397 }
4398 }
4399 }
4400 }
4401 other => {
4402 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4403 self.emit(ev);
4404 }
4405 }
4406 }
4407 }
4408
4409 // -----------------------------------------------------------------------
4410 // Subscription API
4411 // -----------------------------------------------------------------------
4412
4413 /// Distribute post-commit events to all live subscribers.
4414 ///
4415 /// Build a row-key → row-data map from a [`ResultSet`].
4416 ///
4417 /// Each row is serialized to JSON to form its key; a debug fallback is used
4418 /// if serialization fails. Used by both the initial-seed path in
4419 /// [`Self::subscribe_query`] and the per-commit diff path in
4420 /// [`Self::distribute_events`] to keep the two in sync.
4421 fn result_to_row_map(
4422 result: &core_query::ResultSet,
4423 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4424 (0..result.len())
4425 .map(|i| {
4426 let row = result.row(i).to_vec();
4427 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4428 (key, row)
4429 })
4430 .collect()
4431 }
4432
4433 /// Collect the set of label syms touched by a WAL record.
4434 ///
4435 /// Returns `Some(set)` when every record in this commit can be attributed to
4436 /// a known label sym. Returns `None` when the commit must not be skipped:
4437 /// edge records, unresolvable key→label lookups, or any record type not in
4438 /// the explicit handled set.
4439 ///
4440 /// Handled record types and their actions:
4441 /// - `InsertNode` → look up label in interner (fails → None)
4442 /// - `InsertNodeId` → label sym is carried directly
4443 /// - `SetProp` → resolve key→id→label (fails → None)
4444 /// - `DeleteNode` → resolve key→id→label (fails → None)
4445 /// - `Batch` → recurse into every inner record
4446 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4447 /// - everything else → None (conservative)
4448 fn commit_touched_labels(
4449 rec: &WalRecord,
4450 syms: &Interner,
4451 ids: &IdMap,
4452 labels: &[u32],
4453 ) -> Option<BTreeSet<u32>> {
4454 let mut out = BTreeSet::new();
4455 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4456 Some(out)
4457 } else {
4458 None
4459 }
4460 }
4461
4462 fn collect_touched_labels(
4463 rec: &WalRecord,
4464 syms: &Interner,
4465 ids: &IdMap,
4466 labels: &[u32],
4467 out: &mut BTreeSet<u32>,
4468 ) -> bool {
4469 match rec {
4470 // String-key insert: the dense rewrite converts this to
4471 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4472 // records written before the dense path was added.
4473 WalRecord::InsertNode { label, .. } => {
4474 if let Some(sym) = syms.get(label) {
4475 out.insert(sym);
4476 true
4477 } else {
4478 false
4479 }
4480 }
4481 // Dense-id insert (produced by rewrite_wal_dense for every
4482 // insert_node call in the current codebase).
4483 WalRecord::InsertNodeId { label, .. } => {
4484 out.insert(*label);
4485 true
4486 }
4487 // String-key prop set: dense path converts to [Intern, SetPropId].
4488 WalRecord::SetProp { key, .. } => {
4489 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4490 out.insert(sym);
4491 true
4492 } else {
4493 false
4494 }
4495 }
4496 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4497 WalRecord::SetPropId { id, .. } => {
4498 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4499 out.insert(sym);
4500 true
4501 } else {
4502 false
4503 }
4504 }
4505 WalRecord::DeleteNode { key } => {
4506 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4507 out.insert(sym);
4508 true
4509 } else {
4510 false
4511 }
4512 }
4513 WalRecord::Batch(inner) => inner
4514 .iter()
4515 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4516 // Intern is a pure metadata record — it does not touch any node's
4517 // label and is safe to skip for the label-skip predicate.
4518 WalRecord::Intern { .. } => true,
4519 // Edge records: always re-execute (edges can change join results).
4520 WalRecord::InsertEdge { .. }
4521 | WalRecord::DeleteEdge { .. }
4522 | WalRecord::InsertEdgeId { .. } => false,
4523 _ => false,
4524 }
4525 }
4526
4527 /// Resolve a node key to its label sym via the dense id table.
4528 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4529 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4530 let id = ids.get(key)?;
4531 let sym = labels.get(id as usize).copied()?;
4532 (sym != u32::MAX).then_some(sym)
4533 }
4534
4535 /// Distribute post-commit events to all live subscribers.
4536 ///
4537 /// Called from `log_then_apply_with` after apply + fsync, before the
4538 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4539 ///
4540 /// Query subscriptions (subscribe_query) re-execute their plan on every
4541 /// call and diff the result against the previous run. Zero overhead when
4542 /// no query subscriptions are active.
4543 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4544 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4545 return;
4546 }
4547
4548 if !self.subscriptions.is_empty() {
4549 // Build write events from the WAL record.
4550 let write_events: Vec<DbEvent> =
4551 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4552
4553 // Build edge events from engine deltas. Weight is looked up from
4554 // edge_props at distribution time (after apply), so it's always fresh.
4555 let edge_events: Vec<DbEvent> = engine_deltas
4556 .iter()
4557 .map(|d| {
4558 if d.fired {
4559 // The score lives under the rule's declared weight_prop,
4560 // which is not always the literal "weight".
4561 let prop = self
4562 .engine
4563 .rules()
4564 .find(|r| r.name == d.rule)
4565 .and_then(|r| r.weight_prop.as_deref());
4566 let weight = prop.and_then(|p| {
4567 self.edge_props
4568 .get(d.etype_sym, d.src_id, d.dst_id, p)
4569 .and_then(|v| {
4570 if let core_storage::Value::Float(f) = v {
4571 Some(*f)
4572 } else {
4573 None
4574 }
4575 })
4576 });
4577 DbEvent::EdgeFired {
4578 rule: d.rule.clone(),
4579 src_key: d.src_key.clone(),
4580 dst_key: d.dst_key.clone(),
4581 edge_type: d.edge_type.clone(),
4582 weight,
4583 commit_seq: seq,
4584 }
4585 } else {
4586 DbEvent::EdgeRetracted {
4587 rule: d.rule.clone(),
4588 src_key: d.src_key.clone(),
4589 dst_key: d.dst_key.clone(),
4590 edge_type: d.edge_type.clone(),
4591 commit_seq: seq,
4592 }
4593 }
4594 })
4595 .collect();
4596
4597 // Prune dead entries; push matching events to live ones.
4598 self.subscriptions.retain(|entry| {
4599 let Some(inner) = entry.inner.upgrade() else {
4600 return false;
4601 };
4602 for ev in &write_events {
4603 if event_matches(ev, &entry.filter) {
4604 inner.push(ev.clone());
4605 }
4606 }
4607 for ev in &edge_events {
4608 if event_matches(ev, &entry.filter) {
4609 inner.push(ev.clone());
4610 }
4611 }
4612 true
4613 });
4614
4615 // Turn off delta accumulation if all subscribers dropped and no views remain.
4616 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4617 self.engine.set_emit_deltas(false);
4618 }
4619 }
4620
4621 // Query subscriptions: full re-run per commit, then diff rows.
4622 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4623 // Differential evaluation is roadmap / Phase 5.
4624 if !self.query_subscriptions.is_empty() {
4625 // Take the list out so we can call self.view() without borrow conflict.
4626 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4627 let empty_params = BTreeMap::new();
4628 query_subs.retain_mut(|entry| {
4629 let Some(inner) = entry.inner.upgrade() else {
4630 return false; // subscriber dropped — prune
4631 };
4632 // Label-skip: if the plan has a known scan label and this commit
4633 // can be proven to touch only different labels (and no rule-derived
4634 // edge deltas fired), the result set cannot have changed — skip.
4635 if let Some(scan_sym) = entry.scan_label {
4636 if engine_deltas.is_empty() {
4637 let touched =
4638 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4639 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4640 return true; // safe to skip — result set unchanged
4641 }
4642 }
4643 }
4644 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4645 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4646 Ok(r) => r,
4647 Err(e) => {
4648 // Keep the subscription alive; skip the diff for this commit.
4649 // Re-run errors are transient (e.g., planner change) and
4650 // self-heal when the next commit succeeds.
4651 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4652 return true;
4653 }
4654 };
4655 // Build new row map: serialized-key → row data.
4656 let new_row_map = Self::result_to_row_map(&result);
4657 // Removed rows: in prev but not in new.
4658 for (key, row) in &entry.prev_row_map {
4659 if !new_row_map.contains_key(key) {
4660 inner.push(DbEvent::QueryRowRemoved {
4661 columns: entry.columns.clone(),
4662 row: row.clone(),
4663 });
4664 }
4665 }
4666 // Added rows: in new but not in prev.
4667 for (key, row) in &new_row_map {
4668 if !entry.prev_row_map.contains_key(key) {
4669 inner.push(DbEvent::QueryRowAdded {
4670 columns: entry.columns.clone(),
4671 row: row.clone(),
4672 });
4673 }
4674 }
4675 entry.prev_row_map = new_row_map;
4676 true
4677 });
4678 self.query_subscriptions = query_subs;
4679 }
4680 }
4681
4682 /// Returns `true` if any live subscriber or view definition requires delta
4683 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4684 fn needs_emit_deltas(&self) -> bool {
4685 !self.view_store.is_empty()
4686 || self
4687 .subscriptions
4688 .iter()
4689 .any(|e| e.inner.upgrade().is_some())
4690 }
4691
4692 /// Convert a WAL record into `DbEvent` write events with the given seq.
4693 fn write_events_from_record(
4694 rec: &WalRecord,
4695 seq: u64,
4696 intern: &Interner,
4697 ids: &IdMap,
4698 ) -> Vec<DbEvent> {
4699 match rec {
4700 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4701 label: label.clone(),
4702 key: key.clone(),
4703 commit_seq: seq,
4704 }],
4705 // *Id arms run after a successful apply, so resolution can only
4706 // fail on a programming error. Skip the event rather than emit a
4707 // fabricated "" that clients can't tell from a real empty value
4708 // (mirrors event_from_record returning None).
4709 WalRecord::InsertNodeId { label, key, .. } => intern
4710 .resolve(*label)
4711 .map(|label| DbEvent::NodeInserted {
4712 label: label.to_string(),
4713 key: key.clone(),
4714 commit_seq: seq,
4715 })
4716 .into_iter()
4717 .collect(),
4718 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4719 key: key.clone(),
4720 field: field.clone(),
4721 commit_seq: seq,
4722 }],
4723 WalRecord::SetPropId { id, field, .. } => ids
4724 .key_of(*id)
4725 .zip(intern.resolve(*field))
4726 .map(|(key, field)| DbEvent::PropSet {
4727 key: key.to_string(),
4728 field: field.to_string(),
4729 commit_seq: seq,
4730 })
4731 .into_iter()
4732 .collect(),
4733 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4734 key: key.clone(),
4735 field: field.clone(),
4736 commit_seq: seq,
4737 }],
4738 WalRecord::InsertEdge {
4739 edge_type,
4740 src_key,
4741 dst_key,
4742 } => vec![DbEvent::EdgeInserted {
4743 edge_type: edge_type.clone(),
4744 src: src_key.clone(),
4745 dst: dst_key.clone(),
4746 commit_seq: seq,
4747 }],
4748 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4749 Some(DbEvent::EdgeInserted {
4750 edge_type: intern.resolve(*etype)?.to_string(),
4751 src: ids.key_of(*src)?.to_string(),
4752 dst: ids.key_of(*dst)?.to_string(),
4753 commit_seq: seq,
4754 })
4755 })()
4756 .into_iter()
4757 .collect(),
4758 WalRecord::DeleteEdge {
4759 edge_type,
4760 src_key,
4761 dst_key,
4762 } => vec![DbEvent::EdgeDeleted {
4763 edge_type: edge_type.clone(),
4764 src: src_key.clone(),
4765 dst: dst_key.clone(),
4766 commit_seq: seq,
4767 }],
4768 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4769 key: key.clone(),
4770 commit_seq: seq,
4771 }],
4772 WalRecord::Batch(inner) => inner
4773 .iter()
4774 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4775 .collect(),
4776 WalRecord::CreateRule { .. }
4777 | WalRecord::DeleteRule { .. }
4778 | WalRecord::RebuildRule { .. }
4779 | WalRecord::CreateView { .. }
4780 | WalRecord::DeleteView { .. }
4781 | WalRecord::EnableFulltext { .. }
4782 | WalRecord::DisableFulltext { .. }
4783 | WalRecord::EnableIndex { .. }
4784 | WalRecord::DisableIndex { .. }
4785 | WalRecord::Intern { .. }
4786 // History markers produce no DbEvent — the engine delta already
4787 // fired the EdgeFired/EdgeRetracted subscription events.
4788 | WalRecord::DerivedEdgeAdded { .. }
4789 | WalRecord::DerivedEdgeRetracted { .. }
4790 | WalRecord::RenameNode { .. } => vec![],
4791 }
4792 }
4793
4794 /// Subscribe to edge-fire and edge-retract events for one named rule.
4795 ///
4796 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4797 /// currently registered. Dropping the returned [`Subscription`] handle
4798 /// unregisters the subscriber — no further events are queued, no
4799 /// resources leak.
4800 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4801 if self.read_only {
4802 return Err(core_storage::GraphError::ReadOnly);
4803 }
4804 if !self.engine.rules().any(|r| r.name == rule_name) {
4805 return Err(core_storage::GraphError::RuleNotFound {
4806 name: rule_name.to_string(),
4807 });
4808 }
4809 let inner = SubInner::new(self.sub_capacity());
4810 self.subscriptions.push(SubEntry {
4811 filter: SubFilter::Rule(rule_name.to_string()),
4812 inner: std::sync::Arc::downgrade(&inner),
4813 });
4814 self.engine.set_emit_deltas(true);
4815 Ok(Subscription(inner))
4816 }
4817
4818 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4819 ///
4820 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4821 /// as-of instances never commit, so `distribute_events` never runs and the
4822 /// subscription would never deliver events.
4823 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4824 if self.read_only {
4825 return Err(core_storage::GraphError::ReadOnly);
4826 }
4827 let inner = SubInner::new(self.sub_capacity());
4828 self.subscriptions.push(SubEntry {
4829 filter: SubFilter::AllRules,
4830 inner: std::sync::Arc::downgrade(&inner),
4831 });
4832 self.engine.set_emit_deltas(true);
4833 Ok(Subscription(inner))
4834 }
4835
4836 /// Subscribe to write events: node insert/delete, prop set/remove.
4837 ///
4838 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4839 ///
4840 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4841 /// as-of instances never commit, so `distribute_events` never runs and the
4842 /// subscription would never deliver events.
4843 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4844 if self.read_only {
4845 return Err(core_storage::GraphError::ReadOnly);
4846 }
4847 let inner = SubInner::new(self.sub_capacity());
4848 self.subscriptions.push(SubEntry {
4849 filter: SubFilter::Writes,
4850 inner: std::sync::Arc::downgrade(&inner),
4851 });
4852 self.engine.set_emit_deltas(true);
4853 Ok(Subscription(inner))
4854 }
4855
4856 /// Subscribe to incremental Cypher query results.
4857 ///
4858 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4859 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4860 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4861 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4862 ///
4863 /// SKIP is not supported — it shifts the result window on every commit,
4864 /// causing spurious Added/Removed churn for rows whose data never changed.
4865 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4866 /// widens scope beyond the documented single-scan / single-hop subset.
4867 ///
4868 /// After each successful commit, the plan is **fully re-executed** and the
4869 /// result is diffed against the previous run. Added rows produce
4870 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4871 /// [`DbEvent::QueryRowRemoved`].
4872 ///
4873 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4874 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4875 /// is roadmap / Phase 5.
4876 ///
4877 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4878 /// as-of instances never commit, so `distribute_events` never runs and the
4879 /// subscription would never deliver events.
4880 ///
4881 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4882 /// or if the plan shape is not in the allowlist.
4883 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4884 if self.read_only {
4885 return Err(GraphError::ReadOnly);
4886 }
4887 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4888 detail: format!("lex: {e}"),
4889 })?;
4890 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4891 detail: format!("parse: {e}"),
4892 })?;
4893 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4894 detail: format!("plan: {e}"),
4895 })?;
4896 if !is_subscribable(&ops) {
4897 return Err(GraphError::QueryError {
4898 detail: "subscribe_query only supports allowlisted plan shapes: \
4899 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4900 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4901 Not supported: multi-hop Expand chains, SKIP (creates \
4902 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4903 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4904 Use LIMIT to bound re-execution cost."
4905 .to_string(),
4906 });
4907 }
4908 // Execute once to capture initial state (initial rows are not emitted as
4909 // events — the subscriber learns the baseline via the first query call).
4910 let empty_params = BTreeMap::new();
4911 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4912 GraphError::QueryError {
4913 detail: format!("execute: {e}"),
4914 }
4915 })?;
4916 let columns = initial.columns().to_vec();
4917 let prev_row_map = Self::result_to_row_map(&initial);
4918 let inner = SubInner::new(self.sub_capacity());
4919 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4920 // or unrecognized leading scan → None (always re-execute).
4921 let scan_label = extract_scan_label(&ops, &mut self.syms);
4922 self.query_subscriptions.push(QuerySubEntry {
4923 ops,
4924 columns,
4925 prev_row_map,
4926 inner: std::sync::Arc::downgrade(&inner),
4927 scan_label,
4928 });
4929 Ok(Subscription(inner))
4930 }
4931
4932 /// Queue capacity used for new subscriptions.
4933 fn sub_capacity(&self) -> usize {
4934 self.sub_capacity
4935 }
4936
4937 /// Override per-subscriber queue capacity for subsequently created
4938 /// subscriptions on this db instance.
4939 ///
4940 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4941 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4942 /// generating tens of thousands of events.
4943 ///
4944 /// This is a test-support escape hatch. Calling it in production reduces
4945 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4946 /// to discourage accidental production use.
4947 #[doc(hidden)]
4948 pub fn set_sub_capacity(&mut self, capacity: usize) {
4949 self.sub_capacity = capacity;
4950 }
4951
4952 // -----------------------------------------------------------------------
4953
4954 /// Start an atomic batch.
4955 ///
4956 /// The returned [`BatchBuilder`] borrows `self` mutably until
4957 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4958 /// validation, no WAL I/O. `commit` validates every queued op against
4959 /// live state plus preceding ops in this batch (duplicate key inside
4960 /// the batch is `Err`; an edge between two nodes created earlier in
4961 /// the batch is valid; `delete_node` then insert of the same key is a
4962 /// fresh identity). Validation never mutates the database. Any failure
4963 /// leaves WAL bytes and in-memory state identical to before `commit`.
4964 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4965 /// and each inner record is applied in order so rules fire per record.
4966 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4967 ///
4968 /// **Rule-window limitation:** batch validation cannot see edges that a
4969 /// rule created earlier in the *same* batch will derive at apply time, so
4970 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4971 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4972 /// is unaffected (idempotent apply, provenance intact). Create rules in
4973 /// their own batch, or sequentially, when later ops may touch derived
4974 /// edges.
4975 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4976 BatchBuilder {
4977 db: self,
4978 ops: Vec::new(),
4979 }
4980 }
4981
4982 /// Closure-style atomic write batch.
4983 ///
4984 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4985 /// then committing. All ops queued inside `build` are validated in order and
4986 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4987 /// once per inner record, in order, after commit — semantically identical to
4988 /// sequential single-op writes.
4989 ///
4990 /// **Error semantics — validate-then-apply.** `build` queues ops without
4991 /// touching the database. [`BatchBuilder::commit`] validates every op against
4992 /// live state plus earlier ops in this batch before writing anything. If op N
4993 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4994 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4995 /// changes. The database is identical to its state before `write_batch` was
4996 /// called.
4997 ///
4998 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4999 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5000 /// either fully applied or not at all. However, while applying a committed
5001 /// batch, concurrent readers may observe intermediate states as ops are applied
5002 /// sequentially in memory. There is no interactive transaction isolation in v1.
5003 /// This is documented as "crash-atomic write batches; no interactive
5004 /// transactions or read isolation."
5005 ///
5006 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5007 /// writes zero WAL bytes and returns `(0, 0)`.
5008 ///
5009 /// # Example
5010 ///
5011 /// ```rust,ignore
5012 /// let (nodes, edges) = db.write_batch(|b| {
5013 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5014 /// b.insert_node("Person", "bob", vec![]);
5015 /// b.insert_edge("KNOWS", "alice", "bob");
5016 /// b.set_prop("alice", "role", Value::Str("admin".into()));
5017 /// b.delete_node("old_key");
5018 /// })?;
5019 /// // One fsync; on crash replay: all five ops land or none do.
5020 /// ```
5021 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5022 where
5023 C: FnOnce(&mut BatchBuilder<'_, F>),
5024 {
5025 let mut b = self.batch();
5026 build(&mut b);
5027 b.commit()
5028 }
5029
5030 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5031 /// auto-declared KeyMatch rules (if any) first, then the accepted node
5032 /// inserts, so incremental fire sees the new rules. Per-row key problems
5033 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5034 /// `Err` means nothing was applied.
5035 ///
5036 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5037 /// distinct source labels sharing an FK field each get their own rule.
5038 pub fn ingest(
5039 &mut self,
5040 label: &str,
5041 rows: Vec<BTreeMap<String, Value>>,
5042 opts: &IngestOptions,
5043 ) -> Result<IngestReport> {
5044 self.ingest_with_edges(label, rows, opts, &[])
5045 }
5046
5047 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5048 /// A failing edge rejects the whole request; nothing is applied.
5049 pub fn ingest_with_edges(
5050 &mut self,
5051 label: &str,
5052 rows: Vec<BTreeMap<String, Value>>,
5053 opts: &IngestOptions,
5054 edges: &[(String, String, String)],
5055 ) -> Result<IngestReport> {
5056 crate::ingest::run(self, label, rows, opts, edges)
5057 }
5058
5059 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5060 ///
5061 /// JSON `null` fields are silently omitted (not stored, not a row error).
5062 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5063 /// Parse failures and a top-level value that is not an array of objects
5064 /// return [`GraphError::IngestError`].
5065 pub fn ingest_json(
5066 &mut self,
5067 label: &str,
5068 json: &str,
5069 opts: &IngestOptions,
5070 ) -> Result<IngestReport> {
5071 crate::ingest::run_json(self, label, json, opts)
5072 }
5073
5074 fn commit_logged_batch(
5075 &mut self,
5076 ops: Vec<BatchOp>,
5077 ingest: Option<(String, usize)>,
5078 // Two-source rule: write_batch_authz threads authz here directly (never
5079 // touches pending_write_authz); query_write_authz sets the field instead
5080 // and passes None. Only one source is non-None per call.
5081 param_authz: Option<WriteAuthz>,
5082 ) -> Result<(usize, usize)> {
5083 // Read-only guard: catches empty-batch calls before the early-return
5084 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5085 if self.read_only {
5086 return Err(GraphError::ReadOnly);
5087 }
5088 // Ensure provenance is decoded before MutPreview accesses it
5089 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5090 self.engine.ensure_provenance_loaded_mut();
5091
5092 // ── Authz pre-check ──────────────────────────────────────────────────
5093 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5094 // produces no WAL frame (all-or-nothing at the authz boundary extends
5095 // the existing validate-then-apply contract to role-scope checks).
5096 //
5097 // `batch_created` tracks key→label for nodes created by earlier ops in
5098 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5099 // as visible without needing to call `self.ids.get` on not-yet-committed
5100 // keys (they won't be there yet).
5101 //
5102 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5103 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5104 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5105 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5106 if let Some(ref authz) = authz_opt {
5107 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5108 for op in &ops {
5109 self.check_single_op_authz(authz, op, &batch_created)?;
5110 // Update batch_created after a passing authz check so that
5111 // subsequent ops in this batch see the nodes as "about to exist".
5112 match op {
5113 BatchOp::InsertNode { label, key, .. } => {
5114 // Only track genuinely new nodes (absent from the
5115 // snapshot at authz-check time). A pre-existing visible
5116 // key would be a DuplicateKey — not a real creation —
5117 // so MutPreview handles it. Letting it into batch_created
5118 // would allow a later SetProp to bypass update_labels
5119 // via the "batch-created → always updatable" ruling
5120 // (delete+recreate exploit, fix for I1 review round 2).
5121 //
5122 // Accepted edge: for a delete+recreate-with-different-
5123 // label batch, node_status resolves the pre-delete
5124 // (store) label for any subsequent update checks. This
5125 // grants no net-new capability — a role that can delete+
5126 // create can already place arbitrary props via
5127 // InsertNode's own props field.
5128 if self.ids.get(key.as_str()).is_none() {
5129 batch_created.insert(key.clone(), label.clone());
5130 }
5131 }
5132 BatchOp::InsertEdgeUpsert {
5133 placeholder_label,
5134 src_key,
5135 dst_key,
5136 ..
5137 } => {
5138 // Both endpoints will be created if not already in store.
5139 for ep_key in [src_key, dst_key] {
5140 if self.ids.get(ep_key.as_str()).is_none()
5141 && !batch_created.contains_key(ep_key.as_str())
5142 {
5143 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5144 }
5145 }
5146 }
5147 _ => {}
5148 }
5149 }
5150 }
5151
5152 let recs = {
5153 let mut preview = MutPreview::new(self);
5154 let mut recs = Vec::with_capacity(ops.len());
5155 for op in ops {
5156 match op {
5157 BatchOp::InsertNode { label, key, props } => {
5158 preview.check_insert_node(&key)?;
5159 preview.note_insert_node(&key, &props);
5160 recs.push(WalRecord::InsertNode { label, key, props });
5161 }
5162 BatchOp::InsertEdge {
5163 edge_type,
5164 src_key,
5165 dst_key,
5166 } => {
5167 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5168 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5169 recs.push(WalRecord::InsertEdge {
5170 edge_type,
5171 src_key,
5172 dst_key,
5173 });
5174 }
5175 }
5176 BatchOp::SetProp { key, field, value } => {
5177 preview.check_live_key(&key)?;
5178 preview.note_set_prop(&key, &field, &value);
5179 recs.push(WalRecord::SetProp { key, field, value });
5180 }
5181 BatchOp::RemoveProp { key, field } => {
5182 if preview.prepare_remove_prop(&key, &field)? {
5183 preview.note_remove_prop(&key, &field);
5184 recs.push(WalRecord::RemoveProp { key, field });
5185 }
5186 }
5187 BatchOp::DeleteEdge {
5188 edge_type,
5189 src_key,
5190 dst_key,
5191 } => {
5192 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5193 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5194 recs.push(WalRecord::DeleteEdge {
5195 edge_type,
5196 src_key,
5197 dst_key,
5198 });
5199 }
5200 }
5201 BatchOp::DeleteNode { key } => {
5202 preview.check_live_key(&key)?;
5203 preview.note_delete_node(&key);
5204 recs.push(WalRecord::DeleteNode { key });
5205 }
5206 BatchOp::CreateRule(def) => {
5207 preview.check_create_rule(&def)?;
5208 let def_bytes =
5209 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5210 detail: format!("serialize rule: {e}"),
5211 })?;
5212 preview.note_create_rule(&def);
5213 recs.push(WalRecord::CreateRule { def_bytes });
5214 }
5215 BatchOp::DeleteRule { name } => {
5216 preview.check_delete_rule(&name)?;
5217 preview.note_delete_rule(&name);
5218 recs.push(WalRecord::DeleteRule { name });
5219 }
5220 BatchOp::RenameNode { old_key, new_key } => {
5221 preview.check_rename_node(&old_key, &new_key)?;
5222 preview.note_rename_node(&old_key, &new_key);
5223 recs.push(WalRecord::RenameNode { old_key, new_key });
5224 }
5225 BatchOp::InsertEdgeUpsert {
5226 edge_type,
5227 src_key,
5228 dst_key,
5229 placeholder_label,
5230 } => {
5231 // Auto-create any missing endpoints as plain InsertNode ops.
5232 // Rules fire and last-change is updated for each created node.
5233 for key in [&src_key, &dst_key] {
5234 if !preview.has_key(key) {
5235 preview.check_insert_node(key)?;
5236 preview.note_insert_node(key, &[]);
5237 recs.push(WalRecord::InsertNode {
5238 label: placeholder_label.clone(),
5239 key: key.clone(),
5240 props: vec![],
5241 });
5242 }
5243 }
5244 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5245 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5246 recs.push(WalRecord::InsertEdge {
5247 edge_type,
5248 src_key,
5249 dst_key,
5250 });
5251 }
5252 }
5253 }
5254 }
5255 recs
5256 };
5257 if recs.is_empty() {
5258 return Ok((0, 0));
5259 }
5260 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5261 // *Id form, so only the dense variants can appear in `recs` here.
5262 let recs = self.rewrite_wal_dense(recs)?;
5263 let nodes_inserted = recs
5264 .iter()
5265 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5266 .count();
5267 let edges_inserted = recs
5268 .iter()
5269 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5270 .count();
5271 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5272 // under Strict. Pass self.fsync directly so Strict stays Strict —
5273 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5274 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5275 // short-circuit on single-op batches and silently skip the fsync.
5276 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5277 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5278 Ok((nodes_inserted, edges_inserted))
5279 }
5280
5281 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5282 self.commit_logged_batch(ops, None, None)
5283 }
5284
5285 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5286 /// and the group-commit drain thread, which do a single group fsync later.
5287 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5288 // Restore fsync policy even on panic via a raw-pointer drop guard.
5289 // A panic here would poison the RwLock anyway, but the correct policy
5290 // must be in place if the guard is ever unwrapped.
5291 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5292 impl Drop for RestoreFsync {
5293 fn drop(&mut self) {
5294 // SAFETY: the pointer is valid for the full duration of
5295 // commit_batch_nosync; the guard is dropped before the frame
5296 // returns, and GraphDb outlives this frame.
5297 unsafe {
5298 *self.0 = self.1;
5299 }
5300 }
5301 }
5302 let saved = self.fsync;
5303 // SAFETY: raw pointer into self; guard dropped within this frame.
5304 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5305 self.fsync = FsyncPolicy::Relaxed;
5306 self.commit_logged_batch(ops, None, None)
5307 }
5308
5309 /// Commit multiple op-batches as a **group**: each submission gets its own
5310 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5311 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5312 ///
5313 /// # Durability semantics
5314 ///
5315 /// A crash before the group fsync may lose **all** submissions in the group.
5316 /// A crash after the group fsync preserves all of them. No submission is
5317 /// ever torn: each WAL frame is either fully applied on replay or dropped
5318 /// in its entirety (CRC-protected frame boundaries).
5319 ///
5320 /// Events and subscription notifications fire per-submission immediately
5321 /// after apply, which may be before the group fsync. From a subscriber's
5322 /// perspective this is equivalent to the `Relaxed` durability window.
5323 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5324 /// fsync, so from their perspective durability is fully guaranteed.
5325 ///
5326 /// # MVCC interplay
5327 ///
5328 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5329 /// increments per submission (not per group), preserving existing reader
5330 /// snapshot semantics.
5331 ///
5332 /// # Returns
5333 ///
5334 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5335 /// in order. Failures are per-submission (validation errors); the group
5336 /// fsync error (if any) is returned as the second tuple element.
5337 pub fn commit_group(
5338 &mut self,
5339 groups: Vec<Vec<BatchOp>>,
5340 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5341 let mut results = Vec::with_capacity(groups.len());
5342 for ops in groups {
5343 results.push(self.commit_batch_nosync(ops));
5344 }
5345 let any_ok = results.iter().any(|r| r.is_ok());
5346 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5347 self.fs
5348 .sync(core_storage::fs::FileId::Wal)
5349 .map_err(GraphError::Io)
5350 .err()
5351 } else {
5352 None
5353 };
5354 (results, sync_err)
5355 }
5356
5357 /// Like [`commit_group`] but skips the group fsync entirely.
5358 ///
5359 /// Used by the drain thread to apply submissions under the write lock and
5360 /// then perform the single fsync OUTSIDE the lock (via
5361 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5362 /// to concurrent readers.
5363 pub fn commit_group_nosync(
5364 &mut self,
5365 groups: Vec<Vec<BatchOp>>,
5366 ) -> Vec<Result<(usize, usize)>> {
5367 let mut results = Vec::with_capacity(groups.len());
5368 for ops in groups {
5369 results.push(self.commit_batch_nosync(ops));
5370 }
5371 results
5372 }
5373
5374 pub fn insert_node(
5375 &mut self,
5376 label: &str,
5377 key: &str,
5378 props: Vec<(String, Value)>,
5379 ) -> Result<()> {
5380 if self.read_only {
5381 return Err(GraphError::ReadOnly);
5382 }
5383 MutPreview::new(self).check_insert_node(key)?;
5384 self.log_dense(vec![WalRecord::InsertNode {
5385 label: label.into(),
5386 key: key.into(),
5387 props,
5388 }])
5389 }
5390
5391 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5392 if self.read_only {
5393 return Err(GraphError::ReadOnly);
5394 }
5395 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5396 return Ok(false);
5397 }
5398 self.log_dense(vec![WalRecord::InsertEdge {
5399 edge_type: edge_type.into(),
5400 src_key: src_key.into(),
5401 dst_key: dst_key.into(),
5402 }])?;
5403 Ok(true)
5404 }
5405
5406 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5407 if self.read_only {
5408 return Err(GraphError::ReadOnly);
5409 }
5410 if let Some(view_name) = self.view_store.view_for_prop(field) {
5411 return Err(GraphError::ViewPropReadOnly {
5412 view_name: view_name.to_string(),
5413 });
5414 }
5415 MutPreview::new(self).check_live_key(key)?;
5416 self.log_dense(vec![WalRecord::SetProp {
5417 key: key.into(),
5418 field: field.into(),
5419 value,
5420 }])
5421 }
5422
5423 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5424 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5425 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5426 if self.read_only {
5427 return Err(GraphError::ReadOnly);
5428 }
5429 if let Some(view_name) = self.view_store.view_for_prop(field) {
5430 return Err(GraphError::ViewPropReadOnly {
5431 view_name: view_name.to_string(),
5432 });
5433 }
5434 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5435 return Ok(false);
5436 }
5437 self.log_then_apply(WalRecord::RemoveProp {
5438 key: key.into(),
5439 field: field.into(),
5440 })?;
5441 Ok(true)
5442 }
5443
5444 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5445 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5446 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5447 /// (the rule would just put the edge back; delete or change the rule).
5448 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5449 if self.read_only {
5450 return Err(GraphError::ReadOnly);
5451 }
5452 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5453 return Ok(false);
5454 }
5455 self.log_then_apply(WalRecord::DeleteEdge {
5456 edge_type: edge_type.into(),
5457 src_key: src_key.into(),
5458 dst_key: dst_key.into(),
5459 })?;
5460 Ok(true)
5461 }
5462
5463 /// Delete a live node. Unknown or already-tombstoned keys are
5464 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5465 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5466 /// (crash window) is a clean no-op.
5467 ///
5468 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5469 /// removed (computed from live state before the deletion is applied).
5470 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5471 if self.read_only {
5472 return Err(GraphError::ReadOnly);
5473 }
5474 // Provenance must be loaded before we query provenance_touching.
5475 self.engine.ensure_provenance_loaded_mut();
5476 let id = self
5477 .ids
5478 .get(key)
5479 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5480
5481 // Count edges before the delete is applied so we can report counts.
5482 let derived_set: BTreeSet<(u32, u32, u32)> = self
5483 .engine
5484 .provenance_touching(id)
5485 .map(|(_, etype, src, dst)| (etype, src, dst))
5486 .collect();
5487 let derived_edges = derived_set.len() as u64;
5488
5489 let mut total_topo = 0u64;
5490 let tv = self.topo_view();
5491 for et in tv.etypes() {
5492 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5493 + tv.neighbors(et, Direction::In, id).len() as u64;
5494 }
5495 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5496 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5497 // The subtraction remains correct because both counts include both directions.
5498 let manual_edges = total_topo.saturating_sub(derived_edges);
5499
5500 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5501 Ok(DeleteReport {
5502 manual_edges,
5503 derived_edges,
5504 })
5505 }
5506
5507 /// Rename a live node's key. The dense id (and therefore all edges,
5508 /// props, history, and last-change tracking) is unaffected.
5509 ///
5510 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5511 /// Returns `Err(DuplicateKey)` if `new` is already live.
5512 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5513 if self.read_only {
5514 return Err(GraphError::ReadOnly);
5515 }
5516 MutPreview::new(self).check_rename_node(old, new)?;
5517 self.log_then_apply(WalRecord::RenameNode {
5518 old_key: old.into(),
5519 new_key: new.into(),
5520 })
5521 }
5522
5523 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5524 /// `None` if the rule does not exist or is not approximate.
5525 ///
5526 /// The drift counter increments on IVF insert/remove after the last fit.
5527 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5528 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5529 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5530 // SideIvfExport = (centroids, node→cluster, drift)
5531 self.engine
5532 .export_ivf_state()
5533 .remove(rule)
5534 .map(|(_src, dst)| dst.2)
5535 }
5536
5537 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5538 /// Validation and duplicate-name check run before logging so invalid rules
5539 /// never enter the WAL.
5540 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5541 if self.read_only {
5542 return Err(GraphError::ReadOnly);
5543 }
5544 MutPreview::new(self).check_create_rule(&def)?;
5545 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5546 detail: format!("serialize rule: {e}"),
5547 })?;
5548 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5549 }
5550
5551 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5552 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5553 if self.read_only {
5554 return Err(GraphError::ReadOnly);
5555 }
5556 MutPreview::new(self).check_delete_rule(name)?;
5557 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5558 }
5559
5560 /// Return a snapshot of all registered rules.
5561 pub fn rules(&self) -> Vec<RuleDef> {
5562 self.engine.rules().cloned().collect()
5563 }
5564
5565 // -----------------------------------------------------------------------
5566 // Rule suggestion API
5567 // -----------------------------------------------------------------------
5568
5569 /// Profile the database and suggest linking rules with previewed edge counts.
5570 ///
5571 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5572 /// sampling. Suggestions are sorted by estimated edge count (descending).
5573 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5574 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5575 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5576 }
5577
5578 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5579 /// reproducibility. Same seed + same data = identical output.
5580 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5581 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5582 .suggestions
5583 }
5584
5585 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5586 ///
5587 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5588 /// and a `truncated` flag indicating whether the global budget fired before all
5589 /// candidates were evaluated.
5590 pub fn suggest_rules_with_config(
5591 &self,
5592 config: &core_rules::suggest::SuggestConfig,
5593 seed: u64,
5594 ) -> core_rules::SuggestReport {
5595 use std::collections::BTreeMap;
5596
5597 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5598 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5599 for id in 0..self.ids.len() as u32 {
5600 let Some(key) = self.ids.key_of(id) else {
5601 continue;
5602 };
5603 let Some(&sym) = self.labels.get(id as usize) else {
5604 continue;
5605 };
5606 if sym == u32::MAX {
5607 continue; // tombstoned
5608 }
5609 let Some(label) = self.syms.resolve(sym) else {
5610 continue;
5611 };
5612 label_nodes
5613 .entry(label.to_string())
5614 .or_default()
5615 .push((id, key.to_string()));
5616 }
5617
5618 let existing = self.rules();
5619 let pv = build_props_view(&self.props, &self.base);
5620 let all_fields: Vec<String> = pv.field_names();
5621
5622 core_rules::suggest::suggest_rules(
5623 &label_nodes,
5624 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5625 &all_fields,
5626 &existing,
5627 config,
5628 seed,
5629 )
5630 }
5631
5632 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5633 /// plus later mutations replay identically (rebuild is a pure function
5634 /// of state).
5635 ///
5636 /// Only exit from the tripped latch: if the full desired set fits the
5637 /// budget, it is applied completely and `tripped` clears; if it still
5638 /// exceeds the budget, provenance is left untouched and `tripped` stays
5639 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5640 /// Unknown rule → `RuleNotFound`, nothing logged.
5641 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5642 if self.read_only {
5643 return Err(GraphError::ReadOnly);
5644 }
5645 if !self.engine.rules().any(|r| r.name == name) {
5646 return Err(GraphError::RuleNotFound { name: name.into() });
5647 }
5648 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5649 }
5650
5651 // -----------------------------------------------------------------------
5652 // Materialized view API
5653 // -----------------------------------------------------------------------
5654
5655 /// Register a new materialized property view, backfill its values for all
5656 /// existing nodes, and WAL-log the definition.
5657 ///
5658 /// # Errors
5659 /// - `ReadOnly`: called on an as-of instance.
5660 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5661 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5662 if self.read_only {
5663 return Err(GraphError::ReadOnly);
5664 }
5665 // Pre-validate before WAL write.
5666 def.validate()
5667 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5668 if self.view_store.has_view(&def.name) {
5669 return Err(GraphError::RuleInvalid {
5670 detail: format!("view {:?} already exists", def.name),
5671 });
5672 }
5673 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5674 return Err(GraphError::RuleInvalid {
5675 detail: format!(
5676 "view_prop {:?} is already used by view {:?}",
5677 def.view_prop, existing
5678 ),
5679 });
5680 }
5681 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5682 detail: format!("serialize view: {e}"),
5683 })?;
5684 // Enable delta accumulation before the view is registered so subsequent
5685 // incremental edge events reach view maintenance from this point onward.
5686 // (The backfill inside create_view reads topo directly; it does not rely
5687 // on pending deltas.)
5688 self.engine.set_emit_deltas(true);
5689 self.log_then_apply(WalRecord::CreateView { def_bytes })
5690 }
5691
5692 /// Remove a named view and delete its values from every node.
5693 ///
5694 /// # Errors
5695 /// - `ReadOnly`: called on an as-of instance.
5696 /// - `RuleNotFound`: view does not exist.
5697 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5698 if self.read_only {
5699 return Err(GraphError::ReadOnly);
5700 }
5701 if !self.view_store.has_view(name) {
5702 return Err(GraphError::RuleNotFound { name: name.into() });
5703 }
5704 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5705 // After deletion, disable accumulation if no listeners remain.
5706 if !self.needs_emit_deltas() {
5707 self.engine.set_emit_deltas(false);
5708 }
5709 result
5710 }
5711
5712 /// Snapshot of all registered view definitions.
5713 pub fn views(&self) -> Vec<ViewDef> {
5714 self.view_store.views().cloned().collect()
5715 }
5716
5717 // -----------------------------------------------------------------------
5718 // Full-text-lite API
5719 // -----------------------------------------------------------------------
5720
5721 /// Enable full-text indexing for all nodes of `label` on property `field`.
5722 ///
5723 /// After this call, every subsequent write to `(label, field)` is reflected
5724 /// in the index incrementally. Existing nodes are backfilled immediately.
5725 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5726 /// from scratch on re-open (no snapshot format changes).
5727 ///
5728 /// # Errors
5729 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5730 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5731 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5732 if self.read_only {
5733 return Err(GraphError::ReadOnly);
5734 }
5735 if self.fulltext.is_enabled(label, field) {
5736 return Err(GraphError::RuleInvalid {
5737 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5738 });
5739 }
5740 self.log_then_apply(WalRecord::EnableFulltext {
5741 label: label.into(),
5742 field: field.into(),
5743 })
5744 }
5745
5746 /// Disable full-text indexing for `(label, field)` and drop its postings.
5747 ///
5748 /// # Errors
5749 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5750 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5751 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5752 if self.read_only {
5753 return Err(GraphError::ReadOnly);
5754 }
5755 if !self.fulltext.is_enabled(label, field) {
5756 return Err(GraphError::RuleNotFound {
5757 name: format!("fulltext({label},{field})"),
5758 });
5759 }
5760 self.log_then_apply(WalRecord::DisableFulltext {
5761 label: label.into(),
5762 field: field.into(),
5763 })
5764 }
5765
5766 /// Whether `(label, field)` is currently indexed for full-text search.
5767 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5768 self.fulltext.is_enabled(label, field)
5769 }
5770
5771 /// Every `(label, field)` pair with a live full-text index, sorted.
5772 ///
5773 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5774 /// declares which nodes are *indexed*, so callers that want to search
5775 /// everything indexed should query each distinct field once.
5776 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5777 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5778 v.sort();
5779 v
5780 }
5781
5782 /// Enable an equality index for all nodes of `label` on scalar property
5783 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5784 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5785 /// declaration persists via WAL and the postings rebuild on re-open.
5786 ///
5787 /// # Errors
5788 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5789 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5790 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5791 if self.read_only {
5792 return Err(GraphError::ReadOnly);
5793 }
5794 if self.prop_index.is_enabled(label, field) {
5795 return Err(GraphError::RuleInvalid {
5796 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5797 });
5798 }
5799 self.log_then_apply(WalRecord::EnableIndex {
5800 label: label.into(),
5801 field: field.into(),
5802 })
5803 }
5804
5805 /// Disable the equality index for `(label, field)` and drop its postings.
5806 ///
5807 /// # Errors
5808 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5809 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5810 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5811 if self.read_only {
5812 return Err(GraphError::ReadOnly);
5813 }
5814 if !self.prop_index.is_enabled(label, field) {
5815 return Err(GraphError::RuleNotFound {
5816 name: format!("index({label},{field})"),
5817 });
5818 }
5819 self.log_then_apply(WalRecord::DisableIndex {
5820 label: label.into(),
5821 field: field.into(),
5822 })
5823 }
5824
5825 /// Whether `(label, field)` currently has an equality index.
5826 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5827 self.prop_index.is_enabled(label, field)
5828 }
5829
5830 /// Search a full-text-indexed field.
5831 ///
5832 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5833 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5834 ///
5835 /// **Query syntax:**
5836 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5837 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5838 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5839 /// - `AND` keyword is accepted explicitly and is the default.
5840 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5841 ///
5842 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5843 /// Pin: this is the documented, tested, stable behavior for v1.
5844 ///
5845 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5846 /// in-memory and proportional to total indexed text across all enabled fields.
5847 ///
5848 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5849 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5850 /// key ascending for deterministic tiebreaking.
5851 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5852 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5853 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5854 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5855 // which diverges from key order when nodes were not inserted in key-lex order.
5856 let mut results: Vec<(String, f64)> = self
5857 .fulltext
5858 .search(field, query, 0)
5859 .into_iter()
5860 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5861 .collect();
5862 results.sort_by(|a, b| {
5863 b.1.partial_cmp(&a.1)
5864 .unwrap_or(std::cmp::Ordering::Equal)
5865 .then(a.0.cmp(&b.0))
5866 });
5867 results
5868 }
5869
5870 /// [`search`](Self::search), stopping at the `k` best hits.
5871 ///
5872 /// Same ranking and the same deterministic tiebreak, but the index drops
5873 /// everything past `k` before any key is resolved, so a caller that wants
5874 /// the top few out of a field that matched thousands does not pay to
5875 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5876 /// [`search`](Self::search) behaves.
5877 ///
5878 /// The BM25 scoring itself is not bounded by `k` — every candidate is
5879 /// scored either way — so this trims the resolve and the sort, not the
5880 /// search.
5881 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5882 // A tombstoned id resolves to nothing, so asking the index for exactly
5883 // `k` could return fewer. Over-fetching a little and truncating after
5884 // the filter keeps the count right without unbounding the call.
5885 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5886 let mut results: Vec<(String, f64)> = self
5887 .fulltext
5888 .search(field, query, want)
5889 .into_iter()
5890 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5891 .collect();
5892 results.sort_by(|a, b| {
5893 b.1.partial_cmp(&a.1)
5894 .unwrap_or(std::cmp::Ordering::Equal)
5895 .then(a.0.cmp(&b.0))
5896 });
5897 if k > 0 {
5898 results.truncate(k);
5899 }
5900 results
5901 }
5902
5903 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5904 ///
5905 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5906 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5907 /// them with RRF using a fixed constant of 60.
5908 ///
5909 /// ```text
5910 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5911 /// ```
5912 ///
5913 /// Returns the top `k` nodes by fused score, ties broken by node key
5914 /// ascending (deterministic).
5915 ///
5916 /// # Vector leg fallback
5917 ///
5918 /// When `query_vec` is empty the vector leg is skipped entirely and
5919 /// results are ranked by the text list alone through the same RRF path
5920 /// (each text result scores `1/(60 + rank)` from that single list).
5921 ///
5922 /// When `label` is `None`, the vector leg **always** returns empty results.
5923 /// Internally `label` is mapped to `""`, which does not match any rule-created
5924 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5925 /// the brute-force fallback finds no nodes with an empty label. The fused
5926 /// ranking is therefore text-only in this case.
5927 pub fn search_hybrid(
5928 &self,
5929 text_field: &str,
5930 query_text: &str,
5931 vector_field: &str,
5932 query_vec: &[f64],
5933 label: Option<&str>,
5934 k: usize,
5935 ) -> Vec<(String, f64)> {
5936 use std::collections::HashMap;
5937
5938 const RRF_K: f64 = 60.0;
5939 let pool = 4 * k;
5940
5941 // Accumulate per-node RRF scores.
5942 let mut scores: HashMap<String, f64> = HashMap::new();
5943
5944 // Text leg.
5945 let text_hits = self.search(text_field, query_text);
5946 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5947 let rank = (rank0 + 1) as f64;
5948 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5949 }
5950
5951 // Vector leg (skipped when query_vec is empty).
5952 if !query_vec.is_empty() {
5953 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5954 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5955 let rank = (rank0 + 1) as f64;
5956 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5957 }
5958 }
5959
5960 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5961 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5962 ranked.sort_by(|a, b| {
5963 b.1.partial_cmp(&a.1)
5964 .unwrap_or(std::cmp::Ordering::Equal)
5965 .then(a.0.cmp(&b.0))
5966 });
5967 ranked.truncate(k);
5968 ranked
5969 }
5970
5971 /// For DST/testing: scratch BM25 search over live nodes without the index.
5972 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5973 /// returns BM25-ranked results.
5974 ///
5975 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5976 /// `scratch_search(field, q)` at every quiescent state.
5977 #[doc(hidden)]
5978 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5979 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5980 use std::collections::BTreeMap;
5981
5982 let groups = parse_query(query);
5983 if groups.is_empty() {
5984 return vec![];
5985 }
5986
5987 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5988 struct NodeData {
5989 key: String,
5990 /// stemmed_token → positions (sorted)
5991 tokens: BTreeMap<String, Vec<u32>>,
5992 dl: u32,
5993 }
5994
5995 let mut nodes: Vec<NodeData> = Vec::new();
5996 for id in 0..self.ids.len() as u32 {
5997 let Some(key) = self.ids.key_of(id) else {
5998 continue;
5999 };
6000 let Some(&sym) = self.labels.get(id as usize) else {
6001 continue;
6002 };
6003 if sym == u32::MAX {
6004 continue;
6005 }
6006 let label = match self.syms.resolve(sym) {
6007 Some(l) => l,
6008 None => continue,
6009 };
6010 if !self.fulltext.is_enabled(label, field) {
6011 continue;
6012 }
6013 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6014 continue;
6015 };
6016 // Use value_tokens_stemmed_with_positions so list elements are
6017 // separated by POSITION_GAP — identical to the index path, which
6018 // prevents phrase queries from matching across element boundaries.
6019 let stemmed_with_pos = match &value {
6020 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6021 _ => continue,
6022 };
6023 let dl = stemmed_with_pos.len() as u32;
6024 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6025 for (tok, pos) in stemmed_with_pos {
6026 tok_map.entry(tok).or_default().push(pos);
6027 }
6028 nodes.push(NodeData {
6029 key: key.to_string(),
6030 tokens: tok_map,
6031 dl,
6032 });
6033 }
6034
6035 if nodes.is_empty() {
6036 return vec![];
6037 }
6038
6039 // --- BM25 corpus stats ---
6040 let n = nodes.len() as f64;
6041 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6042 // df per stemmed token across all live indexed nodes.
6043 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6044 for nd in &nodes {
6045 for tok in nd.tokens.keys() {
6046 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6047 }
6048 }
6049
6050 const K1: f64 = 1.2;
6051 const B: f64 = 0.75;
6052
6053 // --- Pass 2: score each node against each OR-group ---
6054 let mut results: Vec<(String, f64)> = Vec::new();
6055 for nd in &nodes {
6056 let dl = nd.dl as f64;
6057 let mut total_score = 0.0f64;
6058
6059 'group: for group in &groups {
6060 let mut group_score = 0.0f64;
6061
6062 for term in group {
6063 if term.negated {
6064 // Negated: if doc has this stemmed token → group fails.
6065 let present = if term.prefix {
6066 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6067 } else {
6068 nd.tokens.contains_key(term.token.as_str())
6069 };
6070 if present {
6071 continue 'group;
6072 }
6073 continue;
6074 }
6075 if term.prefix {
6076 // Prefix: sum BM25 for all matching stemmed tokens.
6077 let mut prefix_matched = false;
6078 for (tok, positions) in &nd.tokens {
6079 if tok.starts_with(term.token.as_str()) {
6080 let tf = positions.len() as f64;
6081 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6082 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6083 let tf_norm =
6084 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6085 group_score += idf * tf_norm;
6086 prefix_matched = true;
6087 }
6088 }
6089 if !prefix_matched {
6090 continue 'group;
6091 }
6092 } else {
6093 // term.token is already stemmed by parse_query; use directly.
6094 match nd.tokens.get(term.token.as_str()) {
6095 None => continue 'group,
6096 Some(positions) => {
6097 let tf = positions.len() as f64;
6098 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6099 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6100 let tf_norm =
6101 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6102 group_score += idf * tf_norm;
6103 }
6104 }
6105 }
6106 }
6107
6108 if group_score > 0.0 {
6109 total_score += group_score;
6110 }
6111 }
6112
6113 if total_score > 0.0 {
6114 results.push((nd.key.clone(), total_score));
6115 }
6116 }
6117
6118 results.sort_by(|a, b| {
6119 b.1.partial_cmp(&a.1)
6120 .unwrap_or(std::cmp::Ordering::Equal)
6121 .then(a.0.cmp(&b.0))
6122 });
6123 results
6124 }
6125
6126 /// Return the current view-maintained value of `view_prop` for node `key`.
6127 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6128 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6129 let id = self.ids.get(key)?;
6130 self.props_view()
6131 .get(id, view_prop)
6132 .map(|vr| vr.into_value())
6133 }
6134
6135 /// For testing / DST oracle: scratch recompute of a view value for one node.
6136 ///
6137 /// Returns `None` if the node does not exist, the view does not exist, or
6138 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6139 #[doc(hidden)]
6140 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6141 let node = self.ids.get(key)?;
6142 let def = self.view_store.views().find(|v| v.name == view_name)?;
6143 // Use TopologyView so that NeighborAgg sees base + overlay edges
6144 // without materialising a temporary Topology (I1).
6145 let topo_view = self.topo_view();
6146 core_rules::views::compute_view_value(
6147 def,
6148 node,
6149 self.props_view(),
6150 &topo_view,
6151 &self.ids,
6152 &self.syms,
6153 &self.labels,
6154 )
6155 }
6156
6157 // -----------------------------------------------------------------------
6158 // Graph algorithm API
6159 // -----------------------------------------------------------------------
6160
6161 /// Run PageRank over the unified topology (manual + derived edges).
6162 ///
6163 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6164 /// ascending). Set `config.edge_type` to restrict to one edge type.
6165 /// `config.converged` is `true` only when the power iteration converged
6166 /// within `config.max_iters` and within any time budget.
6167 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6168 let topo = build_topo_view(&self.topo, &self.base);
6169 let edge_props = self.edge_props_view();
6170 crate::algo::pagerank(
6171 &topo,
6172 &self.ids,
6173 &self.syms,
6174 &self.labels,
6175 &edge_props,
6176 config,
6177 )
6178 }
6179
6180 /// Weakly-connected components over the unified topology (treated as
6181 /// undirected regardless of how edges were inserted).
6182 ///
6183 /// Component IDs are the key of the smallest member in the component
6184 /// (deterministic). Result sorted by (component_id, key).
6185 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6186 let topo = build_topo_view(&self.topo, &self.base);
6187 let edge_props = self.edge_props_view();
6188 crate::algo::wcc(
6189 &topo,
6190 &self.ids,
6191 &self.syms,
6192 &self.labels,
6193 &edge_props,
6194 config,
6195 )
6196 }
6197
6198 /// Degree centrality for every live node.
6199 ///
6200 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6201 /// `AlgoDir::Both` = out + in (total directed degree).
6202 ///
6203 /// For one-shot ranking use this; for a live property updated on every
6204 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6205 pub fn degree_centrality(
6206 &self,
6207 config: &crate::algo::DegreeConfig,
6208 ) -> crate::algo::DegreeReport {
6209 let topo = build_topo_view(&self.topo, &self.base);
6210 let edge_props = self.edge_props_view();
6211 crate::algo::degree_centrality(
6212 &topo,
6213 &self.ids,
6214 &self.syms,
6215 &self.labels,
6216 &edge_props,
6217 config,
6218 )
6219 }
6220
6221 /// Louvain community detection over the unified topology (undirected).
6222 ///
6223 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6224 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6225 /// result (communities sorted size-desc, then smallest member key asc).
6226 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6227 let topo = build_topo_view(&self.topo, &self.base);
6228 let edge_props = self.edge_props_view();
6229 crate::algo::louvain(
6230 &topo,
6231 &self.ids,
6232 &self.syms,
6233 &self.labels,
6234 &edge_props,
6235 config,
6236 )
6237 }
6238
6239 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6240 /// atomically via a single write-batch (one WAL frame, one fsync).
6241 ///
6242 /// # Errors
6243 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6244 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6245 /// (collision check mirrors `create_view`).
6246 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6247 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6248 if self.read_only {
6249 return Err(GraphError::ReadOnly);
6250 }
6251 // Collision check: refuse if prop_name is view-managed.
6252 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6253 return Err(GraphError::RuleInvalid {
6254 detail: format!(
6255 "prop {:?} is managed by view {:?} and cannot be written as scores",
6256 prop_name, view_name
6257 ),
6258 });
6259 }
6260 // Refuse if prop_name is a view name itself (confusing namespace collision).
6261 if self.view_store.has_view(prop_name) {
6262 return Err(GraphError::RuleInvalid {
6263 detail: format!(
6264 "prop_name {:?} collides with an existing view name",
6265 prop_name
6266 ),
6267 });
6268 }
6269 // Write all scores in a single crash-atomic batch.
6270 self.write_batch(|b| {
6271 for (key, score) in scores {
6272 b.set_prop(key, prop_name, Value::Float(*score));
6273 }
6274 })?;
6275 Ok(())
6276 }
6277
6278 /// Return the value of `field` for the node with key `key`, or `None` if
6279 /// the node or field is absent. Reads through the overlay-over-base
6280 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6281 /// overlay hits; one clone per base hit).
6282 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6283 let id = self.ids.get(key)?;
6284 self.props_view().get(id, field).map(|vr| vr.into_value())
6285 }
6286
6287 pub fn has_node(&self, key: &str) -> bool {
6288 self.ids.get(key).is_some()
6289 }
6290
6291 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6292 pub(crate) fn ids(&self) -> &IdMap {
6293 &self.ids
6294 }
6295
6296 // -----------------------------------------------------------------------
6297 // RBAC role resolution
6298 // -----------------------------------------------------------------------
6299
6300 /// Parse `roles.json` bytes from `fs`.
6301 ///
6302 /// Return values:
6303 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6304 /// and valid; in both cases `mask_for_role` uses the
6305 /// list normally (an absent file means no roles defined).
6306 /// `Ok(None)` — file present but corrupt or unrecognised version
6307 /// → poisoned state; `mask_for_role` returns `Err` for
6308 /// any role name until the file is fixed and the DB
6309 /// re-opened (or `apply_schema` is called to repair it).
6310 ///
6311 /// Note: `None` signals corruption, not absence — the opposite of what an
6312 /// optional "file missing" convention would suggest. The open path stores
6313 /// this result on `db.roles` directly.
6314 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6315 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6316 if bytes.is_empty() {
6317 // Empty bytes means either the file is absent or zero-byte — both
6318 // are treated identically as "no roles defined". A zero-byte
6319 // roles.json does NOT widen access: an absent file and a zero-byte
6320 // file both resolve to an empty role list (sees nothing by default).
6321 return Ok(Some(vec![]));
6322 }
6323 match serde_json::from_slice::<RolesFile>(&bytes) {
6324 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
6325 // Corrupt or unrecognised version (>2): poison the roles state.
6326 _ => Ok(None),
6327 }
6328 }
6329
6330 /// Resolve a role to a node-visibility mask against the current graph state.
6331 ///
6332 /// Returns `Err` when:
6333 /// - `roles.json` was present but corrupt at open (poisoned state), or
6334 /// - `role` does not match any defined role name.
6335 ///
6336 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6337 /// all live nodes carrying any label in `labels`. Label resolution is live
6338 /// — new nodes of an allowed label are visible without re-applying the
6339 /// schema. An empty union = empty mask = sees nothing.
6340 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6341 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6342 detail:
6343 "roles.json was corrupt at open; fix the file and re-open to restore role access"
6344 .into(),
6345 })?;
6346 let def = roles
6347 .iter()
6348 .find(|r| r.name == role)
6349 .ok_or_else(|| GraphError::KeyNotFound {
6350 key: format!("role:{role}"),
6351 })?;
6352
6353 let mut visible = std::collections::HashSet::new();
6354
6355 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6356 for key in &def.keys {
6357 if let Some(id) = self.ids.get(key) {
6358 visible.insert(id);
6359 }
6360 }
6361
6362 // Label leg: live scan — iterate labels vec for matching symbol.
6363 for label_name in &def.labels {
6364 if let Some(sym) = self.syms.get(label_name) {
6365 for (i, &s) in self.labels.iter().enumerate() {
6366 if s == sym {
6367 visible.insert(i as u32);
6368 }
6369 }
6370 }
6371 }
6372
6373 Ok(crate::mask::NodeMask::from_ids(visible))
6374 }
6375
6376 /// Return the current list of role definitions.
6377 ///
6378 /// Returns an empty list when no roles are defined or when `roles.json`
6379 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6380 /// the fail-loud error in that case).
6381 pub fn roles(&self) -> Vec<RoleDef> {
6382 self.roles.as_deref().unwrap_or(&[]).to_vec()
6383 }
6384
6385 // ── Role-scoped write authz ───────────────────────────────────────────────
6386
6387 /// Execute `ops` with optional role-scoped write authorization.
6388 ///
6389 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6390 /// (zero-cost bypass of all authz checks).
6391 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6392 /// record is built. A denial returns an error with no WAL frame written
6393 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6394 ///
6395 /// See the plan's "authz decision table" section for the full semantics.
6396 pub fn write_batch_authz(
6397 &mut self,
6398 authz: Option<&WriteAuthz>,
6399 ops: Vec<BatchOp>,
6400 ) -> Result<(usize, usize)> {
6401 // Thread authz as a direct parameter — never touches pending_write_authz.
6402 self.commit_logged_batch(ops, None, authz.cloned())
6403 }
6404
6405 /// Execute a Cypher write statement with role-scoped write authorization.
6406 ///
6407 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6408 /// lifetime as execution, satisfying §5 lock discipline). The resolved
6409 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6410 /// call so that all inner `batch.commit()` calls are authz-checked.
6411 ///
6412 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6413 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6414 /// timing-oracle item (hidden ≡ absent for unscoped roles).
6415 ///
6416 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6417 /// "this endpoint is not permitted".
6418 pub fn query_write_authz(
6419 &mut self,
6420 role: &str,
6421 cypher: &str,
6422 params: &BTreeMap<String, Value>,
6423 ) -> Result<ResultSet> {
6424 // Resolve scope (fails fast if role has no write scope).
6425 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6426 let scope =
6427 {
6428 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6429 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6430 })?;
6431 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6432 GraphError::KeyNotFound {
6433 key: format!("role:{role}"),
6434 }
6435 })?;
6436 def.write
6437 .clone()
6438 .ok_or_else(|| GraphError::RoleWriteDenied {
6439 reason: "role-bound token: writes are not permitted".into(),
6440 })?
6441 };
6442 // Resolve mask inside the call (same guard, §5 coherence).
6443 let mask = self.mask_for_role(role)?;
6444 self.pending_write_authz = Some(WriteAuthz {
6445 role: role.into(),
6446 scope,
6447 mask,
6448 });
6449 // RAII guard: always clears pending_write_authz on scope exit, including
6450 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6451 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6452 impl Drop for ClearPendingAuthzOnDrop {
6453 fn drop(&mut self) {
6454 // SAFETY: pointer into the owning GraphDb; guard is dropped
6455 // within this function's frame before it returns.
6456 unsafe { *self.0 = None };
6457 }
6458 }
6459 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6460 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6461 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6462 detail: format!("lex: {e}"),
6463 })?;
6464 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6465 detail: format!("parse: {e}"),
6466 })?;
6467 self.exec_write_stmt(stmt, params)
6468 }
6469
6470 /// Execute `ops` with optional role-scoped write authorization, suppressing
6471 /// fsync (for use inside the group-commit drain thread, which performs one
6472 /// group fsync after releasing the write lock).
6473 ///
6474 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6475 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6476 /// contract established by [`commit_batch_nosync`].
6477 pub(crate) fn write_batch_authz_nosync(
6478 &mut self,
6479 authz: Option<&WriteAuthz>,
6480 ops: Vec<BatchOp>,
6481 ) -> Result<(usize, usize)> {
6482 let saved = self.fsync;
6483 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6484 impl Drop for RestoreFsync {
6485 fn drop(&mut self) {
6486 // SAFETY: pointer into the owning GraphDb; guard is dropped
6487 // within the enclosing function's frame before it returns.
6488 unsafe { *self.0 = self.1 };
6489 }
6490 }
6491 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6492 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6493 self.fsync = FsyncPolicy::Relaxed;
6494 self.commit_logged_batch(ops, None, authz.cloned())
6495 }
6496
6497 /// Execute a `/ingest` request with role-scoped write authorization.
6498 ///
6499 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6500 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6501 /// Sets `pending_write_authz` for the duration of the call so that the
6502 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6503 /// and evaluates the decision table per-op before any WAL write.
6504 ///
6505 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6506 /// denied by the decision table with the appropriate §4.3 scope reason;
6507 /// no special HTTP-layer check is needed.
6508 ///
6509 /// Roles with `write: None` return `RoleWriteDenied` with
6510 /// "writes are not permitted" (byte-identical to v1 blanket 403).
6511 pub fn ingest_with_edges_authz(
6512 &mut self,
6513 role: &str,
6514 label: &str,
6515 rows: Vec<std::collections::BTreeMap<String, Value>>,
6516 opts: &crate::ingest::IngestOptions,
6517 edges: &[(String, String, String)],
6518 ) -> Result<crate::ingest::IngestReport> {
6519 // Resolve scope (fails fast if role has no write scope).
6520 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6521 let scope =
6522 {
6523 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6524 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6525 })?;
6526 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6527 GraphError::KeyNotFound {
6528 key: format!("role:{role}"),
6529 }
6530 })?;
6531 def.write
6532 .clone()
6533 .ok_or_else(|| GraphError::RoleWriteDenied {
6534 reason: "role-bound token: writes are not permitted".into(),
6535 })?
6536 };
6537 let mask = self.mask_for_role(role)?;
6538 self.pending_write_authz = Some(WriteAuthz {
6539 role: role.into(),
6540 scope,
6541 mask,
6542 });
6543 // RAII guard: always clears pending_write_authz on scope exit, including
6544 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6545 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6546 impl Drop for ClearPendingAuthzOnDrop {
6547 fn drop(&mut self) {
6548 // SAFETY: pointer into the owning GraphDb; guard is dropped
6549 // within this function's frame before it returns.
6550 unsafe { *self.0 = None };
6551 }
6552 }
6553 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6554 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6555 self.ingest_with_edges(label, rows, opts, edges)
6556 }
6557
6558 /// Evaluate the write-authz decision table for one `BatchOp`.
6559 ///
6560 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6561 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
6562 /// the remaining ops are not evaluated and no WAL frame is written.
6563 ///
6564 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6565 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
6566 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6567 /// batch creates counts as visible if its label passed the create-class gate").
6568 fn check_single_op_authz(
6569 &self,
6570 authz: &WriteAuthz,
6571 op: &BatchOp,
6572 batch_created: &BTreeMap<String, String>,
6573 ) -> Result<()> {
6574 // Helper: 3-way node status under the authz mask.
6575 //
6576 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6577 // as Visible with their recorded label — their create gate already passed
6578 // and they are not yet in self.ids (not committed). This fixes the
6579 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6580 // the SetProp must not see the node as Absent.
6581 let node_status = |key: &str| -> NodeAuthzStatus {
6582 if let Some(label) = batch_created.get(key) {
6583 return NodeAuthzStatus::Visible(label.clone());
6584 }
6585 match self.ids.get(key) {
6586 None => NodeAuthzStatus::Absent,
6587 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6588 Some(id) => {
6589 let label = self
6590 .labels
6591 .get(id as usize)
6592 .and_then(|&sym| {
6593 if sym == u32::MAX {
6594 None
6595 } else {
6596 self.syms.resolve(sym).map(str::to_string)
6597 }
6598 })
6599 .unwrap_or_default();
6600 NodeAuthzStatus::Visible(label)
6601 }
6602 }
6603 };
6604
6605 // Helper: is an InsertEdgeUpsert endpoint visible?
6606 // A same-batch placeholder counts as visible if its label passed
6607 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6608 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6609 // In store and visible?
6610 if let Some(id) = self.ids.get(ep_key) {
6611 return authz.mask.contains_id(id);
6612 }
6613 // Created by an earlier op in this batch?
6614 if let Some(created_label) = batch_created.get(ep_key) {
6615 return authz.scope.create_labels.contains(created_label);
6616 }
6617 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6618 // must pass the create-class gate.
6619 authz
6620 .scope
6621 .create_labels
6622 .contains(&placeholder_label.to_string())
6623 };
6624
6625 match op {
6626 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6627 // These ops are never routed to role-scoped paths by the HTTP layer,
6628 // but we 403 them here to close any future bypass route.
6629 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6630 return Err(GraphError::RoleWriteDenied {
6631 reason: "role-bound token: this endpoint is not permitted".into(),
6632 });
6633 }
6634
6635 // ── CREATE-class: InsertNode ─────────────────────────────────────
6636 //
6637 // Decision table row 1 (scope-before-lookup): check label in
6638 // create_labels BEFORE any key lookup. This is the structural
6639 // closure of the §6.2 timing-oracle item — the denial fires even
6640 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6641 BatchOp::InsertNode { label, key, .. } => {
6642 if !authz.scope.create_labels.contains(label) {
6643 return Err(GraphError::RoleWriteDenied {
6644 reason: format!(
6645 "role-bound token: label '{}' not in write scope (create_labels)",
6646 label
6647 ),
6648 });
6649 }
6650 // Row 2/3: key lookup.
6651 match self.ids.get(key.as_str()) {
6652 Some(id) if authz.mask.contains_id(id) => {
6653 // Visible: DuplicateKey — let MutPreview handle this.
6654 }
6655 Some(_) => {
6656 // Hidden: indistinguishable from absent to the role.
6657 return Err(GraphError::RoleWriteDenied {
6658 reason: "role-bound token: target node not visible".into(),
6659 });
6660 }
6661 None => {
6662 // Absent: proceed (create).
6663 }
6664 }
6665 }
6666
6667 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6668 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6669 if batch_created.contains_key(key.as_str()) {
6670 // Batch-created node: create gate already passed this batch.
6671 // Updating it in the same batch is always allowed, regardless
6672 // of update_labels (ruling §3.5: "writer just created it").
6673 } else {
6674 let label = match node_status(key) {
6675 NodeAuthzStatus::Visible(lbl) => lbl,
6676 _ => {
6677 return Err(GraphError::RoleWriteDenied {
6678 reason: "role-bound token: target node not visible".into(),
6679 });
6680 }
6681 };
6682 if !authz.scope.update_labels.contains(&label) {
6683 return Err(GraphError::RoleWriteDenied {
6684 reason: format!(
6685 "role-bound token: label '{}' not in write scope (update_labels)",
6686 label
6687 ),
6688 });
6689 }
6690 }
6691 }
6692
6693 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6694 BatchOp::DeleteNode { key } => {
6695 let label = match node_status(key) {
6696 NodeAuthzStatus::Visible(lbl) => lbl,
6697 _ => {
6698 return Err(GraphError::RoleWriteDenied {
6699 reason: "role-bound token: target node not visible".into(),
6700 });
6701 }
6702 };
6703 if !authz.scope.delete_labels.contains(&label) {
6704 return Err(GraphError::RoleWriteDenied {
6705 reason: format!(
6706 "role-bound token: label '{}' not in write scope (delete_labels)",
6707 label
6708 ),
6709 });
6710 }
6711 }
6712
6713 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6714 //
6715 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6716 // check (spec §3.5: "existing derived-edge rejection precedes
6717 // delete_edge_types check").
6718 BatchOp::DeleteEdge {
6719 edge_type,
6720 src_key,
6721 dst_key,
6722 } => {
6723 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6724 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6725 self.ids.get(src_key.as_str()),
6726 self.ids.get(dst_key.as_str()),
6727 self.syms.get(edge_type.as_str()),
6728 ) {
6729 if self.engine.is_owned(et_sym, src_id, dst_id) {
6730 return Err(GraphError::RuleOwned {
6731 detail: format!(
6732 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6733 delete or change the owning rule"
6734 ),
6735 });
6736 }
6737 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6738 let preview = MutPreview::new(self);
6739 if preview.would_derive(edge_type, src_key, dst_key) {
6740 return Err(GraphError::RuleOwned {
6741 detail: format!(
6742 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6743 delete or change the owning rule, or a live rule would \
6744 re-derive it"
6745 ),
6746 });
6747 }
6748 }
6749 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6750 if !authz.scope.delete_edge_types.contains(edge_type) {
6751 return Err(GraphError::RoleWriteDenied {
6752 reason: format!(
6753 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6754 edge_type
6755 ),
6756 });
6757 }
6758 // Both endpoints must be visible.
6759 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6760 match self.ids.get(ep_key) {
6761 None => {
6762 return Err(GraphError::RoleWriteDenied {
6763 reason: "role-bound token: edge endpoint not visible".into(),
6764 });
6765 }
6766 Some(id) if !authz.mask.contains_id(id) => {
6767 return Err(GraphError::RoleWriteDenied {
6768 reason: "role-bound token: edge endpoint not visible".into(),
6769 });
6770 }
6771 _ => {}
6772 }
6773 }
6774 }
6775
6776 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6777 //
6778 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6779 BatchOp::InsertEdge {
6780 edge_type,
6781 src_key,
6782 dst_key,
6783 } => {
6784 if !authz.scope.create_edge_types.contains(edge_type) {
6785 return Err(GraphError::RoleWriteDenied {
6786 reason: format!(
6787 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6788 edge_type
6789 ),
6790 });
6791 }
6792 // Both endpoints must be visible. A node created by an earlier
6793 // InsertNode in the same batch (tracked in batch_created) counts
6794 // as visible if its label passed the create-class gate.
6795 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6796 if batch_created.contains_key(ep_key) {
6797 // Created earlier this batch — already scope-checked.
6798 continue;
6799 }
6800 match self.ids.get(ep_key) {
6801 None => {
6802 return Err(GraphError::RoleWriteDenied {
6803 reason: "role-bound token: edge endpoint not visible".into(),
6804 });
6805 }
6806 Some(id) if !authz.mask.contains_id(id) => {
6807 return Err(GraphError::RoleWriteDenied {
6808 reason: "role-bound token: edge endpoint not visible".into(),
6809 });
6810 }
6811 _ => {}
6812 }
6813 }
6814 }
6815
6816 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6817 //
6818 // Scope check first; then endpoint visibility using same-batch
6819 // placeholder awareness (spec: "a placeholder endpoint the SAME
6820 // batch creates counts as visible if its label passed the
6821 // create-class gate").
6822 BatchOp::InsertEdgeUpsert {
6823 edge_type,
6824 src_key,
6825 dst_key,
6826 placeholder_label,
6827 } => {
6828 if !authz.scope.create_edge_types.contains(edge_type) {
6829 return Err(GraphError::RoleWriteDenied {
6830 reason: format!(
6831 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6832 edge_type
6833 ),
6834 });
6835 }
6836 // Check placeholder label against create_labels (create-class gate).
6837 // This ensures the auto-created endpoints are scope-allowed.
6838 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6839 if !upsert_ep_visible(ep_key, placeholder_label) {
6840 return Err(GraphError::RoleWriteDenied {
6841 reason: "role-bound token: edge endpoint not visible".into(),
6842 });
6843 }
6844 }
6845 }
6846 }
6847 Ok(())
6848 }
6849
6850 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6851 ///
6852 /// Called by `apply_schema` when roles change. Never called on unchanged
6853 /// re-apply — this preserves byte-identical idempotency.
6854 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6855 let file = RolesFile::new_versioned(roles.clone());
6856 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6857 detail: format!("roles serialization: {e}"),
6858 })?;
6859 self.fs
6860 .write_atomic(FileId::Roles, &bytes)
6861 .map_err(GraphError::Io)?;
6862 self.roles = Some(roles);
6863 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6864 // updated role definitions without waiting for the next K-commit fold.
6865 self.fold_now();
6866 Ok(())
6867 }
6868
6869 fn view(&self) -> GraphView<'_> {
6870 GraphView {
6871 ids: &self.ids,
6872 syms: &self.syms,
6873 labels: &self.labels,
6874 props: self.props_view(),
6875 topo: self.topo_view(),
6876 edge_props: self.edge_props_view(),
6877 mask: None,
6878 prop_index: Some(&self.prop_index),
6879 }
6880 }
6881
6882 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6883 GraphView {
6884 ids: &self.ids,
6885 syms: &self.syms,
6886 labels: &self.labels,
6887 props: self.props_view(),
6888 topo: self.topo_view(),
6889 edge_props: self.edge_props_view(),
6890 mask: Some(&mask.visible),
6891 prop_index: Some(&self.prop_index),
6892 }
6893 }
6894
6895 /// Execute a read-only Cypher query with a node visibility mask.
6896 ///
6897 /// Only nodes whose key is in `mask` are accessible: label scans, key
6898 /// lookups, and neighbor expansions all respect the mask. Edges where
6899 /// either endpoint is hidden are silently dropped.
6900 ///
6901 /// Returns `Err` with a "masked queries are read-only" message when
6902 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6903 pub fn query_masked(
6904 &self,
6905 cypher: &str,
6906 params: &std::collections::BTreeMap<String, Value>,
6907 mask: &crate::mask::NodeMask,
6908 ) -> Result<ResultSet> {
6909 // Reject write statements up front.
6910 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6911 detail: format!("lex: {e}"),
6912 })?;
6913 if is_write_tokens(&tokens) {
6914 return Err(GraphError::MaskedReadOnly);
6915 }
6916 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6917 detail: format!("parse: {e}"),
6918 })?;
6919 // Each UNION part executes against the same masked view, so the mask
6920 // applies uniformly across the chain.
6921 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6922 GraphError::QueryError {
6923 detail: format!("execute: {e}"),
6924 }
6925 })
6926 }
6927
6928 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6929 let id = self.ids.get(key)?;
6930 Some(NodeRef { db: self, id })
6931 }
6932
6933 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6934 ///
6935 /// Hidden nodes are never used as traversal intermediaries in either
6936 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6937 /// only through a hidden node will not appear in results.
6938 ///
6939 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6940 /// a visited visible node are appended to the result as stub rows
6941 /// (`label` column is `null`, same key+depth columns as visible rows).
6942 /// They are NOT added to the BFS frontier.
6943 ///
6944 /// Returns `None` when `key` does not exist (caller should 404).
6945 ///
6946 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6947 /// stub rows are never produced on the role path.
6948 pub fn neighborhood_masked(
6949 &self,
6950 key: &str,
6951 depth: u32,
6952 edge_types: Option<&[&str]>,
6953 dir: Dir,
6954 mask: &crate::mask::NodeMask,
6955 ) -> Option<ResultSet> {
6956 let start_id = self.ids.get(key)?;
6957 let view = self.view_masked(mask);
6958 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6959 names
6960 .iter()
6961 .filter_map(|name| view.syms.get(name))
6962 .collect()
6963 });
6964 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6965 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6966 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6967 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6968 visited.push((start_id, 0));
6969 for (nid, d) in &nb.nodes {
6970 let k = view.key_of(*nid);
6971 let label = view
6972 .label_of(*nid)
6973 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6974 rs.push_row(vec![
6975 Some(Value::Str(k.to_string())),
6976 Some(Value::Str(label.to_string())),
6977 Some(Value::Int(*d as i64)),
6978 ]);
6979 visited.push((*nid, *d));
6980 }
6981 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6982 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6983 // frontier, so the BFS never expands through them.
6984 if mask.mode() == crate::mask::MaskMode::Stub {
6985 let raw_view = self.view();
6986 let mut seen: std::collections::HashSet<u32> =
6987 visited.iter().map(|(id, _)| *id).collect();
6988 for (node_id, node_depth) in &visited {
6989 if *node_depth >= depth {
6990 continue;
6991 }
6992 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6993 let nbr = if e.src == *node_id { e.dst } else { e.src };
6994 if !mask.contains_id(nbr) && seen.insert(nbr) {
6995 if let Some(k) = self.ids.key_of(nbr) {
6996 rs.push_row(vec![
6997 Some(Value::Str(k.to_string())),
6998 None,
6999 Some(Value::Int((*node_depth + 1) as i64)),
7000 ]);
7001 }
7002 }
7003 }
7004 }
7005 }
7006 Some(rs)
7007 }
7008
7009 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7010 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7011 let n = self.node_ref(key)?;
7012 Some(NodeInfo {
7013 key: n.key().to_string(),
7014 label: n.label().to_string(),
7015 props: n.props(),
7016 })
7017 }
7018
7019 /// Look up a node with mask awareness.
7020 ///
7021 /// | Key state | Omit mode | Stub mode |
7022 /// |-------------------|-----------------|------------------------|
7023 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
7024 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
7025 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
7026 ///
7027 /// **SECURITY**: only call from client-mask (full-token) paths.
7028 /// Role-token paths must use [`node_info`] after an explicit visibility check.
7029 pub fn node_info_masked(
7030 &self,
7031 key: &str,
7032 mask: &crate::mask::NodeMask,
7033 ) -> Option<MaskedNodeResult> {
7034 let id = self.ids.get(key)?;
7035 if mask.contains_id(id) {
7036 Some(MaskedNodeResult::Visible(self.node_info(key)?))
7037 } else {
7038 match mask.mode() {
7039 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7040 crate::mask::MaskMode::Omit => None,
7041 }
7042 }
7043 }
7044
7045 /// Get edges for `key` with mask-aware hidden-endpoint handling.
7046 ///
7047 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7048 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7049 /// is `true` for each hidden endpoint.
7050 ///
7051 /// Unknown key → [`GraphError::KeyNotFound`].
7052 ///
7053 /// **SECURITY**: only call from client-mask (full-token) paths.
7054 pub fn node_edges_masked(
7055 &self,
7056 key: &str,
7057 mask: &crate::mask::NodeMask,
7058 ) -> Result<Vec<MaskedEdge>> {
7059 self.ensure_v8_base_sections_loaded();
7060 let id = self
7061 .ids
7062 .get(key)
7063 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7064 let derived: BTreeSet<(u32, u32, u32)> = self
7065 .engine
7066 .provenance_touching(id)
7067 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7068 .collect();
7069 let mut edges = Vec::new();
7070 let tv = self.topo_view();
7071 for etype in tv.etypes() {
7072 // etype comes from the archived CSR (access_unchecked, no eager CRC).
7073 // A bit-flip in the large TOPOLOGY section can produce an etype id
7074 // that is not in the interner. Return Corrupt rather than panic.
7075 let edge_type = self
7076 .syms
7077 .resolve(etype)
7078 .ok_or_else(|| GraphError::Corrupt {
7079 detail: format!("v8: topology etype {etype} not in interner"),
7080 })?
7081 .to_string();
7082 for dir in [Direction::Out, Direction::In] {
7083 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7084 let nbr_restricted = !mask.contains_id(nbr);
7085 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7086 continue;
7087 }
7088 let nbr_key = self
7089 .ids
7090 .key_of(nbr)
7091 .ok_or_else(|| GraphError::Corrupt {
7092 detail: format!("topology id {nbr} has no key"),
7093 })?
7094 .to_string();
7095 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7096 match dir {
7097 Direction::Out => {
7098 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7099 }
7100 Direction::In => {
7101 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7102 }
7103 };
7104 edges.push(MaskedEdge {
7105 edge_type: edge_type.clone(),
7106 src_key,
7107 src_restricted,
7108 dst_key,
7109 dst_restricted,
7110 derived: derived.contains(&(etype, src_id, dst_id)),
7111 });
7112 }
7113 }
7114 }
7115 edges.sort_by(|a, b| {
7116 a.edge_type
7117 .cmp(&b.edge_type)
7118 .then(a.src_key.cmp(&b.src_key))
7119 .then(a.dst_key.cmp(&b.dst_key))
7120 });
7121 edges.dedup_by(|a, b| {
7122 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7123 });
7124 Ok(edges)
7125 }
7126
7127 /// Every directed edge incident on `key`, both directions, every etype.
7128 ///
7129 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7130 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7131 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7132 /// Unknown key → [`GraphError::KeyNotFound`].
7133 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7134 self.ensure_v8_base_sections_loaded();
7135 let id = self
7136 .ids
7137 .get(key)
7138 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7139 let derived: BTreeSet<(u32, u32, u32)> = self
7140 .engine
7141 .provenance_touching(id)
7142 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7143 .collect();
7144 let mut edges = Vec::new();
7145 let tv = self.topo_view();
7146 for etype in tv.etypes() {
7147 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7148 let edge_type = self
7149 .syms
7150 .resolve(etype)
7151 .ok_or_else(|| GraphError::Corrupt {
7152 detail: format!("v8: topology etype {etype} not in interner"),
7153 })?
7154 .to_string();
7155 for dir in [Direction::Out, Direction::In] {
7156 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7157 let (src, dst, src_key, dst_key) = match dir {
7158 Direction::Out => (
7159 id,
7160 nbr,
7161 key.to_string(),
7162 self.ids
7163 .key_of(nbr)
7164 .ok_or_else(|| GraphError::Corrupt {
7165 detail: format!("topology id {nbr} has no key"),
7166 })?
7167 .to_string(),
7168 ),
7169 Direction::In => (
7170 nbr,
7171 id,
7172 self.ids
7173 .key_of(nbr)
7174 .ok_or_else(|| GraphError::Corrupt {
7175 detail: format!("topology id {nbr} has no key"),
7176 })?
7177 .to_string(),
7178 key.to_string(),
7179 ),
7180 };
7181 edges.push(EdgeInfo {
7182 edge_type: edge_type.clone(),
7183 src_key,
7184 dst_key,
7185 derived: derived.contains(&(etype, src, dst)),
7186 });
7187 }
7188 }
7189 }
7190 edges.sort_by(|a, b| {
7191 a.edge_type
7192 .cmp(&b.edge_type)
7193 .then(a.src_key.cmp(&b.src_key))
7194 .then(a.dst_key.cmp(&b.dst_key))
7195 });
7196 // Self-loops appear in both Out and In; sort makes the pair adjacent
7197 // (sort key matches PartialEq for this case) so one pass drops the dup.
7198 edges.dedup();
7199 Ok(edges)
7200 }
7201
7202 // ── Backup ────────────────────────────────────────────────────────────────
7203
7204 /// Copy this store to `dest` as a consistent, verified snapshot.
7205 ///
7206 /// Copies every durable file in the database directory — `snapshot.bin`,
7207 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7208 /// `roles.json` — into a freshly created `dest` directory using OS-level
7209 /// `copy` calls (no large in-process buffers).
7210 ///
7211 /// # Consistency guarantee
7212 ///
7213 /// The guarantee is **process-local**: the caller holds `&self`, which
7214 /// prevents any concurrent writer in the **same process** from modifying
7215 /// the files during the copy. Running `mushroomdb backup` against a
7216 /// directory that is **concurrently being written by another process** (e.g.
7217 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
7218 /// `verified: true` result reduces but does not eliminate the risk of a
7219 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7220 /// consistent mid-write snapshot).
7221 ///
7222 /// **The safe path for a live-served store is `POST /backup` on the HTTP
7223 /// server.** That handler acquires the read lock on the shared database
7224 /// before calling this method, which is the correct cross-process
7225 /// synchronisation point because the server is the single process writing
7226 /// the files.
7227 ///
7228 /// After copying, opens the destination read-only and runs the CRC section
7229 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7230 /// `BackupReport::verified` reflects whether both checks passed.
7231 ///
7232 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7233 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7234 // Derive source directory from snapshot_path (RealFs only).
7235 let src_dir = match self.fs.snapshot_path() {
7236 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7237 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7238 })?,
7239 None => {
7240 return Err(GraphError::Io(std::io::Error::other(
7241 "backup_to requires a real filesystem (RealFs)",
7242 )))
7243 }
7244 };
7245
7246 std::fs::create_dir_all(dest)?;
7247
7248 let mut files: Vec<String> = Vec::new();
7249 let mut bytes: u64 = 0;
7250
7251 // Helper: copy src_dir/name → dest/name if the file exists.
7252 let mut try_copy = |name: &str| -> std::io::Result<()> {
7253 let src_path = src_dir.join(name);
7254 if src_path.exists() {
7255 let n = std::fs::copy(&src_path, dest.join(name))?;
7256 bytes += n;
7257 files.push(name.to_string());
7258 }
7259 Ok(())
7260 };
7261
7262 try_copy("snapshot.bin")?;
7263 try_copy("snapshot.bin.bak")?;
7264 try_copy("wal.bin")?;
7265 try_copy("wal.floor")?;
7266 try_copy("wal.genesis")?;
7267 try_copy("roles.json")?;
7268
7269 // Copy WAL archives.
7270 let archives = self.fs.list_archives()?;
7271 for n in &archives {
7272 let name = format!("wal.{n}.archive");
7273 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7274 bytes += n_bytes;
7275 files.push(name);
7276 }
7277
7278 files.sort();
7279
7280 // Post-copy verification: open dest and run CRC checks.
7281 let snap_in_dest = dest.join("snapshot.bin").exists();
7282 let crc_ok = if snap_in_dest {
7283 crate::verify_snapshot(dest)
7284 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7285 .unwrap_or(false)
7286 } else {
7287 true // WAL-only store: nothing to CRC-check in snapshot
7288 };
7289 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7290 let verified = crc_ok && opens_ok;
7291
7292 Ok(BackupReport {
7293 files,
7294 bytes,
7295 verified,
7296 })
7297 }
7298
7299 // ── Export helpers ────────────────────────────────────────────────────────
7300
7301 /// All live nodes, sorted by key (deterministic).
7302 ///
7303 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7304 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7305 self.ensure_v8_base_sections_loaded();
7306 let pv = self.props_view();
7307 let mut nodes = Vec::new();
7308 for id in 0..self.ids.len() as u32 {
7309 let Some(key) = self.ids.key_of(id) else {
7310 continue;
7311 };
7312 let Some(&sym) = self.labels.get(id as usize) else {
7313 continue;
7314 };
7315 if sym == u32::MAX {
7316 continue; // tombstoned
7317 }
7318 let Some(label) = self.syms.resolve(sym) else {
7319 continue;
7320 };
7321 let mut props = BTreeMap::new();
7322 for field in pv.field_names() {
7323 if let Some(vr) = pv.get(id, &field) {
7324 props.insert(field, vr.into_value());
7325 }
7326 }
7327 nodes.push(NodeInfo {
7328 key: key.to_string(),
7329 label: label.to_string(),
7330 props,
7331 });
7332 }
7333 nodes.sort_by(|a, b| a.key.cmp(&b.key));
7334 nodes
7335 }
7336
7337 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7338 ///
7339 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7340 /// Manual edges carry `derived: false` and `rule: None`.
7341 /// `weight` is the creating rule's `weight_prop` value read off the edge
7342 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7343 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7344 /// store state.
7345 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7346 self.ensure_v8_base_sections_loaded();
7347
7348 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7349 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7350 for (rule_name, triples) in self.engine.provenance() {
7351 for &(etype, src, dst) in triples {
7352 prov.insert((etype, src, dst), rule_name.clone());
7353 }
7354 }
7355
7356 // rule_name → weight_prop, for O(1) lookup per derived edge.
7357 let weight_props: HashMap<&str, Option<&str>> = self
7358 .engine
7359 .rules()
7360 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7361 .collect();
7362
7363 let tv = self.topo_view();
7364 let ep = self.edge_props_view();
7365 let mut edges = Vec::new();
7366
7367 for id in 0..self.ids.len() as u32 {
7368 let Some(key) = self.ids.key_of(id) else {
7369 continue;
7370 };
7371 let Some(&lsym) = self.labels.get(id as usize) else {
7372 continue;
7373 };
7374 if lsym == u32::MAX {
7375 continue; // tombstoned
7376 }
7377
7378 for etype_sym in tv.etypes() {
7379 // etype from archived CSR (access_unchecked, no eager CRC).
7380 // Skip edges whose etype is not in the interner; this can only
7381 // occur with a corrupt large TOPOLOGY section (bit-flip on an
7382 // etype field in the archived data). The function returns Vec,
7383 // not Result, so we continue rather than propagate.
7384 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7385 continue;
7386 };
7387 let edge_type = edge_type.to_string();
7388 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7389 let Some(dst_key) = self.ids.key_of(nbr) else {
7390 continue; // skip corrupt entries
7391 };
7392 let prov_key = (etype_sym, id, nbr);
7393 let rule = prov.get(&prov_key).cloned();
7394 let derived = rule.is_some();
7395 let weight = rule
7396 .as_deref()
7397 .and_then(|rn| weight_props.get(rn).copied().flatten())
7398 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7399 Some(Value::Float(f)) => Some(f),
7400 Some(Value::Int(i)) => Some(i as f64),
7401 _ => None,
7402 });
7403 edges.push(ExportEdge {
7404 edge_type: edge_type.clone(),
7405 src: key.to_string(),
7406 dst: dst_key.to_string(),
7407 derived,
7408 rule,
7409 weight,
7410 });
7411 }
7412 }
7413 }
7414
7415 edges.sort_by(|a, b| {
7416 a.edge_type
7417 .cmp(&b.edge_type)
7418 .then(a.src.cmp(&b.src))
7419 .then(a.dst.cmp(&b.dst))
7420 });
7421 edges
7422 }
7423
7424 /// What each edge type *is*, without building one record per edge.
7425 ///
7426 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
7427 /// question by materialising every edge — three `String`s apiece, a
7428 /// provenance `HashMap` over every derived edge, and a final sort. That is
7429 /// the right shape for an export, and the wrong one for a summary: on a
7430 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
7431 /// produce nine lines. This walks the topology instead, summing neighbour
7432 /// slice lengths and collecting *label symbols* rather than label strings,
7433 /// so the per-edge cost is an integer add and a set insert on a set with
7434 /// as many members as the store has labels.
7435 ///
7436 /// The rule names come off the rule *definitions*, which each declare the
7437 /// `edge_type` they derive, so naming them costs one pass over the rules
7438 /// rather than one provenance lookup per edge. That is also why `rules`
7439 /// is a list: two rules may derive the same type — the association store
7440 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
7441 /// talent→job rule — and naming only one of them would be a half-truth.
7442 /// A type with no rules is one written by hand.
7443 ///
7444 /// `sample` is the first edge of the type in the store's own id order,
7445 /// which is insertion order: deterministic for a given store, and not the
7446 /// same as key order, which cannot be had without resolving a key per
7447 /// edge. Sorted by `edge_type`.
7448 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
7449 self.ensure_v8_base_sections_loaded();
7450
7451 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
7452 for r in self.engine.rules() {
7453 rules_by_type
7454 .entry(r.edge_type.as_str())
7455 .or_default()
7456 .insert(r.name.as_str());
7457 }
7458
7459 let tv = self.topo_view();
7460 let node_count = self.ids.len() as u32;
7461 let mut out = Vec::new();
7462 for etype_sym in tv.etypes() {
7463 // An etype the interner cannot resolve means a corrupt TOPOLOGY
7464 // section; skip it rather than name it, as `all_edges_for_export`
7465 // does for the same reason.
7466 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7467 continue;
7468 };
7469 let mut edges: u64 = 0;
7470 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
7471 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
7472 let mut sample: Option<(u32, u32)> = None;
7473 for id in 0..node_count {
7474 let Some(&lsym) = self.labels.get(id as usize) else {
7475 continue;
7476 };
7477 if lsym == u32::MAX {
7478 continue; // tombstoned
7479 }
7480 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
7481 let nbrs = nbrs.as_ref();
7482 if nbrs.is_empty() {
7483 continue;
7484 }
7485 edges += nbrs.len() as u64;
7486 src_syms.insert(lsym);
7487 for &nbr in nbrs {
7488 if let Some(&dsym) = self.labels.get(nbr as usize) {
7489 if dsym != u32::MAX {
7490 dst_syms.insert(dsym);
7491 }
7492 }
7493 }
7494 if sample.is_none() {
7495 sample = Some((id, nbrs[0]));
7496 }
7497 }
7498 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
7499 syms.iter()
7500 .filter_map(|&s| self.syms.resolve(s))
7501 .map(ToString::to_string)
7502 .collect()
7503 };
7504 out.push(EdgeTypeCensus {
7505 edge_type: edge_type.to_string(),
7506 edges,
7507 src_labels: resolve(&src_syms),
7508 dst_labels: resolve(&dst_syms),
7509 rules: rules_by_type
7510 .get(edge_type)
7511 .map(|rs| rs.iter().map(ToString::to_string).collect())
7512 .unwrap_or_default(),
7513 sample: sample.and_then(|(s, d)| {
7514 Some((
7515 self.ids.key_of(s)?.to_string(),
7516 self.ids.key_of(d)?.to_string(),
7517 ))
7518 }),
7519 });
7520 }
7521 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
7522 out
7523 }
7524
7525 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7526 /// on each edge when given.
7527 ///
7528 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7529 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7530 /// `None` — callers that want a default weight (e.g. `1.0` for missing
7531 /// props) apply it themselves, matching the convention used internally
7532 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7533 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7534 ///
7535 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7536 /// (manual + rule-derived edges). An unknown `edge_type` returns an
7537 /// empty vec.
7538 pub fn weighted_edges(
7539 &self,
7540 edge_type: &str,
7541 weight_prop: Option<&str>,
7542 ) -> Vec<(String, String, Option<f64>)> {
7543 let Some(etype_sym) = self.syms.get(edge_type) else {
7544 return Vec::new();
7545 };
7546 let tv = self.topo_view();
7547 let ep = self.edge_props_view();
7548 let mut out = Vec::new();
7549 for id in 0..self.ids.len() as u32 {
7550 let Some(key) = self.ids.key_of(id) else {
7551 continue;
7552 };
7553 let Some(&sym) = self.labels.get(id as usize) else {
7554 continue;
7555 };
7556 if sym == u32::MAX {
7557 continue; // tombstoned
7558 }
7559 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7560 let Some(dst_key) = self.ids.key_of(nbr) else {
7561 continue;
7562 };
7563 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7564 Some(Value::Float(f)) => Some(f),
7565 Some(Value::Int(i)) => Some(i as f64),
7566 _ => None,
7567 });
7568 out.push((key.to_string(), dst_key.to_string(), weight));
7569 }
7570 }
7571 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7572 out
7573 }
7574
7575 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7576 self.view()
7577 .nodes_with_label(label)
7578 .into_iter()
7579 .map(|id| NodeRef { db: self, id })
7580 .collect()
7581 }
7582
7583 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7584 let view = self.view();
7585 view.nodes_with_label(label)
7586 .into_iter()
7587 .filter(|&id| {
7588 eval_filter(filter, &|field| {
7589 view.prop(id, field).map(|vr| vr.into_value())
7590 })
7591 })
7592 .map(|id| NodeRef { db: self, id })
7593 .collect()
7594 }
7595
7596 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7597 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
7598 /// with `label = None` will use the native ANN path rather than the O(n)
7599 /// brute-force scan.
7600 pub fn has_vector_rule(&self, field: &str) -> bool {
7601 self.engine.hnsw_has_rule(field)
7602 }
7603
7604 /// Find nodes whose `field` vector is most similar to `q` (cosine
7605 /// similarity), returning up to `k` results with similarity ≥ `min`,
7606 /// sorted descending.
7607 ///
7608 /// When `label` is `None` the search spans all labels (via
7609 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7610 /// `Some(lbl)` it restricts to nodes with that label.
7611 ///
7612 /// Uses the HNSW index when one is available (fast path); otherwise falls
7613 /// back to an O(n) brute-force scan.
7614 pub fn find_similar_vector(
7615 &self,
7616 field: &str,
7617 label: Option<&str>,
7618 q: &[f64],
7619 k: usize,
7620 min: f64,
7621 ) -> Vec<(String, f64)> {
7622 // Ensure any HNSW blobs retained from the snapshot are deserialized
7623 // before the first ANN query on a clean-open (no-WAL) path.
7624 self.engine.ensure_hnsw_loaded();
7625 // L2-normalise query for cosine via dot product.
7626 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7627 if norm == 0.0 {
7628 return vec![];
7629 }
7630 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7631
7632 // Try HNSW fast path.
7633 // `None` label searches across all VectorSimilar rules covering `field`
7634 // (merging their results); `Some(lbl)` restricts to rules whose
7635 // dst_label matches. Returns `None` when no populated HNSW index
7636 // covers the request — the O(n) brute-force fallback handles that case.
7637 let hnsw_hits = match label {
7638 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7639 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7640 };
7641 if let Some(hits) = hnsw_hits {
7642 let mut out: Vec<(String, f64)> = hits
7643 .into_iter()
7644 .filter(|&(_, sim)| sim >= min)
7645 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7646 .collect();
7647 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7648 out.truncate(k);
7649 return out;
7650 }
7651
7652 // Brute-force fallback: O(n) scan (only reached when no HNSW index
7653 // covers the request).
7654 let view = self.view();
7655 let candidate_ids: Vec<u32> = match label {
7656 Some(lbl) => view.nodes_with_label(lbl),
7657 None => view.nodes_all(),
7658 };
7659 let mut scored: Vec<(String, f64)> = candidate_ids
7660 .into_iter()
7661 .filter_map(|id| {
7662 let v = view.prop(id, field)?;
7663 let v_owned = v.into_value();
7664 let xs = value_as_float_list(&v_owned)?;
7665 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7666 if v_norm == 0.0 {
7667 return None;
7668 }
7669 let dot: f64 = q_unit
7670 .iter()
7671 .zip(xs.iter())
7672 .map(|(a, b)| a * (b / v_norm))
7673 .sum();
7674 if dot < min {
7675 return None;
7676 }
7677 let key = self.ids.key_of(id)?.to_string();
7678 Some((key, dot))
7679 })
7680 .collect();
7681 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7682 scored.truncate(k);
7683 scored
7684 }
7685
7686 /// Like [`find_similar_vector`] but restricts results to nodes visible in
7687 /// `mask`. Hidden nodes never appear in results; the mask is applied
7688 /// **before** k-truncation so a caller still receives up to `k` visible
7689 /// hits.
7690 ///
7691 /// # HNSW path (over-fetch policy)
7692 ///
7693 /// When an HNSW index covers the request, this function fetches `4 * k`
7694 /// candidates from the index and discards hidden nodes in the post-filter
7695 /// step. If fewer than `k` visible nodes remain after filtering the caller
7696 /// receives whatever is available — we do not re-query the index. The 4×
7697 /// multiplier is a heuristic suited for sparsely masked graphs; callers
7698 /// operating under a very selective mask should register a VectorSimilar
7699 /// rule with a non-approximate index, or use the brute-force path (no HNSW
7700 /// rule) which exhaustively filters through the masked [`GraphView`].
7701 ///
7702 /// # Brute-force path
7703 ///
7704 /// When no HNSW index covers the request the function builds a masked
7705 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7706 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7707 /// fewer than `k` exist).
7708 pub fn find_similar_vector_masked(
7709 &self,
7710 field: &str,
7711 label: Option<&str>,
7712 q: &[f64],
7713 k: usize,
7714 min: f64,
7715 mask: &crate::mask::NodeMask,
7716 ) -> Vec<(String, f64)> {
7717 self.engine.ensure_hnsw_loaded();
7718 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7719 if norm == 0.0 {
7720 return vec![];
7721 }
7722 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7723
7724 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7725 // visible hits. See doc comment above for the policy rationale.
7726 let over_k = k.saturating_mul(4).max(k + 1);
7727 let hnsw_hits = match label {
7728 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7729 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7730 };
7731 if let Some(hits) = hnsw_hits {
7732 let mut out: Vec<(String, f64)> = hits
7733 .into_iter()
7734 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7735 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7736 .collect();
7737 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7738 out.truncate(k);
7739 return out;
7740 }
7741
7742 // Brute-force fallback — masked view ensures only visible nodes are
7743 // enumerated by nodes_all(); nodes_with_label() does not filter by
7744 // mask so we apply view.visible() explicitly for the labeled case.
7745 let view = self.view_masked(mask);
7746 let candidate_ids: Vec<u32> = match label {
7747 Some(lbl) => view
7748 .nodes_with_label(lbl)
7749 .into_iter()
7750 .filter(|&id| view.visible(id))
7751 .collect(),
7752 None => view.nodes_all(),
7753 };
7754 let mut scored: Vec<(String, f64)> = candidate_ids
7755 .into_iter()
7756 .filter_map(|id| {
7757 let v = view.prop(id, field)?;
7758 let v_owned = v.into_value();
7759 let xs = value_as_float_list(&v_owned)?;
7760 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7761 if v_norm == 0.0 {
7762 return None;
7763 }
7764 let dot: f64 = q_unit
7765 .iter()
7766 .zip(xs.iter())
7767 .map(|(a, b)| a * (b / v_norm))
7768 .sum();
7769 if dot < min {
7770 return None;
7771 }
7772 let key = self.ids.key_of(id)?.to_string();
7773 Some((key, dot))
7774 })
7775 .collect();
7776 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7777 scored.truncate(k);
7778 scored
7779 }
7780
7781 /// Read a single property from an edge.
7782 ///
7783 /// Returns `None` when the edge does not exist, the field is absent, or any
7784 /// of the string keys cannot be resolved to interned ids. Only edge props
7785 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7786 /// binding; topology-only edges (no props set) return `None` for every field.
7787 pub fn get_edge_prop(
7788 &self,
7789 edge_type: &str,
7790 src_key: &str,
7791 dst_key: &str,
7792 field: &str,
7793 ) -> Option<Value> {
7794 let etype = self.syms.get(edge_type)?;
7795 let src = self.ids.get(src_key)?;
7796 let dst = self.ids.get(dst_key)?;
7797 self.edge_props_view().get(etype, src, dst, field)
7798 }
7799
7800 /// Lex → parse → plan → execute `cypher` over a read-only view.
7801 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7802 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7803 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7804 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7805 detail: format!("lex: {e}"),
7806 })?;
7807 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7808 detail: format!("parse: {e}"),
7809 })?;
7810 let t0 = std::time::Instant::now();
7811 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7812 GraphError::QueryError {
7813 detail: format!("execute: {e}"),
7814 }
7815 });
7816 let elapsed_ms = t0.elapsed().as_millis() as u64;
7817 let threshold = self.slow_query_threshold_ms;
7818 if threshold > 0 && elapsed_ms >= threshold {
7819 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7820 let entry = SlowQueryEntry {
7821 ms: elapsed_ms,
7822 query: cypher.to_string(),
7823 at_commit: self.commit_seq,
7824 };
7825 if let Ok(mut log) = self.slow_queries.lock() {
7826 if log.entries.len() == SLOW_QUERY_RING_CAP {
7827 log.entries.pop_front();
7828 }
7829 log.entries.push_back(entry);
7830 log.total += 1;
7831 }
7832 }
7833 result
7834 }
7835
7836 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7837 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7838 /// calling [`GraphDb::query`].
7839 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7840 let map: BTreeMap<String, Value> = params
7841 .iter()
7842 .map(|(k, v)| (k.to_string(), v.clone()))
7843 .collect();
7844 self.query(cypher, &map)
7845 }
7846
7847 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7848 ///
7849 /// All mutations flow through the same `insert_node` / `set_prop` /
7850 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7851 /// fires and the WAL captures everything with one fsync per statement.
7852 ///
7853 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7854 /// and `deleted` matching the write-result contract.
7855 ///
7856 /// **Mutation routing**: mutations are collected into a single
7857 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7858 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7859 /// over `self.view()` — the borrow is dropped before the batch is opened.
7860 ///
7861 /// **Limitations (v1)**:
7862 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7863 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7864 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7865 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7866 /// - Deleting a derived edge → named error "cannot delete derived edge".
7867 pub fn query_write(
7868 &mut self,
7869 cypher: &str,
7870 params: &BTreeMap<String, Value>,
7871 ) -> Result<ResultSet> {
7872 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7873 detail: format!("lex: {e}"),
7874 })?;
7875 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7876 detail: format!("parse: {e}"),
7877 })?;
7878 self.exec_write_stmt(stmt, params)
7879 }
7880
7881 fn exec_write_stmt(
7882 &mut self,
7883 stmt: WriteStatement,
7884 params: &BTreeMap<String, Value>,
7885 ) -> Result<ResultSet> {
7886 match stmt {
7887 WriteStatement::Create(s) => self.exec_create(s, params),
7888 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7889 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7890 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7891 WriteStatement::Merge(s) => self.exec_merge(s, params),
7892 }
7893 }
7894
7895 fn exec_create(
7896 &mut self,
7897 stmt: core_query::cypher::CreateStmt,
7898 params: &BTreeMap<String, Value>,
7899 ) -> Result<ResultSet> {
7900 // Extract the node key from props: require a string-valued `id` field.
7901 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7902 for node in &stmt.nodes {
7903 let var = node.var.as_deref().unwrap_or("_cn0");
7904 let key = node
7905 .props
7906 .iter()
7907 .find(|(f, _)| f == "id")
7908 .and_then(|(_, v)| {
7909 if let Value::Str(s) = v {
7910 Some(s.clone())
7911 } else {
7912 None
7913 }
7914 })
7915 .ok_or_else(|| GraphError::QueryError {
7916 detail: format!(
7917 "CREATE node ({}:{}) requires a string 'id' property",
7918 var, node.label
7919 ),
7920 })?;
7921 var_to_key.insert(var.to_string(), key);
7922 }
7923
7924 let mut batch = self.batch();
7925 let mut created: usize = 0;
7926 for node in &stmt.nodes {
7927 let var = node.var.as_deref().unwrap_or("_cn0");
7928 let key = &var_to_key[var];
7929 batch.insert_node(&node.label, key, node.props.clone());
7930 created += 1;
7931 }
7932 for edge in &stmt.edges {
7933 let src_key = var_to_key
7934 .get(&edge.src_var)
7935 .ok_or_else(|| GraphError::QueryError {
7936 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7937 })?;
7938 let dst_key = var_to_key
7939 .get(&edge.dst_var)
7940 .ok_or_else(|| GraphError::QueryError {
7941 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7942 })?;
7943 batch.insert_edge(&edge.etype, src_key, dst_key);
7944 }
7945 batch.commit()?;
7946
7947 // Optional RETURN clause: project created bindings as a read result.
7948 if let Some(returns) = stmt.returns {
7949 // Each created node is looked up by its key via a separate MATCH pattern.
7950 // Multiple single-node patterns cross-join to produce 1 output row with
7951 // all variables bound (each pattern returns exactly 1 row).
7952 let patterns: Vec<Pattern> = stmt
7953 .nodes
7954 .iter()
7955 .map(|node| {
7956 let var = node.var.as_deref().unwrap_or("_cn0");
7957 let key = var_to_key[var].clone();
7958 Pattern {
7959 start: NodePat {
7960 var: Some(var.to_string()),
7961 label: Some(node.label.clone()),
7962 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7963 },
7964 chain: vec![],
7965 shortest: false,
7966 }
7967 })
7968 .collect();
7969 let q = Query {
7970 matches: patterns,
7971 optional_clauses: vec![],
7972 where_expr: None,
7973 unwinds: vec![],
7974 post_unwind_where: None,
7975 stages: vec![],
7976 returns,
7977 distinct: false,
7978 order_by: vec![],
7979 skip: None,
7980 limit: None,
7981 };
7982 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7983 detail: format!("plan: {e}"),
7984 })?;
7985 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7986 GraphError::QueryError {
7987 detail: format!("execute: {e}"),
7988 }
7989 });
7990 }
7991
7992 let mut rs = write_result_set();
7993 rs.push_row(vec![
7994 Some(Value::Int(created as i64)),
7995 Some(Value::Int(0)),
7996 Some(Value::Int(0)),
7997 ]);
7998 Ok(rs)
7999 }
8000
8001 fn exec_match_set(
8002 &mut self,
8003 stmt: core_query::cypher::MatchSetStmt,
8004 params: &BTreeMap<String, Value>,
8005 ) -> Result<ResultSet> {
8006 let project_returns = stmt.returns.clone();
8007 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8008 // so the post-write projection can look them up by key.
8009 let mut set_vars: Vec<String> = Vec::new();
8010 for s in &stmt.sets {
8011 if !set_vars.contains(&s.var) {
8012 set_vars.push(s.var.clone());
8013 }
8014 }
8015 let rel_vars = pattern_rel_vars(&stmt.matches);
8016 let mut lookup_vars = set_vars.clone();
8017 for v in pattern_node_vars(&stmt.matches) {
8018 add_var(&mut lookup_vars, &v);
8019 }
8020 if let Some(ref returns) = project_returns {
8021 for v in ret_node_vars(returns) {
8022 if !rel_vars.iter().any(|r| r == &v) {
8023 add_var(&mut lookup_vars, &v);
8024 }
8025 }
8026 }
8027
8028 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8029 // SET values are projected as ScalarExpr items so that arithmetic expressions
8030 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8031 let mut set_returns: Vec<RetItem> = lookup_vars
8032 .iter()
8033 .map(|v| RetItem {
8034 value: RetVal::Var(v.clone()),
8035 alias: None,
8036 })
8037 .collect();
8038 // One computed column per SET clause; alias is `__sv_<i>`.
8039 let set_val_cols: Vec<String> = stmt
8040 .sets
8041 .iter()
8042 .enumerate()
8043 .map(|(i, _)| format!("__sv_{i}"))
8044 .collect();
8045 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8046 set_returns.push(RetItem {
8047 value: RetVal::ScalarExpr(sc.value.clone()),
8048 alias: Some(col.clone()),
8049 });
8050 }
8051 // Capture relationship types while r is bound; SET does not change them.
8052 for r in &rel_vars {
8053 set_returns.push(RetItem {
8054 value: RetVal::FuncCall {
8055 name: "type".into(),
8056 args: vec![Operand::Var(r.clone())],
8057 },
8058 alias: Some(rel_type_alias(r)),
8059 });
8060 }
8061
8062 let read_q = Query {
8063 matches: stmt.matches.clone(),
8064 optional_clauses: vec![],
8065 where_expr: stmt.where_expr.clone(),
8066 unwinds: vec![],
8067 post_unwind_where: None,
8068 stages: vec![],
8069 returns: set_returns,
8070 distinct: false,
8071 order_by: vec![],
8072 skip: None,
8073 limit: None,
8074 };
8075 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8076 detail: format!("plan: {e}"),
8077 })?;
8078 // MATCH phase is read-only; borrow ends before batch opens.
8079 //
8080 // When a role-scoped write is in flight, run the MATCH read through
8081 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8082 // zero-rows (no SetProp ops generated, no existence-oracle 403).
8083 // Full-authority writes (pending_write_authz=None) keep view().
8084 let match_rs = {
8085 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8086 if let Some(ref mask) = mask_opt {
8087 execute(&self.view_masked(mask), &ops, &Params(params))
8088 } else {
8089 execute(&self.view(), &ops, &Params(params))
8090 }
8091 }
8092 .map_err(|e| GraphError::QueryError {
8093 detail: format!("execute: {e}"),
8094 })?;
8095
8096 // Collect (key, field, value) for each matched row × each SET clause.
8097 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8098 for row_i in 0..match_rs.len() {
8099 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8100 let key = match match_rs.get(row_i, &sc.var) {
8101 Some(Value::Str(k)) => k.clone(),
8102 _ => {
8103 return Err(GraphError::QueryError {
8104 detail: format!(
8105 "SET variable '{}' did not resolve to a node key",
8106 sc.var
8107 ),
8108 })
8109 }
8110 };
8111 // The SET value was already evaluated by the executor.
8112 let value = match match_rs.get(row_i, col) {
8113 Some(v) => v.clone(),
8114 None => {
8115 return Err(GraphError::QueryError {
8116 detail: format!(
8117 "SET value for {}.{} evaluated to null",
8118 sc.var, sc.field
8119 ),
8120 })
8121 }
8122 };
8123 set_ops.push((key, sc.field.clone(), value));
8124 }
8125 }
8126
8127 // Apply as one atomic batch.
8128 let props_set = set_ops.len();
8129 let mut batch = self.batch();
8130 for (key, field, value) in set_ops {
8131 batch.set_prop(&key, &field, value);
8132 }
8133 batch.commit()?;
8134
8135 if let Some(returns) = project_returns {
8136 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
8137 }
8138
8139 let mut rs = write_result_set();
8140 rs.push_row(vec![
8141 Some(Value::Int(0)),
8142 Some(Value::Int(props_set as i64)),
8143 Some(Value::Int(0)),
8144 ]);
8145 Ok(rs)
8146 }
8147
8148 fn exec_match_delete(
8149 &mut self,
8150 stmt: core_query::cypher::MatchDeleteStmt,
8151 params: &BTreeMap<String, Value>,
8152 ) -> Result<ResultSet> {
8153 // Collect unique node vars needed to identify edge endpoints.
8154 let mut node_vars: Vec<String> = Vec::new();
8155 for ed in &stmt.deletes {
8156 if !node_vars.contains(&ed.src_var) {
8157 node_vars.push(ed.src_var.clone());
8158 }
8159 if !node_vars.contains(&ed.dst_var) {
8160 node_vars.push(ed.dst_var.clone());
8161 }
8162 }
8163
8164 // Synthesize read query.
8165 let returns: Vec<RetItem> = node_vars
8166 .iter()
8167 .map(|v| RetItem {
8168 value: RetVal::Var(v.clone()),
8169 alias: None,
8170 })
8171 .collect();
8172 let read_q = Query {
8173 matches: stmt.matches,
8174 optional_clauses: vec![],
8175 where_expr: stmt.where_expr,
8176 unwinds: vec![],
8177 post_unwind_where: None,
8178 stages: vec![],
8179 returns,
8180 distinct: false,
8181 order_by: vec![],
8182 skip: None,
8183 limit: None,
8184 };
8185 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8186 detail: format!("plan: {e}"),
8187 })?;
8188 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8189 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8190 let match_rs = {
8191 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8192 if let Some(ref mask) = mask_opt {
8193 execute(&self.view_masked(mask), &ops, &Params(params))
8194 } else {
8195 execute(&self.view(), &ops, &Params(params))
8196 }
8197 }
8198 .map_err(|e| GraphError::QueryError {
8199 detail: format!("execute: {e}"),
8200 })?;
8201
8202 // Collect (etype, src_key, dst_key) for each row × each delete target.
8203 let mut del_ops: Vec<(String, String, String)> = Vec::new();
8204 for row_i in 0..match_rs.len() {
8205 for ed in &stmt.deletes {
8206 let src_key = match match_rs.get(row_i, &ed.src_var) {
8207 Some(Value::Str(k)) => k.clone(),
8208 _ => {
8209 return Err(GraphError::QueryError {
8210 detail: format!(
8211 "DELETE src variable '{}' did not resolve to a node key",
8212 ed.src_var
8213 ),
8214 })
8215 }
8216 };
8217 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8218 Some(Value::Str(k)) => k.clone(),
8219 _ => {
8220 return Err(GraphError::QueryError {
8221 detail: format!(
8222 "DELETE dst variable '{}' did not resolve to a node key",
8223 ed.dst_var
8224 ),
8225 })
8226 }
8227 };
8228 del_ops.push((ed.etype.clone(), src_key, dst_key));
8229 }
8230 }
8231
8232 // Apply as one atomic batch.
8233 let deleted = del_ops.len();
8234 let mut batch = self.batch();
8235 for (etype, src_key, dst_key) in del_ops {
8236 batch.delete_edge(&etype, &src_key, &dst_key);
8237 }
8238 batch.commit().map_err(|e| match e {
8239 GraphError::RuleOwned { .. } => GraphError::QueryError {
8240 detail: "cannot delete derived edge; retract via the rule or change the property"
8241 .to_string(),
8242 },
8243 other => other,
8244 })?;
8245
8246 let mut rs = write_result_set();
8247 rs.push_row(vec![
8248 Some(Value::Int(0)),
8249 Some(Value::Int(0)),
8250 Some(Value::Int(deleted as i64)),
8251 ]);
8252 Ok(rs)
8253 }
8254
8255 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8256 ///
8257 /// Collects the matching node keys via an ephemeral read query, then calls
8258 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
8259 /// the executor first checks that the node has no incident edges; if any
8260 /// remain it returns a named error matching openCypher semantics.
8261 fn exec_match_delete_node(
8262 &mut self,
8263 stmt: MatchDeleteNodeStmt,
8264 params: &BTreeMap<String, Value>,
8265 ) -> Result<ResultSet> {
8266 // Build a read query returning only the node keys we need.
8267 let returns: Vec<RetItem> = stmt
8268 .node_vars
8269 .iter()
8270 .map(|v| RetItem {
8271 value: RetVal::Var(v.clone()),
8272 alias: None,
8273 })
8274 .collect();
8275 let read_q = Query {
8276 matches: stmt.matches,
8277 optional_clauses: vec![],
8278 where_expr: stmt.where_expr,
8279 unwinds: vec![],
8280 post_unwind_where: None,
8281 stages: vec![],
8282 returns,
8283 distinct: false,
8284 order_by: vec![],
8285 skip: None,
8286 limit: None,
8287 };
8288 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8289 detail: format!("plan: {e}"),
8290 })?;
8291 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8292 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8293 let match_rs = {
8294 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8295 if let Some(ref mask) = mask_opt {
8296 execute(&self.view_masked(mask), &ops, &Params(params))
8297 } else {
8298 execute(&self.view(), &ops, &Params(params))
8299 }
8300 }
8301 .map_err(|e| GraphError::QueryError {
8302 detail: format!("execute: {e}"),
8303 })?;
8304
8305 // Collect unique node keys to delete (deduplicate across rows × vars).
8306 let mut keys: Vec<String> = Vec::new();
8307 for row_i in 0..match_rs.len() {
8308 for var in &stmt.node_vars {
8309 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8310 if !keys.contains(k) {
8311 keys.push(k.clone());
8312 }
8313 }
8314 }
8315 }
8316
8317 if !stmt.detach {
8318 // openCypher bare DELETE: error if any matched node has incident edges.
8319 for key in &keys {
8320 if let Some(id) = self.ids.get(key) {
8321 let tv = self.topo_view();
8322 let has_edges = tv.etypes().any(|et| {
8323 !tv.neighbors(et, Direction::Out, id).is_empty()
8324 || !tv.neighbors(et, Direction::In, id).is_empty()
8325 });
8326 if has_edges {
8327 return Err(GraphError::QueryError {
8328 detail: format!(
8329 "Cannot delete node `{key}` because it still has incident edges. \
8330 Use DETACH DELETE to remove the node and all its edges."
8331 ),
8332 });
8333 }
8334 }
8335 }
8336 }
8337
8338 let mut nodes_deleted = 0i64;
8339 let mut edges_deleted = 0i64;
8340 for key in keys {
8341 match self.delete_node(&key) {
8342 Ok(report) => {
8343 nodes_deleted += 1;
8344 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8345 }
8346 Err(GraphError::KeyNotFound { .. }) => {
8347 // Node may have been deleted by an earlier iteration (e.g., via
8348 // multiple MATCH rows for the same node). Safe to skip.
8349 }
8350 Err(e) => return Err(e),
8351 }
8352 }
8353
8354 let mut rs = write_result_set();
8355 rs.push_row(vec![
8356 Some(Value::Int(0)),
8357 Some(Value::Int(0)),
8358 Some(Value::Int(nodes_deleted + edges_deleted)),
8359 ]);
8360 Ok(rs)
8361 }
8362
8363 fn exec_merge(
8364 &mut self,
8365 stmt: core_query::cypher::MergeStmt,
8366 params: &BTreeMap<String, Value>,
8367 ) -> Result<ResultSet> {
8368 // MERGE: check if a node with the given key already exists.
8369 let key = match &stmt.key_value {
8370 Value::Str(s) => s.clone(),
8371 _ => {
8372 return Err(GraphError::QueryError {
8373 detail: format!(
8374 "MERGE key value must be a string (got {:?})",
8375 stmt.key_value
8376 ),
8377 })
8378 }
8379 };
8380
8381 if let Some(var) = stmt.var.as_deref() {
8382 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8383 if sc.var != var {
8384 return Err(GraphError::QueryError {
8385 detail: format!(
8386 "SET variable '{}' does not match MERGE variable '{var}'",
8387 sc.var
8388 ),
8389 });
8390 }
8391 }
8392 }
8393
8394 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8395 //
8396 // MERGE scope precondition: check create OR update scope for the
8397 // declared label BEFORE calling `has_node` (timing-oracle closure,
8398 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8399 // unscoped roles — the scope denial fires without touching the key store).
8400 //
8401 // Clone to avoid holding a borrow on `self.pending_write_authz` while
8402 // also calling `self.ids.get(key)`.
8403 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8404 let has_create = authz.scope.create_labels.contains(&stmt.label);
8405 let has_update = authz.scope.update_labels.contains(&stmt.label);
8406 if !has_create && !has_update {
8407 // Scope-before-lookup: 403 without has_node call (timing oracle
8408 // closure — see test_merge_unscoped_no_key_lookup).
8409 return Err(GraphError::RoleWriteDenied {
8410 reason: format!(
8411 "role-bound token: label '{}' not in write scope (create_labels)",
8412 stmt.label
8413 ),
8414 });
8415 }
8416 // Key lookup under mask.
8417 match self.ids.get(key.as_str()) {
8418 Some(id) if authz.mask.contains_id(id) => {
8419 // Visible: must have update scope to proceed to match arm.
8420 if !has_update {
8421 return Err(GraphError::RoleWriteDenied {
8422 reason: format!(
8423 "role-bound token: label '{}' not in write scope (update_labels)",
8424 stmt.label
8425 ),
8426 });
8427 }
8428 true // existed = true → match arm
8429 }
8430 Some(_) => {
8431 // Hidden: same error as absent to the role (spec §3.1/§3.3).
8432 return Err(GraphError::RoleWriteDenied {
8433 reason: "role-bound token: target node not visible".into(),
8434 });
8435 }
8436 None => {
8437 // Absent: must have create scope to proceed to the create arm.
8438 //
8439 // Update-only roles (create_labels empty, update_labels set):
8440 // return the SAME "not visible" error as the hidden-key branch
8441 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8442 // "confirm existence of hidden nodes: No").
8443 //
8444 // Create-scoped roles (has_create=true): absent → create arm
8445 // as before. The accepted structural key-existence disclosure
8446 // (§THREAT-MODEL) applies only when the role holds create scope.
8447 if !has_create {
8448 return Err(GraphError::RoleWriteDenied {
8449 reason: "role-bound token: target node not visible".into(),
8450 });
8451 }
8452 false // existed = false → create arm
8453 }
8454 }
8455 } else {
8456 // Full authority: use the existing non-masked has_node check.
8457 self.has_node(&key)
8458 };
8459
8460 let existed = merge_existed;
8461 let mut created = 0i64;
8462 if !existed || !stmt.on_match.is_empty() {
8463 let mut batch = self.batch();
8464 if !existed {
8465 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8466 batch.insert_node(&stmt.label, &key, props);
8467 for sc in &stmt.on_create {
8468 let value = resolve_merge_set_value(&sc.value, params)?;
8469 batch.set_prop(&key, &sc.field, value);
8470 }
8471 created = 1;
8472 } else {
8473 for sc in &stmt.on_match {
8474 let value = resolve_merge_set_value(&sc.value, params)?;
8475 batch.set_prop(&key, &sc.field, value);
8476 }
8477 }
8478 batch.commit()?;
8479 }
8480
8481 // Refresh the role mask so the just-created node is visible to this
8482 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8483 // (apply_schema subset rule), so the new node's label is already in the
8484 // role's read scope — this never widens beyond the role's declared labels.
8485 if !existed {
8486 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8487 let new_mask = self.mask_for_role(&role)?;
8488 if let Some(a) = self.pending_write_authz.as_mut() {
8489 a.mask = new_mask;
8490 }
8491 }
8492 }
8493
8494 // Optional RETURN clause: project the node (created or matched) as a read result.
8495 if let Some(returns) = stmt.returns {
8496 let var = stmt.var.as_deref().unwrap_or("_mn0");
8497 let q = Query {
8498 matches: vec![Pattern {
8499 start: NodePat {
8500 var: Some(var.to_string()),
8501 label: Some(stmt.label.clone()),
8502 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8503 },
8504 chain: vec![],
8505 shortest: false,
8506 }],
8507 optional_clauses: vec![],
8508 where_expr: None,
8509 unwinds: vec![],
8510 post_unwind_where: None,
8511 stages: vec![],
8512 returns,
8513 distinct: false,
8514 order_by: vec![],
8515 skip: None,
8516 limit: None,
8517 };
8518 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8519 detail: format!("plan: {e}"),
8520 })?;
8521 // Use view_masked when a role-scoped write is in flight so the
8522 // post-merge projection is consistent with the masked read phase.
8523 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8524 return (if let Some(ref mask) = mask_opt {
8525 execute(&self.view_masked(mask), &ops, &Params(params))
8526 } else {
8527 execute(&self.view(), &ops, &Params(params))
8528 })
8529 .map_err(|e| GraphError::QueryError {
8530 detail: format!("execute: {e}"),
8531 });
8532 }
8533
8534 let mut rs = write_result_set();
8535 rs.push_row(vec![
8536 Some(Value::Int(created)),
8537 Some(Value::Int(0)),
8538 Some(Value::Int(0)),
8539 ]);
8540 Ok(rs)
8541 }
8542
8543 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8544 /// annotated with rule name, edge type, direction, and weight.
8545 /// Results are sorted by (rule, edge_type).
8546 /// Returns `Err(KeyNotFound)` if either key is unknown.
8547 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8548 self.ensure_v8_base_sections_loaded();
8549 let id_a = self
8550 .ids
8551 .get(key_a)
8552 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8553 let id_b = self
8554 .ids
8555 .get(key_b)
8556 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8557
8558 let mut results = Vec::new();
8559
8560 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8561 // rather than O(total provenance).
8562 let scan = if self.engine.provenance_touching_len(id_a)
8563 <= self.engine.provenance_touching_len(id_b)
8564 {
8565 id_a
8566 } else {
8567 id_b
8568 };
8569 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8570 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8571 continue;
8572 }
8573 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8574 continue;
8575 };
8576 let edge_type = match self.syms.resolve(etype) {
8577 Some(s) => s.to_string(),
8578 None => continue,
8579 };
8580 // Provenance (src, dst) ids come from the archived PROVENANCE section
8581 // (large, no eager CRC). A corrupt section can produce ids that are
8582 // out of range; return Corrupt rather than panic.
8583 let src_key = self
8584 .ids
8585 .key_of(src)
8586 .ok_or_else(|| GraphError::Corrupt {
8587 detail: format!("v8: provenance src id {src} not in id table"),
8588 })?
8589 .to_string();
8590 let dst_key = self
8591 .ids
8592 .key_of(dst)
8593 .ok_or_else(|| GraphError::Corrupt {
8594 detail: format!("v8: provenance dst id {dst} not in id table"),
8595 })?
8596 .to_string();
8597 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8598 self.edge_props_view()
8599 .get(etype, src, dst, prop)
8600 .and_then(|v| {
8601 if let Value::Float(f) = v {
8602 Some(f)
8603 } else {
8604 None
8605 }
8606 })
8607 });
8608 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8609 // still have a score: recompute it from the predicate so explain
8610 // never reports "no score" for an edge the engine scored. Via-hop
8611 // rules score over their via set, not over (src, dst), so leave
8612 // those None rather than report a number the rule did not produce.
8613 let weight = stored.or_else(|| {
8614 if rule_def.via_edge.is_some() {
8615 return None;
8616 }
8617 let props_view = build_props_view(&self.props, &self.base);
8618 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8619 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8620 let src_view = NodeView {
8621 key: &src_key,
8622 props: &src_get,
8623 };
8624 let dst_view = NodeView {
8625 key: &dst_key,
8626 props: &dst_get,
8627 };
8628 evaluate(&rule_def.predicate, &src_view, &dst_view)
8629 });
8630 results.push(Explanation {
8631 rule: rule_name.to_string(),
8632 edge_type,
8633 src_key,
8634 dst_key,
8635 weight,
8636 predicate: PredicateSummary {
8637 approximate: rule_def.approximate,
8638 ..PredicateSummary::from(&rule_def.predicate)
8639 },
8640 via_edge: rule_def.via_edge.clone(),
8641 });
8642 }
8643
8644 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8645 Ok(results)
8646 }
8647
8648 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8649 let id = self
8650 .ids
8651 .get(key)
8652 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8653 let Some(sym) = self.syms.get(edge_type) else {
8654 return Ok(Vec::new());
8655 };
8656 self.topo_view()
8657 .neighbors(sym, dir, id)
8658 .iter()
8659 .map(|&n| {
8660 self.ids
8661 .key_of(n)
8662 .map(|k| k.to_string())
8663 .ok_or_else(|| GraphError::Corrupt {
8664 detail: format!("topology id {n} has no key"),
8665 })
8666 })
8667 .collect::<Result<Vec<_>>>()
8668 }
8669
8670 /// Return the last-change commit sequence for `key`, or `None` if the node
8671 /// does not exist or has never been mutated since the last V5-V7 snapshot
8672 /// (horizon-bounded for legacy stores).
8673 ///
8674 /// The returned sequence is a monotonically increasing counter that starts
8675 /// at 1 for the first commit after `open` and increments with every
8676 /// successful write. WAL replay at open also assigns sequences (1..N for N
8677 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8678 ///
8679 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8680 /// in the snapshot but not touched by any WAL frame will return `None`
8681 /// (horizon-bounded: CAS against such nodes is only safe after the first
8682 /// V8 snapshot or after the node is next mutated).
8683 pub fn last_changed(&self, key: &str) -> Option<u64> {
8684 let id = self.ids.get(key)?;
8685 self.last_change.get(&id).copied()
8686 }
8687
8688 /// The current commit sequence (number of successful commits since open,
8689 /// including WAL replay frames). Useful for recording a baseline before
8690 /// a read-modify-write cycle.
8691 pub fn commit_seq(&self) -> u64 {
8692 self.commit_seq
8693 }
8694
8695 /// Check that all `preconds` are satisfied against the current db state.
8696 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8697 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8698 for precond in preconds {
8699 match precond {
8700 Precondition::NodeUnchangedSince { key, expected } => {
8701 // Missing entry means the node predates the WAL window or
8702 // does not exist; treat as 0 (before any commit).
8703 let actual = self.last_changed(key).unwrap_or_default();
8704 if actual != *expected {
8705 return Err(GraphError::CasConflict {
8706 key: key.clone(),
8707 expected: *expected,
8708 actual,
8709 });
8710 }
8711 }
8712 Precondition::NodeAbsent { key } => {
8713 // Node must not exist (not live).
8714 if self.ids.get(key).is_some() {
8715 let actual = self.last_changed(key).unwrap_or(0);
8716 return Err(GraphError::CasConflict {
8717 key: key.clone(),
8718 expected: u64::MAX,
8719 actual,
8720 });
8721 }
8722 }
8723 }
8724 }
8725 Ok(())
8726 }
8727
8728 /// Apply a batch of mutations with compare-and-set preconditions.
8729 ///
8730 /// All preconditions are checked atomically before any operation is applied.
8731 /// If any precondition fails, the entire batch is rejected with
8732 /// [`GraphError::CasConflict`] and no WAL frame is written.
8733 ///
8734 /// # Returns
8735 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8736 ///
8737 /// # Errors
8738 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8739 /// - Any error that [`write_batch`] would return for the ops themselves.
8740 pub fn write_batch_cas(
8741 &mut self,
8742 preconds: Vec<Precondition>,
8743 ops: Vec<BatchOp>,
8744 ) -> Result<(usize, usize)> {
8745 self.check_preconditions(&preconds)?;
8746 self.commit_logged_batch(ops, None, None)
8747 }
8748
8749 /// Update the per-node last-change map for a WAL record at commit `seq`.
8750 ///
8751 /// Called after a successful apply to record which nodes were touched.
8752 /// For replay, called with the WAL-frame's replayed seq.
8753 ///
8754 /// Touch definition (see [`Precondition`] doc):
8755 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8756 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8757 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8758 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8759 /// - Batch → recurse into inner records.
8760 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8761 match rec {
8762 WalRecord::InsertNode { key, .. }
8763 | WalRecord::SetProp { key, .. }
8764 | WalRecord::RemoveProp { key, .. } => {
8765 if let Some(id) = self.ids.get(key) {
8766 self.last_change.insert(id, seq);
8767 }
8768 }
8769 WalRecord::InsertNodeId { key, .. } => {
8770 if let Some(id) = self.ids.get(key) {
8771 self.last_change.insert(id, seq);
8772 }
8773 }
8774 WalRecord::SetPropId { id, .. } => {
8775 self.last_change.insert(*id, seq);
8776 }
8777 WalRecord::InsertEdge {
8778 src_key, dst_key, ..
8779 }
8780 | WalRecord::DeleteEdge {
8781 src_key, dst_key, ..
8782 } => {
8783 if let Some(src_id) = self.ids.get(src_key) {
8784 self.last_change.insert(src_id, seq);
8785 }
8786 if let Some(dst_id) = self.ids.get(dst_key) {
8787 self.last_change.insert(dst_id, seq);
8788 }
8789 }
8790 WalRecord::InsertEdgeId { src, dst, .. } => {
8791 self.last_change.insert(*src, seq);
8792 self.last_change.insert(*dst, seq);
8793 }
8794 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8795 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8796 // History markers: state no-ops; the underlying mutation already
8797 // touched the relevant nodes' last_change entries.
8798 WalRecord::DeleteNode { .. }
8799 | WalRecord::DerivedEdgeAdded { .. }
8800 | WalRecord::DerivedEdgeRetracted { .. }
8801 | WalRecord::Intern { .. }
8802 | WalRecord::CreateRule { .. }
8803 | WalRecord::DeleteRule { .. }
8804 | WalRecord::RebuildRule { .. }
8805 | WalRecord::CreateView { .. }
8806 | WalRecord::DeleteView { .. }
8807 | WalRecord::EnableFulltext { .. }
8808 | WalRecord::DisableFulltext { .. }
8809 | WalRecord::EnableIndex { .. }
8810 | WalRecord::DisableIndex { .. } => {}
8811 // RenameNode: node id is stable; update last_change via the new key.
8812 // Called after apply(), so ids already reflects new_key.
8813 WalRecord::RenameNode { new_key, .. } => {
8814 if let Some(id) = self.ids.get(new_key) {
8815 self.last_change.insert(id, seq);
8816 }
8817 }
8818 WalRecord::Batch(inner) => {
8819 for inner_rec in inner {
8820 self.update_last_change_from_rec(inner_rec, seq);
8821 }
8822 }
8823 }
8824 }
8825
8826 pub fn node_count(&self) -> usize {
8827 self.ids.len()
8828 }
8829
8830 /// Configure archive retention: keep the `N` newest WAL archives at each
8831 /// [`snapshot_with`] call when `archive_wal: true`.
8832 ///
8833 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8834 /// `Some(0)` or `None` → unlimited (no pruning).
8835 ///
8836 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8837 /// stores the policy. Archives below the retention limit are deleted
8838 /// oldest-first. The horizon floor is updated so that
8839 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8840 /// in pruned archives rather than silently returning wrong data.
8841 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8842 self.wal_archive_retention = keep;
8843 }
8844
8845 /// Delete any WAL archives that are fully below the current horizon floor.
8846 ///
8847 /// Orphaned archives arise when the floor is written first during retention
8848 /// pruning and then a crash interrupts the archive-delete sequence. The
8849 /// opening cleanup ensures no subsequent read path sees stale data.
8850 ///
8851 /// Under the monotonic naming scheme, the archive name N equals the
8852 /// cumulative end-frame index of the archive in global commit space (i.e.
8853 /// the archive covers global frames `[prev_n, N)`). An archive is
8854 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8855 /// below the floor and have already been counted in it.
8856 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8857 if self.wal_horizon_floor == 0 {
8858 // Floor at 0 means no pruning has ever occurred; nothing to clean.
8859 return Ok(());
8860 }
8861 let archive_ns = self.fs.list_archives()?;
8862 for n in archive_ns {
8863 if n <= self.wal_horizon_floor {
8864 // Archive N ends at global frame N; all its frames are below
8865 // the floor (floor already accounts for them) → orphaned.
8866 self.fs.delete_archive(n).map_err(GraphError::Io)?;
8867 } else {
8868 // Archives are sorted ascending; first one above floor stops scan.
8869 break;
8870 }
8871 }
8872 Ok(())
8873 }
8874
8875 /// Collect all WAL frames from surviving archives (oldest-first) then the
8876 /// live WAL into one flat list, and return the total along with the number
8877 /// of archive frames at the front of the list.
8878 ///
8879 /// Commit indices into the returned list are LOCAL (0 = first frame of
8880 /// oldest surviving archive). To obtain the GLOBAL index add
8881 /// `self.wal_horizon_floor`.
8882 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8883 let archive_ns = self.fs.list_archives()?;
8884 let mut all: Vec<WalRecord> = Vec::new();
8885 for n in archive_ns {
8886 let bytes = self.fs.read_archive(n)?;
8887 let (frames, _) = decode_all(&bytes);
8888 all.extend(frames);
8889 }
8890 let archive_count = all.len() as u64;
8891 let live_bytes = self.fs.read(FileId::Wal)?;
8892 let (live_frames, _) = decode_all(&live_bytes);
8893 all.extend(live_frames);
8894 Ok((all, archive_count))
8895 }
8896
8897 /// Return the total number of committed WAL frames visible in the current
8898 /// horizon window, including frames in surviving WAL archives.
8899 ///
8900 /// This is the exclusive upper bound for valid `at_commit` indices in
8901 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8902 ///
8903 /// Returns the horizon floor when all surviving history is empty.
8904 pub fn wal_total_commits(&self) -> Result<u64> {
8905 let (frames, _) = self.all_frames()?;
8906 Ok(self.wal_horizon_floor + frames.len() as u64)
8907 }
8908
8909 /// The global frame index of the first commit reachable through surviving
8910 /// archives (0 when no archives have been pruned).
8911 pub fn wal_horizon_floor(&self) -> u64 {
8912 self.wal_horizon_floor
8913 }
8914
8915 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8916 ///
8917 /// ## Horizon
8918 ///
8919 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8920 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8921 /// zero-cost contract; a durable history log is out of scope.
8922 ///
8923 /// ## Derived edges
8924 ///
8925 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8926 /// history. Only edges written directly by the application are recorded.
8927 ///
8928 /// ## Deleted nodes
8929 ///
8930 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8931 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8932 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8933 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8934 ///
8935 /// ## Dense-id edge entries and tombstoned partners
8936 ///
8937 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8938 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8939 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8940 /// Build commit-bounded alias intervals for `queried_key`.
8941 ///
8942 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8943 /// A record written under `key` at commit `c` matches the queried identity iff
8944 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8945 ///
8946 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8947 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8948 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8949 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8950 /// only identity-2's events (commits 7–9 under "a") are in scope.
8951 ///
8952 /// Only **forward aliasing**: querying the *new* key surfaces events written
8953 /// under the *old* key. The reverse direction is not supported.
8954 fn build_key_alias_intervals(
8955 &self,
8956 frames: &[core_storage::wal::WalRecord],
8957 queried_key: &str,
8958 ) -> Vec<(String, u64, Option<u64>)> {
8959 use core_storage::wal::WalRecord;
8960
8961 // Pre-pass: build reverse_rename and key_starts maps.
8962 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8963 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8964
8965 for (local_i, frame) in frames.iter().enumerate() {
8966 let commit = self.wal_horizon_floor + local_i as u64;
8967 let records: &[WalRecord] = match frame {
8968 WalRecord::Batch(inner) => inner.as_slice(),
8969 single => std::slice::from_ref(single),
8970 };
8971 for rec in records {
8972 match rec {
8973 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8974 key_starts.entry(key.clone()).or_default().push(commit);
8975 }
8976 WalRecord::RenameNode { old_key, new_key } => {
8977 // new_key came into existence at this commit.
8978 key_starts.entry(new_key.clone()).or_default().push(commit);
8979 // Record the reverse rename: new_key was introduced by renaming old_key.
8980 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8981 }
8982 _ => {}
8983 }
8984 }
8985 }
8986
8987 // Build alias intervals by following the reverse rename chain.
8988 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8989 let mut current_key = queried_key.to_string();
8990 let mut current_valid_until: Option<u64> = None;
8991
8992 loop {
8993 // valid_from: the most recent commit where current_key was assigned to this
8994 // identity. For aliases (valid_until = Some(vu)), find the last start event
8995 // for the key strictly before vu — this is where the alias's occupancy by
8996 // this identity began, correctly excluding prior identities that reused the key.
8997 let valid_from = if let Some(vu) = current_valid_until {
8998 key_starts
8999 .get(¤t_key)
9000 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9001 .unwrap_or(self.wal_horizon_floor)
9002 } else {
9003 // Queried key — no upper bound; may have been introduced at any commit.
9004 self.wal_horizon_floor
9005 };
9006
9007 result.push((current_key.clone(), valid_from, current_valid_until));
9008
9009 match reverse_rename.get(¤t_key) {
9010 Some((old_key, rename_commit)) => {
9011 current_valid_until = Some(*rename_commit);
9012 current_key = old_key.clone();
9013 }
9014 None => break,
9015 }
9016 }
9017
9018 result
9019 }
9020
9021 /// Returns true if `record_key` matches any alias interval that covers `commit`.
9022 fn aliases_match(
9023 intervals: &[(String, u64, Option<u64>)],
9024 record_key: &str,
9025 commit: u64,
9026 ) -> bool {
9027 intervals
9028 .iter()
9029 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9030 }
9031
9032 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
9033 use crate::history::{HistoryChange, HistoryEntry};
9034 use core_storage::wal::WalRecord;
9035
9036 let (frames, _) = self.all_frames()?;
9037
9038 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9039 let alias_intervals = self.build_key_alias_intervals(&frames, key);
9040
9041 let mut out: Vec<HistoryEntry> = Vec::new();
9042
9043 for (local_i, frame) in frames.iter().enumerate() {
9044 let commit = self.wal_horizon_floor + local_i as u64;
9045 // Collect the inner records to process — Batch is one commit, single records are one commit.
9046 let records: &[WalRecord] = match frame {
9047 WalRecord::Batch(inner) => inner.as_slice(),
9048 single => std::slice::from_ref(single),
9049 };
9050
9051 for rec in records {
9052 let change = match rec {
9053 WalRecord::InsertNode { label, key: k, .. }
9054 if Self::aliases_match(&alias_intervals, k, commit) =>
9055 {
9056 Some(HistoryChange::NodeInserted {
9057 label: label.clone(),
9058 })
9059 }
9060 WalRecord::InsertNodeId { label, key: k, .. }
9061 if Self::aliases_match(&alias_intervals, k, commit) =>
9062 {
9063 let label_str = match self.syms.resolve(*label) {
9064 Some(s) => s.to_string(),
9065 None => continue,
9066 };
9067 Some(HistoryChange::NodeInserted { label: label_str })
9068 }
9069 WalRecord::SetProp {
9070 key: k,
9071 field,
9072 value,
9073 } if Self::aliases_match(&alias_intervals, k, commit) => {
9074 Some(HistoryChange::PropSet {
9075 field: field.clone(),
9076 value: value.clone(),
9077 })
9078 }
9079 WalRecord::SetPropId { id, field, value } => {
9080 // Use key_of_historical (not key_of) so a node's prop_set
9081 // events remain visible after the node is later deleted:
9082 // key_of returns None for a tombstoned id, which would
9083 // silently drop every PropSet between insert and delete.
9084 // Mirrors the InsertEdgeId arm below and edge_history's
9085 // own id-keyed arms.
9086 match self.ids.key_of_historical(*id) {
9087 // key_of_historical returns the last-known (possibly
9088 // post-rename, possibly post-delete) key; compare to queried key.
9089 Some(resolved) if resolved == key => {
9090 let field_str = match self.syms.resolve(*field) {
9091 Some(s) => s.to_string(),
9092 None => continue,
9093 };
9094 Some(HistoryChange::PropSet {
9095 field: field_str,
9096 value: value.clone(),
9097 })
9098 }
9099 _ => None,
9100 }
9101 }
9102 WalRecord::RemoveProp { key: k, field }
9103 if Self::aliases_match(&alias_intervals, k, commit) =>
9104 {
9105 Some(HistoryChange::PropRemoved {
9106 field: field.clone(),
9107 })
9108 }
9109 WalRecord::InsertEdge {
9110 edge_type,
9111 src_key,
9112 dst_key,
9113 } => {
9114 if Self::aliases_match(&alias_intervals, src_key, commit) {
9115 Some(HistoryChange::EdgeAdded {
9116 edge_type: edge_type.clone(),
9117 other: dst_key.clone(),
9118 outgoing: true,
9119 })
9120 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9121 Some(HistoryChange::EdgeAdded {
9122 edge_type: edge_type.clone(),
9123 other: src_key.clone(),
9124 outgoing: false,
9125 })
9126 } else {
9127 None
9128 }
9129 }
9130 WalRecord::InsertEdgeId { etype, src, dst } => {
9131 let etype_str = match self.syms.resolve(*etype) {
9132 Some(s) => s.to_string(),
9133 None => continue,
9134 };
9135 // key_of_historical (not key_of): an edge added before
9136 // either endpoint was later deleted must still resolve —
9137 // see the SetPropId arm above and edge_history's
9138 // InsertEdgeId arm, which use the same lookup for the
9139 // same reason.
9140 let src_key = self.ids.key_of_historical(*src);
9141 let dst_key = self.ids.key_of_historical(*dst);
9142 if src_key == Some(key) {
9143 let other = match dst_key {
9144 Some(s) => s.to_string(),
9145 None => continue,
9146 };
9147 Some(HistoryChange::EdgeAdded {
9148 edge_type: etype_str,
9149 other,
9150 outgoing: true,
9151 })
9152 } else if dst_key == Some(key) {
9153 let other = match src_key {
9154 Some(s) => s.to_string(),
9155 None => continue,
9156 };
9157 Some(HistoryChange::EdgeAdded {
9158 edge_type: etype_str,
9159 other,
9160 outgoing: false,
9161 })
9162 } else {
9163 None
9164 }
9165 }
9166 WalRecord::DeleteEdge {
9167 edge_type,
9168 src_key,
9169 dst_key,
9170 } => {
9171 if Self::aliases_match(&alias_intervals, src_key, commit) {
9172 Some(HistoryChange::EdgeRemoved {
9173 edge_type: edge_type.clone(),
9174 other: dst_key.clone(),
9175 outgoing: true,
9176 })
9177 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9178 Some(HistoryChange::EdgeRemoved {
9179 edge_type: edge_type.clone(),
9180 other: src_key.clone(),
9181 outgoing: false,
9182 })
9183 } else {
9184 None
9185 }
9186 }
9187 WalRecord::DeleteNode { key: k }
9188 if Self::aliases_match(&alias_intervals, k, commit) =>
9189 {
9190 Some(HistoryChange::NodeDeleted)
9191 }
9192 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9193 _ => None,
9194 };
9195
9196 if let Some(change) = change {
9197 out.push(HistoryEntry { commit, change });
9198 }
9199 }
9200 }
9201
9202 Ok(out)
9203 }
9204
9205 /// Return the per-edge change history between nodes `a` and `b` by scanning
9206 /// the on-disk WAL.
9207 ///
9208 /// ## Horizon
9209 ///
9210 /// History reaches back only to the last WAL-truncating snapshot, exactly
9211 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9212 /// `total_commits` (= number of WAL frames), which is the exclusive upper
9213 /// bound for valid commit indices.
9214 ///
9215 /// ## Derived edges
9216 ///
9217 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9218 /// WAL markers written by `log_then_apply_with` after each rule-firing
9219 /// mutation. The `rule` field of those events carries the rule name.
9220 ///
9221 /// ## DeleteNode
9222 ///
9223 /// When a node is deleted, its manual incident edges are swept inline without
9224 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9225 /// events for either endpoint and synthesises `Retracted(rule:None)` events
9226 /// for each manual edge that was active at that point. Derived edges active at
9227 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9228 /// the engine appends immediately after the `DeleteNode` record; those events
9229 /// carry correct rule attribution and are emitted by the marker arm, not the
9230 /// synthetic sweep.
9231 ///
9232 /// ## Masks
9233 ///
9234 /// Like `node_history`, this method has no mask parameter and returns WAL
9235 /// history regardless of any role mask. For masked history semantics, apply
9236 /// the mask at the caller level.
9237 pub fn edge_history(
9238 &self,
9239 a: &str,
9240 b: &str,
9241 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9242 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9243 use core_storage::wal::WalRecord;
9244
9245 let (frames, _) = self.all_frames()?;
9246 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9247
9248 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9249 // Intervals are commit-bounded so recycled keys don't contaminate histories.
9250 let alias_a = self.build_key_alias_intervals(&frames, a);
9251 let alias_b = self.build_key_alias_intervals(&frames, b);
9252
9253 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9254 // The is_derived flag is used by the DeleteNode sweep: manual edges are
9255 // swept with a synthetic Retracted(rule:None); derived edges are skipped
9256 // because the engine writes a DerivedEdgeRetracted marker immediately after
9257 // the DeleteNode record, which carries the correct rule attribution.
9258 let mut active: Vec<(String, String, String, bool)> = Vec::new();
9259 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9260
9261 for (local_i, frame) in frames.iter().enumerate() {
9262 let commit = self.wal_horizon_floor + local_i as u64;
9263 let records: &[WalRecord] = match frame {
9264 WalRecord::Batch(inner) => inner.as_slice(),
9265 single => std::slice::from_ref(single),
9266 };
9267
9268 for rec in records {
9269 match rec {
9270 WalRecord::InsertEdge {
9271 edge_type,
9272 src_key,
9273 dst_key,
9274 } => {
9275 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9276 && Self::aliases_match(&alias_b, dst_key, commit);
9277 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9278 && Self::aliases_match(&alias_a, dst_key, commit);
9279 if is_ab || is_ba {
9280 active.push((
9281 edge_type.clone(),
9282 src_key.clone(),
9283 dst_key.clone(),
9284 false,
9285 ));
9286 out.push(EdgeHistoryEvent {
9287 edge_type: edge_type.clone(),
9288 commit,
9289 event: EdgeEvent::Added,
9290 rule: None,
9291 });
9292 }
9293 }
9294 WalRecord::InsertEdgeId { etype, src, dst } => {
9295 let etype_str = match self.syms.resolve(*etype) {
9296 Some(s) => s.to_string(),
9297 None => continue,
9298 };
9299 // Use key_of_historical so tombstoned nodes (deleted
9300 // later in the WAL) still resolve during the scan.
9301 let src_key = self.ids.key_of_historical(*src);
9302 let dst_key = self.ids.key_of_historical(*dst);
9303 let is_ab = src_key == Some(a) && dst_key == Some(b);
9304 let is_ba = src_key == Some(b) && dst_key == Some(a);
9305 if is_ab || is_ba {
9306 let src_str = src_key.unwrap().to_string();
9307 let dst_str = dst_key.unwrap().to_string();
9308 active.push((etype_str.clone(), src_str, dst_str, false));
9309 out.push(EdgeHistoryEvent {
9310 edge_type: etype_str,
9311 commit,
9312 event: EdgeEvent::Added,
9313 rule: None,
9314 });
9315 }
9316 }
9317 WalRecord::DeleteEdge {
9318 edge_type,
9319 src_key,
9320 dst_key,
9321 } => {
9322 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9323 && Self::aliases_match(&alias_b, dst_key, commit);
9324 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9325 && Self::aliases_match(&alias_a, dst_key, commit);
9326 if is_ab || is_ba {
9327 // Remove the first matching active entry (flag ignored).
9328 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9329 et == edge_type && s == src_key && d == dst_key
9330 }) {
9331 active.remove(pos);
9332 }
9333 out.push(EdgeHistoryEvent {
9334 edge_type: edge_type.clone(),
9335 commit,
9336 event: EdgeEvent::Retracted,
9337 rule: None,
9338 });
9339 }
9340 }
9341 WalRecord::DeleteNode { key: k }
9342 if Self::aliases_match(&alias_a, k, commit)
9343 || Self::aliases_match(&alias_b, k, commit) =>
9344 {
9345 // Sweep: implicitly retract only MANUAL active edges.
9346 // Derived active edges are skipped here because the rule
9347 // engine appends a DerivedEdgeRetracted marker immediately
9348 // after this DeleteNode record; that marker produces the
9349 // single correctly-attributed Retracted event. Derived
9350 // entries are dropped from `active` (the marker arm's
9351 // idempotent retain finds nothing to remove).
9352 for (et, _, _, is_derived) in active.drain(..) {
9353 if !is_derived {
9354 out.push(EdgeHistoryEvent {
9355 edge_type: et,
9356 commit,
9357 event: EdgeEvent::Retracted,
9358 rule: None,
9359 });
9360 }
9361 // Derived: drop silently; marker carries the Retracted event.
9362 }
9363 }
9364 WalRecord::DerivedEdgeAdded {
9365 rule,
9366 edge_type: et,
9367 src_key,
9368 dst_key,
9369 } => {
9370 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9371 && Self::aliases_match(&alias_b, dst_key, commit);
9372 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9373 && Self::aliases_match(&alias_a, dst_key, commit);
9374 if is_ab || is_ba {
9375 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9376 out.push(EdgeHistoryEvent {
9377 edge_type: et.clone(),
9378 commit,
9379 event: EdgeEvent::Added,
9380 rule: Some(rule.clone()),
9381 });
9382 }
9383 }
9384 WalRecord::DerivedEdgeRetracted {
9385 rule,
9386 edge_type: et,
9387 src_key,
9388 dst_key,
9389 } => {
9390 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9391 && Self::aliases_match(&alias_b, dst_key, commit);
9392 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9393 && Self::aliases_match(&alias_a, dst_key, commit);
9394 if is_ab || is_ba {
9395 // Push unconditionally: a derived edge whose Added marker
9396 // predates the history horizon has no `active` entry, but
9397 // the retraction is still a real in-window event.
9398 // Remove from active idempotently if present.
9399 active.retain(|(aet, s, d, _)| {
9400 !(aet == et && s == src_key && d == dst_key)
9401 });
9402 out.push(EdgeHistoryEvent {
9403 edge_type: et.clone(),
9404 commit,
9405 event: EdgeEvent::Retracted,
9406 rule: Some(rule.clone()),
9407 });
9408 }
9409 }
9410 // All other records (InsertNode, SetProp, CreateRule, etc.)
9411 // do not affect edges between a and b.
9412 _ => {}
9413 }
9414 }
9415 }
9416
9417 Ok(HistoryResult {
9418 items: out,
9419 total_commits,
9420 })
9421 }
9422
9423 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9424 /// (in either direction) at the WAL commit `at_commit`.
9425 ///
9426 /// ## Horizon
9427 ///
9428 /// Valid commit indices are `0..total_commits` where `total_commits` is the
9429 /// number of WAL frames. An `at_commit >= total_commits` is outside the
9430 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9431 ///
9432 /// ## Derived edges
9433 ///
9434 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9435 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9436 /// and therefore includes derived edges in its point-in-time evaluation,
9437 /// matching `edge_history`'s fidelity.
9438 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9439 use core_storage::wal::WalRecord;
9440
9441 let (frames, _) = self.all_frames()?;
9442 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9443
9444 // Horizon floor: commits in pruned archives are unreachable.
9445 if at_commit < self.wal_horizon_floor {
9446 return Err(GraphError::CommitOutOfRange {
9447 commit: at_commit,
9448 total: total_commits,
9449 });
9450 }
9451 if at_commit >= total_commits {
9452 return Err(GraphError::CommitOutOfRange {
9453 commit: at_commit,
9454 total: total_commits,
9455 });
9456 }
9457
9458 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9459 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9460 let alias_a = self.build_key_alias_intervals(&frames, a);
9461 let alias_b = self.build_key_alias_intervals(&frames, b);
9462
9463 // Local index into surviving frames (0 = first frame of oldest archive).
9464 let local_commit = at_commit - self.wal_horizon_floor;
9465
9466 // Replay local frames 0..=local_commit, tracking active edges.
9467 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9468
9469 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9470 let commit = self.wal_horizon_floor + local_i as u64;
9471 let records: &[WalRecord] = match frame {
9472 WalRecord::Batch(inner) => inner.as_slice(),
9473 single => std::slice::from_ref(single),
9474 };
9475
9476 for rec in records {
9477 match rec {
9478 WalRecord::InsertEdge {
9479 edge_type: et,
9480 src_key,
9481 dst_key,
9482 } => {
9483 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9484 && Self::aliases_match(&alias_b, dst_key, commit);
9485 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9486 && Self::aliases_match(&alias_a, dst_key, commit);
9487 if is_ab || is_ba {
9488 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9489 }
9490 }
9491 WalRecord::InsertEdgeId { etype, src, dst } => {
9492 let etype_str = match self.syms.resolve(*etype) {
9493 Some(s) => s.to_string(),
9494 None => continue,
9495 };
9496 // Use key_of_historical so tombstoned nodes resolve.
9497 let src_key = self.ids.key_of_historical(*src);
9498 let dst_key = self.ids.key_of_historical(*dst);
9499 let is_ab = src_key == Some(a) && dst_key == Some(b);
9500 let is_ba = src_key == Some(b) && dst_key == Some(a);
9501 if is_ab || is_ba {
9502 active.insert((
9503 etype_str,
9504 src_key.unwrap().to_string(),
9505 dst_key.unwrap().to_string(),
9506 ));
9507 }
9508 }
9509 WalRecord::DeleteEdge {
9510 edge_type: et,
9511 src_key,
9512 dst_key,
9513 } => {
9514 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9515 && Self::aliases_match(&alias_b, dst_key, commit);
9516 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9517 && Self::aliases_match(&alias_a, dst_key, commit);
9518 if is_ab || is_ba {
9519 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9520 }
9521 }
9522 WalRecord::DeleteNode { key: k }
9523 if Self::aliases_match(&alias_a, k, commit)
9524 || Self::aliases_match(&alias_b, k, commit) =>
9525 {
9526 // All edges touching the deleted node are gone.
9527 active.retain(|(_, s, d)| s != k && d != k);
9528 }
9529 WalRecord::DerivedEdgeAdded {
9530 edge_type: et,
9531 src_key,
9532 dst_key,
9533 ..
9534 } => {
9535 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9536 && Self::aliases_match(&alias_b, dst_key, commit);
9537 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9538 && Self::aliases_match(&alias_a, dst_key, commit);
9539 if is_ab || is_ba {
9540 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9541 }
9542 }
9543 WalRecord::DerivedEdgeRetracted {
9544 edge_type: et,
9545 src_key,
9546 dst_key,
9547 ..
9548 } => {
9549 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9550 && Self::aliases_match(&alias_b, dst_key, commit);
9551 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9552 && Self::aliases_match(&alias_a, dst_key, commit);
9553 if is_ab || is_ba {
9554 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9555 }
9556 }
9557 _ => {}
9558 }
9559 }
9560 }
9561
9562 Ok(active.iter().any(|(et, _, _)| et == edge_type))
9563 }
9564
9565 /// Every edge incident to `key` — either endpoint — that existed at WAL
9566 /// commit `commit`, from ONE scan of the WAL.
9567 ///
9568 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
9569 /// "what did K's relationships look like at commit C" with one call instead
9570 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
9571 /// The two agree edge for edge.
9572 ///
9573 /// Results are sorted by `(edge_type, src_key, dst_key)`.
9574 ///
9575 /// ## Horizon
9576 ///
9577 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
9578 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
9579 /// `was_linked`. An unknown key is not an error — it simply had no edges.
9580 ///
9581 /// ## Derived edges
9582 ///
9583 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
9584 /// attribution, so a rule-owned edge comes back with `derived: true` and
9585 /// `rule: Some(name)`.
9586 ///
9587 /// ## Renames
9588 ///
9589 /// `key` is matched through the same commit-bounded alias intervals
9590 /// `edge_history` uses, so querying a node's *current* key surfaces edges
9591 /// written under an earlier name. Endpoint keys in the result are reported
9592 /// under the name the node carries today, so they can be fed straight back
9593 /// into `node_info`, `explain` or another `edges_at`.
9594 ///
9595 /// ## Masks
9596 ///
9597 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
9598 /// any role mask. Apply masking at the caller level.
9599 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
9600 use core_storage::wal::WalRecord;
9601
9602 let (frames, _) = self.all_frames()?;
9603 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9604
9605 // Horizon floor: commits in pruned archives are unreachable.
9606 if commit < self.wal_horizon_floor || commit >= total_commits {
9607 return Err(GraphError::CommitOutOfRange {
9608 commit,
9609 total: total_commits,
9610 });
9611 }
9612
9613 // Commit-bounded historical names of `key` (handles RenameNode).
9614 let alias = self.build_key_alias_intervals(&frames, key);
9615
9616 // Forward rename chain, for reporting endpoints under their current
9617 // names: old key → [(commit, new key)] in ascending commit order.
9618 // Built over the whole WAL, not just the prefix up to `commit`, because
9619 // a rename after `commit` still changes what the node is called today.
9620 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
9621 for (local_i, frame) in frames.iter().enumerate() {
9622 let c = self.wal_horizon_floor + local_i as u64;
9623 let records: &[WalRecord] = match frame {
9624 WalRecord::Batch(inner) => inner.as_slice(),
9625 single => std::slice::from_ref(single),
9626 };
9627 for rec in records {
9628 if let WalRecord::RenameNode { old_key, new_key } = rec {
9629 renames
9630 .entry(old_key.clone())
9631 .or_default()
9632 .push((c, new_key.clone()));
9633 }
9634 }
9635 }
9636
9637 // The name a node written as `k` at commit `from` carries today.
9638 // Follows the first rename at or after `from`, then keeps going. The
9639 // iteration cap bounds a rename cycle inside a single batch.
9640 let canon = |k: &str, from: u64| -> String {
9641 if renames.is_empty() {
9642 return k.to_string();
9643 }
9644 let mut cur = k.to_string();
9645 let mut at = from;
9646 for _ in 0..64 {
9647 match renames
9648 .get(&cur)
9649 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
9650 {
9651 Some((c, new)) => {
9652 at = *c;
9653 cur = new.clone();
9654 }
9655 None => break,
9656 }
9657 }
9658 cur
9659 };
9660
9661 let local_commit = commit - self.wal_horizon_floor;
9662 // (edge_type, src_key, dst_key) → (derived, rule)
9663 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
9664 BTreeMap::new();
9665
9666 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9667 let c = self.wal_horizon_floor + local_i as u64;
9668 let records: &[WalRecord] = match frame {
9669 WalRecord::Batch(inner) => inner.as_slice(),
9670 single => std::slice::from_ref(single),
9671 };
9672
9673 for rec in records {
9674 match rec {
9675 WalRecord::InsertEdge {
9676 edge_type,
9677 src_key,
9678 dst_key,
9679 } => {
9680 if Self::aliases_match(&alias, src_key, c)
9681 || Self::aliases_match(&alias, dst_key, c)
9682 {
9683 active.insert(
9684 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9685 (false, None),
9686 );
9687 }
9688 }
9689 WalRecord::InsertEdgeId { etype, src, dst } => {
9690 let Some(etype_str) = self.syms.resolve(*etype) else {
9691 continue;
9692 };
9693 // `key_of_historical` resolves tombstoned ids too, and
9694 // already returns the node's current key — no rename
9695 // canonicalisation needed on this arm.
9696 let (Some(src_key), Some(dst_key)) = (
9697 self.ids.key_of_historical(*src),
9698 self.ids.key_of_historical(*dst),
9699 ) else {
9700 continue;
9701 };
9702 if src_key == key || dst_key == key {
9703 active.insert(
9704 (
9705 etype_str.to_string(),
9706 src_key.to_string(),
9707 dst_key.to_string(),
9708 ),
9709 (false, None),
9710 );
9711 }
9712 }
9713 WalRecord::DeleteEdge {
9714 edge_type,
9715 src_key,
9716 dst_key,
9717 } => {
9718 if Self::aliases_match(&alias, src_key, c)
9719 || Self::aliases_match(&alias, dst_key, c)
9720 {
9721 active.remove(&(
9722 edge_type.clone(),
9723 canon(src_key, c),
9724 canon(dst_key, c),
9725 ));
9726 }
9727 }
9728 WalRecord::DeleteNode { key: k } => {
9729 if active.is_empty() {
9730 continue;
9731 }
9732 if Self::aliases_match(&alias, k, c) {
9733 // Our node is gone; every incident edge goes with it.
9734 active.clear();
9735 } else {
9736 // A partner is gone; its edges to us go with it.
9737 let ck = canon(k, c);
9738 active.retain(|(_, s, d), _| *s != ck && *d != ck);
9739 }
9740 }
9741 WalRecord::DerivedEdgeAdded {
9742 rule,
9743 edge_type,
9744 src_key,
9745 dst_key,
9746 } => {
9747 if Self::aliases_match(&alias, src_key, c)
9748 || Self::aliases_match(&alias, dst_key, c)
9749 {
9750 active.insert(
9751 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9752 (true, Some(rule.clone())),
9753 );
9754 }
9755 }
9756 WalRecord::DerivedEdgeRetracted {
9757 edge_type,
9758 src_key,
9759 dst_key,
9760 ..
9761 } => {
9762 if Self::aliases_match(&alias, src_key, c)
9763 || Self::aliases_match(&alias, dst_key, c)
9764 {
9765 active.remove(&(
9766 edge_type.clone(),
9767 canon(src_key, c),
9768 canon(dst_key, c),
9769 ));
9770 }
9771 }
9772 // InsertNode, SetProp, CreateRule, … do not move edges.
9773 _ => {}
9774 }
9775 }
9776 }
9777
9778 // BTreeMap iteration is already (edge_type, src, dst) order.
9779 Ok(active
9780 .into_iter()
9781 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
9782 edge_type,
9783 src_key,
9784 dst_key,
9785 derived,
9786 rule,
9787 })
9788 .collect())
9789 }
9790
9791 /// The derived edges that would be retracted and derived if `key.field`
9792 /// were set to `value` — computed WITHOUT writing anything.
9793 ///
9794 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
9795 /// provenance, its candidate indexes, the topology and the property columns
9796 /// are all cloned first, the change is applied to the clone, and the real
9797 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
9798 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
9799 /// it emits are the answer, so rule semantics — predicates, top-k,
9800 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
9801 ///
9802 /// Works on a read-only handle.
9803 ///
9804 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
9805 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
9806 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
9807 /// (the node already holds `value`, or no rule watches `field`) returns
9808 /// empty lists.
9809 ///
9810 /// ## Cost
9811 ///
9812 /// One clone of the property columns, the topology overlay, the symbol
9813 /// interner, the edge properties and the provenance map, plus one candidate
9814 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
9815 /// directory, but it is not free — this is an interactive "what if", not a
9816 /// hot path.
9817 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
9818 // The engine's provenance, HNSW and IVF state live in the mmap'd base
9819 // until something asks for them. On a store opened cold from a snapshot
9820 // this is the first ask, and without it the clone below starts from an
9821 // empty provenance map: nothing to retract, so `lost` comes back empty.
9822 self.ensure_v8_base_sections_loaded();
9823
9824 let empty = WhatIf {
9825 lost: Vec::new(),
9826 gained: Vec::new(),
9827 };
9828
9829 if let Some(view_name) = self.view_store.view_for_prop(field) {
9830 return Err(GraphError::ViewPropReadOnly {
9831 view_name: view_name.to_string(),
9832 });
9833 }
9834 MutPreview::new(self).check_live_key(key)?;
9835 let id = self
9836 .ids
9837 .get(key)
9838 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9839
9840 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
9841 if rules.is_empty() {
9842 return Ok(empty);
9843 }
9844
9845 // No rule watches this field → no derivation can change.
9846 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
9847 return Ok(empty);
9848 }
9849
9850 let old_value = build_props_view(&self.props, &self.base)
9851 .get(id, field)
9852 .map(|vr| vr.into_value());
9853 if old_value.as_ref() == Some(&value) {
9854 return Ok(empty);
9855 }
9856
9857 // --- Clone every piece of state the re-derivation writes to. ---
9858 let mut props = self.props.clone();
9859 let mut topo = self.topo.clone();
9860 let mut syms = self.syms.clone();
9861 let mut edge_props = self.edge_props.clone();
9862
9863 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
9864 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
9865 for r in &rules {
9866 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
9867 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
9868 }
9869 // `provenance()` decodes retained snapshot bytes on first use; the
9870 // engine clone needs the real map, not an empty one.
9871 let provenance = self.engine.provenance().clone();
9872 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
9873
9874 // Build the candidate indexes from the state BEFORE the change, exactly
9875 // as apply() sees them: `on_node_changed` withdraws the node under its
9876 // old value and refiles it under the new one, so the index must not
9877 // already reflect the change.
9878 engine.reindex_all_load_ivf(
9879 &self.ids,
9880 &syms,
9881 &self.labels,
9882 build_props_view(&self.props, &self.base),
9883 self.engine.export_ivf_state(),
9884 );
9885 engine.load_hnsw_state(self.engine.export_hnsw_state_passthrough());
9886 engine.set_emit_deltas(true);
9887
9888 // --- Apply the hypothetical change and re-derive. ---
9889 props.set(id, field, value);
9890 {
9891 let mut gm = make_graph_mut(
9892 &self.ids,
9893 &mut syms,
9894 &self.labels,
9895 build_props_view(&props, &self.base),
9896 &mut topo,
9897 &self.base,
9898 &mut edge_props,
9899 );
9900 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
9901 }
9902
9903 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
9904 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
9905 for d in engine.drain_deltas() {
9906 let edge = EdgeAt {
9907 edge_type: d.edge_type,
9908 src_key: d.src_key,
9909 dst_key: d.dst_key,
9910 derived: true,
9911 rule: Some(d.rule),
9912 };
9913 if d.fired {
9914 gained.insert(edge);
9915 } else {
9916 lost.insert(edge);
9917 }
9918 }
9919 // An edge retracted and re-derived within the same re-derivation (top-k
9920 // churn) is not a change the caller would see.
9921 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
9922 for e in churn {
9923 lost.remove(&e);
9924 gained.remove(&e);
9925 }
9926
9927 Ok(WhatIf {
9928 lost: lost.into_iter().collect(),
9929 gained: gained.into_iter().collect(),
9930 })
9931 }
9932
9933 pub fn edge_count(&self) -> u64 {
9934 self.topo_view().edge_count()
9935 }
9936
9937 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
9938 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
9939 pub fn stats(&self) -> Stats {
9940 self.ensure_v8_base_sections_loaded();
9941 let rules: Vec<RuleStats> = self
9942 .engine
9943 .rules()
9944 .map(|r| RuleStats {
9945 name: r.name.clone(),
9946 edges: self
9947 .engine
9948 .provenance()
9949 .get(&r.name)
9950 .map(|s| s.len() as u64)
9951 .unwrap_or(0),
9952 tripped: self.engine.is_tripped(&r.name),
9953 fires: self.engine.fire_count(&r.name),
9954 approximate: r.approximate,
9955 })
9956 .collect();
9957 Stats {
9958 nodes_live: self.ids.live_len(),
9959 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
9960 edges: self.topo_view().edge_count(),
9961 rules,
9962 chain_truncations: self.engine.chain_truncations(),
9963 }
9964 }
9965
9966 /// On-disk size of the WAL file in bytes.
9967 ///
9968 /// Reads file metadata without loading WAL contents. Returns `Err` for
9969 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
9970 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
9971 let path = self.fs.wal_path().ok_or_else(|| {
9972 std::io::Error::new(
9973 std::io::ErrorKind::Unsupported,
9974 "wal_path not available for this Fs implementation",
9975 )
9976 })?;
9977 Ok(std::fs::metadata(path)?.len())
9978 }
9979
9980 /// Set the slow-query threshold. Queries whose execution time equals or
9981 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
9982 ///
9983 /// Use this setter in tests — the environment variable
9984 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
9985 /// threads.
9986 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
9987 self.slow_query_threshold_ms = ms;
9988 }
9989
9990 /// Snapshot of the slow-query ring buffer and lifetime counter.
9991 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
9992 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
9993 SlowQuerySnapshot {
9994 threshold_ms: self.slow_query_threshold_ms,
9995 count: log.total,
9996 last: log.entries.iter().cloned().collect(),
9997 }
9998 }
9999
10000 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
10001 /// to compute uptime.
10002 pub fn started_at(&self) -> std::time::Instant {
10003 self.started_at
10004 }
10005
10006 /// On-disk snapshot format version this binary writes and reads.
10007 pub fn format_version() -> u16 {
10008 core_storage::snapshot::VERSION
10009 }
10010
10011 /// Test-support: total bytes appended (SimFs only usage).
10012 pub fn fs_total_appended(&self) -> usize
10013 where
10014 F: FsIntrospect,
10015 {
10016 self.fs.total_appended()
10017 }
10018
10019 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10020 pub fn fs_sync_count(&self) -> usize
10021 where
10022 F: FsIntrospect,
10023 {
10024 self.fs.sync_count()
10025 }
10026
10027 /// Consume the db, returning its fs (for crash simulation).
10028 pub fn into_fs(self) -> F {
10029 self.fs
10030 }
10031
10032 pub fn snapshot(&mut self) -> Result<()> {
10033 self.snapshot_with(SnapshotOptions::default())
10034 }
10035
10036 /// Snapshot with explicit options.
10037 ///
10038 /// # `keep_wal`
10039 ///
10040 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10041 /// - The WAL is replaced with a minimal baseline containing one
10042 /// `EnableFulltext` record per active declaration. All pre-snapshot
10043 /// history is discarded; `open_at` can only reach post-snapshot commits.
10044 ///
10045 /// When `keep_wal` is `true`:
10046 /// - The WAL is left intact. All pre-snapshot commits remain reachable
10047 /// via `open_at`. The existing WAL already contains the original
10048 /// `EnableFulltext` records, so no baseline re-write is needed; the
10049 /// recovery guards in `apply()` silently skip any duplicate records on
10050 /// replay.
10051 /// - Crash window: a crash after the snapshot write but before the next
10052 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
10053 /// snapshot is loaded and the WAL replayed idempotently over it — safe
10054 /// because every `apply()` arm is idempotent when replayed over an
10055 /// already-current snapshot.
10056 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10057 if self.read_only {
10058 return Err(GraphError::ReadOnly);
10059 }
10060 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10061 // appending ends up holding a descriptor on an unlinked inode and loses
10062 // commits it believes durable. Snapshotting therefore requires the
10063 // cross-process write lock, exactly as appending does. Unlike the WAL
10064 // append path this does not go through `log_then_apply_with`, so both
10065 // guards are repeated here.
10066 if self.degraded {
10067 return Err(GraphError::Io(std::io::Error::other(
10068 "database degraded after group-commit fsync failure; reopen required",
10069 )));
10070 }
10071 if self.lock_denied {
10072 return Err(GraphError::Busy { holder: None });
10073 }
10074 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10075 // Used by the archive path's conservative genesis-chain check: if a prior
10076 // snapshot exists but wal.truncated does not, we cannot distinguish a
10077 // legacy store (may have been truncated in an older code version) from a
10078 // new store that only used keep_wal=true. Conservative: refuse genesis in
10079 // both cases. Must be sampled here, before the snapshot write below.
10080 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10081 self.ensure_v8_base_sections_loaded();
10082 // Ensure provenance is decoded before to_persist() clones it.
10083 self.engine.ensure_provenance_loaded_mut();
10084 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10085 let rule_defs = rule_defs_typed
10086 .iter()
10087 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10088 .collect();
10089 // Collect HNSW state and IVF state. When indexes are not yet
10090 // populated (clean open, no mutation since open), pass the retained
10091 // raw bytes through directly so that migrate/snapshot does not
10092 // silently discard fitted approximate-rule indexes.
10093 let hnsw_state = self.engine.export_hnsw_state_passthrough();
10094 let ivf_bytes = if !self.engine.indexes_populated() {
10095 // Pass retained IVF bytes through unchanged (no re-encode).
10096 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10097 } else {
10098 // Indexes live: encode from current state.
10099 let raw_ivf = self.engine.export_ivf_state();
10100 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10101 .into_iter()
10102 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10103 (
10104 name,
10105 core_storage::snapshot::PerRuleIvfState {
10106 src: core_storage::snapshot::SideIvfState {
10107 centroids: sc,
10108 clusters: sa,
10109 drift: sd,
10110 },
10111 dst: core_storage::snapshot::SideIvfState {
10112 centroids: dc,
10113 clusters: da,
10114 drift: dd,
10115 },
10116 },
10117 )
10118 })
10119 .collect();
10120 if ivf_state_map.is_empty() {
10121 Vec::new()
10122 } else {
10123 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
10124 }
10125 };
10126 let view_defs: Vec<Vec<u8>> = self
10127 .view_store
10128 .views()
10129 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
10130 .collect();
10131 if self.base.is_some() {
10132 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
10133 // write it atomically, remap it as the new base, then clear the overlay.
10134 let meta = V8Meta {
10135 labels: self.labels.clone(),
10136 edge_props: self.edge_props.clone(),
10137 rule_defs,
10138 provenance,
10139 rule_tripped,
10140 rule_fires,
10141 ivf_bytes,
10142 view_defs,
10143 wal_truncated: !opts.keep_wal,
10144 hnsw: hnsw_state,
10145 last_change: self.last_change.clone(),
10146 };
10147 let mut buf: Vec<u8> = Vec::new();
10148 {
10149 // Clone the Arc so the old base stays alive while we encode.
10150 // The borrow of archived_csr (into old_base's mmap) is released
10151 // at the end of this block, before we replace self.base.
10152 let old_base = self.base.clone().expect("is_some checked above");
10153 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
10154 detail: format!("v8 snapshot: topology section: {e:?}"),
10155 })?;
10156 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
10157 detail: format!("v8 snapshot: columns section: {e:?}"),
10158 })?;
10159 let archived_edge_props =
10160 old_base
10161 .edge_props_section()
10162 .map_err(|e| GraphError::Corrupt {
10163 detail: format!("v8 snapshot: edge_props section: {e:?}"),
10164 })?;
10165 let edge_props_raw =
10166 old_base
10167 .edge_props_raw_bytes()
10168 .map_err(|e| GraphError::Corrupt {
10169 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
10170 })?;
10171 let prov_raw =
10172 old_base
10173 .provenance_raw_bytes()
10174 .map_err(|e| GraphError::Corrupt {
10175 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
10176 })?;
10177 encode_v8(
10178 Some(archived_csr),
10179 Some(archived_cols),
10180 Some((archived_edge_props, edge_props_raw)),
10181 Some(prov_raw),
10182 &self.topo,
10183 &self.props,
10184 &self.ids,
10185 &self.syms,
10186 &meta,
10187 &mut buf,
10188 )?;
10189 }
10190 self.fs.write_atomic(FileId::Snapshot, &buf)?;
10191 // Remap the freshly-written snapshot as the new base.
10192 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
10193 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10194 core_storage::v8::MappedBase::map(&snap_path)
10195 } else {
10196 core_storage::v8::MappedBase::from_bytes(buf)
10197 }
10198 .map_err(|e| GraphError::Corrupt {
10199 detail: format!("v8 snapshot: remap new base: {e:?}"),
10200 })?;
10201 self.base = Some(Arc::new(new_base));
10202 // Clear the overlay and prop tombstones — all data is now in the new base.
10203 self.topo = Topology::new();
10204 self.props = core_storage::columns::ColumnStore::new();
10205 } else {
10206 // Legacy path (V5–V7 stores without a V8 base).
10207 //
10208 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
10209 // clone and no encode_v8_from_state intermediate clones. The big
10210 // structures (self.topo, self.props) are borrowed, not cloned.
10211 // self.edge_props is moved (not cloned) because we immediately clear it
10212 // when we remap the new V8 snapshot as self.base (see below).
10213 //
10214 // Eliminates from peak RSS vs. the old SnapshotState path:
10215 // • self.topo.clone() (~topology HashMap footprint)
10216 // • self.props.clone() (~column-store footprint)
10217 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
10218 let meta = V8Meta {
10219 labels: self.labels.clone(),
10220 wal_truncated: !opts.keep_wal,
10221 // Move edge_props out so the large overlay is freed when meta
10222 // drops at end of this block (self.edge_props is now empty; reads
10223 // after base assignment go through the mmap'd base section).
10224 edge_props: std::mem::take(&mut self.edge_props),
10225 rule_defs,
10226 provenance,
10227 rule_tripped,
10228 rule_fires,
10229 ivf_bytes,
10230 view_defs,
10231 hnsw: hnsw_state,
10232 last_change: self.last_change.clone(),
10233 };
10234 let mut buf = Vec::new();
10235 encode_v8(
10236 None,
10237 None,
10238 None,
10239 None,
10240 &self.topo,
10241 &self.props,
10242 &self.ids,
10243 &self.syms,
10244 &meta,
10245 &mut buf,
10246 )?;
10247 // meta (and the moved edge_props inside it) is no longer needed;
10248 // drop it before the write to keep the peak window narrow.
10249 drop(meta);
10250 self.fs.write_atomic(FileId::Snapshot, &buf)?;
10251 // Remap the freshly-written V8 snapshot as self.base.
10252 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
10253 // On SimFs (tests): pass buf to from_bytes.
10254 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10255 drop(buf);
10256 core_storage::v8::MappedBase::map(&snap_path)
10257 } else {
10258 core_storage::v8::MappedBase::from_bytes(buf)
10259 }
10260 .map_err(|e| GraphError::Corrupt {
10261 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
10262 })?;
10263 self.base = Some(Arc::new(new_base));
10264 // Free the large heap-allocated decoded state — all data is now in the
10265 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
10266 // self.edge_props was already moved into meta and is effectively empty.
10267 self.topo = Topology::new();
10268 self.props = core_storage::columns::ColumnStore::new();
10269 }
10270
10271 if opts.archive_wal {
10272 // History-preserving snapshot (Task 4):
10273 // 1. Snapshot already written above (write_atomic → fsynced).
10274 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
10275 // Crash window B: crash here leaves archive present, WAL
10276 // absent. Reopen: snapshot loaded (full state), no WAL
10277 // replay. Archive is NOT replayed into live state — it is
10278 // pre-snapshot by construction. Safe.
10279 // 3. Optionally write genesis marker (first archive only, no
10280 // prior WAL truncation).
10281 // 4. Prune old archives (retention), update horizon floor.
10282 // Pruning invalidates the genesis chain; delete marker.
10283 // 5. Write new minimal baseline WAL (write_atomic).
10284 // Crash window C: crash here leaves new archive plus no live
10285 // WAL. Same as window B — handled above.
10286 //
10287 // Sample existing archives BEFORE the rename so we can detect
10288 // whether this is the first archive.
10289 let existing_archives = self.fs.list_archives()?;
10290 let is_first_archive = existing_archives.is_empty();
10291
10292 // Compute a globally-monotonic archive name: the name equals the
10293 // cumulative end-frame index of the archive in global commit space.
10294 //
10295 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
10296 // commit_seq is seeded from max(last_change), which underestimates
10297 // the WAL depth when trailing commits (e.g. insert_edge) do not
10298 // update last_change. A session-2 archive could then receive a name
10299 // ≤ the session-1 archive, causing incorrect sort order or collision.
10300 //
10301 // Instead: read and decode the live WAL here (before the rename) to
10302 // get its exact frame count, then add it to the last known global
10303 // end-frame index (the name of the most recent existing archive, or
10304 // wal_horizon_floor if no archives exist). This is O(WAL size) but
10305 // snapshot is already serialising the full graph state, so the cost
10306 // is dominated.
10307 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
10308 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
10309 let archive_n = existing_archives
10310 .last()
10311 .copied()
10312 .unwrap_or(self.wal_horizon_floor)
10313 + live_frames_for_name.len() as u64;
10314 self.fs.archive_wal(archive_n)?;
10315
10316 // Genesis marker: written once when the first archive is taken
10317 // from a store that has never undergone a WAL-truncating snapshot.
10318 // When present, `open_at` may replay archive-resident commits from
10319 // empty state (the archive chain covers from global index 0).
10320 //
10321 // Two conditions must ALL hold:
10322 // 1. This is the first archive (existing_archives was empty).
10323 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
10324 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
10325 // before truncating the WAL, so if any prior truncating snapshot was taken
10326 // — even in a previous session — snapshot.bin is present and this condition
10327 // is false. This subsumes the cross-session truncation case without
10328 // requiring a separate wal.truncated sidecar file.
10329 // For legacy stores (snapshot.bin written by an older code version that
10330 // may have truncated the WAL), the same conservative refusal applies:
10331 // we cannot prove the chain is complete, so we refuse genesis (cost =
10332 // no as-of-through-archives; never silent wrong data).
10333 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
10334 // so SimFs always passes this check.
10335 if is_first_archive && !had_prior_snapshot {
10336 self.fs.write_genesis_marker()?;
10337 self.archive_genesis_chain = true;
10338 }
10339
10340 // Retention pruning: keep newest `keep` archives; delete oldest.
10341 // Pruning is the ONLY deletion site for archives.
10342 //
10343 // Crash-safety ordering (C1 fix):
10344 // 1. Count frames in surplus archives (reads only — no mutation).
10345 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
10346 // rename (atomic). A crash after this point leaves orphaned
10347 // archives on disk, but the floor is correct. The opening
10348 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
10349 // the next open, so the store is always safe to reopen.
10350 // 3. Delete the genesis marker (floor > 0 already blocks open_at
10351 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
10352 // 4. Delete surplus archives. A crash between any two deletes
10353 // leaves the floor committed and orphaned archives cleaned at
10354 // next open — never a stale floor with a missing archive prefix.
10355 if let Some(keep) = self.wal_archive_retention {
10356 if keep > 0 {
10357 let archives = self.fs.list_archives()?;
10358 // archives is sorted ascending (oldest first)
10359 if archives.len() as u32 > keep {
10360 let surplus = archives.len() - keep as usize;
10361 // Step 1: count pruned frames (reads, no mutation).
10362 let mut pruned_frames = 0u64;
10363 for &n in &archives[..surplus] {
10364 let bytes = self.fs.read_archive(n)?;
10365 let (frames, _) = decode_all(&bytes);
10366 pruned_frames += frames.len() as u64;
10367 }
10368 // Step 2: advance and persist floor FIRST.
10369 self.wal_horizon_floor += pruned_frames;
10370 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
10371 // Step 3: delete genesis marker (floor > 0 already
10372 // blocks open_at; this is belt-and-suspenders cleanup).
10373 if pruned_frames > 0 && self.archive_genesis_chain {
10374 self.fs.delete_genesis_marker()?;
10375 self.archive_genesis_chain = false;
10376 }
10377 // Step 4: delete surplus archives. Crash here →
10378 // orphaned archives; cleaned at next open.
10379 for &n in &archives[..surplus] {
10380 self.fs.delete_archive(n)?;
10381 }
10382 }
10383 }
10384 }
10385
10386 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
10387 let mut baseline_wal: Vec<u8> = Vec::new();
10388 for (label, field) in self.fulltext.enabled_pairs() {
10389 let rec = WalRecord::EnableFulltext {
10390 label: label.clone(),
10391 field: field.clone(),
10392 };
10393 baseline_wal.extend_from_slice(&encode_record(&rec));
10394 }
10395 for (label, field) in self.prop_index.enabled_pairs() {
10396 let rec = WalRecord::EnableIndex {
10397 label: label.clone(),
10398 field: field.clone(),
10399 };
10400 baseline_wal.extend_from_slice(&encode_record(&rec));
10401 }
10402 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10403 } else if opts.keep_wal {
10404 // keep_wal=true: WAL is left untouched. The existing WAL already
10405 // contains the EnableFulltext records from the original enable calls;
10406 // replay is idempotent (guards in apply() skip already-live entries).
10407 // No baseline re-write is needed or safe here — the full WAL history
10408 // must remain intact for open_at to reach pre-snapshot commits.
10409 } else {
10410 // keep_wal=false (default): truncate by replacing the WAL with a
10411 // minimal baseline of one EnableFulltext record per active pair.
10412 //
10413 // Crash-ordering: write_atomic is atomic.
10414 // • Crash before snapshot write → WAL unchanged. Safe.
10415 // • Crash after snapshot write but before this WAL write → full
10416 // pre-snapshot WAL still present; open_with replays idempotently.
10417 // • Crash after both writes → normal post-snapshot state.
10418 //
10419 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
10420 // for any archives taken AFTER this point (their WAL slices would
10421 // not start at genesis). Delete any existing genesis marker so that
10422 // open_at refuses archive-resident commits. Future sessions are
10423 // covered by had_prior_snapshot: snapshot.bin written here persists
10424 // across sessions and prevents a later archiving session from
10425 // incorrectly claiming a complete genesis chain.
10426 if self.archive_genesis_chain {
10427 self.fs.delete_genesis_marker()?;
10428 self.archive_genesis_chain = false;
10429 }
10430 let mut baseline_wal: Vec<u8> = Vec::new();
10431 for (label, field) in self.fulltext.enabled_pairs() {
10432 let rec = WalRecord::EnableFulltext {
10433 label: label.clone(),
10434 field: field.clone(),
10435 };
10436 baseline_wal.extend_from_slice(&encode_record(&rec));
10437 }
10438 for (label, field) in self.prop_index.enabled_pairs() {
10439 let rec = WalRecord::EnableIndex {
10440 label: label.clone(),
10441 field: field.clone(),
10442 };
10443 baseline_wal.extend_from_slice(&encode_record(&rec));
10444 }
10445 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10446 }
10447 // After snapshot the overlay may have changed (V8 merge path clears
10448 // self.topo and self.props). Refresh the MVCC fold so future readers
10449 // see the post-snapshot state rather than stale overlay data.
10450 self.fold_now();
10451 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
10452 // markers this handle uses to detect other processes' work must be
10453 // re-taken from disk. Skipping this would make our own snapshot look
10454 // like a peer's on the next staleness check and force a needless
10455 // reload.
10456 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
10457 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
10458 Ok(())
10459 }
10460}
10461
10462/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
10463///
10464/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
10465/// callers can build a set of mutations without holding `&mut GraphDb` and
10466/// hand them off to the group-committing writer for durable, batched I/O.
10467pub enum BatchOp {
10468 InsertNode {
10469 label: String,
10470 key: String,
10471 props: Vec<(String, Value)>,
10472 },
10473 InsertEdge {
10474 edge_type: String,
10475 src_key: String,
10476 dst_key: String,
10477 },
10478 SetProp {
10479 key: String,
10480 field: String,
10481 value: Value,
10482 },
10483 RemoveProp {
10484 key: String,
10485 field: String,
10486 },
10487 DeleteEdge {
10488 edge_type: String,
10489 src_key: String,
10490 dst_key: String,
10491 },
10492 DeleteNode {
10493 key: String,
10494 },
10495 CreateRule(RuleDef),
10496 DeleteRule {
10497 name: String,
10498 },
10499 /// Rename a node's key. Validated: old must exist, new must not.
10500 RenameNode {
10501 old_key: String,
10502 new_key: String,
10503 },
10504 /// Insert an edge, auto-creating any missing endpoint as a plain node with
10505 /// `placeholder_label` and no props. Rules fire and last-change is updated
10506 /// for each created endpoint (normal InsertNode semantics in the batch frame).
10507 InsertEdgeUpsert {
10508 edge_type: String,
10509 src_key: String,
10510 dst_key: String,
10511 placeholder_label: String,
10512 },
10513}
10514
10515/// Three-way node visibility status used by `check_single_op_authz`.
10516enum NodeAuthzStatus {
10517 /// Node exists in the store and is in the role's read mask.
10518 Visible(String), // carries the node's label
10519 /// Node exists in the store but is NOT in the role's read mask.
10520 Hidden,
10521 /// Node does not exist in the store.
10522 Absent,
10523}
10524
10525/// Overlay of ops already accepted earlier in the same batch. Never written
10526/// back to the database — validation only.
10527#[derive(Default)]
10528struct Overlay {
10529 extra_keys: BTreeSet<String>,
10530 deleted_keys: BTreeSet<String>,
10531 extra_props: BTreeMap<(String, String), Value>,
10532 removed_props: BTreeSet<(String, String)>,
10533 extra_edges: BTreeSet<(String, String, String)>,
10534 deleted_edges: BTreeSet<(String, String, String)>,
10535 extra_rules: BTreeSet<String>,
10536 deleted_rules: BTreeSet<String>,
10537 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10538 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10539 /// sees only the rules already committed to the engine. Keyed by name so a
10540 /// later `DeleteRule` in the same batch drops the arc with the rule.
10541 extra_rule_arcs: BTreeMap<String, (String, String)>,
10542}
10543
10544/// Read-only view of live db state plus a batch overlay. Shared by single-op
10545/// public methods (empty overlay) and `commit_batch`.
10546struct MutPreview<'a, F: Fs> {
10547 db: &'a GraphDb<F>,
10548 overlay: Overlay,
10549}
10550
10551/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10552/// `None` if `target` is unreachable.
10553///
10554/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10555/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10556/// reported path is stable for a given rule set, and iterative so a pathological
10557/// rule graph cannot overflow the stack.
10558fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10559 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10560 for (from, to) in arcs {
10561 adj.entry(from.as_str()).or_default().insert(to.as_str());
10562 }
10563 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10564 let mut visited: BTreeSet<&str> = BTreeSet::new();
10565 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10566 visited.insert(start);
10567 queue.push_back(start);
10568 while let Some(node) = queue.pop_front() {
10569 if node == target {
10570 let mut path = vec![node.to_string()];
10571 let mut cur = node;
10572 while let Some(&p) = parent.get(cur) {
10573 path.push(p.to_string());
10574 cur = p;
10575 }
10576 path.reverse();
10577 return Some(path);
10578 }
10579 for &next in adj.get(node).into_iter().flatten() {
10580 if visited.insert(next) {
10581 parent.insert(next, node);
10582 queue.push_back(next);
10583 }
10584 }
10585 }
10586 None
10587}
10588
10589impl<'a, F: Fs> MutPreview<'a, F> {
10590 fn new(db: &'a GraphDb<F>) -> Self {
10591 Self {
10592 db,
10593 overlay: Overlay::default(),
10594 }
10595 }
10596
10597 fn has_key(&self, key: &str) -> bool {
10598 if self.overlay.extra_keys.contains(key) {
10599 return true;
10600 }
10601 if self.overlay.deleted_keys.contains(key) {
10602 return false;
10603 }
10604 self.db.ids.get(key).is_some()
10605 }
10606
10607 fn has_prop(&self, key: &str, field: &str) -> bool {
10608 if !self.has_key(key) {
10609 return false;
10610 }
10611 let k = (key.to_string(), field.to_string());
10612 if self.overlay.removed_props.contains(&k) {
10613 return false;
10614 }
10615 if self.overlay.extra_props.contains_key(&k) {
10616 return true;
10617 }
10618 // Fresh identity (first insert in this batch, or delete+reinsert):
10619 // ignore props still sitting on the soon-to-be-tombstoned slot.
10620 if self.overlay.extra_keys.contains(key) {
10621 return false;
10622 }
10623 self.db.get_prop(key, field).is_some()
10624 }
10625
10626 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10627 let k = (
10628 edge_type.to_string(),
10629 src_key.to_string(),
10630 dst_key.to_string(),
10631 );
10632 if self.overlay.deleted_edges.contains(&k) {
10633 return false;
10634 }
10635 if self.overlay.extra_edges.contains(&k) {
10636 return true;
10637 }
10638 // A key created in this batch (including reinsert) has no db edges.
10639 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10640 return false;
10641 }
10642 if self.overlay.deleted_keys.contains(src_key)
10643 || self.overlay.deleted_keys.contains(dst_key)
10644 {
10645 return false;
10646 }
10647 let Some(src) = self.db.ids.get(src_key) else {
10648 return false;
10649 };
10650 let Some(dst) = self.db.ids.get(dst_key) else {
10651 return false;
10652 };
10653 let Some(sym) = self.db.syms.get(edge_type) else {
10654 return false;
10655 };
10656 self.db
10657 .topo_view()
10658 .neighbors(sym, Direction::Out, src)
10659 .binary_search(&dst)
10660 .is_ok()
10661 }
10662
10663 fn has_rule(&self, name: &str) -> bool {
10664 if self.overlay.extra_rules.contains(name) {
10665 return true;
10666 }
10667 if self.overlay.deleted_rules.contains(name) {
10668 return false;
10669 }
10670 self.db.engine.rules().any(|r| r.name == name)
10671 }
10672
10673 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10674 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10675 return false;
10676 }
10677 if self.overlay.deleted_keys.contains(src_key)
10678 || self.overlay.deleted_keys.contains(dst_key)
10679 {
10680 return false;
10681 }
10682 let Some(src) = self.db.ids.get(src_key) else {
10683 return false;
10684 };
10685 let Some(dst) = self.db.ids.get(dst_key) else {
10686 return false;
10687 };
10688 let Some(et) = self.db.syms.get(edge_type) else {
10689 return false;
10690 };
10691 // extra_rules is deliberately not consulted: a CreateRule earlier in
10692 // this batch has not fired, so it contributes no provenance. That is
10693 // the documented rule-window gap (see GraphDb::batch).
10694 if self.overlay.deleted_rules.is_empty() {
10695 return self.db.engine.is_owned(et, src, dst);
10696 }
10697 for (rule, triples) in self.db.engine.provenance() {
10698 if self.overlay.deleted_rules.contains(rule) {
10699 continue;
10700 }
10701 if triples.contains(&(et, src, dst)) {
10702 return true;
10703 }
10704 }
10705 false
10706 }
10707
10708 fn check_insert_node(&self, key: &str) -> Result<()> {
10709 if self.has_key(key) {
10710 Err(GraphError::DuplicateKey { key: key.into() })
10711 } else {
10712 Ok(())
10713 }
10714 }
10715
10716 fn check_live_key(&self, key: &str) -> Result<()> {
10717 if self.has_key(key) {
10718 Ok(())
10719 } else {
10720 Err(GraphError::KeyNotFound { key: key.into() })
10721 }
10722 }
10723
10724 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10725 for k in [src_key, dst_key] {
10726 if !self.has_key(k) {
10727 return Err(GraphError::KeyNotFound { key: k.into() });
10728 }
10729 }
10730 if self.is_rule_owned(edge_type, src_key, dst_key) {
10731 return Err(GraphError::RuleOwned {
10732 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10733 });
10734 }
10735 Ok(!self.has_edge(edge_type, src_key, dst_key))
10736 }
10737
10738 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10739 self.check_live_key(key)?;
10740 Ok(self.has_prop(key, field))
10741 }
10742
10743 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10744 for k in [src_key, dst_key] {
10745 if !self.has_key(k) {
10746 return Err(GraphError::KeyNotFound { key: k.into() });
10747 }
10748 }
10749 // Provenance-owned OR a live rule would derive this pair. User-first
10750 // edges that a later rule matches are not in `owned`, but deleting
10751 // them would leave a hole `rebuild_rule` immediately fills.
10752 if self.is_rule_owned(edge_type, src_key, dst_key) {
10753 return Err(GraphError::RuleOwned {
10754 detail: format!(
10755 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10756 delete or change the owning rule"
10757 ),
10758 });
10759 }
10760 if self.would_derive(edge_type, src_key, dst_key) {
10761 return Err(GraphError::RuleOwned {
10762 detail: format!(
10763 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10764 delete or change the owning rule, or a live rule would re-derive it"
10765 ),
10766 });
10767 }
10768 Ok(self.has_edge(edge_type, src_key, dst_key))
10769 }
10770
10771 /// True if any live rule (minus overlay-deleted names) would derive
10772 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10773 /// CreateRule names in `extra_rules` are ignored — same documented
10774 /// same-batch rule-window as [`Self::is_rule_owned`].
10775 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10776 if src_key == dst_key {
10777 return false;
10778 }
10779 let Some(src_label) = self.label_of(src_key) else {
10780 return false;
10781 };
10782 let Some(dst_label) = self.label_of(dst_key) else {
10783 return false;
10784 };
10785 for rule in self.db.engine.rules() {
10786 if self.overlay.deleted_rules.contains(&rule.name) {
10787 continue;
10788 }
10789 if rule.edge_type != edge_type {
10790 continue;
10791 }
10792 if rule.src_label != src_label || rule.dst_label != dst_label {
10793 continue;
10794 }
10795 let src_props = |f: &str| self.prop_value(src_key, f);
10796 let dst_props = |f: &str| self.prop_value(dst_key, f);
10797 let src_view = NodeView {
10798 key: src_key,
10799 props: &src_props,
10800 };
10801 let dst_view = NodeView {
10802 key: dst_key,
10803 props: &dst_props,
10804 };
10805 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
10806 return true;
10807 }
10808 }
10809 false
10810 }
10811
10812 fn label_of(&self, key: &str) -> Option<String> {
10813 if self.overlay.deleted_keys.contains(key) {
10814 return None;
10815 }
10816 // Fresh identities created in this batch have no stored label in the
10817 // overlay; they cannot be provenance-owned yet either.
10818 let id = self.db.ids.get(key)?;
10819 let sym = self.db.labels.get(id as usize).copied()?;
10820 if sym == u32::MAX {
10821 return None;
10822 }
10823 self.db.syms.resolve(sym).map(str::to_string)
10824 }
10825
10826 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
10827 if !self.has_key(key) {
10828 return None;
10829 }
10830 let k = (key.to_string(), field.to_string());
10831 if self.overlay.removed_props.contains(&k) {
10832 return None;
10833 }
10834 if let Some(v) = self.overlay.extra_props.get(&k) {
10835 return Some(v.clone());
10836 }
10837 if self.overlay.extra_keys.contains(key) {
10838 return None;
10839 }
10840 self.db.get_prop(key, field)
10841 }
10842
10843 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
10844 def.validate()
10845 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
10846 if self.has_rule(&def.name) {
10847 return Err(GraphError::RuleInvalid {
10848 detail: format!("rule {:?} already exists", def.name),
10849 });
10850 }
10851 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
10852 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
10853 // `edge_type`". A cycle in that graph is a rule set that would re-fire
10854 // itself forever; the engine's depth cap would silently truncate it
10855 // instead, leaving an arbitrary partial result. Reject it here, the one
10856 // place that sees the whole rule set.
10857 //
10858 // Rules accepted earlier in the same batch count too: the overlay
10859 // carries their arcs, so a cycle cannot be assembled one op at a time.
10860 if let Some(via) = def.via_edge.as_deref() {
10861 if via == def.edge_type {
10862 return Err(GraphError::RuleInvalid {
10863 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
10864 });
10865 }
10866 let mut arcs: Vec<(String, String)> = self
10867 .db
10868 .engine
10869 .rules()
10870 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
10871 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
10872 .collect();
10873 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
10874 arcs.push((via.to_string(), def.edge_type.clone()));
10875 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
10876 return Err(GraphError::RuleInvalid {
10877 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
10878 });
10879 }
10880 }
10881 Ok(())
10882 }
10883
10884 fn check_delete_rule(&self, name: &str) -> Result<()> {
10885 if self.has_rule(name) {
10886 Ok(())
10887 } else {
10888 Err(GraphError::RuleNotFound { name: name.into() })
10889 }
10890 }
10891
10892 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
10893 self.overlay.deleted_keys.remove(key);
10894 self.overlay.extra_keys.insert(key.to_string());
10895 self.overlay.extra_props.retain(|(k, _), _| k != key);
10896 self.overlay.removed_props.retain(|(k, _)| k != key);
10897 for (field, value) in props {
10898 self.overlay
10899 .extra_props
10900 .insert((key.to_string(), field.clone()), value.clone());
10901 }
10902 }
10903
10904 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10905 let k = (
10906 edge_type.to_string(),
10907 src_key.to_string(),
10908 dst_key.to_string(),
10909 );
10910 self.overlay.deleted_edges.remove(&k);
10911 self.overlay.extra_edges.insert(k);
10912 }
10913
10914 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
10915 let k = (key.to_string(), field.to_string());
10916 self.overlay.removed_props.remove(&k);
10917 self.overlay.extra_props.insert(k, value.clone());
10918 }
10919
10920 fn note_remove_prop(&mut self, key: &str, field: &str) {
10921 let k = (key.to_string(), field.to_string());
10922 self.overlay.extra_props.remove(&k);
10923 self.overlay.removed_props.insert(k);
10924 }
10925
10926 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10927 let k = (
10928 edge_type.to_string(),
10929 src_key.to_string(),
10930 dst_key.to_string(),
10931 );
10932 self.overlay.extra_edges.remove(&k);
10933 self.overlay.deleted_edges.insert(k);
10934 }
10935
10936 fn note_delete_node(&mut self, key: &str) {
10937 self.overlay.extra_keys.remove(key);
10938 self.overlay.deleted_keys.insert(key.to_string());
10939 self.overlay.extra_props.retain(|(k, _), _| k != key);
10940 self.overlay.removed_props.retain(|(k, _)| k != key);
10941 self.overlay
10942 .extra_edges
10943 .retain(|(_, s, d)| s != key && d != key);
10944 self.overlay
10945 .deleted_edges
10946 .retain(|(_, s, d)| s != key && d != key);
10947 }
10948
10949 fn note_create_rule(&mut self, def: &RuleDef) {
10950 self.overlay.deleted_rules.remove(&def.name);
10951 self.overlay.extra_rules.insert(def.name.clone());
10952 // Rules accepted earlier in this batch are not in the engine yet, so
10953 // the cycle check would not see their arcs. Keep the arc, not just the
10954 // name, so a batch cannot smuggle in a cycle one op at a time.
10955 if let Some(via) = def.via_edge.clone() {
10956 self.overlay
10957 .extra_rule_arcs
10958 .insert(def.name.clone(), (via, def.edge_type.clone()));
10959 }
10960 }
10961
10962 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
10963 if !self.has_key(old) {
10964 return Err(GraphError::KeyNotFound { key: old.into() });
10965 }
10966 if self.has_key(new) {
10967 return Err(GraphError::DuplicateKey { key: new.into() });
10968 }
10969 Ok(())
10970 }
10971
10972 fn note_rename_node(&mut self, old: &str, new: &str) {
10973 // Mark old as deleted so subsequent batch ops cannot reference it.
10974 self.overlay.extra_keys.remove(old);
10975 self.overlay.deleted_keys.insert(old.to_string());
10976 // Mark new as extra so subsequent batch ops can reference it.
10977 self.overlay.deleted_keys.remove(new);
10978 self.overlay.extra_keys.insert(new.to_string());
10979 // Migrate any overlay props from old key to new key.
10980 let new_str = new.to_string();
10981 let transferred: Vec<((String, String), Value)> = self
10982 .overlay
10983 .extra_props
10984 .iter()
10985 .filter(|((k, _), _)| k.as_str() == old)
10986 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
10987 .collect();
10988 self.overlay
10989 .extra_props
10990 .retain(|(k, _), _| k.as_str() != old);
10991 for (k, v) in transferred {
10992 self.overlay.extra_props.insert(k, v);
10993 }
10994 // Migrate removed_props.
10995 let transferred_removed: Vec<(String, String)> = self
10996 .overlay
10997 .removed_props
10998 .iter()
10999 .filter(|(k, _)| k.as_str() == old)
11000 .map(|(_, f)| (new_str.clone(), f.clone()))
11001 .collect();
11002 self.overlay
11003 .removed_props
11004 .retain(|(k, _)| k.as_str() != old);
11005 for k in transferred_removed {
11006 self.overlay.removed_props.insert(k);
11007 }
11008 }
11009
11010 fn note_delete_rule(&mut self, name: &str) {
11011 self.overlay.extra_rules.remove(name);
11012 // Drop its chain arc too: a rule created and then deleted in the same
11013 // batch must not make a later, legal rule look like a cycle.
11014 self.overlay.extra_rule_arcs.remove(name);
11015 self.overlay.deleted_rules.insert(name.to_string());
11016 // Treat the deleted rule's current provenance as gone so a later
11017 // delete_edge of those triples is a no-op (matches sequential).
11018 if let Some(triples) = self.db.engine.provenance().get(name) {
11019 for &(et, s, d) in triples {
11020 let Some(etype) = self.db.syms.resolve(et) else {
11021 continue;
11022 };
11023 let Some(src) = self.db.ids.key_of(s) else {
11024 continue;
11025 };
11026 let Some(dst) = self.db.ids.key_of(d) else {
11027 continue;
11028 };
11029 let k = (etype.to_string(), src.to_string(), dst.to_string());
11030 self.overlay.extra_edges.remove(&k);
11031 self.overlay.deleted_edges.insert(k);
11032 }
11033 }
11034 }
11035}
11036
11037/// Collects mutations and commits them as one WAL `Batch` frame.
11038///
11039/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11040/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11041/// See [`GraphDb::batch`] for validation and atomicity rules.
11042pub struct BatchBuilder<'a, F: Fs> {
11043 db: &'a mut GraphDb<F>,
11044 ops: Vec<BatchOp>,
11045}
11046
11047impl<'a, F: Fs> BatchBuilder<'a, F> {
11048 pub fn insert_node(
11049 &mut self,
11050 label: &str,
11051 key: &str,
11052 props: Vec<(String, Value)>,
11053 ) -> &mut Self {
11054 self.ops.push(BatchOp::InsertNode {
11055 label: label.into(),
11056 key: key.into(),
11057 props,
11058 });
11059 self
11060 }
11061
11062 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11063 self.ops.push(BatchOp::InsertEdge {
11064 edge_type: edge_type.into(),
11065 src_key: src_key.into(),
11066 dst_key: dst_key.into(),
11067 });
11068 self
11069 }
11070
11071 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
11072 self.ops.push(BatchOp::SetProp {
11073 key: key.into(),
11074 field: field.into(),
11075 value,
11076 });
11077 self
11078 }
11079
11080 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
11081 self.ops.push(BatchOp::RemoveProp {
11082 key: key.into(),
11083 field: field.into(),
11084 });
11085 self
11086 }
11087
11088 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11089 self.ops.push(BatchOp::DeleteEdge {
11090 edge_type: edge_type.into(),
11091 src_key: src_key.into(),
11092 dst_key: dst_key.into(),
11093 });
11094 self
11095 }
11096
11097 pub fn delete_node(&mut self, key: &str) -> &mut Self {
11098 self.ops.push(BatchOp::DeleteNode { key: key.into() });
11099 self
11100 }
11101
11102 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
11103 self.ops.push(BatchOp::CreateRule(def));
11104 self
11105 }
11106
11107 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
11108 self.ops.push(BatchOp::DeleteRule { name: name.into() });
11109 self
11110 }
11111
11112 /// Queue a node-rename in this batch.
11113 ///
11114 /// Validation (old exists, new not taken) runs at commit time.
11115 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
11116 self.ops.push(BatchOp::RenameNode {
11117 old_key: old_key.into(),
11118 new_key: new_key.into(),
11119 });
11120 self
11121 }
11122
11123 /// Queue an edge insert with endpoint auto-creation.
11124 ///
11125 /// Any missing endpoint is created as a plain node `{key, label:
11126 /// placeholder_label, no props}` inside this batch frame. Rules fire and
11127 /// last-change is updated for each auto-created node.
11128 pub fn insert_edge_upsert(
11129 &mut self,
11130 edge_type: &str,
11131 src_key: &str,
11132 dst_key: &str,
11133 placeholder_label: &str,
11134 ) -> &mut Self {
11135 self.ops.push(BatchOp::InsertEdgeUpsert {
11136 edge_type: edge_type.into(),
11137 src_key: src_key.into(),
11138 dst_key: dst_key.into(),
11139 placeholder_label: placeholder_label.into(),
11140 });
11141 self
11142 }
11143
11144 /// Validate every queued op, then log one `Batch` frame and apply.
11145 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
11146 /// A second `commit()` after a successful one is an empty-batch no-op
11147 /// (queued ops were taken).
11148 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
11149 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
11150 ///
11151 /// **Rule-window limitation:** batch validation cannot see edges that a
11152 /// rule created earlier in the *same* batch will derive at apply time, so
11153 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
11154 /// where sequential calls would return `Err(RuleOwned)`. State integrity
11155 /// is unaffected (idempotent apply, provenance intact). Create rules in
11156 /// their own batch, or sequentially, when later ops may touch derived
11157 /// edges.
11158 /// Validate every queued op and commit atomically.
11159 ///
11160 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
11161 /// WAL records actually written (duplicate edges are silent no-ops and are
11162 /// NOT counted). Both are 0 when the batch is empty or all-noop.
11163 pub fn commit(&mut self) -> Result<(usize, usize)> {
11164 let ops = std::mem::take(&mut self.ops);
11165 self.db.commit_batch(ops)
11166 }
11167
11168 /// Same as [`commit`](Self::commit) but tail the inner events with
11169 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
11170 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
11171 let ops = std::mem::take(&mut self.ops);
11172 self.db
11173 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
11174 }
11175}
11176
11177pub struct NodeRef<'a, F: Fs> {
11178 db: &'a GraphDb<F>,
11179 id: u32,
11180}
11181
11182impl<'a, F: Fs> NodeRef<'a, F> {
11183 pub fn key(&self) -> &str {
11184 self.db.ids.key_of(self.id).expect("dense ids")
11185 }
11186
11187 pub fn label(&self) -> &str {
11188 let sym = self
11189 .db
11190 .labels
11191 .get(self.id as usize)
11192 .copied()
11193 .filter(|&s| s != u32::MAX)
11194 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11195 self.db.syms.resolve(sym).expect("interned label symbol")
11196 }
11197
11198 pub fn prop(&self, field: &str) -> Option<Value> {
11199 self.db
11200 .props_view()
11201 .get(self.id, field)
11202 .map(|vr| vr.into_value())
11203 }
11204
11205 /// All stored fields for this node, sorted by field name.
11206 ///
11207 /// Reads from the full base+overlay view so that props stored only in the
11208 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
11209 pub fn props(&self) -> BTreeMap<String, Value> {
11210 let mut out = BTreeMap::new();
11211 let pv = self.db.props_view();
11212 for field in pv.field_names() {
11213 if let Some(vr) = pv.get(self.id, &field) {
11214 out.insert(field, vr.into_value());
11215 }
11216 }
11217 out
11218 }
11219
11220 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
11221 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
11222 let view = self.db.view();
11223 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
11224 names
11225 .iter()
11226 .filter_map(|name| view.syms.get(name))
11227 .collect()
11228 });
11229 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
11230 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
11231 for (nid, d) in nb.nodes {
11232 let key = view.key_of(nid);
11233 let label = view
11234 .label_of(nid)
11235 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11236 rs.push_row(vec![
11237 Some(Value::Str(key.to_string())),
11238 Some(Value::Str(label.to_string())),
11239 Some(Value::Int(d as i64)),
11240 ]);
11241 }
11242 rs
11243 }
11244
11245 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
11246 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
11247 let view = self.db.view();
11248 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11249 for e in expand(&view, self.id, None, Dir::Both) {
11250 // Skip edges with unknown etypes (only possible from corrupt large
11251 // TOPOLOGY section; function returns BTreeMap not Result).
11252 let Some(etype) = view.syms.resolve(e.etype) else {
11253 continue;
11254 };
11255 let etype = etype.to_string();
11256 let nbr = if e.src == self.id { e.dst } else { e.src };
11257 groups
11258 .entry(etype)
11259 .or_default()
11260 .insert(view.key_of(nbr).to_string());
11261 }
11262 groups
11263 .into_iter()
11264 .map(|(k, v)| (k, v.into_iter().collect()))
11265 .collect()
11266 }
11267}
11268
11269#[cfg(test)]
11270mod tests {
11271 use super::*;
11272 use core_rules::Predicate;
11273
11274 fn tmp_dir(name: &str) -> std::path::PathBuf {
11275 let d =
11276 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
11277 let _ = std::fs::remove_dir_all(&d);
11278 d
11279 }
11280
11281 fn fk_rule() -> RuleDef {
11282 RuleDef {
11283 name: "works_at".into(),
11284 src_label: "Person".into(),
11285 dst_label: "Org".into(),
11286 predicate: Predicate::KeyMatch {
11287 field: "org_id".into(),
11288 },
11289 edge_type: "WORKS_AT".into(),
11290 weight_prop: None,
11291 max_edges: None,
11292 approximate: false,
11293 via_label: None,
11294 via_edge: None,
11295 via_dir: None,
11296 }
11297 }
11298
11299 /// Regression guard for the no-views delta-copy fast path.
11300 ///
11301 /// When no views are defined, `pending_deltas_since().to_vec()` must never
11302 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
11303 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
11304 /// a count of 0 after the entire sequence proves the guard fires correctly.
11305 #[test]
11306 fn no_delta_copy_when_no_views() {
11307 DELTA_COPY_COUNT.with(|c| c.set(0));
11308 let dir = tmp_dir("no-delta-copy");
11309 {
11310 let mut db = GraphDb::open(&dir).unwrap();
11311 // Insert 50 Org + 50 Person nodes with FK links.
11312 for i in 0..50u32 {
11313 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11314 }
11315 for i in 0..50u32 {
11316 db.insert_node(
11317 "Person",
11318 &format!("p{i}"),
11319 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11320 )
11321 .unwrap();
11322 }
11323 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
11324 db.create_rule(fk_rule()).unwrap();
11325
11326 // Counter must stay 0 — no views, no copies.
11327 let copies = DELTA_COPY_COUNT.with(|c| c.get());
11328 assert_eq!(
11329 copies, 0,
11330 "pending_deltas_since().to_vec() called despite no views"
11331 );
11332
11333 // Derived edges must still be correct (the guard skips only the
11334 // empty delta propagation loop, not the rule application itself).
11335 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
11336 assert_eq!(
11337 nbrs,
11338 vec!["o0"],
11339 "rule must derive edges even with no views"
11340 );
11341 }
11342 let _ = std::fs::remove_dir_all(&dir);
11343 }
11344
11345 /// Gating regression: subscribe AFTER a backfill must see no stale events.
11346 /// subscribe BEFORE a backfill must see every edge-fire event.
11347 #[test]
11348 fn subscribe_after_backfill_no_stale_events() {
11349 let dir = tmp_dir("sub-after-backfill");
11350 {
11351 let mut db = GraphDb::open(&dir).unwrap();
11352 for i in 0..10u32 {
11353 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11354 db.insert_node(
11355 "Person",
11356 &format!("p{i}"),
11357 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11358 )
11359 .unwrap();
11360 }
11361 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
11362 db.create_rule(fk_rule()).unwrap();
11363
11364 // Subscribe AFTER the backfill — queue must be empty (no stale events).
11365 let sub = db.subscribe_all_rules().unwrap();
11366 // No events should have queued for the prior backfill.
11367 assert!(
11368 sub.try_recv().is_none(),
11369 "subscribe after backfill must see no stale events"
11370 );
11371
11372 // Inserting a new node now should fire an event (emit_deltas is now true).
11373 db.insert_node("Org", "o_new", vec![]).unwrap();
11374 db.insert_node(
11375 "Person",
11376 "p_new",
11377 vec![("org_id".into(), Value::Str("o_new".into()))],
11378 )
11379 .unwrap();
11380 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
11381 assert!(
11382 ev.is_some(),
11383 "edge-fire event must arrive after subscribe (emit_deltas=true)"
11384 );
11385 }
11386 let _ = std::fs::remove_dir_all(&dir);
11387 }
11388
11389 /// Gating regression: subscribe BEFORE a backfill → events flow.
11390 #[test]
11391 fn subscribe_before_backfill_events_flow() {
11392 let dir = tmp_dir("sub-before-backfill");
11393 {
11394 let mut db = GraphDb::open(&dir).unwrap();
11395 // Subscribe FIRST — emit_deltas becomes true.
11396 let sub = db.subscribe_all_rules().unwrap();
11397
11398 for i in 0..5u32 {
11399 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11400 db.insert_node(
11401 "Person",
11402 &format!("p{i}"),
11403 vec![("org_id".into(), Value::Str(format!("o{i}")))],
11404 )
11405 .unwrap();
11406 }
11407 // Backfill fires with emit_deltas=true → events queued.
11408 db.create_rule(fk_rule()).unwrap();
11409
11410 // Should receive at least one edge-fired event from the backfill.
11411 let mut received = 0usize;
11412 while sub.try_recv().is_some() {
11413 received += 1;
11414 }
11415 assert!(
11416 received > 0,
11417 "subscribe before backfill must receive edge-fire events (got 0)"
11418 );
11419 }
11420 let _ = std::fs::remove_dir_all(&dir);
11421 }
11422
11423 /// Companion: when a view IS defined, the delta path fires and view values update.
11424 #[test]
11425 fn delta_copy_fires_when_view_exists() {
11426 use core_rules::ViewSource;
11427 DELTA_COPY_COUNT.with(|c| c.set(0));
11428 let dir = tmp_dir("delta-copy-with-view");
11429 {
11430 let mut db = GraphDb::open(&dir).unwrap();
11431 db.insert_node("Org", "o1", vec![]).unwrap();
11432 db.insert_node(
11433 "Person",
11434 "p1",
11435 vec![("org_id".into(), Value::Str("o1".into()))],
11436 )
11437 .unwrap();
11438 // Declare a Degree view so is_empty() returns false.
11439 db.create_view(ViewDef {
11440 name: "degree_out".into(),
11441 label: "Person".into(),
11442 view_prop: "degree_out".into(),
11443 source: ViewSource::Degree {
11444 edge_type: "WORKS_AT".into(),
11445 direction: Direction::Out,
11446 },
11447 })
11448 .unwrap();
11449 db.create_rule(fk_rule()).unwrap();
11450
11451 // At least one delta copy should have happened (CreateRule backfill).
11452 let copies = DELTA_COPY_COUNT.with(|c| c.get());
11453 assert!(
11454 copies > 0,
11455 "expected delta copy to fire when a view is defined"
11456 );
11457
11458 // View value should be computed: p1 has one WORKS_AT out-edge.
11459 let info = db.node_info("p1").unwrap();
11460 let degree = info.props.get("degree_out");
11461 assert!(
11462 degree.is_some(),
11463 "view prop should be written to node props"
11464 );
11465 }
11466 let _ = std::fs::remove_dir_all(&dir);
11467 }
11468
11469 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
11470 /// derived-edge-driven view values reflect the as-of state rather than just
11471 /// the initial backfill written at `CreateView` time.
11472 ///
11473 /// Base WAL frames (indices 0..=5 before history markers):
11474 /// 0: insert Org "o1"
11475 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
11476 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
11477 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
11478 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
11479 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
11480 ///
11481 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
11482 /// no-op), so the total commit count is higher than the base frame count.
11483 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
11484 ///
11485 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
11486 /// initial backfill value (0) instead of reflecting the replayed derived edges.
11487 #[test]
11488 fn open_at_derived_edge_view_values_correct() {
11489 use core_rules::ViewSource;
11490 let dir = tmp_dir("open-at-view-rebuild");
11491 {
11492 let mut db = GraphDb::open(&dir).unwrap();
11493 // frame 0
11494 db.insert_node("Org", "o1", vec![]).unwrap();
11495 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
11496 db.create_view(ViewDef {
11497 name: "employee_count".into(),
11498 label: "Org".into(),
11499 view_prop: "emp".into(),
11500 source: ViewSource::Degree {
11501 edge_type: "WORKS_AT".into(),
11502 direction: Direction::In,
11503 },
11504 })
11505 .unwrap();
11506 // frame 2: create rule — no Persons yet; backfill is a no-op
11507 db.create_rule(fk_rule()).unwrap();
11508 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
11509 db.insert_node(
11510 "Person",
11511 "p1",
11512 vec![("org_id".into(), Value::Str("o1".into()))],
11513 )
11514 .unwrap();
11515 // frame 4: p2 — degree = 2
11516 db.insert_node(
11517 "Person",
11518 "p2",
11519 vec![("org_id".into(), Value::Str("o1".into()))],
11520 )
11521 .unwrap();
11522 // frame 5: p3 — degree = 3
11523 db.insert_node(
11524 "Person",
11525 "p3",
11526 vec![("org_id".into(), Value::Str("o1".into()))],
11527 )
11528 .unwrap();
11529 // Sanity: normal open sees degree = 3.
11530 assert_eq!(
11531 db.get_view_prop("o1", "emp"),
11532 Some(Value::Int(3)),
11533 "normal db must show degree 3 after 3 derived edges"
11534 );
11535 } // WAL flushed
11536
11537 // Re-open normally to get the authoritative reference value.
11538 let normal_db = GraphDb::open(&dir).unwrap();
11539 let normal_emp = normal_db.get_view_prop("o1", "emp");
11540 assert_eq!(
11541 normal_emp,
11542 Some(Value::Int(3)),
11543 "re-opened normal db must show degree 3"
11544 );
11545
11546 // Latest as-of (last WAL commit): must match the normal open.
11547 // History-marker frames are appended after each rule-fire, so the total
11548 // commit count is computed dynamically rather than hardcoded.
11549 let total = crate::wal_commit_count_at(&dir).unwrap();
11550 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11551 assert_eq!(
11552 aof_latest.get_view_prop("o1", "emp"),
11553 normal_emp,
11554 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11555 );
11556
11557 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11558 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11559 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11560 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11561 assert_eq!(
11562 aof_mid.get_view_prop("o1", "emp"),
11563 Some(Value::Int(1)),
11564 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11565 );
11566
11567 let _ = std::fs::remove_dir_all(&dir);
11568 }
11569
11570 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11571 /// as-of instances never commit, so distribute_events never runs and any
11572 /// subscription would wait forever.
11573 #[test]
11574 fn subscribe_on_as_of_returns_read_only_error() {
11575 let dir = tmp_dir("sub-as-of-read-only");
11576 {
11577 let mut db = GraphDb::open(&dir).unwrap();
11578 db.insert_node("Org", "o1", vec![]).unwrap();
11579 db.create_rule(fk_rule()).unwrap();
11580 }
11581 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11582
11583 assert!(
11584 matches!(
11585 aof.subscribe_all_rules(),
11586 Err(core_storage::GraphError::ReadOnly)
11587 ),
11588 "subscribe_all_rules on as-of must return ReadOnly"
11589 );
11590 assert!(
11591 matches!(
11592 aof.subscribe_writes(),
11593 Err(core_storage::GraphError::ReadOnly)
11594 ),
11595 "subscribe_writes on as-of must return ReadOnly"
11596 );
11597 assert!(
11598 matches!(
11599 aof.subscribe_rule("works_at"),
11600 Err(core_storage::GraphError::ReadOnly)
11601 ),
11602 "subscribe_rule on as-of must return ReadOnly"
11603 );
11604 let _ = std::fs::remove_dir_all(&dir);
11605 }
11606
11607 /// Regression: a failed dense WAL rewrite must not leave speculative
11608 /// interns in `syms`. If it does, the next successful mutation logs an
11609 /// `Intern` record with an inflated id; replay (which never saw the
11610 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11611 #[test]
11612 fn dense_rewrite_error_rolls_back_speculative_interns() {
11613 let dir = tmp_dir("dense-rewrite-rollback");
11614 {
11615 let mut db = GraphDb::open(&dir).unwrap();
11616 db.insert_node("Person", "a", vec![]).unwrap();
11617
11618 // Bypass MutPreview validation to hit the rewrite's own error path
11619 // (same shape as an id-exhaustion failure mid-rewrite). The
11620 // InsertEdge arm interns the edge type before it resolves keys.
11621 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11622 edge_type: "ORPHAN_TYPE".into(),
11623 src_key: "missing".into(),
11624 dst_key: "a".into(),
11625 }]);
11626 assert!(err.is_err(), "rewrite of a missing src key must fail");
11627 assert_eq!(
11628 db.syms.get("ORPHAN_TYPE"),
11629 None,
11630 "failed rewrite must roll back speculative interns"
11631 );
11632
11633 // A later successful mutation must produce a replayable WAL.
11634 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11635 }
11636 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11637 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11638 let _ = std::fs::remove_dir_all(&dir);
11639 }
11640}