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, BuildProgress, EngineEdgeDelta, GraphMut, NodeView, Predicate,
15 RuleDef, RuleEngine, 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 namespace_of_value, ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result,
29 Topology, Value,
30};
31pub use core_storage::{valid_namespace, NS_DEFAULT, NS_MAX_LEN, NS_PROP};
32
33/// Index of [`NS_DEFAULT`] in `GraphDb::ns_names` — always zero, so the
34/// open-time pass over a store with no `ns` column fills `node_ns` with one
35/// constant and allocates no names.
36const NS_DEFAULT_IDX: u32 = 0;
37use serde::{Deserialize, Serialize};
38use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::sync::Arc;
40
41/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
42/// Zero-cost when the env var is absent (the var check is O(1) after first call).
43macro_rules! trace_open {
44 ($phase:literal, $t:expr) => {
45 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
46 eprintln!(
47 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
48 $phase,
49 $t.elapsed()
50 );
51 }
52 };
53}
54
55/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
56/// Zero-cost when the env var is absent (the var check is O(1) after first call).
57macro_rules! trace_migrate {
58 ($phase:literal, $t:expr) => {
59 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
60 eprintln!(
61 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
62 $phase,
63 $t.elapsed()
64 );
65 }
66 };
67}
68
69// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
70// executes (i.e., at least one view is defined). Used to verify the fast-path
71// guard skips the allocation when `view_store.is_empty()`.
72#[cfg(test)]
73thread_local! {
74 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
75}
76
77// Per-thread count of query-subscription `execute` calls in `distribute_events`.
78//
79// Incremented each time a query subscription actually runs its plan (i.e.,
80// the label-skip fast-path did not fire). Because `distribute_events` is
81// called synchronously on the writer thread, this thread-local correctly
82// isolates each test thread's count even when integration tests run in
83// parallel. Read via [`query_sub_exec_count`].
84thread_local! {
85 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
86}
87
88/// Return the number of query-subscription re-executions logged on this
89/// thread since the process started (or since last reset via
90/// [`reset_query_sub_exec_count`]).
91///
92/// Primarily for integration tests that verify the label-skip fast-path.
93#[doc(hidden)]
94pub fn query_sub_exec_count() -> usize {
95 QUERY_SUB_EXECS_TL.with(|c| c.get())
96}
97
98/// Reset the per-thread query-subscription execution counter to zero.
99#[doc(hidden)]
100pub fn reset_query_sub_exec_count() {
101 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
102}
103
104/// Internal state for a single `subscribe_query` subscription.
105///
106/// On every commit, `distribute_events` re-executes `ops` against the current
107/// graph state, diffs the result against `prev_rows`, and pushes
108/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
109///
110/// **Full re-run per commit; use LIMIT to bound execution cost.**
111/// (Differential evaluation is roadmap / Phase 5.)
112pub(crate) struct QuerySubEntry {
113 /// Compiled plan for the subscribed Cypher query.
114 ops: Vec<PlanOp>,
115 /// Column names from the first execution (fixed for the subscription lifetime).
116 columns: Vec<String>,
117 /// Serialized (JSON) row key → row data, representing the result set at
118 /// the end of the last commit. Used to diff against the new result.
119 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
120 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
121 inner: std::sync::Weak<SubInner>,
122 /// Interned label sym captured at subscribe time from the plan's leading scan
123 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
124 ///
125 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
126 /// with a concrete label), and this subscription must re-execute on every
127 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
128 /// queries are never skipped because edges can alter join results regardless
129 /// of which node labels were written.
130 scan_label: Option<u32>,
131}
132
133/// A post-commit mutation notification.
134///
135/// Emitted from `log_then_apply` after the WAL append, fsync, and
136/// in-memory `apply` all succeed. Never emitted for rejected operations
137/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
138/// deletes/removes). Event payloads carry user keys and rule names, never
139/// internal ids.
140///
141/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
142/// `apply` only. Emission lives exclusively in `log_then_apply`, so
143/// recovery is silent even if a sink were installed (it cannot be: the
144/// sink is in-memory and set after open).
145///
146/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
147/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
148/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
149/// or all-noop batch writes no WAL and emits nothing (including no summary).
150///
151/// **Derived edges:** rule-created or retracted edges are not individually
152/// evented — they are recoverable from the triggering mutation plus the live
153/// rule set. Only the triggering record is emitted.
154///
155/// **Wire form:** externally tagged snake_case JSON
156/// (`{"node_inserted":{"label":"A","key":"k"}}`).
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum MutationEvent {
160 NodeInserted {
161 label: String,
162 key: String,
163 },
164 PropSet {
165 key: String,
166 field: String,
167 },
168 PropRemoved {
169 key: String,
170 field: String,
171 },
172 EdgeInserted {
173 edge_type: String,
174 src: String,
175 dst: String,
176 },
177 EdgeDeleted {
178 edge_type: String,
179 src: String,
180 dst: String,
181 },
182 NodeDeleted {
183 key: String,
184 },
185 RuleCreated {
186 name: String,
187 },
188 RuleDeleted {
189 name: String,
190 },
191 RuleRebuilt {
192 name: String,
193 },
194 BatchApplied {
195 ops: usize,
196 },
197 Ingested {
198 label: String,
199 inserted: usize,
200 },
201}
202
203fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
204 match rec {
205 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
206 label: label.clone(),
207 key: key.clone(),
208 }),
209 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
210 label: intern.resolve(*label)?.to_string(),
211 key: key.clone(),
212 }),
213 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
214 key: key.clone(),
215 field: field.clone(),
216 }),
217 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
218 key: ids.key_of(*id)?.to_string(),
219 field: intern.resolve(*field)?.to_string(),
220 }),
221 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
222 key: key.clone(),
223 field: field.clone(),
224 }),
225 WalRecord::InsertEdge {
226 edge_type,
227 src_key,
228 dst_key,
229 } => Some(MutationEvent::EdgeInserted {
230 edge_type: edge_type.clone(),
231 src: src_key.clone(),
232 dst: dst_key.clone(),
233 }),
234 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
235 edge_type: intern.resolve(*etype)?.to_string(),
236 src: ids.key_of(*src)?.to_string(),
237 dst: ids.key_of(*dst)?.to_string(),
238 }),
239 WalRecord::DeleteEdge {
240 edge_type,
241 src_key,
242 dst_key,
243 } => Some(MutationEvent::EdgeDeleted {
244 edge_type: edge_type.clone(),
245 src: src_key.clone(),
246 dst: dst_key.clone(),
247 }),
248 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
249 WalRecord::CreateRule { def_bytes } => {
250 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
251 Some(MutationEvent::RuleCreated { name: def.name })
252 }
253 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
254 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
255 WalRecord::Batch(_)
256 | WalRecord::CreateView { .. }
257 | WalRecord::DeleteView { .. }
258 | WalRecord::EnableFulltext { .. }
259 | WalRecord::DisableFulltext { .. }
260 | WalRecord::EnableIndex { .. }
261 | WalRecord::DisableIndex { .. }
262 | WalRecord::Intern { .. }
263 // History markers are no-ops for mutation events — they carry no new
264 // state and rules re-derive deterministically on replay.
265 | WalRecord::DerivedEdgeAdded { .. }
266 | WalRecord::DerivedEdgeRetracted { .. }
267 // RenameNode carries no node/edge count change; no special event.
268 | WalRecord::RenameNode { .. } => None,
269 }
270}
271
272/// Database-wide counters plus per-rule budget/fire stats.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
274pub struct Stats {
275 pub nodes_live: usize,
276 pub nodes_tombstoned: usize,
277 pub edges: u64,
278 pub rules: Vec<RuleStats>,
279 /// How many writes hit the rule-chaining depth cap with work still pending,
280 /// since this handle was opened. Non-zero means some derived edges beyond
281 /// the cap are stale and no single later write will repair them: split the
282 /// rule chain or shorten it. Never persisted, so it resets on reopen.
283 #[serde(default)]
284 pub chain_truncations: u64,
285 /// The oldest commit index history still reaches (the WAL horizon floor).
286 /// `0` means nothing has been pruned and history is complete; a non-zero
287 /// value means events before that commit were pruned and are gone.
288 #[serde(default)]
289 pub history_floor: u64,
290 /// Live node counts per namespace, in name order. Always carries
291 /// `default` — a store is at least its default namespace — so a
292 /// single-tenant store reads `[{"name":"default", …}]` and a reader can
293 /// tell "no namespaces in use" from one entry.
294 #[serde(default)]
295 pub namespaces: Vec<NamespaceStats>,
296}
297
298/// Live node count for one namespace; one entry of [`Stats::namespaces`].
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct NamespaceStats {
301 pub name: String,
302 pub nodes_live: usize,
303}
304
305/// One rule's provenance size, trip latch, and fire counter.
306///
307/// `tripped` is a one-way latch: once set, the engine adds no new edges for
308/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
309/// set then fits). `fires` counts `on_node_changed` evaluations plus
310/// backfill/rebuild participant ticks (rebuild counts even when it is a
311/// provenance no-op).
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct RuleStats {
314 pub name: String,
315 pub edges: u64,
316 pub tripped: bool,
317 pub fires: u64,
318 /// Whether this rule uses the approximate IVF-Flat candidate path.
319 pub approximate: bool,
320 /// `Some` while this rule's vector index is still being built.
321 ///
322 /// The rule derives **no** edges until it is `None`: the backfill is one
323 /// commit that runs after the index is whole, so a caller never sees a
324 /// partial edge set. Absent from the JSON when the rule is not building,
325 /// which is every rule created over a corpus at or below
326 /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub building: Option<BuildProgress>,
329}
330
331/// One entry in the slow-query ring buffer.
332#[derive(Debug, Clone, Serialize)]
333pub struct SlowQueryEntry {
334 /// Execution time in whole milliseconds.
335 pub ms: u64,
336 /// The Cypher query string that was slow.
337 pub query: String,
338 /// The commit sequence number at the time the query ran.
339 pub at_commit: u64,
340}
341
342/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
343#[derive(Debug, Clone, Serialize)]
344pub struct SlowQuerySnapshot {
345 /// Current threshold in milliseconds (0 = disabled).
346 pub threshold_ms: u64,
347 /// Total number of slow queries ever recorded (not capped by ring size).
348 pub count: u64,
349 /// Most-recent slow queries (up to 16), oldest first.
350 pub last: Vec<SlowQueryEntry>,
351}
352
353/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
354/// write to it without a mutable borrow.
355struct SlowQueryLog {
356 entries: std::collections::VecDeque<SlowQueryEntry>,
357 total: u64,
358}
359
360/// Maximum number of entries kept in the slow-query ring buffer.
361const SLOW_QUERY_RING_CAP: usize = 16;
362
363/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
364/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct PredicateSummary {
367 pub kind: String,
368 pub fields: Vec<String>,
369 pub min: Option<f64>,
370 pub tolerance: Option<f64>,
371 pub km: Option<f64>,
372 pub parts: Option<Vec<PredicateSummary>>,
373 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
374 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
375 #[serde(default)]
376 pub approximate: bool,
377}
378
379impl From<&Predicate> for PredicateSummary {
380 fn from(p: &Predicate) -> Self {
381 match p {
382 Predicate::KeyMatch { field } => PredicateSummary {
383 kind: "key_match".into(),
384 fields: vec![field.clone()],
385 min: None,
386 tolerance: None,
387 km: None,
388 parts: None,
389 approximate: false,
390 },
391 Predicate::FieldEqual { field } => PredicateSummary {
392 kind: "field_equal".into(),
393 fields: vec![field.clone()],
394 min: None,
395 tolerance: None,
396 km: None,
397 parts: None,
398 approximate: false,
399 },
400 Predicate::Overlap { field, min } => PredicateSummary {
401 kind: "overlap".into(),
402 fields: vec![field.clone()],
403 min: Some(*min),
404 tolerance: None,
405 km: None,
406 parts: None,
407 approximate: false,
408 },
409 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
410 kind: "numeric_within".into(),
411 fields: vec![field.clone()],
412 min: None,
413 tolerance: Some(*tolerance),
414 km: None,
415 parts: None,
416 approximate: false,
417 },
418 Predicate::GeoRadius { field, km } => PredicateSummary {
419 kind: "geo_radius".into(),
420 fields: vec![field.clone()],
421 min: None,
422 tolerance: None,
423 km: Some(*km),
424 parts: None,
425 approximate: false,
426 },
427 Predicate::VectorSimilar { field, min } => PredicateSummary {
428 kind: "vector_similar".into(),
429 fields: vec![field.clone()],
430 min: Some(*min),
431 tolerance: None,
432 km: None,
433 parts: None,
434 approximate: false,
435 },
436 Predicate::All(inner) => {
437 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
438 let mut fields = Vec::new();
439 for part in &parts {
440 for f in &part.fields {
441 if !fields.contains(f) {
442 fields.push(f.clone());
443 }
444 }
445 }
446 PredicateSummary {
447 kind: "all".into(),
448 fields,
449 min: None,
450 tolerance: None,
451 km: None,
452 parts: Some(parts),
453 approximate: false,
454 }
455 }
456 Predicate::Any(inner) => {
457 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
458 let mut fields = Vec::new();
459 for part in &parts {
460 for f in &part.fields {
461 if !fields.contains(f) {
462 fields.push(f.clone());
463 }
464 }
465 }
466 PredicateSummary {
467 kind: "any".into(),
468 fields,
469 min: None,
470 tolerance: None,
471 km: None,
472 parts: Some(parts),
473 approximate: false,
474 }
475 }
476 }
477 }
478}
479
480/// Snapshot of a live node's key, label, and columnar properties.
481///
482/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
483/// regardless of insert order or the columnar store's `HashMap` iteration.
484///
485/// Deliberately does not derive `Serialize`: `Value`'s serde form is
486/// internally tagged. Wire JSON is built by `value_to_json` in the server.
487#[derive(Debug, Clone, PartialEq)]
488pub struct NodeInfo {
489 pub key: String,
490 pub label: String,
491 pub props: BTreeMap<String, Value>,
492}
493
494/// Counts returned by [`GraphDb::delete_node`].
495#[derive(Debug, Clone, PartialEq, Eq, Default)]
496pub struct DeleteReport {
497 /// Number of manual (user-inserted) edges removed.
498 pub manual_edges: u64,
499 /// Number of derived (rule-owned) edges retracted.
500 pub derived_edges: u64,
501}
502
503/// One directed edge incident on a node, with provenance membership.
504///
505/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
506/// Plan-8 `by_node` provenance index.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct EdgeInfo {
509 pub edge_type: String,
510 pub src_key: String,
511 pub dst_key: String,
512 pub derived: bool,
513}
514
515/// One directed edge incident on a node at a point in WAL history, with the
516/// rule that derived it when it is rule-owned.
517///
518/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
519/// and by [`GraphDb::what_if_set_prop`].
520#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
521pub struct EdgeAt {
522 pub edge_type: String,
523 pub src_key: String,
524 pub dst_key: String,
525 /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
526 /// live provenance entry).
527 pub derived: bool,
528 /// The rule that derived the edge. `None` for a manual edge.
529 pub rule: Option<String>,
530}
531
532/// The derived edges a hypothetical property change would retract and derive.
533///
534/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
535/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
537pub struct WhatIf {
538 /// Derived edges that exist now and would be retracted.
539 pub lost: Vec<EdgeAt>,
540 /// Derived edges that do not exist now and would be derived.
541 pub gained: Vec<EdgeAt>,
542}
543
544/// An edge with mask-aware endpoint visibility.
545///
546/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
547/// mode — hidden endpoints carry `*_restricted: true`.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct MaskedEdge {
550 pub edge_type: String,
551 pub src_key: String,
552 /// `true` when `src_key` is in the DB but hidden from the mask.
553 pub src_restricted: bool,
554 pub dst_key: String,
555 /// `true` when `dst_key` is in the DB but hidden from the mask.
556 pub dst_restricted: bool,
557 pub derived: bool,
558}
559
560/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
561///
562/// `None` from that method means the key does not exist (→ 404).
563/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
564#[derive(Debug, PartialEq)]
565pub enum MaskedNodeResult {
566 Visible(NodeInfo),
567 /// Node exists in the DB but is hidden from this mask.
568 Restricted,
569}
570
571/// One rule-owned edge between two nodes, with the rule name, edge type,
572/// direction (src_key → dst_key), and weight if the rule stores one.
573#[derive(Debug, Clone, PartialEq, Serialize)]
574pub struct Explanation {
575 pub rule: String,
576 pub edge_type: String,
577 pub src_key: String,
578 pub dst_key: String,
579 pub weight: Option<f64>,
580 pub predicate: PredicateSummary,
581 /// For a via-hop rule, the edge type the rule hops over to reach its
582 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
583 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
584 /// was written by another rule in the same commit.
585 #[serde(default)]
586 pub via_edge: Option<String>,
587}
588
589/// Report returned by [`GraphDb::backup_to`].
590#[derive(Debug, Clone)]
591pub struct BackupReport {
592 /// Filenames copied into the destination directory (sorted ascending).
593 pub files: Vec<String>,
594 /// Total bytes written across all copied files.
595 pub bytes: u64,
596 /// `true` when the destination opened cleanly and passed post-copy checks.
597 ///
598 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
599 /// matched **and** the destination opened without error.
600 ///
601 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
602 /// CRC-check; `verified` is `true` when the destination opened and
603 /// replayed the WAL without error (record-level checksums in the WAL
604 /// provide the integrity signal, not section CRCs).
605 pub verified: bool,
606}
607
608/// One directed edge in export form, with optional rule attribution for derived edges.
609///
610/// Returned by [`GraphDb::all_edges_for_export`].
611///
612/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
613/// order. Callers that need a stable edge ordering already sort by
614/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
615#[derive(Debug, Clone, PartialEq, PartialOrd)]
616pub struct ExportEdge {
617 pub edge_type: String,
618 pub src: String,
619 pub dst: String,
620 pub derived: bool,
621 /// Rule name that created this edge, if derived. `None` for manual edges.
622 pub rule: Option<String>,
623 /// The creating rule's declared `weight_prop`, read off this edge, when
624 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
625 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
626 pub weight: Option<f64>,
627}
628
629/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
630///
631/// Deliberately per *type* and not per edge: everything here is a summary a
632/// caller can print in one line, and none of it costs a record per edge.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct EdgeTypeCensus {
635 pub edge_type: String,
636 /// Directed edges of this type. Counted the way
637 /// [`GraphDb::edge_count`] counts: each edge once, from its source.
638 pub edges: u64,
639 /// Every label seen on a source of this type, sorted.
640 pub src_labels: Vec<String>,
641 /// Every label seen on a destination of this type, sorted.
642 pub dst_labels: Vec<String>,
643 /// The rules that declare this `edge_type`, sorted. Empty for a type
644 /// written by hand.
645 pub rules: Vec<String>,
646 /// `(src key, dst key)` of the first edge of this type in the store's own
647 /// id order — a real pair to quote in an example.
648 pub sample: Option<(String, String)>,
649}
650
651/// Construct the standard write-query result set (columns: created, properties_set, deleted).
652fn write_result_set() -> ResultSet {
653 ResultSet::new(vec![
654 "created".into(),
655 "properties_set".into(),
656 "deleted".into(),
657 ])
658}
659
660fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
661 match op {
662 Operand::Lit(v) => Ok(v.clone()),
663 Operand::Param(name) => params
664 .get(name)
665 .cloned()
666 .ok_or_else(|| GraphError::QueryError {
667 detail: format!("missing parameter `{name}`"),
668 }),
669 _ => Err(GraphError::QueryError {
670 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
671 }),
672 }
673}
674
675fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
676 match op {
677 Operand::Prop { var, .. } | Operand::Var(var) => {
678 if !out.contains(var) {
679 out.push(var.clone());
680 }
681 }
682 Operand::FuncCall { args, .. } => {
683 for arg in args {
684 operand_node_vars(arg, out);
685 }
686 }
687 Operand::BinArith { left, right, .. } => {
688 operand_node_vars(left, out);
689 operand_node_vars(right, out);
690 }
691 Operand::Case { branches, default } => {
692 // Branch conditions reference vars already bound (and mask-filtered)
693 // by the MATCH phase, so collecting from the value operands + ELSE
694 // is sufficient for RETURN-projection var discovery.
695 for (_, value) in branches {
696 operand_node_vars(value, out);
697 }
698 if let Some(d) = default {
699 operand_node_vars(d, out);
700 }
701 }
702 Operand::Index { base, index } => {
703 operand_node_vars(base, out);
704 operand_node_vars(index, out);
705 }
706 Operand::Lit(_) | Operand::Param(_) => {}
707 }
708}
709
710fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
711 let mut out = Vec::new();
712 for item in items {
713 match &item.value {
714 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
715 if !out.contains(v) {
716 out.push(v.clone());
717 }
718 }
719 RetVal::FuncCall { args, .. } => {
720 for arg in args {
721 operand_node_vars(arg, &mut out);
722 }
723 }
724 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
725 RetVal::Agg { .. } => {}
726 }
727 }
728 out
729}
730
731fn add_var(out: &mut Vec<String>, v: &str) {
732 if !out.iter().any(|x| x == v) {
733 out.push(v.to_string());
734 }
735}
736
737fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
738 let mut out = Vec::new();
739 for p in pats {
740 if let Some(v) = &p.start.var {
741 add_var(&mut out, v);
742 }
743 for (_, dest) in &p.chain {
744 if let Some(v) = &dest.var {
745 add_var(&mut out, v);
746 }
747 }
748 }
749 out
750}
751
752fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
753 let mut out = Vec::new();
754 for p in pats {
755 for (rel, _) in &p.chain {
756 if rel.hops.is_none() {
757 if let Some(v) = &rel.var {
758 add_var(&mut out, v);
759 }
760 }
761 }
762 }
763 out
764}
765
766fn rel_type_alias(var: &str) -> String {
767 format!("__rt_{var}")
768}
769
770fn ret_column_name(item: &RetItem) -> String {
771 if let Some(alias) = &item.alias {
772 return alias.clone();
773 }
774 // The same naming rule the planner and the executor use, so a
775 // write-statement RETURN names its columns exactly as a read query does.
776 // An aggregate is not legal in a write-statement RETURN; it keeps the
777 // placeholder it always had.
778 ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
779}
780
781fn eval_set_return_operand<F: Fs>(
782 db: &GraphDb<F>,
783 match_rs: &ResultSet,
784 row: usize,
785 rel_vars: &[String],
786 op: &Operand,
787 params: &BTreeMap<String, Value>,
788) -> Result<Option<Value>> {
789 match op {
790 Operand::Lit(v) => Ok(Some(v.clone())),
791 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
792 detail: format!("missing parameter `{name}`"),
793 }).map(Some),
794 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
795 detail: format!(
796 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
797 ),
798 }),
799 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
800 Operand::Prop { var, field } => {
801 if rel_vars.iter().any(|r| r == var) {
802 return Ok(None);
803 }
804 let Some(Value::Str(key)) = match_rs.get(row, var) else {
805 return Ok(None);
806 };
807 Ok(db.get_prop(key, field))
808 }
809 Operand::FuncCall { name, args } => {
810 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
811 }
812 Operand::BinArith { op, left, right } => {
813 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
814 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
815 eval_set_return_arith(op, lv, rv)
816 }
817 // CASE is supported in read-query RETURN; in a write-statement RETURN
818 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
819 Operand::Case { .. } => Err(GraphError::QueryError {
820 detail: "CASE is not supported in a write-statement RETURN projection; \
821 use a read query"
822 .into(),
823 }),
824 // Same as CASE: a list subscript is supported in a read-query RETURN
825 // but not yet in a write-statement RETURN projection.
826 Operand::Index { .. } => Err(GraphError::QueryError {
827 detail: "a list subscript is not supported in a write-statement RETURN \
828 projection; use a read query"
829 .into(),
830 }),
831 }
832}
833
834fn eval_set_return_arith(
835 op: &ArithOp,
836 lv: Option<Value>,
837 rv: Option<Value>,
838) -> Result<Option<Value>> {
839 match (lv, rv) {
840 (None, _) | (_, None) => Ok(None),
841 (Some(Value::Int(a)), Some(Value::Int(b))) => {
842 let result = match op {
843 ArithOp::Sub => a.saturating_sub(b),
844 ArithOp::Mul => a.saturating_mul(b),
845 ArithOp::Add => a.saturating_add(b),
846 ArithOp::Div => {
847 if b == 0 {
848 return Err(GraphError::QueryError {
849 detail: "division by zero".into(),
850 });
851 }
852 a.checked_div(b).unwrap_or(i64::MAX)
853 }
854 };
855 Ok(Some(Value::Int(result)))
856 }
857 (Some(lv), Some(rv)) => {
858 let a = match &lv {
859 Value::Float(f) => *f,
860 Value::Int(i) => *i as f64,
861 _ => {
862 return Err(GraphError::QueryError {
863 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
864 })
865 }
866 };
867 let b = match &rv {
868 Value::Float(f) => *f,
869 Value::Int(i) => *i as f64,
870 _ => {
871 return Err(GraphError::QueryError {
872 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
873 })
874 }
875 };
876 let result = match op {
877 ArithOp::Sub => a - b,
878 ArithOp::Mul => a * b,
879 ArithOp::Add => a + b,
880 ArithOp::Div => {
881 if b == 0.0 {
882 return Err(GraphError::QueryError {
883 detail: "division by zero".into(),
884 });
885 }
886 a / b
887 }
888 };
889 Ok(Some(Value::Float(result)))
890 }
891 }
892}
893
894fn eval_set_return_func<F: Fs>(
895 db: &GraphDb<F>,
896 match_rs: &ResultSet,
897 row: usize,
898 rel_vars: &[String],
899 name: &str,
900 args: &[Operand],
901 params: &BTreeMap<String, Value>,
902) -> Result<Option<Value>> {
903 let norm = name.to_ascii_lowercase();
904 if norm == "type" {
905 if args.len() != 1 {
906 return Err(GraphError::QueryError {
907 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
908 });
909 }
910 let Operand::Var(rel) = &args[0] else {
911 return Err(GraphError::QueryError {
912 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
913 });
914 };
915 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
916 }
917 if norm == "key" {
918 if args.len() != 1 {
919 return Err(GraphError::QueryError {
920 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
921 });
922 }
923 let Operand::Var(var) = &args[0] else {
924 return Err(GraphError::QueryError {
925 detail: "key() argument must be a node variable (e.g. key(n))".into(),
926 });
927 };
928 if rel_vars.iter().any(|r| r == var) {
929 return Err(GraphError::QueryError {
930 detail: format!("key() argument `{var}` is a relationship, not a node"),
931 });
932 }
933 // MATCH rows bind node variables to their key string, so the column
934 // value *is* the key.
935 return Ok(match_rs.get(row, var).cloned());
936 }
937 let mut vals = Vec::with_capacity(args.len());
938 for arg in args {
939 vals.push(eval_set_return_operand(
940 db, match_rs, row, rel_vars, arg, params,
941 )?);
942 }
943 match norm.as_str() {
944 "tolower" => {
945 if vals.len() != 1 {
946 return Err(GraphError::QueryError {
947 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
948 });
949 }
950 Ok(vals[0].clone().map(|val| match val {
951 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
952 other => other,
953 }))
954 }
955 "toupper" => {
956 if vals.len() != 1 {
957 return Err(GraphError::QueryError {
958 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
959 });
960 }
961 Ok(vals[0].clone().map(|val| match val {
962 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
963 other => other,
964 }))
965 }
966 "size" => match vals.first().cloned().flatten() {
967 None => Ok(None),
968 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
969 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
970 Some(_) => Ok(None),
971 },
972 "coalesce" => Ok(vals.into_iter().flatten().next()),
973 "abs" => match vals.first().cloned().flatten() {
974 None => Ok(None),
975 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
976 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
977 Some(_) => Ok(None),
978 },
979 "round" => match vals.first().cloned().flatten() {
980 None => Ok(None),
981 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
982 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
983 Some(_) => Ok(None),
984 },
985 "decay" => {
986 if vals.len() != 3 {
987 return Err(GraphError::QueryError {
988 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
989 });
990 }
991 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
992 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
993 (Some(b), Some(a), Some(h)) => {
994 let numeric = |v: Value| -> Result<f64> {
995 match v {
996 Value::Int(n) => Ok(n as f64),
997 Value::Float(f) => Ok(f),
998 other => Err(GraphError::QueryError {
999 detail: format!(
1000 "decay() requires numeric arguments, got {other:?}"
1001 ),
1002 }),
1003 }
1004 };
1005 let b = numeric(b)?;
1006 let a = numeric(a)?;
1007 let h = numeric(h)?;
1008 if h <= 0.0 {
1009 return Err(GraphError::QueryError {
1010 detail: "decay() requires halflife > 0".into(),
1011 });
1012 }
1013 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1014 }
1015 }
1016 }
1017 _ => Err(GraphError::QueryError {
1018 detail: format!(
1019 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
1020 ),
1021 }),
1022 }
1023}
1024
1025fn eval_set_return_item<F: Fs>(
1026 db: &GraphDb<F>,
1027 match_rs: &ResultSet,
1028 row: usize,
1029 rel_vars: &[String],
1030 item: &RetItem,
1031 params: &BTreeMap<String, Value>,
1032) -> Result<Option<Value>> {
1033 match &item.value {
1034 RetVal::Var(v) => eval_set_return_operand(
1035 db,
1036 match_rs,
1037 row,
1038 rel_vars,
1039 &Operand::Var(v.clone()),
1040 params,
1041 ),
1042 RetVal::Prop { var, field } => eval_set_return_operand(
1043 db,
1044 match_rs,
1045 row,
1046 rel_vars,
1047 &Operand::Prop {
1048 var: var.clone(),
1049 field: field.clone(),
1050 },
1051 params,
1052 ),
1053 RetVal::FuncCall { name, args } => {
1054 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1055 }
1056 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1057 RetVal::Agg { .. } => Err(GraphError::QueryError {
1058 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1059 }),
1060 }
1061}
1062
1063/// Project user RETURN from original MATCH rows after SET. No rematch.
1064fn project_set_return_rows<F: Fs>(
1065 db: &GraphDb<F>,
1066 rel_vars: &[String],
1067 match_rs: &ResultSet,
1068 returns: &[RetItem],
1069 params: &BTreeMap<String, Value>,
1070) -> Result<ResultSet> {
1071 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1072 let mut out = ResultSet::new(columns);
1073 for row in 0..match_rs.len() {
1074 let mut cells = Vec::with_capacity(returns.len());
1075 for item in returns {
1076 cells.push(eval_set_return_item(
1077 db, match_rs, row, rel_vars, item, params,
1078 )?);
1079 }
1080 out.push_row(cells);
1081 }
1082 Ok(out)
1083}
1084
1085/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1086/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1087/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1088/// Returns `None` for non-list values or lists with non-numeric elements.
1089/// Extra candidates pulled from an approximate index before re-scoring, over and
1090/// above the `k` asked for.
1091///
1092/// The index orders candidates by `f32` distances, which agree with the exact
1093/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1094/// candidates inside a band that narrow — it cannot move a hit past one that is
1095/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1096/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1097/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1098/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1099/// then the members are interchangeable anyway. `min` is applied to the exact
1100/// score, never to the index's, so a hit sitting on the threshold is decided
1101/// exactly.
1102const VECTOR_RESCORE_MARGIN: usize = 16;
1103
1104/// Cosine similarity between an already-unit query and node `id`'s `field`
1105/// vector, read from the **`f64`** properties. `None` when the node has no
1106/// numeric-list vector there, or its norm is zero.
1107///
1108/// The single definition of the score this API reports. Both the brute-force
1109/// scan and the re-scoring step that follows an index lookup go through it, so
1110/// the two paths cannot disagree — which is the property
1111/// `index_and_brute_force_agree_on_scores` pins.
1112fn exact_vector_similarity(
1113 view: &GraphView<'_>,
1114 id: u32,
1115 field: &str,
1116 q_unit: &[f64],
1117) -> Option<f64> {
1118 let v = view.prop(id, field)?;
1119 let xs = value_as_float_list(&v.into_value())?;
1120 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1121 if v_norm == 0.0 {
1122 return None;
1123 }
1124 Some(
1125 q_unit
1126 .iter()
1127 .zip(xs.iter())
1128 .map(|(a, b)| a * (b / v_norm))
1129 .sum(),
1130 )
1131}
1132
1133fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1134 match v {
1135 Value::List(items) => items
1136 .iter()
1137 .map(|item| match item {
1138 Value::Float(f) => Some(*f),
1139 Value::Int(i) => Some(*i as f64),
1140 _ => None,
1141 })
1142 .collect(),
1143 _ => None,
1144 }
1145}
1146
1147fn make_graph_mut<'a>(
1148 ids: &'a IdMap,
1149 syms: &'a mut Interner,
1150 labels: &'a [u32],
1151 props: core_storage::v8::seam::ColumnsView<'a>,
1152 topo: &'a mut Topology,
1153 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1154 edge_props: &'a mut EdgeProps,
1155) -> GraphMut<'a> {
1156 GraphMut {
1157 ids,
1158 syms,
1159 labels,
1160 props,
1161 topo,
1162 base_topo: base_csr(base),
1163 edge_props,
1164 }
1165}
1166
1167/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1168///
1169/// A store opened from a snapshot keeps its edges in the mapping and its
1170/// overlay empty, so a rule that reads the graph's shape has to see both.
1171fn base_csr(
1172 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1173) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1174 base.as_ref().map(|b| {
1175 b.topology()
1176 .expect("base topology section bounds validated at open")
1177 })
1178}
1179
1180/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1181///
1182/// Takes explicit field references rather than `&self` so the caller can hold
1183/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1184fn build_props_view<'a>(
1185 props: &'a ColumnStore,
1186 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1187) -> core_storage::v8::seam::ColumnsView<'a> {
1188 match base {
1189 None => core_storage::v8::seam::ColumnsView::owned(props),
1190 Some(b) => {
1191 let archived = b
1192 .columns()
1193 .expect("base columns section bounds validated at open");
1194 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1195 .with_shared_strings(base_string_table(b))
1196 }
1197 }
1198}
1199
1200/// The base columns section paired with the string table that resolves its
1201/// string ids — what `ViewStore` needs to read a neighbour's string property
1202/// out of a V9 snapshot.
1203fn base_columns(
1204 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1205) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1206 base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1207 cols: b
1208 .columns()
1209 .expect("base columns section bounds validated at open"),
1210 strings: base_string_table(b),
1211 })
1212}
1213
1214/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1215///
1216/// Every `ColumnsView` built over a base must carry it: without it a V9
1217/// snapshot's string columns, whose own tables are empty, read back as absent.
1218fn base_string_table(
1219 base: &core_storage::v8::MappedBase,
1220) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1221 base.string_table()
1222 .transpose()
1223 .expect("base strings section bounds validated at open")
1224}
1225
1226fn build_topo_view<'a>(
1227 overlay: &'a Topology,
1228 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1229) -> core_storage::v8::seam::TopologyView<'a> {
1230 match base {
1231 None => core_storage::v8::seam::TopologyView::owned(overlay),
1232 Some(b) => {
1233 let archived_csr = b
1234 .topology()
1235 .expect("base topology section bounds validated at open");
1236 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1237 }
1238 }
1239}
1240
1241/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1242///
1243/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1244/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1245/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1246/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1247/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1248#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1249pub enum FsyncPolicy {
1250 /// Every WAL commit calls `fs.sync` (today's behavior).
1251 #[default]
1252 Strict,
1253 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1254 /// this policy is set on the database.
1255 Batched,
1256 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1257 Relaxed,
1258}
1259
1260/// A precondition for a compare-and-set batch write.
1261///
1262/// All preconditions in a [`GraphDb::write_batch_cas`] or
1263/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1264/// any operation in the batch is applied. If any precondition fails, the
1265/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1266/// is written.
1267///
1268/// # Touch definition
1269///
1270/// A node's last-change commit (`last_changed`) is updated when any of the
1271/// following state-changing WAL records touch it:
1272///
1273/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1274/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1275/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1276/// endpoints (an edge change touches both sides).
1277/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1278/// for deleted keys so the pre-deletion entry is never observed.
1279///
1280/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1281/// state no-ops. The underlying mutation that triggered rule firing already
1282/// updated the relevant nodes' last-change entries. Rule-management records
1283/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1284/// do not touch any node's last-change.
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum Precondition {
1287 /// The node's last-change commit must equal `expected`.
1288 ///
1289 /// Fails with [`GraphError::CasConflict`] when:
1290 /// - The node does not exist (`last_changed` returns `None`), or
1291 /// - The recorded commit seq does not match `expected`.
1292 NodeUnchangedSince { key: String, expected: u64 },
1293 /// The node must not exist (not inserted, or already deleted).
1294 ///
1295 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1296 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1297 NodeAbsent { key: String },
1298}
1299
1300pub struct GraphDb<F: Fs> {
1301 fs: F,
1302 ids: IdMap,
1303 syms: Interner,
1304 topo: Topology,
1305 props: ColumnStore,
1306 labels: Vec<u32>, // node id -> label symbol
1307 /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1308 /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1309 ///
1310 /// A private table rather than the shared [`Interner`]: interning
1311 /// `"default"` at open would add a symbol to the store's symbol table and
1312 /// change the bytes of the next snapshot of a store that has no namespaces
1313 /// at all.
1314 ns_names: Vec<String>,
1315 /// Namespace index per dense node id, into [`Self::ns_names`];
1316 /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1317 ///
1318 /// Derived: built by one pass over the `ns` column at open (which reads
1319 /// nothing when the column does not exist) and maintained at every node
1320 /// insert. Never written to a snapshot or the WAL, because the property it
1321 /// mirrors already is. A namespace cannot change, so no other record shape
1322 /// can move a node between namespaces.
1323 node_ns: Vec<u32>,
1324 edge_props: EdgeProps,
1325 engine: RuleEngine,
1326 view_store: ViewStore,
1327 /// Incremental inverted index for full-text-lite search.
1328 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1329 fulltext: FulltextIndex,
1330 /// Opt-in equality index over scalar node properties.
1331 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1332 /// open end (mirrors `fulltext`).
1333 prop_index: PropertyIndex,
1334 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1335 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1336 fsync: FsyncPolicy,
1337 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1338 /// call increments this once; all events emitted from that call share the same
1339 /// `commit_seq` value.
1340 commit_seq: u64,
1341 /// RBAC role definitions loaded from `roles.json` at open.
1342 ///
1343 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1344 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1345 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1346 roles: Option<Vec<RoleDef>>,
1347 /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1348 /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1349 ///
1350 /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1351 /// taken from this handle. Replaced (not cleared) whenever the role
1352 /// definitions change or the store is reloaded, which `commit_seq` does not
1353 /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1354 role_masks: Arc<crate::mask::RoleMaskCache>,
1355 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1356 /// distribute_events call.
1357 subscriptions: Vec<SubEntry>,
1358 /// Live query subscriptions. Re-executed on every commit when non-empty.
1359 /// Dead `Weak` entries are pruned inside `distribute_events`.
1360 query_subscriptions: Vec<QuerySubEntry>,
1361 /// Queue capacity for new subscriptions created by this db. Default is
1362 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1363 /// to test Lagged behaviour with small queues.
1364 sub_capacity: usize,
1365 /// True for as-of instances opened via [`GraphDb::open_at`].
1366 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1367 /// when this flag is set.
1368 read_only: bool,
1369 /// Total WAL commit count at the time [`open_at`] was called.
1370 /// 0 for normal (non-as-of) instances.
1371 total_wal_commits: u64,
1372 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1373 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1374 /// reads go through a merged `TopologyView`. `self.props` is always
1375 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1376 base: Option<Arc<core_storage::v8::MappedBase>>,
1377 // ── MVCC epoch reader state ───────────────────────────────────────────────
1378 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1379 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1380 /// `None` only between struct creation and the first fold.
1381 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1382 /// Per-commit deltas accumulated since the last fold.
1383 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1384 /// How many commits have occurred since the last fold.
1385 commits_since_fold: usize,
1386 /// When true, `log_then_apply_with` buffers event notifications instead of
1387 /// firing them immediately. Used by the group-commit drain thread to defer
1388 /// events until after the group fsync (R2: durability before notification).
1389 /// Cleared to false once the drain thread flushes or discards the buffer.
1390 defer_events: bool,
1391 /// Buffered events accumulated while `defer_events` is true.
1392 deferred_events: Vec<DeferredEvent>,
1393 /// Set to true by the group-commit drain thread when a group fsync fails
1394 /// after WAL truncation. All subsequent mutation attempts return an IO
1395 /// error until the database is reopened.
1396 degraded: bool,
1397 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1398 /// HNSW, and IVF sections from the mmap base into the engine's retained
1399 /// fields. `false` on all opens until first use; always `true` for non-V8
1400 /// opens (base is None, fast-path sets flag immediately).
1401 v8_sections_loaded: std::sync::atomic::AtomicBool,
1402 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1403 v8_sections_mutex: std::sync::Mutex<()>,
1404 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1405 /// the node was last modified by commit `seq`.
1406 ///
1407 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1408 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1409 /// nodes return `None` from `last_changed` until they are next mutated.
1410 ///
1411 /// See [`Precondition`] for the full touch definition.
1412 last_change: HashMap<u32, u64>,
1413 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1414 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1415 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1416 wal_archive_retention: Option<u32>,
1417 /// Global frame index of the first commit that is still reachable through
1418 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1419 /// Default 0 = all history reachable.
1420 wal_horizon_floor: u64,
1421 /// True when the surviving archive chain forms a continuous WAL history
1422 /// starting from the store's first commit (the genesis chain).
1423 ///
1424 /// `open_at` may replay archive-resident commits from empty state only when
1425 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1426 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1427 /// already exist (breaks the chain for subsequent archives), or
1428 /// - any archive is pruned (floor advances past zero).
1429 ///
1430 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1431 archive_genesis_chain: bool,
1432 /// Transient write-authz context set by `write_batch_authz` /
1433 /// `query_write_authz` for the duration of ONE mutation call.
1434 /// Always `None` at rest. Never serialized, never WAL-replayed.
1435 pending_write_authz: Option<WriteAuthz>,
1436 /// Slow-query threshold in milliseconds. 0 = disabled.
1437 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1438 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1439 /// — env vars are process-global and race parallel test threads).
1440 slow_query_threshold_ms: u64,
1441 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1442 /// can record entries without requiring `&mut self`).
1443 slow_queries: std::sync::Mutex<SlowQueryLog>,
1444 /// Instant at which the database was opened (used by `/metrics` uptime).
1445 started_at: std::time::Instant,
1446 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1447 /// Byte offset of the WAL prefix already applied to in-memory state.
1448 ///
1449 /// Advanced by exactly the encoded length of every frame this handle
1450 /// appends, and by the decoded byte count of every tail
1451 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1452 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1453 /// drain thread truncates a failed group. Compared against the WAL's
1454 /// on-disk length to decide staleness.
1455 wal_consumed: u64,
1456 /// Identity of the snapshot this handle's base state came from, as
1457 /// `(len, mtime_nanos)`. A different value means another process replaced
1458 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1459 snapshot_ident: Option<(u64, u64)>,
1460 /// The options this handle was opened with. Replayed verbatim when
1461 /// `refresh` has to rebuild from disk.
1462 open_opts: OpenOptions,
1463 /// True when this handle holds the cross-process write lock for its whole
1464 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1465 /// no-op on such a handle, and never releases the lock.
1466 holds_lifetime_lock: bool,
1467 /// True between a failed lock acquisition and the end of the write scope
1468 /// that failed. Makes every WAL-appending mutation in that scope return
1469 /// [`GraphError::Busy`] instead of writing.
1470 lock_denied: bool,
1471 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1472 /// pinned to one commit, so it is never stale and never refreshes — later
1473 /// commits by any process are deliberately invisible to it.
1474 pinned: bool,
1475}
1476
1477/// One group of deferred event notifications, held until the group fsync
1478/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1479struct DeferredEvent {
1480 rec: core_storage::WalRecord,
1481 engine_deltas: Vec<EngineEdgeDelta>,
1482 seq: u64,
1483 ingest: Option<(String, usize)>,
1484}
1485
1486/// Options for [`GraphDb::open_with_options`].
1487#[derive(Clone, Copy, Debug)]
1488pub struct OpenOptions {
1489 /// Rewrite an old-format snapshot to the current VERSION after a
1490 /// successful load (default `true`). The old snapshot is kept as
1491 /// `snapshot.bin.bak` until the next clean open at the current version,
1492 /// at which point the `.bak` is deleted.
1493 ///
1494 /// Set to `false` to open a store without touching any on-disk files
1495 /// (useful for read-only inspection of a store at an older format).
1496 pub auto_migrate: bool,
1497
1498 /// Write the valid WAL prefix back over a torn tail on open (default
1499 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1500 ///
1501 /// Set to `false` for an unattended reader. The valid prefix is still
1502 /// decoded and replayed in memory, but nothing is written: a reader that
1503 /// opens while another process is mid-append would otherwise discard a
1504 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1505 /// every prompt, passes `false` for exactly this reason.
1506 pub repair_wal: bool,
1507
1508 /// Open without ever writing to the store (default `false`).
1509 ///
1510 /// A read-only handle:
1511 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1512 /// `snapshot()`;
1513 /// - performs no disk write at open — no WAL repair write-back and no
1514 /// auto-migration rewrite, whatever the other two flags say;
1515 /// - never takes the cross-process write lock, so it opens immediately even
1516 /// while another process is writing, and never makes a writer wait.
1517 ///
1518 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1519 /// normally, so a read-only handle can follow another process's commits.
1520 pub read_only: bool,
1521}
1522
1523impl Default for OpenOptions {
1524 fn default() -> Self {
1525 Self {
1526 auto_migrate: true,
1527 repair_wal: true,
1528 read_only: false,
1529 }
1530 }
1531}
1532
1533/// How long a writer polls for the cross-process write lock before giving up
1534/// with [`GraphError::Busy`].
1535///
1536/// Long enough to ride out another process's commit (a batch apply plus one
1537/// fsync), short enough that a stuck peer surfaces as an error rather than a
1538/// hang.
1539pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1540
1541/// Interval between poll attempts while waiting for the cross-process lock.
1542pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1543
1544/// Why `load_from_disk` is running, which decides whether it may repair.
1545#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1546enum LoadOrigin {
1547 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1548 /// the signature of a crash and truncating it is correct, and archives
1549 /// orphaned by an interrupted prune can be swept.
1550 Open,
1551 /// A reload driven by [`GraphDb::refresh`], because another process
1552 /// replaced the snapshot. Nothing here is crash recovery — the store is
1553 /// live and someone else is writing it — so this origin writes nothing.
1554 Reload,
1555}
1556
1557/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1558///
1559/// `None` at the call site = full authority (today's zero-cost behavior).
1560/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1561/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1562/// record is built. A denial returns an error with no WAL frame written.
1563///
1564/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1565/// hidden-node existence to callers.
1566#[derive(Clone, Debug)]
1567pub struct WriteAuthz {
1568 pub role: String,
1569 pub scope: WriteScope,
1570 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1571 /// Always `Omit`-mode — never `Stub`.
1572 pub mask: crate::mask::NodeMask,
1573}
1574
1575/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1576///
1577/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1578/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1579/// syncs the directory entry. This is the only correct path for writing the
1580/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1581/// the directory sync.
1582pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1583 use core_storage::fs::{FileId, Fs as _};
1584 RealFs::new(dir)
1585 .map_err(core_storage::GraphError::Io)?
1586 .write_atomic(FileId::SnapshotBak, bytes)
1587 .map_err(core_storage::GraphError::Io)
1588}
1589
1590/// Return the on-disk snapshot format version without decoding the full snapshot.
1591///
1592/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1593/// snapshot file exists (WAL-only store). Returns an error if the header is
1594/// malformed.
1595pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1596 use std::io::Read as _;
1597 let path = dir.join("snapshot.bin");
1598 let mut header = [0u8; 6];
1599 let n = match std::fs::File::open(&path) {
1600 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1601 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1602 Err(e) => return Err(core_storage::GraphError::Io(e)),
1603 };
1604 core_storage::snapshot::peek_version(&header[..n])
1605}
1606
1607/// Options for [`GraphDb::snapshot_with`].
1608#[derive(Debug, Clone, Default)]
1609pub struct SnapshotOptions {
1610 /// When `true`, the WAL is preserved after the snapshot write.
1611 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1612 /// When `false` (the default), the WAL is truncated to a minimal
1613 /// baseline so cold-start replay stays fast.
1614 pub keep_wal: bool,
1615 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1616 /// before a fresh WAL baseline is written (history-preserving snapshot).
1617 ///
1618 /// This is the feature opt-in: `false` (the default) leaves the existing
1619 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1620 /// precedence over `keep_wal` when both are set.
1621 ///
1622 /// Archives can be scanned by [`GraphDb::node_history`],
1623 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1624 /// [`GraphDb::open_at`], extending the reachable history horizon across
1625 /// snapshot boundaries.
1626 pub archive_wal: bool,
1627}
1628
1629/// Derive the scan-label sym for the commit-skip fast-path.
1630///
1631/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1632/// or `IndexIntersect`) with a concrete label string, then interns it.
1633///
1634/// Returns `None` in all cases where skipping is unsafe:
1635/// - Any `Expand` op is present (edge traversal; edges change results regardless
1636/// of node labels).
1637/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1638/// - No recognizable leading scan op is found.
1639///
1640/// This is the conservative v0.4.3 boundary. The caller stores the result in
1641/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1642fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1643 // Any Expand → must always re-execute (edges can change join results).
1644 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1645 return None;
1646 }
1647 for op in ops {
1648 match op {
1649 PlanOp::ScanLabel {
1650 label: Some(label), ..
1651 } => return Some(syms.intern(label)),
1652 PlanOp::IndexScan {
1653 label: Some(label), ..
1654 } => return Some(syms.intern(label)),
1655 PlanOp::IndexIntersect {
1656 label: Some(label), ..
1657 } => return Some(syms.intern(label)),
1658 _ => {}
1659 }
1660 }
1661 None
1662}
1663
1664/// How an as-of read is restricted — the argument to
1665/// [`GraphDb::query_at_scoped`].
1666///
1667/// Every variant is resolved against the graph **as it was at the requested
1668/// commit**, not against the current graph.
1669#[derive(Debug, Clone, Copy)]
1670pub enum AsOfScope<'a> {
1671 /// Everything the named role may see. The role *definition* is the current
1672 /// one — `roles.json` is a sidecar and has no past version — but its
1673 /// `keys` and `labels` are resolved against the as-of graph.
1674 Role(&'a str),
1675 /// An explicit node-key allow-list. Keys that did not exist at that commit
1676 /// resolve to nothing.
1677 Keys(&'a [String]),
1678 /// A role intersected with a client-supplied allow-list. The intersection
1679 /// is the never-widen rule: a client mask can only narrow a role.
1680 RoleAndKeys(&'a str, &'a [String]),
1681 /// Every live node in one namespace, as the graph was at that commit.
1682 ///
1683 /// A namespace cannot change — it is set at insert and immutable — so the
1684 /// answer is simply "the nodes that existed then and are in this
1685 /// namespace". A name no node uses resolves to nothing, never to
1686 /// everything.
1687 Namespace(&'a str),
1688}
1689
1690impl GraphDb<RealFs> {
1691 /// Open the database at `dir` with default options.
1692 ///
1693 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1694 /// Old-format snapshots (V5, V6) are automatically migrated to the
1695 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1696 pub fn open(dir: &std::path::Path) -> Result<Self> {
1697 Self::open_with_options(dir, OpenOptions::default())
1698 }
1699
1700 /// Open the database at `dir` with explicit options.
1701 ///
1702 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1703 /// snapshot is an older format version, this function:
1704 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1705 /// + fsynced) before any modification.
1706 /// 2. Rewrites `snapshot.bin` at the current format version via
1707 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1708 ///
1709 /// If migration fails the error is returned and the original files are
1710 /// intact (the `.bak` was written before the new snapshot was attempted).
1711 ///
1712 /// A clean open that finds the snapshot already at the current version
1713 /// deletes any leftover `.bak` file.
1714 ///
1715 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1716 ///
1717 /// `opts.repair_wal` controls the other write this function can make; see
1718 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1719 /// no file on disk.
1720 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1721 Self::open_dir(dir, opts, true)
1722 }
1723
1724 /// Open without taking the cross-process write lock for the handle's
1725 /// lifetime.
1726 ///
1727 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1728 /// its handle open indefinitely, so it takes the lock per write instead of
1729 /// keeping every other process out of the store for as long as it runs.
1730 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1731 Self::open_dir(dir, OpenOptions::default(), false)
1732 }
1733
1734 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1735 // Header-only peek — 6 bytes, no full decode.
1736 let snap_version = snapshot_version_at(dir)?;
1737
1738 // Full load: decode snapshot + replay WAL + rebuild indexes.
1739 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1740
1741 // A read-only handle writes nothing at open, so it never migrates —
1742 // the old-format snapshot is loaded and left exactly as it is.
1743 if opts.auto_migrate && !opts.read_only {
1744 match snap_version {
1745 Some(ver) if ver < core_storage::snapshot::VERSION => {
1746 let _tm = std::time::Instant::now();
1747 // Copy the original snapshot to .bak at OS level — no in-memory
1748 // buffer required for a 2+ GiB file.
1749 //
1750 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1751 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1752 // A torn .bak on crash is acceptable because the original
1753 // snapshot.bin is the authoritative source until after the rename.
1754 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1755 .map_err(core_storage::GraphError::Io)?;
1756 trace_migrate!("bak copy done", _tm);
1757 // Rewrite snapshot at current version; keep WAL intact.
1758 db.snapshot_with(SnapshotOptions {
1759 keep_wal: true,
1760 ..SnapshotOptions::default()
1761 })?;
1762 trace_migrate!("snapshot_with done", _tm);
1763 }
1764 Some(_) => {
1765 // Already current version: remove any leftover .bak.
1766 let bak = dir.join("snapshot.bin.bak");
1767 if bak.exists() {
1768 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1769 }
1770 }
1771 None => {
1772 // WAL-only store — nothing to migrate on open.
1773 }
1774 }
1775 }
1776
1777 Ok(db)
1778 }
1779
1780 /// Open a read-only view of the database as it existed after `commit`.
1781 ///
1782 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1783 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1784 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1785 ///
1786 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1787 /// so as-of can only reach commits recorded in the current WAL (those
1788 /// written after the most recent snapshot, or all commits if no snapshot
1789 /// was ever taken). Commit 0 in `open_at` always refers to the first
1790 /// frame in the WAL that exists on disk, not the first ever write to the
1791 /// database. When the on-disk snapshot recorded that it truncated the
1792 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1793 /// before frame replay, so the as-of view includes all pre-snapshot data.
1794 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1795 /// are ignored and replay is WAL-only, as before.
1796 ///
1797 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1798 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1799 /// `stats()` work normally.
1800 ///
1801 /// # Errors
1802 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1803 /// when the WAL is empty after a snapshot).
1804 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1805 Self::open_at_with(RealFs::new(dir)?, commit)
1806 }
1807
1808 /// Run a **read-only** Cypher query against the graph as it existed at
1809 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1810 /// of this store's directory at that commit and executes the read there.
1811 ///
1812 /// The current instance is unaffected. Write statements are rejected (the
1813 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1814 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1815 /// state. Prefer this over holding many historical instances open.
1816 ///
1817 /// # Errors
1818 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1819 /// - A query error for a malformed or write query.
1820 pub fn query_at(
1821 &self,
1822 commit: u64,
1823 cypher: &str,
1824 params: &std::collections::BTreeMap<String, Value>,
1825 ) -> Result<ResultSet> {
1826 let temporal = self.open_at_for_read(commit, cypher)?;
1827 temporal.query(cypher, params)
1828 }
1829
1830 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1831 ///
1832 /// The **graph** is as of `commit`; the **role definition** is as it is
1833 /// now, because `roles.json` is a sidecar and is never a WAL record — it
1834 /// has no past version to read. A role's `keys` and `labels` are resolved
1835 /// against the commit-`commit` graph, so a role that may see a label sees
1836 /// exactly the nodes that carried it then, and an explicit key that did
1837 /// not exist yet resolves to nothing.
1838 ///
1839 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1840 /// only narrow what a role may see, never widen it.
1841 ///
1842 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1843 /// them.
1844 ///
1845 /// # Errors
1846 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1847 /// range; the error carries that range.
1848 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1849 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1850 /// - A query error for a malformed or write query.
1851 pub fn query_at_scoped(
1852 &self,
1853 commit: u64,
1854 cypher: &str,
1855 params: &std::collections::BTreeMap<String, Value>,
1856 scope: AsOfScope<'_>,
1857 ) -> Result<ResultSet> {
1858 let temporal = self.open_at_for_read(commit, cypher)?;
1859 let mask = temporal.mask_at_scope(scope)?;
1860 temporal.query_masked(cypher, params, &mask)
1861 }
1862
1863 /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1864 /// whatever `scope` resolves to.
1865 ///
1866 /// This is what a surface needs when a caller passes `namespace` beside a
1867 /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1868 /// restriction, and the namespace is a second one that composes with it
1869 /// rather than replacing it. The intersection is the never-widen rule — a
1870 /// namespace can only narrow what the scope already allows — and both legs
1871 /// are resolved against the graph as it was at `commit`.
1872 ///
1873 /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1874 pub fn query_at_scoped_in_namespace(
1875 &self,
1876 commit: u64,
1877 cypher: &str,
1878 params: &std::collections::BTreeMap<String, Value>,
1879 scope: AsOfScope<'_>,
1880 namespace: &str,
1881 ) -> Result<ResultSet> {
1882 let temporal = self.open_at_for_read(commit, cypher)?;
1883 let mask = temporal
1884 .mask_at_scope(scope)?
1885 .intersect(&temporal.mask_for_namespace(namespace));
1886 temporal.query_masked(cypher, params, &mask)
1887 }
1888
1889 /// Open the temporal view for a time-travel read and refuse write Cypher.
1890 ///
1891 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1892 /// resolve the commit and reject writes identically.
1893 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1894 let dir = self.fs.dir().to_path_buf();
1895 let temporal = Self::open_at(&dir, commit)?;
1896 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1897 detail: format!("lex: {e}"),
1898 })?) {
1899 return Err(GraphError::QueryError {
1900 detail: "query_at is read-only: write statements are not permitted in a \
1901 time-travel query"
1902 .into(),
1903 });
1904 }
1905 Ok(temporal)
1906 }
1907}
1908
1909impl<F: Fs> GraphDb<F> {
1910 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1911 pub fn open_with(fs: F) -> Result<Self> {
1912 Self::open_with_repair(fs, true)
1913 }
1914
1915 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1916 /// prefix without writing the truncation back. See
1917 /// [`OpenOptions::repair_wal`].
1918 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1919 Self::open_generic(
1920 fs,
1921 OpenOptions {
1922 repair_wal,
1923 ..OpenOptions::default()
1924 },
1925 true,
1926 )
1927 }
1928
1929 /// Shared open path.
1930 ///
1931 /// `hold_lock` requests the cross-process write lock for the whole handle
1932 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1933 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1934 /// `false` and takes the lock per write instead, so that a long-lived
1935 /// server does not keep every other process out of the store.
1936 ///
1937 /// A read-only open never takes the lock regardless of `hold_lock`.
1938 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1939 let mut db = Self::new_empty(fs, opts);
1940 db.read_only = opts.read_only;
1941 if hold_lock && !opts.read_only {
1942 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1943 return Err(GraphError::Busy { holder: None });
1944 }
1945 db.holds_lifetime_lock = true;
1946 }
1947 db.load_from_disk(LoadOrigin::Open)?;
1948 Ok(db)
1949 }
1950
1951 /// A handle with no state loaded: every field at its empty value, the
1952 /// filesystem and options in place. Only [`load_from_disk`] makes it
1953 /// usable.
1954 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1955 Self {
1956 fs,
1957 ids: IdMap::new(),
1958 syms: Interner::new(),
1959 topo: Topology::new(),
1960 props: ColumnStore::new(),
1961 labels: Vec::new(),
1962 ns_names: vec![NS_DEFAULT.to_string()],
1963 node_ns: Vec::new(),
1964 edge_props: EdgeProps::new(),
1965 engine: RuleEngine::new(),
1966 view_store: ViewStore::new(),
1967 fulltext: FulltextIndex::new(),
1968 prop_index: PropertyIndex::new(),
1969 event_sink: None,
1970 fsync: FsyncPolicy::Strict,
1971 commit_seq: 0,
1972 roles: Some(vec![]),
1973 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1974 subscriptions: Vec::new(),
1975 query_subscriptions: Vec::new(),
1976 sub_capacity: DEFAULT_SUB_CAPACITY,
1977 read_only: false,
1978 total_wal_commits: 0,
1979 base: None,
1980 fold_overlay: None,
1981 delta_tail: Vec::new(),
1982 commits_since_fold: 0,
1983 defer_events: false,
1984 deferred_events: Vec::new(),
1985 degraded: false,
1986 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1987 v8_sections_mutex: std::sync::Mutex::new(()),
1988 last_change: HashMap::new(),
1989 wal_archive_retention: None,
1990 wal_horizon_floor: 0,
1991 archive_genesis_chain: false,
1992 pending_write_authz: None,
1993 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1994 .ok()
1995 .and_then(|v| v.parse().ok())
1996 .unwrap_or(100),
1997 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1998 entries: std::collections::VecDeque::new(),
1999 total: 0,
2000 }),
2001 started_at: std::time::Instant::now(),
2002 wal_consumed: 0,
2003 snapshot_ident: None,
2004 open_opts: opts,
2005 holds_lifetime_lock: false,
2006 lock_denied: false,
2007 pinned: false,
2008 }
2009 }
2010
2011 /// Return every field describing stored graph state to its empty value,
2012 /// leaving this handle's own identity alone.
2013 ///
2014 /// Preserved on purpose: the filesystem, open options, lock ownership, the
2015 /// event sink and subscriptions, fsync policy, degraded flag, and the
2016 /// slow-query configuration and log. A caller that registered a sink or a
2017 /// subscription keeps it across a reload.
2018 fn reset_for_reload(&mut self) {
2019 self.ids = IdMap::new();
2020 self.syms = Interner::new();
2021 self.topo = Topology::new();
2022 self.props = ColumnStore::new();
2023 self.labels = Vec::new();
2024 self.ns_names = vec![NS_DEFAULT.to_string()];
2025 self.node_ns = Vec::new();
2026 self.edge_props = EdgeProps::new();
2027 self.engine = RuleEngine::new();
2028 self.view_store = ViewStore::new();
2029 self.fulltext = FulltextIndex::new();
2030 self.prop_index = PropertyIndex::new();
2031 self.commit_seq = 0;
2032 self.roles = Some(vec![]);
2033 // A fresh cache, not a cleared one: any reader snapshot still holding
2034 // the old `Arc` keeps it to itself, so nothing it memoised against the
2035 // pre-reload store can be read back through this handle.
2036 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2037 self.total_wal_commits = 0;
2038 self.base = None;
2039 self.fold_overlay = None;
2040 self.delta_tail = Vec::new();
2041 self.commits_since_fold = 0;
2042 self.deferred_events = Vec::new();
2043 self.v8_sections_loaded
2044 .store(false, std::sync::atomic::Ordering::Release);
2045 self.last_change = HashMap::new();
2046 self.wal_horizon_floor = 0;
2047 self.archive_genesis_chain = false;
2048 self.pending_write_authz = None;
2049 self.wal_consumed = 0;
2050 self.snapshot_ident = None;
2051 }
2052
2053 /// Load the snapshot base and replay the WAL into an empty handle — the
2054 /// whole of what opening a store does after the struct exists.
2055 ///
2056 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2057 /// rebuild a handle in place, without ownership of `F`, when another
2058 /// process replaces the snapshot underneath it.
2059 ///
2060 /// `origin` decides whether the two repair writes this function can make
2061 /// are appropriate; see [`LoadOrigin`].
2062 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2063 // Both writes below are crash recovery, and only an open is entitled to
2064 // perform them. A read-only handle promises to touch nothing, and a
2065 // reload driven by `refresh` is looking at a store another process is
2066 // actively writing: what looks like a torn tail there is a peer
2067 // mid-append, and what looks like an orphaned archive may be one that
2068 // peer is about to reference.
2069 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2070 let repair_wal = self.open_opts.repair_wal && may_repair;
2071 let db = self;
2072 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2073 db.archive_genesis_chain = db.fs.has_genesis_marker();
2074 // Opening cleanup: remove orphaned archives — archives whose frames all
2075 // fall below the horizon floor. Orphans arise when a crash interrupted
2076 // the retention-prune sequence after the floor was written but before
2077 // all surplus archives were deleted. Safe to delete: floor already
2078 // accounts for their frames.
2079 if may_repair {
2080 db.cleanup_orphaned_archives()?;
2081 }
2082 let _t0 = std::time::Instant::now();
2083 // Peek 6 bytes to determine snapshot version without reading the full
2084 // file. For RealFs this is a true partial read (O(1)); for SimFs the
2085 // default impl reads all bytes and truncates (still correct).
2086 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2087 // V8 and V9 share the mmap-able container; V9 only adds section 12.
2088 let is_v8 = snap_header.len() >= 6
2089 && &snap_header[0..4] == b"GDB1"
2090 && matches!(
2091 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2092 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2093 );
2094 if is_v8 {
2095 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2096 // No 2.4GB heap Vec is allocated on RealFs.
2097 let mapped = Arc::new(
2098 if let Some(snap_path) = db.fs.snapshot_path() {
2099 core_storage::v8::MappedBase::map(&snap_path)
2100 } else {
2101 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2102 core_storage::v8::MappedBase::from_bytes(snap_bytes)
2103 }
2104 .map_err(|e| GraphError::Corrupt {
2105 detail: format!("v8: mmap open: {e:?}"),
2106 })?,
2107 );
2108 db.restore_v8_base(Arc::clone(&mapped))?;
2109 trace_open!("restore_v8_base", _t0);
2110 db.base = Some(mapped);
2111 trace_open!("base assigned", _t0);
2112 } else if !snap_header.is_empty() {
2113 // Legacy V5-V7: full read required for decode.
2114 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2115 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2116 db.restore_snapshot_state(state)?;
2117 }
2118 }
2119 // else: snap_header is empty = no snapshot file, fresh store.
2120 //
2121 // Seed commit_seq from the highest seq persisted in last_change so that
2122 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2123 // already stored in the snapshot. Without this, a db with one snapshot
2124 // commit would save last_change["a"]=1, then on reopen the first WAL
2125 // frame would replay at seq=1 again — colliding and making WAL-tail
2126 // mutations indistinguishable from the snapshot baseline.
2127 //
2128 // Safety invariant (seq-recycling):
2129 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2130 // last_change because they belonged to a previous db lifetime — a new
2131 // db starts at commit_seq=0 with an empty last_change. Therefore no
2132 // CAS precondition can carry a recycled seq as its `expected` value
2133 // and accidentally match a live node's last_change entry.
2134 //
2135 // `expected:0` on a deleted-then-reinserted node:
2136 // After deletion, last_changed() returns None; callers that call
2137 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2138 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2139 // expected=0 correctly conflicts. The only way to observe actual=0 in
2140 // a CasConflict would be a caller that invented expected=0 without ever
2141 // calling last_changed() — unreachable via the documented API contract.
2142 if let Some(&max_seq) = db.last_change.values().max() {
2143 db.commit_seq = db.commit_seq.max(max_seq);
2144 }
2145 let bytes = db.fs.read(FileId::Wal)?;
2146 let (records, valid_len) = decode_all(&bytes);
2147 // The valid prefix is replayed either way; `repair_wal` only decides
2148 // whether the truncation is written back. A reader that races a live
2149 // appender must not persist a truncation the writer never asked for.
2150 if valid_len < bytes.len() && repair_wal {
2151 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2152 }
2153 // WAL-present path: build indexes eagerly BEFORE replay so that the
2154 // first replayed record does not trigger the lazy-init guard (which
2155 // would call reindex_all_load_state on an empty graph, defeating the
2156 // point of restoring IVF/HNSW blobs from the snapshot).
2157 if !records.is_empty() {
2158 db.ensure_v8_base_sections_loaded();
2159 trace_open!("lazy sections loaded (WAL path)", _t0);
2160 }
2161 let replayed = db.apply_frames(records)?;
2162 // The cursor sits at the end of the valid prefix, not the end of the
2163 // file: a torn or still-being-written tail is unconsumed by definition
2164 // and stays visible to `is_stale` until it decodes.
2165 db.wal_consumed = valid_len as u64;
2166 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2167 trace_open!("wal replay done", _t0);
2168 // Rebuild view values after WAL replay only when there is no V8 base.
2169 // With a V8 base, view values are correct in the snapshot and are updated
2170 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2171 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2172 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2173 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2174 // base value).
2175 if db.base.is_none() {
2176 let topo_view = TopologyView::owned(&db.topo);
2177 db.view_store
2178 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2179 }
2180 // Rebuild full-text index after WAL replay. Corrects drift from
2181 // per-record incremental apply during replay.
2182 db.fulltext.rebuild_all(
2183 &db.ids,
2184 &db.labels,
2185 &db.syms,
2186 build_props_view(&db.props, &db.base),
2187 );
2188 db.prop_index.rebuild_all(
2189 &db.ids,
2190 &db.labels,
2191 &db.syms,
2192 build_props_view(&db.props, &db.base),
2193 );
2194 // Namespaces: one pass over the `ns` column, after the snapshot is
2195 // restored and the WAL replayed. Replay maintains `node_ns` record by
2196 // record as well; this pass is what makes a snapshot-only open right,
2197 // and it reads nothing on a store with no `ns` column.
2198 db.rebuild_node_ns();
2199 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2200 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2201 db.roles = Self::load_roles_from_fs(&db.fs)?;
2202 // Capture the initial MVCC fold so reader() is ready immediately.
2203 db.fold_now();
2204 trace_open!("open_with complete", _t0);
2205 Ok(replayed)
2206 }
2207
2208 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2209 /// replay does — same `apply` calls, same per-frame delta drain, same
2210 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2211 /// edges appear identically whether a frame arrives at open, from a local
2212 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2213 ///
2214 /// Returns the number of frames applied.
2215 ///
2216 /// Deltas are drained and discarded per frame: replayed frames are already
2217 /// reflected on disk, so they are not news to a subscriber, and draining
2218 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2219 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2220 if records.is_empty() {
2221 return Ok(0);
2222 }
2223 // Materialize any state retained in the mmap base before the first
2224 // frame lands, so a replayed record cannot trip the lazy-init guard and
2225 // rebuild indexes from an empty graph. Both calls are idempotent.
2226 self.ensure_v8_base_sections_loaded();
2227 self.engine.consume_retained_state_eager(
2228 &self.ids,
2229 &self.syms,
2230 &self.labels,
2231 build_props_view(&self.props, &self.base),
2232 );
2233 let applied = records.len();
2234 for rec in records {
2235 self.apply(&rec)?;
2236 let _ = self.engine.drain_deltas();
2237 // Track commit_seq during replay so last_change entries are
2238 // consistent with the seqs assigned by log_then_apply_with on
2239 // subsequent live commits. After N replayed frames, commit_seq=N;
2240 // live commits begin at N+1.
2241 self.commit_seq += 1;
2242 let replay_seq = self.commit_seq;
2243 self.update_last_change_from_rec(&rec, replay_seq);
2244 }
2245 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2246 // this assert catches the regression in debug builds immediately.
2247 debug_assert_eq!(
2248 self.engine.pending_delta_count(),
2249 0,
2250 "pending_deltas non-empty after replay — \
2251 per-frame drain must run inside the loop to keep memory O(1)"
2252 );
2253 // T2 note: the per-frame drain IS the suppression seam for replay.
2254 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2255 // replaying subscribers; the mechanism is already in place.
2256 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2257 Ok(applied)
2258 }
2259
2260 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2261 //
2262 // mushroomdb is many-readers / one-writer across processes. Writers take an
2263 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2264 // Every handle tracks how much of the WAL it has consumed, so it can pick
2265 // up another process's commits by decoding only the new tail rather than
2266 // reopening. See `docs/site/concurrency.md`.
2267
2268 /// Whether the store on disk has moved ahead of (or out from under) this
2269 /// handle's in-memory state.
2270 ///
2271 /// True when the WAL's length differs from this handle's cursor — another
2272 /// process committed, or is mid-append — or when the snapshot file's
2273 /// identity changed. Costs two metadata lookups and reads no file contents,
2274 /// so it is cheap enough for a read path to call.
2275 ///
2276 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2277 /// pinned to one commit and later commits are deliberately invisible to it.
2278 pub fn is_stale(&self) -> Result<bool> {
2279 if self.pinned {
2280 return Ok(false);
2281 }
2282 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2283 return Ok(true);
2284 }
2285 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2286 }
2287
2288 /// Bring this handle up to date with every commit other processes have made,
2289 /// and return how many frames were applied.
2290 ///
2291 /// The WAL tail is decoded from this handle's cursor and applied through the
2292 /// same path the open replay uses, so rules fire and derived edges appear
2293 /// exactly as they would on a fresh open. Interners, id maps and indexes
2294 /// stay valid for the same reason.
2295 ///
2296 /// A frame another process is still writing is left alone: a trailing
2297 /// partial frame is a wait, not a corruption, and the handle stays stale
2298 /// until that frame is complete. Nothing is written to disk, so a read-only
2299 /// handle can refresh freely.
2300 ///
2301 /// When the snapshot file's identity changed, or the WAL is shorter than
2302 /// this handle's cursor, the WAL no longer continues our state — another
2303 /// process snapshotted or archived. The handle is then rebuilt from disk
2304 /// with the options it was opened with, and the return value is the number
2305 /// of frames in the new WAL.
2306 ///
2307 /// Returns 0 for an as-of view, which never follows later commits.
2308 ///
2309 /// # Errors
2310 ///
2311 /// An error here leaves the handle **degraded**: it got partway through
2312 /// applying the tail, or partway through a reload, so its in-memory state
2313 /// no longer matches any point on disk. Further mutations are refused and
2314 /// the handle must be reopened. Nothing on disk was damaged — the store
2315 /// itself is fine, and a fresh open recovers it.
2316 pub fn refresh(&mut self) -> Result<u64> {
2317 if self.pinned {
2318 return Ok(0);
2319 }
2320 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2321 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2322 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2323 // The WAL no longer continues our state: rebuild from disk. State
2324 // is cleared first, so a failed load leaves an empty handle — mark
2325 // it degraded rather than let a caller read an empty graph as if
2326 // it were the store's contents.
2327 self.reset_for_reload();
2328 return match self.load_from_disk(LoadOrigin::Reload) {
2329 Ok(frames) => Ok(frames as u64),
2330 Err(e) => {
2331 self.degraded = true;
2332 Err(e)
2333 }
2334 };
2335 }
2336 if wal_len == self.wal_consumed {
2337 return Ok(0);
2338 }
2339 let tail = self
2340 .fs
2341 .read_range(FileId::Wal, self.wal_consumed)
2342 .map_err(GraphError::Io)?;
2343 let (records, valid_len) = decode_all(&tail);
2344 let applied = match self.apply_frames(records) {
2345 Ok(n) => n,
2346 Err(e) => {
2347 // Some frames landed and some did not, and the cursor cannot
2348 // say how many. Advancing it would skip the rest; leaving it
2349 // would replay what already applied. Neither is recoverable in
2350 // place, so refuse further writes and require a reopen.
2351 self.degraded = true;
2352 return Err(e);
2353 }
2354 };
2355 // Advance by the bytes actually decoded, never by the file length: an
2356 // incomplete trailing frame stays unconsumed for the next refresh.
2357 self.wal_consumed += valid_len as u64;
2358 if applied > 0 {
2359 // Peer commits must reach `reader()` snapshots taken from here on.
2360 // A full fold is what open does; refresh does not build per-commit
2361 // deltas, so there is nothing cheaper that stays correct.
2362 self.fold_now();
2363 }
2364 Ok(applied as u64)
2365 }
2366
2367 /// Byte offset of the WAL prefix this handle has applied.
2368 ///
2369 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2370 #[doc(hidden)]
2371 pub fn wal_consumed(&self) -> u64 {
2372 self.wal_consumed
2373 }
2374
2375 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2376 /// failed group off the tail, so the cursor still describes the file.
2377 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2378 self.wal_consumed = len;
2379 }
2380
2381 /// One non-blocking attempt at the cross-process write lock.
2382 ///
2383 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2384 /// in-process write guard. That ordering is what keeps a busy peer in
2385 /// another process from stalling this process's readers.
2386 ///
2387 /// A handle that owns the lock for its lifetime always succeeds.
2388 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2389 if self.holds_lifetime_lock {
2390 return Ok(true);
2391 }
2392 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2393 }
2394
2395 /// Poll for the cross-process write lock until `wait` elapses.
2396 ///
2397 /// One attempt is always made, so a zero wait is a single try. Returns
2398 /// `false` when the lock is still held elsewhere at the deadline; nothing
2399 /// has been written and retrying later is safe.
2400 ///
2401 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2402 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2403 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2404 /// that it holds no in-process guard while it waits.
2405 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2406 let deadline = std::time::Instant::now() + wait;
2407 loop {
2408 if self.try_cross_process_lock()? {
2409 return Ok(true);
2410 }
2411 let now = std::time::Instant::now();
2412 if now >= deadline {
2413 return Ok(false);
2414 }
2415 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2416 }
2417 }
2418
2419 /// Open a cross-process write scope, given the outcome of an already-made
2420 /// lock attempt.
2421 ///
2422 /// The caller polls for the lock first — outside any in-process guard — and
2423 /// passes what it got. On success this refreshes, so the writes about to
2424 /// happen land on top of every other process's commits. On failure the
2425 /// handle refuses WAL-appending mutations and `snapshot()` with
2426 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2427 /// closes the scope, so a caller holding a guard cannot write behind
2428 /// another process's back.
2429 ///
2430 /// A handle that already owns the lock for its lifetime skips the refresh:
2431 /// no other process can have written, so there is nothing to pick up.
2432 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2433 self.lock_denied = !acquired;
2434 if !acquired || self.holds_lifetime_lock {
2435 return Ok(());
2436 }
2437 if let Err(e) = self.refresh() {
2438 // Do not hold a lock we cannot use: release it and let the caller
2439 // see the underlying failure.
2440 let _ = self.fs.unlock();
2441 self.lock_denied = true;
2442 return Err(e);
2443 }
2444 Ok(())
2445 }
2446
2447 /// Close a cross-process write scope opened by
2448 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2449 /// clear the Busy latch. Safe to call when the lock was never taken.
2450 pub(crate) fn end_write_lock(&mut self) {
2451 self.lock_denied = false;
2452 if !self.holds_lifetime_lock {
2453 // Releasing a lock we do not hold is a no-op; a failure to release
2454 // is reported by the OS closing the descriptor at handle drop.
2455 let _ = self.fs.unlock();
2456 }
2457 }
2458
2459 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2460 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2461 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2462 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2463 /// Restore all persisted state from a decoded snapshot. Shared by
2464 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2465 fn restore_snapshot_state(
2466 &mut self,
2467 state: core_storage::snapshot::SnapshotState,
2468 ) -> Result<()> {
2469 self.ids = state.ids;
2470 self.syms = state.syms;
2471 self.topo = state.topo;
2472 self.props = state.props;
2473 self.labels = state.labels;
2474 self.edge_props = state.edge_props;
2475 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2476 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2477 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2478 // open successfully and panic later in `NodeRef::label()` or
2479 // `neighborhood_masked()`. Catching it here turns those into typed
2480 // `GraphError::Corrupt` at open time.
2481 {
2482 let ids_len = self.ids.len();
2483 if self.labels.len() != ids_len {
2484 return Err(GraphError::Corrupt {
2485 detail: format!(
2486 "snapshot: labels vec has {} entries but id table has {} total slots",
2487 self.labels.len(),
2488 ids_len,
2489 ),
2490 });
2491 }
2492 let syms_len = self.syms.len() as u32;
2493 for (i, &sym) in self.labels.iter().enumerate() {
2494 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2495 if sym == u32::MAX {
2496 if !is_tombstoned {
2497 return Err(GraphError::Corrupt {
2498 detail: format!(
2499 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2500 ),
2501 });
2502 }
2503 } else if sym >= syms_len {
2504 return Err(GraphError::Corrupt {
2505 detail: format!(
2506 "snapshot: label at id slot {i} references sym {sym} \
2507 which is out of interner range ({syms_len})"
2508 ),
2509 });
2510 }
2511 }
2512 }
2513 let defs: Vec<RuleDef> = state
2514 .rule_defs
2515 .iter()
2516 .map(|b| {
2517 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2518 detail: format!("snapshot rule_def deserialize: {e}"),
2519 })
2520 })
2521 .collect::<Result<Vec<_>>>()?;
2522 self.engine =
2523 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2524 // Candidate indexes are rebuilt lazily on the first mutation (see
2525 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2526 // snapshot are retained without deserializing so that:
2527 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2528 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2529 // lazy-init guard which calls reindex_all_load_state (the scan
2530 // skips the HNSW build for every side the blob supplies).
2531 // - WAL-present: open_with calls consume_retained_state_eager before
2532 // replay so HNSW/IVF are live before any record fires the hooks.
2533 let ivf_bytes = if state.ivf_state.is_empty() {
2534 Vec::new()
2535 } else {
2536 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2537 };
2538 // Store blobs without eagerly deserializing them.
2539 // `self.ids` is the snapshot's id table at this point — WAL replay has
2540 // not run — so its length is the line an interrupted build is detected
2541 // against.
2542 let snapshot_ids = self.ids.len() as u32;
2543 self.engine
2544 .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2545 // Restore view defs from snapshot (V5).
2546 // The ColumnStore already contains view values from the snapshot;
2547 // use restore_view (no collision check, no backfill) so the store
2548 // is aware of the definitions. rebuild_all runs after WAL replay.
2549 for def_bytes in &state.view_defs {
2550 let def: ViewDef =
2551 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2552 detail: format!("snapshot view_def deserialize: {e}"),
2553 })?;
2554 self.view_store
2555 .restore_view(def)
2556 .map_err(|e| GraphError::Corrupt {
2557 detail: format!("snapshot view restore: {e}"),
2558 })?;
2559 }
2560 Ok(())
2561 }
2562
2563 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2564 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2565 ///
2566 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2567 /// deserialization and view rebuild have access to all column data.
2568 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2569 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2570 detail: format!("v8: ids section: {e:?}"),
2571 })?);
2572 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2573 detail: format!("v8: syms section: {e:?}"),
2574 })?);
2575
2576 // C1: self.props is left as an empty overlay. Column reads go through
2577 // props_view() (ColumnsView::with_base), which consults the archived base
2578 // section zero-copy. This avoids the O(columns) heap copy at every open.
2579
2580 // self.topo deliberately left as Topology::new() — overlay path.
2581
2582 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2583 detail: format!("v8: meta section: {e:?}"),
2584 })?)
2585 .map_err(|e| GraphError::Corrupt {
2586 detail: format!("v8: meta decode: {e:?}"),
2587 })?;
2588 self.labels = meta.labels;
2589 // Cross-section label integrity: labels must cover every id slot (live
2590 // and tombstoned), every non-sentinel sym must be within the interner's
2591 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2592 // sentinel label. Without this check, a crafted snapshot where the META
2593 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2594 // sym ids, or a sentinel label on a live node, would open successfully
2595 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2596 // related read paths. Catching the inconsistency here converts those
2597 // panics into typed `GraphError::Corrupt` at open time.
2598 {
2599 let ids_len = self.ids.len();
2600 if self.labels.len() != ids_len {
2601 return Err(GraphError::Corrupt {
2602 detail: format!(
2603 "v8: labels section has {} entries but id table has {} total slots",
2604 self.labels.len(),
2605 ids_len,
2606 ),
2607 });
2608 }
2609 let syms_len = self.syms.len() as u32;
2610 for (i, &sym) in self.labels.iter().enumerate() {
2611 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2612 if sym == u32::MAX {
2613 // Sentinel is only valid for tombstoned slots.
2614 if !is_tombstoned {
2615 return Err(GraphError::Corrupt {
2616 detail: format!(
2617 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2618 ),
2619 });
2620 }
2621 } else if sym >= syms_len {
2622 return Err(GraphError::Corrupt {
2623 detail: format!(
2624 "v8: label at id slot {i} references sym {sym} \
2625 which is out of interner range ({syms_len})"
2626 ),
2627 });
2628 }
2629 }
2630 }
2631 // C3: self.edge_props stays as an empty overlay. Reads go through
2632 // edge_props_view() which consults the mmap'd base section zero-copy
2633 // via EdgePropsView::with_base. No heap decode at open time.
2634
2635 // Restore rule engine.
2636 let (rule_def_bytes, rule_tripped, rule_fires) =
2637 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2638 GraphError::Corrupt {
2639 detail: format!("v8: rules_meta section: {e:?}"),
2640 }
2641 })?);
2642 let defs: Vec<RuleDef> = rule_def_bytes
2643 .iter()
2644 .map(|b| {
2645 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2646 detail: format!("v8: rule_def deserialize: {e}"),
2647 })
2648 })
2649 .collect::<Result<Vec<_>>>()?;
2650 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2651 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2652 // `ensure_v8_base_sections_loaded` reads them on first use from
2653 // `self.base` (set by the caller immediately after this returns).
2654 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2655
2656 // Restore view definitions.
2657 let view_defs =
2658 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2659 detail: format!("v8: views section: {e:?}"),
2660 })?);
2661 for def_bytes in &view_defs {
2662 let def: ViewDef =
2663 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2664 detail: format!("v8: view_def deserialize: {e}"),
2665 })?;
2666 self.view_store
2667 .restore_view(def)
2668 .map_err(|e| GraphError::Corrupt {
2669 detail: format!("v8: view restore: {e}"),
2670 })?;
2671 }
2672 // Load the last-change map from section 11 (small section; load eagerly).
2673 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2674 // in that case and `decode_last_change_bytes` returns an empty map.
2675 let last_change_raw = mapped
2676 .last_change_bytes()
2677 .map_err(|e| GraphError::Corrupt {
2678 detail: format!("v8: last_change section: {e:?}"),
2679 })?;
2680 self.last_change = decode_last_change_bytes(last_change_raw);
2681
2682 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2683 // the file. Pure bounds check — no bytes read, no page faults triggered.
2684 // Catches truncated snapshots at open time before the lazy deferred reads.
2685 mapped.validate_section_bounds().map_err(|e| match e {
2686 GraphError::Corrupt { detail } => GraphError::Corrupt {
2687 detail: format!("v8: section bounds: {detail}"),
2688 },
2689 other => other,
2690 })?;
2691 Ok(())
2692 }
2693
2694 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2695 /// engine's retained fields on first call. Subsequent calls are a no-op
2696 /// (AtomicBool fast-path).
2697 ///
2698 /// Must be called before any code path that reads or mutates engine
2699 /// provenance, HNSW, or IVF state:
2700 /// - WAL replay (before `consume_retained_state_eager`)
2701 /// - First mutation (`log_then_apply_with`)
2702 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2703 /// - Snapshot (`snapshot_with`)
2704 ///
2705 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2706 fn ensure_v8_base_sections_loaded(&self) {
2707 use std::sync::atomic::Ordering;
2708 if self.v8_sections_loaded.load(Ordering::Acquire) {
2709 return;
2710 }
2711 let _guard = self
2712 .v8_sections_mutex
2713 .lock()
2714 .expect("v8 sections mutex poisoned");
2715 if self.v8_sections_loaded.load(Ordering::Acquire) {
2716 return; // another caller populated while we waited
2717 }
2718 let _t = std::time::Instant::now();
2719 if let Some(base) = &self.base {
2720 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2721 // Bounds are already validated at open time (restore_v8_base →
2722 // validate_section_bounds) — unreachable post-validate_section_bounds;
2723 // unwrap_or_default is a safety belt against impossible errors.
2724 let prov_bytes = base
2725 .provenance_raw_bytes()
2726 .map(|b| b.to_vec())
2727 .unwrap_or_default();
2728 self.engine.store_provenance_bytes(prov_bytes);
2729 // HNSW: decode rkyv blobs into owned map.
2730 let hnsw_state = base
2731 .hnsw_section()
2732 .map(archived_hnsw_to_owned)
2733 .unwrap_or_default();
2734 // IVF: raw bincode bytes; deserialized on first mutation/query.
2735 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2736 // Called before WAL replay on a WAL-present open (`open_with`) and
2737 // before any write on a clean one, so this is the snapshot's count.
2738 let snapshot_ids = self.ids.len() as u32;
2739 self.engine
2740 .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2741 }
2742 self.v8_sections_loaded.store(true, Ordering::Release);
2743 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2744 eprintln!(
2745 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2746 _t.elapsed()
2747 );
2748 }
2749 }
2750
2751 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2752 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2753 /// the full merged topology without going through `self.view()`.
2754 fn topo_view(&self) -> TopologyView<'_> {
2755 match self.base {
2756 None => TopologyView::owned(&self.topo),
2757 Some(ref base) => {
2758 // SAFETY: base lives as long as self; section bounds validated at open.
2759 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2760 let archived = base
2761 .topology()
2762 .expect("base topology section bounds validated at open");
2763 TopologyView::with_base(&self.topo, archived)
2764 }
2765 }
2766 }
2767
2768 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2769 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2770 /// overlay first, then fall through to the archived base section zero-copy.
2771 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2772 match self.base {
2773 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2774 Some(ref base) => {
2775 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2776 let archived = base
2777 .columns()
2778 .expect("base columns section bounds validated at open");
2779 core_storage::v8::seam::ColumnsView::with_base_cached(
2780 &self.props,
2781 archived,
2782 base.mixed_cache(),
2783 )
2784 .with_shared_strings(base_string_table(base))
2785 }
2786 }
2787 }
2788
2789 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2790 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2791 ///
2792 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2793 /// through to the archived base section zero-copy. Tombstones in the
2794 /// overlay mask deleted-from-base entries.
2795 fn edge_props_view(&self) -> EdgePropsView<'_> {
2796 match self.base {
2797 None => EdgePropsView::owned(&self.edge_props),
2798 Some(ref base) => {
2799 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2800 let archived = base
2801 .edge_props_section()
2802 .expect("base edge_props section bounds validated at open");
2803 EdgePropsView::with_base(&self.edge_props, archived)
2804 }
2805 }
2806 }
2807
2808 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2809 // An as-of view never writes and is pinned to one commit: it takes no
2810 // cross-process lock and does not follow later commits.
2811 let mut db = Self::new_empty(
2812 fs,
2813 OpenOptions {
2814 repair_wal: false,
2815 auto_migrate: false,
2816 read_only: true,
2817 },
2818 );
2819 db.pinned = true; // read_only is set after replay, but pinning is immediate
2820 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2821 db.archive_genesis_chain = db.fs.has_genesis_marker();
2822 // Same orphaned-archive cleanup as open_with: floor was written first
2823 // during pruning, so a crash may have left stale archives below floor.
2824 db.cleanup_orphaned_archives()?;
2825 // Collect archive frames (oldest-first) and live WAL frames.
2826 // Archives represent pre-snapshot history; the snapshot captures the
2827 // cumulative state at the time of archiving. Crash-window guarantee:
2828 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2829 // B: crash after rename, before new WAL → archive present, WAL
2830 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2831 // C: crash after new baseline WAL written → normal post-archive.
2832 let archive_ns = db.fs.list_archives()?;
2833 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2834 for n in &archive_ns {
2835 let arc_bytes = db.fs.read_archive(*n)?;
2836 let (arc_frames, _) = decode_all(&arc_bytes);
2837 archive_frames_all.extend(arc_frames);
2838 }
2839 let total_archive_frames = archive_frames_all.len() as u64;
2840
2841 let live_bytes = db.fs.read(FileId::Wal)?;
2842 let (live_records, _valid_len) = decode_all(&live_bytes);
2843 let total_surviving = total_archive_frames + live_records.len() as u64;
2844 // Global total including any pruned history below the horizon floor.
2845 let total = db.wal_horizon_floor + total_surviving;
2846
2847 // Horizon and range check.
2848 if commit < db.wal_horizon_floor {
2849 return Err(GraphError::CommitOutOfRange {
2850 commit,
2851 total,
2852 floor: db.wal_horizon_floor,
2853 });
2854 }
2855 if commit >= total {
2856 return Err(GraphError::CommitOutOfRange {
2857 commit,
2858 total,
2859 floor: db.wal_horizon_floor,
2860 });
2861 }
2862
2863 // Local index into surviving frames (0 = first frame of oldest archive).
2864 let local = commit - db.wal_horizon_floor;
2865
2866 if local < total_archive_frames {
2867 // Target commit is in an archive. Correct replay from empty state
2868 // is only possible when the archive chain is an uninterrupted
2869 // genesis chain (first archive taken from a fresh store, no prior
2870 // WAL truncation) and no archives have been pruned (floor == 0).
2871 //
2872 // If either condition is violated the prefix needed to reconstruct
2873 // the requested state is gone; refuse rather than return wrong data.
2874 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2875 return Err(GraphError::CommitOutOfRange {
2876 commit,
2877 total,
2878 floor: db.wal_horizon_floor,
2879 });
2880 }
2881 // Replay all archive frames up to and including the target commit
2882 // from an empty database state. Archives must be replayed in order
2883 // so that dense-id intern tables are built up correctly.
2884 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2885 db.apply(&rec)?;
2886 let _ = db.engine.drain_deltas();
2887 }
2888 } else {
2889 // Target commit is in the live WAL: load snapshot as base, then
2890 // replay the needed live WAL prefix.
2891 //
2892 // Base state: a truncating snapshot (wal_truncated=true) compacts
2893 // all pre-truncation / pre-archive commits. Dense-id records in
2894 // the live WAL reference ids/interns that the snapshot provides.
2895 // Peek 6 bytes (same pattern as open_with).
2896 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2897 let is_v8 = snap_header.len() >= 6
2898 && &snap_header[0..4] == b"GDB1"
2899 && matches!(
2900 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2901 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2902 );
2903 if is_v8 {
2904 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2905 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2906 GraphError::Corrupt {
2907 detail: format!("v8: open_at mmap: {e:?}"),
2908 }
2909 })?;
2910 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2911 } else {
2912 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2913 core_storage::snapshot::decode(&snap_bytes)?
2914 };
2915 if let Some(state) = state {
2916 if state.wal_truncated {
2917 db.restore_snapshot_state(state)?;
2918 }
2919 }
2920 } else if !snap_header.is_empty() {
2921 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2922 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2923 if state.wal_truncated {
2924 db.restore_snapshot_state(state)?;
2925 }
2926 }
2927 }
2928 // else: snap_header empty = no snapshot file.
2929 let live_local = local - total_archive_frames;
2930 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2931 db.apply(&rec)?;
2932 let _ = db.engine.drain_deltas();
2933 }
2934 }
2935 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2936 // post-loop assert in open_with.
2937 debug_assert_eq!(
2938 db.engine.pending_delta_count(),
2939 0,
2940 "pending_deltas non-empty after open_at replay — \
2941 per-frame drain must run inside the loop to keep memory O(1)"
2942 );
2943 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2944 // Rebuild view values after WAL replay so derived-edge-driven views
2945 // reflect the as-of state. open_at always uses the legacy path (no V8
2946 // base), so topo_view is always owned.
2947 {
2948 let topo_view = TopologyView::owned(&db.topo);
2949 db.view_store
2950 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2951 }
2952 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2953 db.fulltext.rebuild_all(
2954 &db.ids,
2955 &db.labels,
2956 &db.syms,
2957 build_props_view(&db.props, &db.base),
2958 );
2959 db.prop_index.rebuild_all(
2960 &db.ids,
2961 &db.labels,
2962 &db.syms,
2963 build_props_view(&db.props, &db.base),
2964 );
2965 // Namespaces on the temporal handle, built by the same pass the live
2966 // open uses, so an as-of mask narrows by the namespaces of that commit.
2967 db.rebuild_node_ns();
2968 // Load roles sidecar (current roles, not point-in-time).
2969 db.roles = Self::load_roles_from_fs(&db.fs)?;
2970 db.read_only = true;
2971 db.total_wal_commits = total;
2972 // Capture initial fold so reader() is immediately usable.
2973 db.fold_now();
2974 Ok(db)
2975 }
2976
2977 /// Whether this instance is a read-only as-of view.
2978 pub fn is_read_only(&self) -> bool {
2979 self.read_only
2980 }
2981
2982 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2983
2984 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2985 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2986 /// the end of `open_with` / `open_at_with` to prime the reader.
2987 fn fold_now(&mut self) {
2988 let frozen = crate::reader::FrozenOverlay {
2989 ids: self.ids.clone(),
2990 syms: self.syms.clone(),
2991 topo: self.topo.clone(),
2992 props: self.props.clone(),
2993 labels: self.labels.clone(),
2994 edge_props: self.edge_props.clone(),
2995 roles: self.roles.clone(),
2996 fulltext: self.fulltext.clone(),
2997 };
2998 self.fold_overlay = Some(Arc::new(frozen));
2999 self.delta_tail.clear();
3000 self.commits_since_fold = 0;
3001 }
3002
3003 /// Capture a lock-free reader snapshot of the current db state.
3004 ///
3005 /// The read lock is held only for the duration of this call (to clone a
3006 /// handful of `Arc` handles). Subsequent query operations run without any
3007 /// lock.
3008 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3009 crate::reader::ReaderSnapshot::new(
3010 self.fold_overlay
3011 .clone()
3012 .expect("fold_overlay is always Some after open_with; call reader() after open"),
3013 self.base.clone(),
3014 self.delta_tail.clone(),
3015 // The snapshot's effective state is exactly this handle's state at
3016 // this commit, so it shares the memo and its version key.
3017 self.commit_seq,
3018 Arc::clone(&self.role_masks),
3019 )
3020 }
3021
3022 /// Total number of WAL commits at the time [`open_at`] was called.
3023 /// Returns 0 for normal (non-as-of) instances.
3024 pub fn total_wal_commits(&self) -> u64 {
3025 self.total_wal_commits
3026 }
3027
3028 /// Apply a record to in-memory state. Used by both live writes and replay,
3029 /// so replay is definitionally identical to the original execution.
3030 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3031 // Before the record mutates anything: a store restored from a snapshot
3032 // defers building its candidate indexes until the first write, and that
3033 // build is a full node scan. Left where it used to fire — inside the
3034 // engine hook, after `props.set` and the label assignment — the scan
3035 // read the half-applied record and took the in-flight node's vector for
3036 // one the snapshot should have carried, which read as an interrupted
3037 // vector-index build and cost a full `RebuildRule` on the first
3038 // embedded write after every reopen. Hoisted here the scan sees exactly
3039 // the persisted state; the record's own hook then files its vector
3040 // through the ordinary insert path a line later.
3041 self.populate_indexes_before_write();
3042 match rec {
3043 WalRecord::InsertNode { label, key, props } => {
3044 let id = self.ids.try_insert(key)?;
3045 let sym = self.syms.intern(label);
3046 if self.labels.len() <= id as usize {
3047 // gap slots are sentinels, never valid label symbols
3048 self.labels.resize(id as usize + 1, u32::MAX);
3049 }
3050 self.labels[id as usize] = sym;
3051 let mut ns_name = NS_DEFAULT.to_string();
3052 for (field, value) in props {
3053 if field == NS_PROP {
3054 ns_name = namespace_of_value(Some(value)).to_string();
3055 }
3056 self.props.set(id, field, value.clone());
3057 }
3058 self.set_node_ns(id, &ns_name);
3059 // Initialize view values for the new node before the engine runs so
3060 // delta-based increments start from a known zero baseline.
3061 self.view_store
3062 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3063 // Fire rules for the newly inserted node.
3064 let cursor = self.engine.pending_delta_count();
3065 let mut eng = std::mem::take(&mut self.engine);
3066 {
3067 let mut gm = make_graph_mut(
3068 &self.ids,
3069 &mut self.syms,
3070 &self.labels,
3071 build_props_view(&self.props, &self.base),
3072 &mut self.topo,
3073 &self.base,
3074 &mut self.edge_props,
3075 );
3076 eng.on_node_changed(id, None, &mut gm);
3077 }
3078 self.engine = eng;
3079 // Process derived-edge deltas for view maintenance.
3080 // Fast path: skip the O(delta_count) allocation when no views exist.
3081 if !self.view_store.is_empty() {
3082 #[cfg(test)]
3083 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3084 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3085 for d in &new_deltas {
3086 self.view_store.on_edge_changed(
3087 d.etype_sym,
3088 d.src_id,
3089 d.dst_id,
3090 d.fired,
3091 &mut self.props,
3092 &build_topo_view(&self.topo, &self.base),
3093 &self.ids,
3094 &self.syms,
3095 &self.labels,
3096 base_columns(&self.base),
3097 );
3098 }
3099 }
3100 // Full-text index maintenance: index enabled fields for this label.
3101 if self.fulltext.has_label(label) {
3102 for (field, value) in props {
3103 if self.fulltext.is_enabled(label, field) {
3104 self.fulltext.add_tokens(id, field, value);
3105 }
3106 }
3107 }
3108 // Property (equality) index maintenance.
3109 if self.prop_index.has_label(label) {
3110 for (field, value) in props {
3111 self.prop_index.set(label, field, id, value);
3112 }
3113 }
3114 }
3115 WalRecord::InsertEdge {
3116 edge_type,
3117 src_key,
3118 dst_key,
3119 } => {
3120 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3121 detail: format!("wal replay references unknown key {src_key}"),
3122 })?;
3123 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3124 detail: format!("wal replay references unknown key {dst_key}"),
3125 })?;
3126 let etype = self.syms.intern(edge_type);
3127 // Skip if the edge is already visible in the merged base+overlay
3128 // view. This keeps WAL replay idempotent when the WAL contains
3129 // pre-snapshot records that are already encoded in a V8 base
3130 // (keep_wal=true opens and crash-before-truncation scenarios).
3131 if self.base.is_some()
3132 && self
3133 .topo_view()
3134 .neighbors(etype, Direction::Out, src)
3135 .contains(&dst)
3136 {
3137 return Ok(());
3138 }
3139 self.topo.add_edge(etype, src, dst);
3140 // View maintenance for manual edge insert.
3141 self.view_store.on_edge_changed(
3142 etype,
3143 src,
3144 dst,
3145 true,
3146 &mut self.props,
3147 &build_topo_view(&self.topo, &self.base),
3148 &self.ids,
3149 &self.syms,
3150 &self.labels,
3151 base_columns(&self.base),
3152 );
3153 // Rule engine: via-hop rules must update when user edges change.
3154 let cursor = self.engine.pending_delta_count();
3155 let mut eng = std::mem::take(&mut self.engine);
3156 {
3157 let mut gm = make_graph_mut(
3158 &self.ids,
3159 &mut self.syms,
3160 &self.labels,
3161 build_props_view(&self.props, &self.base),
3162 &mut self.topo,
3163 &self.base,
3164 &mut self.edge_props,
3165 );
3166 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3167 }
3168 self.engine = eng;
3169 if !self.view_store.is_empty() {
3170 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3171 for d in &new_deltas {
3172 self.view_store.on_edge_changed(
3173 d.etype_sym,
3174 d.src_id,
3175 d.dst_id,
3176 d.fired,
3177 &mut self.props,
3178 &build_topo_view(&self.topo, &self.base),
3179 &self.ids,
3180 &self.syms,
3181 &self.labels,
3182 base_columns(&self.base),
3183 );
3184 }
3185 }
3186 }
3187 WalRecord::SetProp { key, field, value } => {
3188 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3189 detail: format!("wal replay references unknown key {key}"),
3190 })?;
3191 let old_value = build_props_view(&self.props, &self.base)
3192 .get(id, field)
3193 .map(|vr| vr.into_value());
3194 self.props.set(id, field, value.clone());
3195 // Fire rules for the changed field.
3196 let cursor = self.engine.pending_delta_count();
3197 let mut eng = std::mem::take(&mut self.engine);
3198 {
3199 let mut gm = make_graph_mut(
3200 &self.ids,
3201 &mut self.syms,
3202 &self.labels,
3203 build_props_view(&self.props, &self.base),
3204 &mut self.topo,
3205 &self.base,
3206 &mut self.edge_props,
3207 );
3208 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3209 }
3210 self.engine = eng;
3211 // Derived-edge deltas → view updates.
3212 if !self.view_store.is_empty() {
3213 #[cfg(test)]
3214 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3215 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3216 for d in &new_deltas {
3217 self.view_store.on_edge_changed(
3218 d.etype_sym,
3219 d.src_id,
3220 d.dst_id,
3221 d.fired,
3222 &mut self.props,
3223 &build_topo_view(&self.topo, &self.base),
3224 &self.ids,
3225 &self.syms,
3226 &self.labels,
3227 base_columns(&self.base),
3228 );
3229 }
3230 }
3231 // Neighbor-aggregate views that read `field` must also update.
3232 self.view_store.on_prop_changed(
3233 id,
3234 field,
3235 &mut self.props,
3236 &build_topo_view(&self.topo, &self.base),
3237 &self.ids,
3238 &self.syms,
3239 &self.labels,
3240 base_columns(&self.base),
3241 );
3242 // Full-text index maintenance: update tokens for this field if indexed.
3243 if self.fulltext.field_indexed(field) {
3244 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3245 if sym == u32::MAX {
3246 None
3247 } else {
3248 self.syms.resolve(sym)
3249 }
3250 });
3251 if let Some(label) = label_opt {
3252 if self.fulltext.is_enabled(label, field) {
3253 self.fulltext.remove_node_field(id, field);
3254 self.fulltext.add_tokens(id, field, value);
3255 }
3256 }
3257 }
3258 // Property (equality) index maintenance: re-key this node's value.
3259 if self.prop_index.field_indexed(field) {
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, id, value);
3269 }
3270 }
3271 }
3272 WalRecord::Intern { id, text } => {
3273 if let Some(existing) = self.syms.get(text) {
3274 if existing != *id {
3275 return Err(GraphError::Corrupt {
3276 detail: format!(
3277 "wal intern mismatch for {text:?}: have {existing}, record {id}"
3278 ),
3279 });
3280 }
3281 } else {
3282 let got = self.syms.intern(text);
3283 if got != *id {
3284 return Err(GraphError::Corrupt {
3285 detail: format!(
3286 "wal intern assigned {got} for {text:?}, record wanted {id}"
3287 ),
3288 });
3289 }
3290 }
3291 }
3292 WalRecord::InsertNodeId { label, key, props } => {
3293 let id = self.ids.try_insert(key)?;
3294 if self.labels.len() <= id as usize {
3295 self.labels.resize(id as usize + 1, u32::MAX);
3296 }
3297 self.labels[id as usize] = *label;
3298 let label_str = self
3299 .syms
3300 .resolve(*label)
3301 .ok_or_else(|| GraphError::Corrupt {
3302 detail: format!("wal InsertNodeId unknown label intern {label}"),
3303 })?
3304 .to_string();
3305 let mut ns_name = NS_DEFAULT.to_string();
3306 for (field_sym, value) in props {
3307 let field =
3308 self.syms
3309 .resolve(*field_sym)
3310 .ok_or_else(|| GraphError::Corrupt {
3311 detail: format!(
3312 "wal InsertNodeId unknown field intern {field_sym}"
3313 ),
3314 })?;
3315 if field == NS_PROP {
3316 ns_name = namespace_of_value(Some(value)).to_string();
3317 }
3318 self.props.set(id, field, value.clone());
3319 }
3320 self.set_node_ns(id, &ns_name);
3321 self.view_store
3322 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3323 let cursor = self.engine.pending_delta_count();
3324 let mut eng = std::mem::take(&mut self.engine);
3325 {
3326 let mut gm = make_graph_mut(
3327 &self.ids,
3328 &mut self.syms,
3329 &self.labels,
3330 build_props_view(&self.props, &self.base),
3331 &mut self.topo,
3332 &self.base,
3333 &mut self.edge_props,
3334 );
3335 eng.on_node_changed(id, None, &mut gm);
3336 }
3337 self.engine = eng;
3338 if !self.view_store.is_empty() {
3339 #[cfg(test)]
3340 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3341 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3342 for d in &new_deltas {
3343 self.view_store.on_edge_changed(
3344 d.etype_sym,
3345 d.src_id,
3346 d.dst_id,
3347 d.fired,
3348 &mut self.props,
3349 &build_topo_view(&self.topo, &self.base),
3350 &self.ids,
3351 &self.syms,
3352 &self.labels,
3353 base_columns(&self.base),
3354 );
3355 }
3356 }
3357 if self.fulltext.has_label(&label_str) {
3358 for (field_sym, value) in props {
3359 let Some(field) = self.syms.resolve(*field_sym) else {
3360 continue;
3361 };
3362 if self.fulltext.is_enabled(&label_str, field) {
3363 self.fulltext.add_tokens(id, field, value);
3364 }
3365 }
3366 }
3367 if self.prop_index.has_label(&label_str) {
3368 for (field_sym, value) in props {
3369 let Some(field) = self.syms.resolve(*field_sym) else {
3370 continue;
3371 };
3372 self.prop_index.set(&label_str, field, id, value);
3373 }
3374 }
3375 }
3376 WalRecord::InsertEdgeId { etype, src, dst } => {
3377 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3378 // already be tombstoned. Skip rather than attaching edges to
3379 // dead ids (DeleteNode keys the live re-insert, not the old id).
3380 if self.ids.is_tombstoned(*src)
3381 || self.ids.is_tombstoned(*dst)
3382 || self.ids.key_of(*src).is_none()
3383 || self.ids.key_of(*dst).is_none()
3384 {
3385 return Ok(());
3386 }
3387 // Skip if already visible in the merged view (same idempotency
3388 // guard as InsertEdge above: prevents double-counting when
3389 // pre-snapshot WAL records are replayed over a V8 base).
3390 if self.base.is_some()
3391 && self
3392 .topo_view()
3393 .neighbors(*etype, Direction::Out, *src)
3394 .contains(dst)
3395 {
3396 return Ok(());
3397 }
3398 self.topo.add_edge(*etype, *src, *dst);
3399 self.view_store.on_edge_changed(
3400 *etype,
3401 *src,
3402 *dst,
3403 true,
3404 &mut self.props,
3405 &build_topo_view(&self.topo, &self.base),
3406 &self.ids,
3407 &self.syms,
3408 &self.labels,
3409 base_columns(&self.base),
3410 );
3411 // Rule engine: via-hop rules fire when user via-edges are inserted.
3412 // Resolve etype back to string so on_edge_changed can match rules by name.
3413 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3414 let cursor = self.engine.pending_delta_count();
3415 let mut eng = std::mem::take(&mut self.engine);
3416 {
3417 let mut gm = make_graph_mut(
3418 &self.ids,
3419 &mut self.syms,
3420 &self.labels,
3421 build_props_view(&self.props, &self.base),
3422 &mut self.topo,
3423 &self.base,
3424 &mut self.edge_props,
3425 );
3426 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3427 }
3428 self.engine = eng;
3429 if !self.view_store.is_empty() {
3430 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3431 for d in &new_deltas {
3432 self.view_store.on_edge_changed(
3433 d.etype_sym,
3434 d.src_id,
3435 d.dst_id,
3436 d.fired,
3437 &mut self.props,
3438 &build_topo_view(&self.topo, &self.base),
3439 &self.ids,
3440 &self.syms,
3441 &self.labels,
3442 base_columns(&self.base),
3443 );
3444 }
3445 }
3446 }
3447 }
3448 WalRecord::SetPropId { id, field, value } => {
3449 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3450 return Ok(());
3451 }
3452 let field_str = self
3453 .syms
3454 .resolve(*field)
3455 .ok_or_else(|| GraphError::Corrupt {
3456 detail: format!("wal SetPropId unknown field intern {field}"),
3457 })?
3458 .to_string();
3459 let old_value = build_props_view(&self.props, &self.base)
3460 .get(*id, &field_str)
3461 .map(|vr| vr.into_value());
3462 self.props.set(*id, &field_str, value.clone());
3463 let cursor = self.engine.pending_delta_count();
3464 let mut eng = std::mem::take(&mut self.engine);
3465 {
3466 let mut gm = make_graph_mut(
3467 &self.ids,
3468 &mut self.syms,
3469 &self.labels,
3470 build_props_view(&self.props, &self.base),
3471 &mut self.topo,
3472 &self.base,
3473 &mut self.edge_props,
3474 );
3475 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3476 }
3477 self.engine = eng;
3478 if !self.view_store.is_empty() {
3479 #[cfg(test)]
3480 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3481 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3482 for d in &new_deltas {
3483 self.view_store.on_edge_changed(
3484 d.etype_sym,
3485 d.src_id,
3486 d.dst_id,
3487 d.fired,
3488 &mut self.props,
3489 &build_topo_view(&self.topo, &self.base),
3490 &self.ids,
3491 &self.syms,
3492 &self.labels,
3493 base_columns(&self.base),
3494 );
3495 }
3496 }
3497 self.view_store.on_prop_changed(
3498 *id,
3499 &field_str,
3500 &mut self.props,
3501 &build_topo_view(&self.topo, &self.base),
3502 &self.ids,
3503 &self.syms,
3504 &self.labels,
3505 base_columns(&self.base),
3506 );
3507 if self.fulltext.field_indexed(&field_str) {
3508 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3509 if sym == u32::MAX {
3510 None
3511 } else {
3512 self.syms.resolve(sym)
3513 }
3514 });
3515 if let Some(label) = label_opt {
3516 if self.fulltext.is_enabled(label, &field_str) {
3517 self.fulltext.remove_node_field(*id, &field_str);
3518 self.fulltext.add_tokens(*id, &field_str, value);
3519 }
3520 }
3521 }
3522 if self.prop_index.field_indexed(&field_str) {
3523 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3524 if sym == u32::MAX {
3525 None
3526 } else {
3527 self.syms.resolve(sym)
3528 }
3529 });
3530 if let Some(label) = label_opt {
3531 self.prop_index.set(label, &field_str, *id, value);
3532 }
3533 }
3534 }
3535 WalRecord::CreateRule { def_bytes } => {
3536 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3537 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3538 })?;
3539 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3540 // so the engine already has it; silently skip to avoid a spurious
3541 // RuleInvalid error in the crash window between snapshot write and WAL
3542 // truncation.
3543 if self.engine.rules().any(|r| r.name == def.name) {
3544 return Ok(());
3545 }
3546 let cursor = self.engine.pending_delta_count();
3547 let mut eng = std::mem::take(&mut self.engine);
3548 let result = {
3549 let mut gm = make_graph_mut(
3550 &self.ids,
3551 &mut self.syms,
3552 &self.labels,
3553 build_props_view(&self.props, &self.base),
3554 &mut self.topo,
3555 &self.base,
3556 &mut self.edge_props,
3557 );
3558 eng.create_rule(def, &mut gm)
3559 };
3560 self.engine = eng;
3561 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3562 // Derived-edge fires from backfill → view updates.
3563 // Fast path: skip O(edge_count) allocation when no views exist.
3564 if !self.view_store.is_empty() {
3565 #[cfg(test)]
3566 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3567 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3568 for d in &new_deltas {
3569 self.view_store.on_edge_changed(
3570 d.etype_sym,
3571 d.src_id,
3572 d.dst_id,
3573 d.fired,
3574 &mut self.props,
3575 &build_topo_view(&self.topo, &self.base),
3576 &self.ids,
3577 &self.syms,
3578 &self.labels,
3579 base_columns(&self.base),
3580 );
3581 }
3582 }
3583 }
3584 WalRecord::DeleteRule { name } => {
3585 // Replay-over-snapshot idempotency: the snapshot already captured the
3586 // post-delete state so the rule is absent; silently skip to avoid a
3587 // spurious RuleNotFound error in the crash window between snapshot write
3588 // and WAL truncation.
3589 if !self.engine.rules().any(|r| r.name == *name) {
3590 return Ok(());
3591 }
3592 let cursor = self.engine.pending_delta_count();
3593 let mut eng = std::mem::take(&mut self.engine);
3594 let result = {
3595 let mut gm = make_graph_mut(
3596 &self.ids,
3597 &mut self.syms,
3598 &self.labels,
3599 build_props_view(&self.props, &self.base),
3600 &mut self.topo,
3601 &self.base,
3602 &mut self.edge_props,
3603 );
3604 eng.delete_rule(name, &mut gm)
3605 };
3606 self.engine = eng;
3607 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3608 // Derived-edge retractions → view updates.
3609 if !self.view_store.is_empty() {
3610 #[cfg(test)]
3611 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3612 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3613 for d in &new_deltas {
3614 self.view_store.on_edge_changed(
3615 d.etype_sym,
3616 d.src_id,
3617 d.dst_id,
3618 d.fired,
3619 &mut self.props,
3620 &build_topo_view(&self.topo, &self.base),
3621 &self.ids,
3622 &self.syms,
3623 &self.labels,
3624 base_columns(&self.base),
3625 );
3626 }
3627 }
3628 }
3629 WalRecord::RemoveProp { key, field } => {
3630 // Recovery-safe: unknown key or already-absent field is a
3631 // clean no-op. Crash-window replay over a snapshot that
3632 // already applied this record must not Err.
3633 let Some(id) = self.ids.get(key) else {
3634 return Ok(());
3635 };
3636 // Read old value through the seam for rule retraction.
3637 let old = build_props_view(&self.props, &self.base)
3638 .get(id, field)
3639 .map(|vr| vr.into_value());
3640 self.props.remove(id, field);
3641 // If the base still supplies the value after the overlay removal,
3642 // record a tombstone so ColumnsView::get does not resurrect it.
3643 // This covers both the base-only case AND the both-resident case:
3644 // base-only (in_overlay=false): old prop was only in base, remove
3645 // is a no-op on overlay, base still visible → tombstone needed.
3646 // both-resident (in_overlay=true): overlay had v2, base has v1;
3647 // removing overlay uncovers v1 → tombstone needed.
3648 // Idempotent on double-replay: second pass sees the tombstone →
3649 // get() returns None → condition is false → no duplicate tombstone.
3650 if build_props_view(&self.props, &self.base)
3651 .get(id, field)
3652 .is_some()
3653 {
3654 self.props.record_prop_tombstone(id, field);
3655 }
3656 let cursor = self.engine.pending_delta_count();
3657 let mut eng = std::mem::take(&mut self.engine);
3658 {
3659 let mut gm = make_graph_mut(
3660 &self.ids,
3661 &mut self.syms,
3662 &self.labels,
3663 build_props_view(&self.props, &self.base),
3664 &mut self.topo,
3665 &self.base,
3666 &mut self.edge_props,
3667 );
3668 eng.on_node_changed(id, Some((field, old)), &mut gm);
3669 }
3670 self.engine = eng;
3671 // Derived-edge deltas → view updates.
3672 if !self.view_store.is_empty() {
3673 #[cfg(test)]
3674 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3675 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3676 for d in &new_deltas {
3677 self.view_store.on_edge_changed(
3678 d.etype_sym,
3679 d.src_id,
3680 d.dst_id,
3681 d.fired,
3682 &mut self.props,
3683 &build_topo_view(&self.topo, &self.base),
3684 &self.ids,
3685 &self.syms,
3686 &self.labels,
3687 base_columns(&self.base),
3688 );
3689 }
3690 }
3691 // Neighbor-aggregate views that read `field` must also update.
3692 self.view_store.on_prop_changed(
3693 id,
3694 field,
3695 &mut self.props,
3696 &build_topo_view(&self.topo, &self.base),
3697 &self.ids,
3698 &self.syms,
3699 &self.labels,
3700 base_columns(&self.base),
3701 );
3702 // Full-text index maintenance: remove tokens for this field.
3703 if self.fulltext.field_indexed(field) {
3704 self.fulltext.remove_node_field(id, field);
3705 }
3706 // Property (equality) index maintenance: drop this node's entry.
3707 if self.prop_index.field_indexed(field) {
3708 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3709 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3710 }) {
3711 self.prop_index.remove_node(label, field, id);
3712 }
3713 }
3714 }
3715 WalRecord::DeleteEdge {
3716 edge_type,
3717 src_key,
3718 dst_key,
3719 } => {
3720 // Recovery-safe: unknown keys, unknown etype, or already-
3721 // absent edge is a clean no-op (remove_edge returns false).
3722 let Some(src) = self.ids.get(src_key) else {
3723 return Ok(());
3724 };
3725 let Some(dst) = self.ids.get(dst_key) else {
3726 return Ok(());
3727 };
3728 let Some(etype) = self.syms.get(edge_type) else {
3729 return Ok(());
3730 };
3731 // I3: phantom-tombstone guard. When a V8 base is present, a
3732 // DeleteEdge WAL record for an edge that was already absorbed into
3733 // the new base (i.e. neither in overlay nor in base) must be skipped.
3734 // Without this guard, remove_edge records a tombstone for an edge
3735 // that no longer exists, incorrectly understating edge_count.
3736 if self.base.is_some()
3737 && !self
3738 .topo_view()
3739 .neighbors(etype, core_storage::topology::Direction::Out, src)
3740 .contains(&dst)
3741 {
3742 return Ok(());
3743 }
3744 self.topo.remove_edge(etype, src, dst);
3745 self.edge_props.remove_edge(etype, src, dst);
3746 // View maintenance for manual edge delete (topo already updated above).
3747 self.view_store.on_edge_changed(
3748 etype,
3749 src,
3750 dst,
3751 false,
3752 &mut self.props,
3753 &build_topo_view(&self.topo, &self.base),
3754 &self.ids,
3755 &self.syms,
3756 &self.labels,
3757 base_columns(&self.base),
3758 );
3759 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3760 let cursor = self.engine.pending_delta_count();
3761 let mut eng = std::mem::take(&mut self.engine);
3762 {
3763 let mut gm = make_graph_mut(
3764 &self.ids,
3765 &mut self.syms,
3766 &self.labels,
3767 build_props_view(&self.props, &self.base),
3768 &mut self.topo,
3769 &self.base,
3770 &mut self.edge_props,
3771 );
3772 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3773 }
3774 self.engine = eng;
3775 if !self.view_store.is_empty() {
3776 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3777 for d in &new_deltas {
3778 self.view_store.on_edge_changed(
3779 d.etype_sym,
3780 d.src_id,
3781 d.dst_id,
3782 d.fired,
3783 &mut self.props,
3784 &build_topo_view(&self.topo, &self.base),
3785 &self.ids,
3786 &self.syms,
3787 &self.labels,
3788 base_columns(&self.base),
3789 );
3790 }
3791 }
3792 }
3793 WalRecord::DeleteNode { key } => {
3794 // Recovery-safe: already-tombstoned / unknown key is a clean
3795 // no-op. Crash-window replay over a snapshot that already
3796 // applied this record cannot recover the retired id from the
3797 // key (`IdMap::get` is None), so every subsequent step is
3798 // skipped. Each step is independently idempotent if invoked
3799 // twice on a still-live id: retraction is a no-op on empty
3800 // provenance, `remove_edge` returns false, `remove_all` is a
3801 // no-op, `ids.delete` returns None, label sentinel is sticky.
3802 let Some(n) = self.ids.get(key) else {
3803 return Ok(());
3804 };
3805
3806 // (1) Retract derived edges + de-index while props/labels live.
3807 let cursor = self.engine.pending_delta_count();
3808 let mut eng = std::mem::take(&mut self.engine);
3809 {
3810 let mut gm = make_graph_mut(
3811 &self.ids,
3812 &mut self.syms,
3813 &self.labels,
3814 build_props_view(&self.props, &self.base),
3815 &mut self.topo,
3816 &self.base,
3817 &mut self.edge_props,
3818 );
3819 eng.on_node_removed(n, &mut gm);
3820 }
3821 self.engine = eng;
3822 // Derived-edge retractions → view updates for neighbors.
3823 if !self.view_store.is_empty() {
3824 #[cfg(test)]
3825 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3826 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3827 for d in &new_deltas {
3828 self.view_store.on_edge_changed(
3829 d.etype_sym,
3830 d.src_id,
3831 d.dst_id,
3832 d.fired,
3833 &mut self.props,
3834 &build_topo_view(&self.topo, &self.base),
3835 &self.ids,
3836 &self.syms,
3837 &self.labels,
3838 base_columns(&self.base),
3839 );
3840 }
3841 }
3842
3843 // (2) Sweep ALL remaining edges incident to n, both directions,
3844 // every etype. This cascade is intentionally mask-independent:
3845 // topology integrity requires removing every edge touching the
3846 // deleted node regardless of the caller's visibility scope.
3847 // (The mask limits which nodes a role's read phase can return;
3848 // the WAL delete always executes with full storage authority.)
3849 // Collect then remove so neighbor slices stay valid during
3850 // iteration. Remove from topo first, then call view maintenance
3851 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3852 let etypes: Vec<u32> = self.topo.etypes().collect();
3853 let mut doomed = Vec::new();
3854 for et in &etypes {
3855 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3856 doomed.push((*et, n, dst));
3857 }
3858 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3859 doomed.push((*et, src, n));
3860 }
3861 }
3862 for (et, s, d) in doomed {
3863 self.topo.remove_edge(et, s, d);
3864 self.edge_props.remove_edge(et, s, d);
3865 // View maintenance: n's own view values will be cleared by
3866 // remove_all below; only update surviving neighbors.
3867 self.view_store.on_edge_changed(
3868 et,
3869 s,
3870 d,
3871 false,
3872 &mut self.props,
3873 &build_topo_view(&self.topo, &self.base),
3874 &self.ids,
3875 &self.syms,
3876 &self.labels,
3877 base_columns(&self.base),
3878 );
3879 }
3880
3881 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3882 self.props.remove_all(n);
3883 // Full-text index maintenance: remove all tokens for this node.
3884 self.fulltext.remove_node(n);
3885 // Property (equality) index maintenance: drop all entries for n.
3886 self.prop_index.remove_node_all(n);
3887
3888 // (4) Retire the dense id and stamp the label sentinel.
3889 self.ids.delete(key);
3890 if let Some(slot) = self.labels.get_mut(n as usize) {
3891 *slot = u32::MAX;
3892 }
3893 }
3894 WalRecord::Batch(inner) => {
3895 // Apply each inner record in order through the same apply path.
3896 // Inner records are validated free of nested Batch by encode_record.
3897 for rec in inner {
3898 self.apply(rec)?;
3899 }
3900 }
3901 WalRecord::RebuildRule { name } => {
3902 // Replay-over-snapshot idempotency: the snapshot may already
3903 // reflect a later delete_rule, so the rule is absent; skip.
3904 if !self.engine.rules().any(|r| r.name == *name) {
3905 return Ok(());
3906 }
3907 let cursor = self.engine.pending_delta_count();
3908 let mut eng = std::mem::take(&mut self.engine);
3909 let result = {
3910 let mut gm = make_graph_mut(
3911 &self.ids,
3912 &mut self.syms,
3913 &self.labels,
3914 build_props_view(&self.props, &self.base),
3915 &mut self.topo,
3916 &self.base,
3917 &mut self.edge_props,
3918 );
3919 eng.rebuild(name, &mut gm)
3920 };
3921 self.engine = eng;
3922 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3923 // Derived-edge delta changes → view updates.
3924 if !self.view_store.is_empty() {
3925 #[cfg(test)]
3926 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3927 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3928 for d in &new_deltas {
3929 self.view_store.on_edge_changed(
3930 d.etype_sym,
3931 d.src_id,
3932 d.dst_id,
3933 d.fired,
3934 &mut self.props,
3935 &build_topo_view(&self.topo, &self.base),
3936 &self.ids,
3937 &self.syms,
3938 &self.labels,
3939 base_columns(&self.base),
3940 );
3941 }
3942 }
3943 }
3944 WalRecord::CreateView { def_bytes } => {
3945 let def: ViewDef =
3946 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3947 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3948 })?;
3949 // Replay-over-snapshot idempotency: view already present → skip.
3950 if self.view_store.has_view(&def.name) {
3951 return Ok(());
3952 }
3953 self.view_store
3954 .create_view(
3955 def,
3956 &mut self.props,
3957 &build_topo_view(&self.topo, &self.base),
3958 &self.ids,
3959 &self.syms,
3960 &self.labels,
3961 )
3962 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3963 }
3964 WalRecord::DeleteView { name } => {
3965 // Replay-over-snapshot idempotency: view already absent → skip.
3966 if !self.view_store.has_view(name) {
3967 return Ok(());
3968 }
3969 self.view_store
3970 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3971 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3972 }
3973 WalRecord::EnableFulltext { label, field } => {
3974 // Replay-over-snapshot idempotency: already enabled → skip.
3975 if self.fulltext.is_enabled(label, field) {
3976 return Ok(());
3977 }
3978 self.fulltext.enable(label, field);
3979 // Backfill: index all live nodes of this label that have the field.
3980 let n = self.ids.len() as u32;
3981 for id in 0..n {
3982 let Some(&sym) = self.labels.get(id as usize) else {
3983 continue;
3984 };
3985 if sym == u32::MAX {
3986 continue; // tombstoned
3987 }
3988 let Some(lbl) = self.syms.resolve(sym) else {
3989 continue;
3990 };
3991 if lbl != label {
3992 continue;
3993 }
3994 if let Some(value) = build_props_view(&self.props, &self.base)
3995 .get(id, field)
3996 .map(|vr| vr.into_value())
3997 {
3998 self.fulltext.add_tokens(id, field, &value);
3999 }
4000 }
4001 }
4002 WalRecord::DisableFulltext { label, field } => {
4003 // Replay-over-snapshot idempotency: already disabled → skip.
4004 if !self.fulltext.is_enabled(label, field) {
4005 return Ok(());
4006 }
4007 // If another label still indexes this field, the postings column
4008 // is kept — but it must not contain node_ids from the now-disabled
4009 // label. Remove them before calling disable() so the field_indexed
4010 // guard inside disable() sees the correct post-removal state.
4011 if self.fulltext.field_indexed_by_other(label, field) {
4012 if let Some(label_sym) = self.syms.get(label) {
4013 for (node_id, &lsym) in self.labels.iter().enumerate() {
4014 if lsym == label_sym {
4015 self.fulltext.remove_node_field(node_id as u32, field);
4016 }
4017 }
4018 }
4019 }
4020 self.fulltext.disable(label, field);
4021 }
4022 WalRecord::EnableIndex { label, field } => {
4023 // Replay-over-snapshot idempotency: already enabled → skip.
4024 if self.prop_index.is_enabled(label, field) {
4025 return Ok(());
4026 }
4027 self.prop_index.enable(label, field);
4028 // Backfill: index all live nodes of this label that have the field.
4029 let n = self.ids.len() as u32;
4030 for id in 0..n {
4031 let Some(&sym) = self.labels.get(id as usize) else {
4032 continue;
4033 };
4034 if sym == u32::MAX {
4035 continue; // tombstoned
4036 }
4037 let Some(lbl) = self.syms.resolve(sym) else {
4038 continue;
4039 };
4040 if lbl != label {
4041 continue;
4042 }
4043 if let Some(value) = build_props_view(&self.props, &self.base)
4044 .get(id, field)
4045 .map(|vr| vr.into_value())
4046 {
4047 self.prop_index.set(label, field, id, &value);
4048 }
4049 }
4050 }
4051 WalRecord::DisableIndex { label, field } => {
4052 self.prop_index.disable(label, field);
4053 }
4054 // History markers carry no replay state — rules re-derive edges
4055 // deterministically on open/replay. Skip unconditionally.
4056 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4057 // ── rename_node ──────────────────────────────────────────────────
4058 WalRecord::RenameNode { old_key, new_key } => {
4059 // Recovery-safe: if old_key is already gone (key was renamed
4060 // by a snapshot or a prior replay frame), skip cleanly.
4061 if self.ids.get(old_key).is_none() {
4062 return Ok(());
4063 }
4064 // The rename only updates the key-table; the dense id, all
4065 // topo edges, props, labels, and rule state are id-indexed and
4066 // require no change.
4067 self.ids
4068 .rename(old_key, new_key)
4069 .map_err(|e| GraphError::Corrupt {
4070 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4071 })?;
4072 }
4073 }
4074 Ok(())
4075 }
4076
4077 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4078 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4079 /// idempotent when the string is already bound. Always emit: after
4080 /// `snapshot()` the WAL is truncated and live intern is not on disk.
4081 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4082 let id = if let Some(id) = self.syms.get(s) {
4083 id
4084 } else {
4085 self.syms.intern(s)
4086 };
4087 (
4088 id,
4089 WalRecord::Intern {
4090 id,
4091 text: s.to_string(),
4092 },
4093 )
4094 }
4095
4096 /// Rewrite user-facing records into dense-id records. On `Err`, no live
4097 /// state is left mutated: speculative interns made while building the
4098 /// output are rolled back, so a later successful mutation cannot log an
4099 /// `Intern` record whose id replay would never reproduce.
4100 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4101 let syms_checkpoint = self.syms.len();
4102 let result = self.rewrite_wal_dense_inner(recs);
4103 if result.is_err() {
4104 self.syms.truncate(syms_checkpoint);
4105 }
4106 result
4107 }
4108
4109 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4110 let mut out = Vec::with_capacity(recs.len());
4111 // Node ids allocated by later apply(InsertNodeId) in this same batch.
4112 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4113 // Namespace of each node inserted earlier in this same frame, so a SET
4114 // on a node this frame created is measured against the namespace it was
4115 // created in rather than against the store, where it does not exist yet.
4116 let mut pending_ns: std::collections::HashMap<String, String> =
4117 std::collections::HashMap::new();
4118 let mut interned = std::collections::HashSet::<u32>::new();
4119 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4120 detail: "id space exhausted".into(),
4121 })?;
4122 let lookup = |ids: &IdMap,
4123 pending: &std::collections::HashMap<String, u32>,
4124 key: &str|
4125 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4126 for rec in recs {
4127 match rec {
4128 WalRecord::InsertNode { label, key, props } => {
4129 // Namespace validation and normalisation, on the one seam
4130 // every user-visible node insert passes through: insert_node,
4131 // a batch, ingest, Cypher CREATE and MERGE all arrive here
4132 // before the WAL append, and replay never does.
4133 let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4134 pending_ns.insert(key.clone(), ns_name);
4135 let (label_id, intern) = self.intern_wal(&label);
4136 if interned.insert(label_id) {
4137 out.push(intern);
4138 }
4139 let mut props_id = Vec::with_capacity(props.len());
4140 for (field, value) in props {
4141 let (field_id, intern) = self.intern_wal(&field);
4142 if interned.insert(field_id) {
4143 out.push(intern);
4144 }
4145 props_id.push((field_id, value));
4146 }
4147 if lookup(&self.ids, &pending, &key).is_none() {
4148 pending.insert(key.clone(), next);
4149 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4150 detail: "id space exhausted".into(),
4151 })?;
4152 }
4153 out.push(WalRecord::InsertNodeId {
4154 label: label_id,
4155 key,
4156 props: props_id,
4157 });
4158 }
4159 WalRecord::SetProp { key, field, value } => {
4160 // A namespace is set at insert and fixed after: the write is
4161 // refused when it would move the node, and dropped when it
4162 // names the namespace the node is already in. Checked here
4163 // so set_prop, a batch, Cypher SET/MERGE and every upsert
4164 // that merges props get the same answer.
4165 if field == NS_PROP {
4166 let Value::Str(ref to) = value else {
4167 return Err(GraphError::RuleInvalid {
4168 detail: format!(
4169 "node {key}: {NS_PROP} must be a string naming a namespace, \
4170 got {value:?}"
4171 ),
4172 });
4173 };
4174 let from = pending_ns
4175 .get(&key)
4176 .cloned()
4177 .or_else(|| self.namespace_of(&key))
4178 .unwrap_or_else(|| NS_DEFAULT.to_string());
4179 let to = to.clone();
4180 if to != from {
4181 return Err(GraphError::NamespaceImmutable {
4182 key: key.clone(),
4183 from,
4184 to,
4185 });
4186 }
4187 continue;
4188 }
4189 let id =
4190 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4191 detail: format!("dense WAL rewrite missing key {key}"),
4192 })?;
4193 let (field_id, intern) = self.intern_wal(&field);
4194 if interned.insert(field_id) {
4195 out.push(intern);
4196 }
4197 out.push(WalRecord::SetPropId {
4198 id,
4199 field: field_id,
4200 value,
4201 });
4202 }
4203 WalRecord::InsertEdge {
4204 edge_type,
4205 src_key,
4206 dst_key,
4207 } => {
4208 let (etype, intern) = self.intern_wal(&edge_type);
4209 if interned.insert(etype) {
4210 out.push(intern);
4211 }
4212 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4213 GraphError::Corrupt {
4214 detail: format!("dense WAL rewrite missing src {src_key}"),
4215 }
4216 })?;
4217 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4218 GraphError::Corrupt {
4219 detail: format!("dense WAL rewrite missing dst {dst_key}"),
4220 }
4221 })?;
4222 out.push(WalRecord::InsertEdgeId { etype, src, dst });
4223 }
4224 WalRecord::RenameNode {
4225 ref old_key,
4226 ref new_key,
4227 } => {
4228 // Track the rename in `pending` so subsequent InsertEdge /
4229 // SetProp records in this batch can resolve the new key.
4230 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4231 GraphError::Corrupt {
4232 detail: format!(
4233 "dense WAL rewrite: RenameNode old key {old_key} not found"
4234 ),
4235 }
4236 })?;
4237 pending.remove(old_key.as_str());
4238 pending.insert(new_key.clone(), id);
4239 out.push(rec);
4240 }
4241 // # Symbol-order invariant (load-bearing)
4242 //
4243 // Write-time and replay-time symbol assignment must agree: every
4244 // symbol in a `Batch` frame has to receive the same dense id when
4245 // the frame's records are replayed in order as it received when
4246 // the frame was written.
4247 //
4248 // A rule's backfill interns its `edge_type` lazily
4249 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4250 // site), and that backfill runs from `apply` — during the
4251 // `CreateRule` record itself, and again from any later
4252 // `InsertNodeId` in the same frame that makes the rule fire. At
4253 // write time the whole batch is rewritten before any of it is
4254 // applied, so a later `InsertEdge` in the same batch would win the
4255 // lower id for its edge type; on replay the rule's lazy intern
4256 // gets there first and steals it, and the `Intern` record fails at
4257 // the `wal intern assigned …` check in `apply`.
4258 //
4259 // Pre-interning the rule's `edge_type` here, and emitting its
4260 // `Intern` record ahead of the `CreateRule` record, makes both
4261 // orders identical. `weight_prop` needs no pre-intern:
4262 // `EdgeProps::set` keys props by `String`, never through the
4263 // interner. `via_edge` needs none either: via-hop rules resolve it
4264 // with `syms.get` and skip when it is absent.
4265 //
4266 // `RebuildRule` and `DeleteRule` need no such handling here:
4267 // `RebuildRule` has no `BatchOp` variant, so it never appears
4268 // inside a `Batch` today — it is only ever issued as its own
4269 // standalone commit (`rebuild_rule`, or the auto-rebuild path
4270 // that logs it as a second commit after the triggering op).
4271 // `DeleteRule` does have a `BatchOp` variant and can appear
4272 // inside a `Batch`, but it carries only a rule `name` — no
4273 // `edge_type` or other symbol that needs pre-interning — so
4274 // only `CreateRule` needs this arm.
4275 WalRecord::CreateRule { ref def_bytes } => {
4276 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4277 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4278 })?;
4279 let (etype, intern) = self.intern_wal(&def.edge_type);
4280 if interned.insert(etype) {
4281 out.push(intern);
4282 }
4283 out.push(rec);
4284 }
4285 other => out.push(other),
4286 }
4287 }
4288 Ok(out)
4289 }
4290
4291 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4292 let recs = self.rewrite_wal_dense(recs)?;
4293 match recs.len() {
4294 0 => Ok(()),
4295 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4296 _ => self.log_then_apply(WalRecord::Batch(recs)),
4297 }
4298 }
4299
4300 /// Durable write, then notify the event sink. Replay (`apply` during
4301 /// `open`) never enters this function, so it is the replay-silent seam.
4302 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4303 self.log_then_apply_with(rec, None, self.fsync)
4304 }
4305
4306 /// Whether this frame must fsync under `policy`.
4307 ///
4308 /// Batched contract: user-visible batches (>1 mutation) fsync; single
4309 /// mutations do not. The dense rewrite wraps a single mutation in a
4310 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4311 /// from the count — removing that filter would make every single-op write
4312 /// fsync under Batched (or, if the threshold were raised instead, skip a
4313 /// needed fsync for real two-op batches).
4314 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4315 match policy {
4316 FsyncPolicy::Relaxed => false,
4317 FsyncPolicy::Strict => true,
4318 FsyncPolicy::Batched => match rec {
4319 // Intern + one mutation is the single-op rewrite, not a user batch.
4320 WalRecord::Batch(inner) => {
4321 inner
4322 .iter()
4323 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4324 .count()
4325 > 1
4326 }
4327 _ => false,
4328 },
4329 }
4330 }
4331
4332 /// # Apply-infallibility invariant (load-bearing)
4333 ///
4334 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4335 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4336 /// the full frame while in-memory state would reflect only the ops before
4337 /// the failure. On reopen, WAL replay would then apply the entire batch —
4338 /// diverging permanently from what the pre-crash process had in memory.
4339 ///
4340 /// For `Batch` frames this situation cannot arise because:
4341 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4342 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4343 /// use, with no concurrent mutation between validation exit and apply entry.
4344 /// - Every `apply` arm for a validated op is either infallible by construction
4345 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4346 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4347 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4348 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4349 ///
4350 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4351 /// `Err` for a `Batch` frame, making any future regression immediately visible
4352 /// in tests rather than silently diverging crash-recovery behaviour.
4353 fn log_then_apply_with(
4354 &mut self,
4355 rec: WalRecord,
4356 ingest: Option<(String, usize)>,
4357 policy: FsyncPolicy,
4358 ) -> Result<()> {
4359 // Read-only guard: as-of instances must never write the WAL.
4360 if self.read_only {
4361 return Err(GraphError::ReadOnly);
4362 }
4363 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4364 // partway; in-memory state is ahead of (or out of step with) the
4365 // on-disk WAL, so further mutations would deepen the divergence.
4366 // Reopen the database to recover. Checked before the lock guard: this
4367 // is the more serious condition and the more useful error.
4368 if self.degraded {
4369 return Err(GraphError::Io(std::io::Error::other(
4370 "database degraded after group-commit fsync failure; reopen required",
4371 )));
4372 }
4373 // Cross-process guard: this write scope asked for the store's write
4374 // lock and did not get it. Writing anyway would append frames on top of
4375 // a WAL another process is extending, so refuse instead.
4376 if self.lock_denied {
4377 return Err(GraphError::Busy { holder: None });
4378 }
4379 // Ensure retained provenance bytes are decoded into the live mutable
4380 // fields before any mutation touches self.engine.provenance. This is a
4381 // no-op if provenance was never stored (fresh store) or has already been
4382 // consumed (subsequent mutations). WAL replay calls apply() directly
4383 // and is covered by consume_retained_state_eager before replay.
4384 self.ensure_v8_base_sections_loaded();
4385 self.engine.ensure_provenance_loaded_mut();
4386 // Invariant (I-1): no stale deltas may enter from a previous apply.
4387 // If any engine method ever accumulates deltas before erroring, they would
4388 // contaminate the *next* commit's event stream. This assert fires in debug
4389 // builds, making any future regression visible at the earliest point.
4390 debug_assert_eq!(
4391 self.engine.pending_delta_count(),
4392 0,
4393 "stale engine deltas at log_then_apply_with entry — \
4394 a previous apply arm may have accumulated deltas before erroring; \
4395 the caller must drain_deltas() on any error path before returning"
4396 );
4397 let frame = encode_record(&rec);
4398 self.fs.append(FileId::Wal, &frame)?;
4399 // The cursor advances by exactly the bytes appended: these frames are
4400 // ours and already applied, so a later refresh must not replay them.
4401 self.wal_consumed += frame.len() as u64;
4402 if Self::wal_needs_sync(policy, &rec) {
4403 self.fs.sync(FileId::Wal)?;
4404 }
4405 // Marker writing always needs the engine deltas, but the engine only
4406 // accumulates them when emit_deltas is true (normally gated on subscribers
4407 // or views being present). Enable emission for this apply if it is
4408 // currently off, then restore the original state unconditionally via an
4409 // RAII guard — this prevents a panic in apply() from leaking the flag.
4410 // The same guard resets the engine's transient chaining state. A panic
4411 // unwinding out of a rule hook would otherwise leave `chain_depth`
4412 // non-zero, which makes every later `begin_chain` decide chaining is
4413 // already running and silently switch it off for good.
4414 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4415 impl Drop for RestoreEmitDeltas {
4416 fn drop(&mut self) {
4417 // SAFETY: pointer into self (GraphDb); guard is dropped within
4418 // this frame before log_then_apply_with returns.
4419 unsafe {
4420 (*self.0).set_emit_deltas(self.1);
4421 (*self.0).reset_chain_state();
4422 }
4423 }
4424 }
4425 let original_emit = self.engine.emit_deltas();
4426 if !original_emit {
4427 self.engine.set_emit_deltas(true);
4428 }
4429 // SAFETY: raw pointer into self; guard dropped within this frame.
4430 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4431
4432 let apply_result = self.apply(&rec);
4433 // For Batch frames, post-validation apply must be infallible (see above).
4434 // A debug_assert here catches any future change that makes apply fallible
4435 // before the caller notices via silent WAL/memory divergence.
4436 if matches!(&rec, WalRecord::Batch(_)) {
4437 debug_assert!(
4438 apply_result.is_ok(),
4439 "Batch apply returned Err after successful WAL write — \
4440 the validate-then-apply invariant has been violated; \
4441 see log_then_apply_with invariant doc"
4442 );
4443 }
4444 if apply_result.is_err() {
4445 // Discard any partial deltas accumulated by the failed apply.
4446 // They must not ride the next commit's event stream (I-1).
4447 // _emit_guard restores emit_deltas on drop automatically.
4448 let _ = self.engine.drain_deltas();
4449 let _ = self.engine.take_rebuild_needed();
4450 apply_result?;
4451 }
4452 self.commit_seq += 1;
4453 let seq = self.commit_seq;
4454 // Update per-node last-change map for the committed record.
4455 // Must happen after commit_seq is incremented so the seq is correct.
4456 self.update_last_change_from_rec(&rec, seq);
4457 // Drain engine deltas and distribute to subscribers before the existing
4458 // MutationEvent sink fires — both happen post-fsync, post-apply.
4459 // _emit_guard restores emit_deltas after this line when it drops.
4460 let engine_deltas = self.engine.drain_deltas();
4461
4462 // Append history-marker WAL records for any derived-edge changes so
4463 // that `edge_history` and `was_linked` can surface rule-attributed
4464 // events. Markers are STATE NO-OPS during replay; they are written
4465 // without an additional fsync (the triggering commit's sync already
4466 // happened; the next commit's sync covers these lazily).
4467 if !engine_deltas.is_empty() {
4468 let markers: Vec<WalRecord> = engine_deltas
4469 .iter()
4470 .map(|d| {
4471 if d.fired {
4472 WalRecord::DerivedEdgeAdded {
4473 rule: d.rule.clone(),
4474 edge_type: d.edge_type.clone(),
4475 src_key: d.src_key.clone(),
4476 dst_key: d.dst_key.clone(),
4477 }
4478 } else {
4479 WalRecord::DerivedEdgeRetracted {
4480 rule: d.rule.clone(),
4481 edge_type: d.edge_type.clone(),
4482 src_key: d.src_key.clone(),
4483 dst_key: d.dst_key.clone(),
4484 }
4485 }
4486 })
4487 .collect();
4488 let marker_frame = if markers.len() == 1 {
4489 markers.into_iter().next().unwrap()
4490 } else {
4491 WalRecord::Batch(markers)
4492 };
4493 // Ignore append errors: markers are best-effort history
4494 // annotations. Losing them does not affect state correctness.
4495 // The cursor only advances when the bytes actually landed.
4496 let marker_bytes = encode_record(&marker_frame);
4497 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4498 self.wal_consumed += marker_bytes.len() as u64;
4499 }
4500 }
4501
4502 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4503 // stored as-is (including any nested Batch / Intern records); the
4504 // ReaderSnapshot's apply_one function handles all variants.
4505 {
4506 let derived_inserts = engine_deltas
4507 .iter()
4508 .filter(|d| d.fired)
4509 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4510 .collect();
4511 let derived_deletes = engine_deltas
4512 .iter()
4513 .filter(|d| !d.fired)
4514 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4515 .collect();
4516 let delta = Arc::new(crate::reader::CommitDelta {
4517 records: vec![rec.clone()],
4518 derived_inserts,
4519 derived_deletes,
4520 });
4521 self.delta_tail.push(delta);
4522 self.commits_since_fold += 1;
4523 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4524 self.fold_now();
4525 }
4526 }
4527
4528 if self.defer_events {
4529 // Group-commit drain thread: hold events until after the group
4530 // fsync so subscribers only observe durable data (R2).
4531 self.deferred_events.push(DeferredEvent {
4532 rec: rec.clone(),
4533 engine_deltas,
4534 seq,
4535 ingest,
4536 });
4537 } else {
4538 self.distribute_events(&rec, &engine_deltas, seq);
4539 self.emit_committed(&rec, ingest);
4540 }
4541 // Drift is only known after apply, so auto-rebuild cannot join the
4542 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4543 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4544 // retrigger loop is impossible if the fit succeeded, but we still
4545 // drain the flag so a leftover cannot re-enter.
4546 // One slice of any outstanding vector-index build rides here too, so a
4547 // store that is being written to finishes its build without anyone
4548 // calling `pump_index_build`. A rule that becomes whole joins the same
4549 // RebuildRule loop below.
4550 let mut rebuilds = self.engine.take_rebuild_needed();
4551 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4552 // Not after `CreateRule`: that record's own apply already did the
4553 // rule's first slice, and pumping again here would make one
4554 // `create_rule` call do two slices' work under one lock.
4555 // Nothing pending is the overwhelmingly common case and must cost
4556 // a map lookup, not an engine swap: a store being written to has
4557 // long since populated its indexes, so the `pump_index_build`
4558 // entry point owns the not-yet-populated case on its own.
4559 if !matches!(&rec, WalRecord::CreateRule { .. })
4560 && !self.engine.builds_in_progress().is_empty()
4561 {
4562 rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4563 }
4564 let mut failed = Vec::new();
4565 for name in rebuilds {
4566 if self.engine.rules().any(|r| r.name == name) {
4567 // User op is already durable. A failed second commit must
4568 // not surface as the caller's error.
4569 if let Err(e) =
4570 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4571 {
4572 eprintln!(
4573 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4574 );
4575 failed.push(name);
4576 }
4577 }
4578 }
4579 for name in failed {
4580 self.engine.queue_rebuild_needed(name);
4581 }
4582 }
4583 Ok(())
4584 }
4585
4586 /// Install a post-commit hook. Replaces any previous sink.
4587 ///
4588 /// The sink runs inside `log_then_apply` after a successful
4589 /// durable commit, while the caller still holds `&mut self`. When this
4590 /// database is behind a [`crate::SharedDb`], that means the **write
4591 /// guard is held**. The sink must never call `read` / `write` (or any
4592 /// other method) on the same `SharedDb` — the `RwLock` is not
4593 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4594 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4595 /// Intended examples: `std::sync::mpsc::SyncSender`,
4596 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4597 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4598 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4599 self.event_sink = Some(sink);
4600 }
4601
4602 /// Whether a post-commit event sink is currently installed.
4603 pub fn has_event_sink(&self) -> bool {
4604 self.event_sink.is_some()
4605 }
4606
4607 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4608 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4609 self.fsync = p;
4610 }
4611
4612 /// Return the current WAL fsync cadence.
4613 pub fn fsync_policy(&self) -> FsyncPolicy {
4614 self.fsync
4615 }
4616
4617 // ── Group-commit event deferral ───────────────────────────────────────────
4618
4619 /// Enable or disable deferred event mode.
4620 ///
4621 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4622 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4623 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4624 /// or [`discard_deferred_events`] if the fsync failed and the group must
4625 /// be treated as lost.
4626 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4627 self.defer_events = defer;
4628 }
4629
4630 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4631 /// was set to true. Clears the buffer.
4632 ///
4633 /// Called by the drain thread AFTER a successful group fsync, so
4634 /// subscribers observe only data that is durably on disk.
4635 pub fn flush_deferred_events(&mut self) {
4636 let events = std::mem::take(&mut self.deferred_events);
4637 for de in events {
4638 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4639 self.emit_committed(&de.rec, de.ingest);
4640 }
4641 }
4642
4643 /// Discard all buffered events without firing them.
4644 ///
4645 /// Called by the drain thread when a group fsync fails: the WAL has been
4646 /// truncated back to the pre-group offset, so the committed-but-unsynced
4647 /// ops must not be observable to subscribers.
4648 pub fn discard_deferred_events(&mut self) {
4649 self.deferred_events.clear();
4650 }
4651
4652 // ── Degraded state ────────────────────────────────────────────────────────
4653
4654 /// Mark this database as degraded.
4655 ///
4656 /// Called by the group-commit drain thread after a group fsync failure and
4657 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4658 /// further mutations would deepen the divergence. All subsequent calls to
4659 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4660 pub fn set_degraded(&mut self) {
4661 self.degraded = true;
4662 }
4663
4664 fn emit(&self, ev: MutationEvent) {
4665 if let Some(sink) = &self.event_sink {
4666 sink(ev);
4667 }
4668 }
4669
4670 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4671 match rec {
4672 WalRecord::Batch(inner) => {
4673 for r in inner {
4674 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4675 self.emit(ev);
4676 }
4677 }
4678 match ingest {
4679 Some((label, inserted)) => {
4680 self.emit(MutationEvent::Ingested { label, inserted })
4681 }
4682 None => {
4683 let ops = inner
4684 .iter()
4685 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4686 .count();
4687 if ops > 1 {
4688 self.emit(MutationEvent::BatchApplied { ops });
4689 }
4690 }
4691 }
4692 }
4693 other => {
4694 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4695 self.emit(ev);
4696 }
4697 }
4698 }
4699 }
4700
4701 // -----------------------------------------------------------------------
4702 // Subscription API
4703 // -----------------------------------------------------------------------
4704
4705 /// Distribute post-commit events to all live subscribers.
4706 ///
4707 /// Build a row-key → row-data map from a [`ResultSet`].
4708 ///
4709 /// Each row is serialized to JSON to form its key; a debug fallback is used
4710 /// if serialization fails. Used by both the initial-seed path in
4711 /// [`Self::subscribe_query`] and the per-commit diff path in
4712 /// [`Self::distribute_events`] to keep the two in sync.
4713 fn result_to_row_map(
4714 result: &core_query::ResultSet,
4715 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4716 (0..result.len())
4717 .map(|i| {
4718 let row = result.row(i).to_vec();
4719 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4720 (key, row)
4721 })
4722 .collect()
4723 }
4724
4725 /// Collect the set of label syms touched by a WAL record.
4726 ///
4727 /// Returns `Some(set)` when every record in this commit can be attributed to
4728 /// a known label sym. Returns `None` when the commit must not be skipped:
4729 /// edge records, unresolvable key→label lookups, or any record type not in
4730 /// the explicit handled set.
4731 ///
4732 /// Handled record types and their actions:
4733 /// - `InsertNode` → look up label in interner (fails → None)
4734 /// - `InsertNodeId` → label sym is carried directly
4735 /// - `SetProp` → resolve key→id→label (fails → None)
4736 /// - `DeleteNode` → resolve key→id→label (fails → None)
4737 /// - `Batch` → recurse into every inner record
4738 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4739 /// - everything else → None (conservative)
4740 fn commit_touched_labels(
4741 rec: &WalRecord,
4742 syms: &Interner,
4743 ids: &IdMap,
4744 labels: &[u32],
4745 ) -> Option<BTreeSet<u32>> {
4746 let mut out = BTreeSet::new();
4747 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4748 Some(out)
4749 } else {
4750 None
4751 }
4752 }
4753
4754 fn collect_touched_labels(
4755 rec: &WalRecord,
4756 syms: &Interner,
4757 ids: &IdMap,
4758 labels: &[u32],
4759 out: &mut BTreeSet<u32>,
4760 ) -> bool {
4761 match rec {
4762 // String-key insert: the dense rewrite converts this to
4763 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4764 // records written before the dense path was added.
4765 WalRecord::InsertNode { label, .. } => {
4766 if let Some(sym) = syms.get(label) {
4767 out.insert(sym);
4768 true
4769 } else {
4770 false
4771 }
4772 }
4773 // Dense-id insert (produced by rewrite_wal_dense for every
4774 // insert_node call in the current codebase).
4775 WalRecord::InsertNodeId { label, .. } => {
4776 out.insert(*label);
4777 true
4778 }
4779 // String-key prop set: dense path converts to [Intern, SetPropId].
4780 WalRecord::SetProp { key, .. } => {
4781 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4782 out.insert(sym);
4783 true
4784 } else {
4785 false
4786 }
4787 }
4788 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4789 WalRecord::SetPropId { id, .. } => {
4790 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4791 out.insert(sym);
4792 true
4793 } else {
4794 false
4795 }
4796 }
4797 WalRecord::DeleteNode { key } => {
4798 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4799 out.insert(sym);
4800 true
4801 } else {
4802 false
4803 }
4804 }
4805 WalRecord::Batch(inner) => inner
4806 .iter()
4807 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4808 // Intern is a pure metadata record — it does not touch any node's
4809 // label and is safe to skip for the label-skip predicate.
4810 WalRecord::Intern { .. } => true,
4811 // Edge records: always re-execute (edges can change join results).
4812 WalRecord::InsertEdge { .. }
4813 | WalRecord::DeleteEdge { .. }
4814 | WalRecord::InsertEdgeId { .. } => false,
4815 _ => false,
4816 }
4817 }
4818
4819 /// Resolve a node key to its label sym via the dense id table.
4820 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4821 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4822 let id = ids.get(key)?;
4823 let sym = labels.get(id as usize).copied()?;
4824 (sym != u32::MAX).then_some(sym)
4825 }
4826
4827 /// Distribute post-commit events to all live subscribers.
4828 ///
4829 /// Called from `log_then_apply_with` after apply + fsync, before the
4830 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4831 ///
4832 /// Query subscriptions (subscribe_query) re-execute their plan on every
4833 /// call and diff the result against the previous run. Zero overhead when
4834 /// no query subscriptions are active.
4835 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4836 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4837 return;
4838 }
4839
4840 if !self.subscriptions.is_empty() {
4841 // Build write events from the WAL record.
4842 let write_events: Vec<DbEvent> =
4843 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4844
4845 // Build edge events from engine deltas. Weight is looked up from
4846 // edge_props at distribution time (after apply), so it's always fresh.
4847 let edge_events: Vec<DbEvent> = engine_deltas
4848 .iter()
4849 .map(|d| {
4850 if d.fired {
4851 // The score lives under the rule's declared weight_prop,
4852 // which is not always the literal "weight".
4853 let prop = self
4854 .engine
4855 .rules()
4856 .find(|r| r.name == d.rule)
4857 .and_then(|r| r.weight_prop.as_deref());
4858 let weight = prop.and_then(|p| {
4859 self.edge_props
4860 .get(d.etype_sym, d.src_id, d.dst_id, p)
4861 .and_then(|v| {
4862 if let core_storage::Value::Float(f) = v {
4863 Some(*f)
4864 } else {
4865 None
4866 }
4867 })
4868 });
4869 DbEvent::EdgeFired {
4870 rule: d.rule.clone(),
4871 src_key: d.src_key.clone(),
4872 dst_key: d.dst_key.clone(),
4873 edge_type: d.edge_type.clone(),
4874 weight,
4875 commit_seq: seq,
4876 }
4877 } else {
4878 DbEvent::EdgeRetracted {
4879 rule: d.rule.clone(),
4880 src_key: d.src_key.clone(),
4881 dst_key: d.dst_key.clone(),
4882 edge_type: d.edge_type.clone(),
4883 commit_seq: seq,
4884 }
4885 }
4886 })
4887 .collect();
4888
4889 // Prune dead entries; push matching events to live ones.
4890 self.subscriptions.retain(|entry| {
4891 let Some(inner) = entry.inner.upgrade() else {
4892 return false;
4893 };
4894 for ev in &write_events {
4895 if event_matches(ev, &entry.filter) {
4896 inner.push(ev.clone());
4897 }
4898 }
4899 for ev in &edge_events {
4900 if event_matches(ev, &entry.filter) {
4901 inner.push(ev.clone());
4902 }
4903 }
4904 true
4905 });
4906
4907 // Turn off delta accumulation if all subscribers dropped and no views remain.
4908 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4909 self.engine.set_emit_deltas(false);
4910 }
4911 }
4912
4913 // Query subscriptions: full re-run per commit, then diff rows.
4914 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4915 // Differential evaluation is roadmap / Phase 5.
4916 if !self.query_subscriptions.is_empty() {
4917 // Take the list out so we can call self.view() without borrow conflict.
4918 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4919 let empty_params = BTreeMap::new();
4920 query_subs.retain_mut(|entry| {
4921 let Some(inner) = entry.inner.upgrade() else {
4922 return false; // subscriber dropped — prune
4923 };
4924 // Label-skip: if the plan has a known scan label and this commit
4925 // can be proven to touch only different labels (and no rule-derived
4926 // edge deltas fired), the result set cannot have changed — skip.
4927 if let Some(scan_sym) = entry.scan_label {
4928 if engine_deltas.is_empty() {
4929 let touched =
4930 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4931 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4932 return true; // safe to skip — result set unchanged
4933 }
4934 }
4935 }
4936 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4937 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4938 Ok(r) => r,
4939 Err(e) => {
4940 // Keep the subscription alive; skip the diff for this commit.
4941 // Re-run errors are transient (e.g., planner change) and
4942 // self-heal when the next commit succeeds.
4943 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4944 return true;
4945 }
4946 };
4947 // Build new row map: serialized-key → row data.
4948 let new_row_map = Self::result_to_row_map(&result);
4949 // Removed rows: in prev but not in new.
4950 for (key, row) in &entry.prev_row_map {
4951 if !new_row_map.contains_key(key) {
4952 inner.push(DbEvent::QueryRowRemoved {
4953 columns: entry.columns.clone(),
4954 row: row.clone(),
4955 });
4956 }
4957 }
4958 // Added rows: in new but not in prev.
4959 for (key, row) in &new_row_map {
4960 if !entry.prev_row_map.contains_key(key) {
4961 inner.push(DbEvent::QueryRowAdded {
4962 columns: entry.columns.clone(),
4963 row: row.clone(),
4964 });
4965 }
4966 }
4967 entry.prev_row_map = new_row_map;
4968 true
4969 });
4970 self.query_subscriptions = query_subs;
4971 }
4972 }
4973
4974 /// Returns `true` if any live subscriber or view definition requires delta
4975 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4976 fn needs_emit_deltas(&self) -> bool {
4977 !self.view_store.is_empty()
4978 || self
4979 .subscriptions
4980 .iter()
4981 .any(|e| e.inner.upgrade().is_some())
4982 }
4983
4984 /// Convert a WAL record into `DbEvent` write events with the given seq.
4985 fn write_events_from_record(
4986 rec: &WalRecord,
4987 seq: u64,
4988 intern: &Interner,
4989 ids: &IdMap,
4990 ) -> Vec<DbEvent> {
4991 match rec {
4992 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4993 label: label.clone(),
4994 key: key.clone(),
4995 commit_seq: seq,
4996 }],
4997 // *Id arms run after a successful apply, so resolution can only
4998 // fail on a programming error. Skip the event rather than emit a
4999 // fabricated "" that clients can't tell from a real empty value
5000 // (mirrors event_from_record returning None).
5001 WalRecord::InsertNodeId { label, key, .. } => intern
5002 .resolve(*label)
5003 .map(|label| DbEvent::NodeInserted {
5004 label: label.to_string(),
5005 key: key.clone(),
5006 commit_seq: seq,
5007 })
5008 .into_iter()
5009 .collect(),
5010 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5011 key: key.clone(),
5012 field: field.clone(),
5013 commit_seq: seq,
5014 }],
5015 WalRecord::SetPropId { id, field, .. } => ids
5016 .key_of(*id)
5017 .zip(intern.resolve(*field))
5018 .map(|(key, field)| DbEvent::PropSet {
5019 key: key.to_string(),
5020 field: field.to_string(),
5021 commit_seq: seq,
5022 })
5023 .into_iter()
5024 .collect(),
5025 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5026 key: key.clone(),
5027 field: field.clone(),
5028 commit_seq: seq,
5029 }],
5030 WalRecord::InsertEdge {
5031 edge_type,
5032 src_key,
5033 dst_key,
5034 } => vec![DbEvent::EdgeInserted {
5035 edge_type: edge_type.clone(),
5036 src: src_key.clone(),
5037 dst: dst_key.clone(),
5038 commit_seq: seq,
5039 }],
5040 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5041 Some(DbEvent::EdgeInserted {
5042 edge_type: intern.resolve(*etype)?.to_string(),
5043 src: ids.key_of(*src)?.to_string(),
5044 dst: ids.key_of(*dst)?.to_string(),
5045 commit_seq: seq,
5046 })
5047 })()
5048 .into_iter()
5049 .collect(),
5050 WalRecord::DeleteEdge {
5051 edge_type,
5052 src_key,
5053 dst_key,
5054 } => vec![DbEvent::EdgeDeleted {
5055 edge_type: edge_type.clone(),
5056 src: src_key.clone(),
5057 dst: dst_key.clone(),
5058 commit_seq: seq,
5059 }],
5060 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5061 key: key.clone(),
5062 commit_seq: seq,
5063 }],
5064 WalRecord::Batch(inner) => inner
5065 .iter()
5066 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5067 .collect(),
5068 WalRecord::CreateRule { .. }
5069 | WalRecord::DeleteRule { .. }
5070 | WalRecord::RebuildRule { .. }
5071 | WalRecord::CreateView { .. }
5072 | WalRecord::DeleteView { .. }
5073 | WalRecord::EnableFulltext { .. }
5074 | WalRecord::DisableFulltext { .. }
5075 | WalRecord::EnableIndex { .. }
5076 | WalRecord::DisableIndex { .. }
5077 | WalRecord::Intern { .. }
5078 // History markers produce no DbEvent — the engine delta already
5079 // fired the EdgeFired/EdgeRetracted subscription events.
5080 | WalRecord::DerivedEdgeAdded { .. }
5081 | WalRecord::DerivedEdgeRetracted { .. }
5082 | WalRecord::RenameNode { .. } => vec![],
5083 }
5084 }
5085
5086 /// Subscribe to edge-fire and edge-retract events for one named rule.
5087 ///
5088 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5089 /// currently registered. Dropping the returned [`Subscription`] handle
5090 /// unregisters the subscriber — no further events are queued, no
5091 /// resources leak.
5092 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5093 if self.read_only {
5094 return Err(core_storage::GraphError::ReadOnly);
5095 }
5096 if !self.engine.rules().any(|r| r.name == rule_name) {
5097 return Err(core_storage::GraphError::RuleNotFound {
5098 name: rule_name.to_string(),
5099 });
5100 }
5101 let inner = SubInner::new(self.sub_capacity());
5102 self.subscriptions.push(SubEntry {
5103 filter: SubFilter::Rule(rule_name.to_string()),
5104 inner: std::sync::Arc::downgrade(&inner),
5105 });
5106 self.engine.set_emit_deltas(true);
5107 Ok(Subscription(inner))
5108 }
5109
5110 /// Subscribe to edge-fire and edge-retract events for **all** rules.
5111 ///
5112 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5113 /// as-of instances never commit, so `distribute_events` never runs and the
5114 /// subscription would never deliver events.
5115 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5116 if self.read_only {
5117 return Err(core_storage::GraphError::ReadOnly);
5118 }
5119 let inner = SubInner::new(self.sub_capacity());
5120 self.subscriptions.push(SubEntry {
5121 filter: SubFilter::AllRules,
5122 inner: std::sync::Arc::downgrade(&inner),
5123 });
5124 self.engine.set_emit_deltas(true);
5125 Ok(Subscription(inner))
5126 }
5127
5128 /// Subscribe to write events: node insert/delete, prop set/remove.
5129 ///
5130 /// Does not include edge-fire / edge-retract (rule-derived edge events).
5131 ///
5132 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5133 /// as-of instances never commit, so `distribute_events` never runs and the
5134 /// subscription would never deliver events.
5135 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5136 if self.read_only {
5137 return Err(core_storage::GraphError::ReadOnly);
5138 }
5139 let inner = SubInner::new(self.sub_capacity());
5140 self.subscriptions.push(SubEntry {
5141 filter: SubFilter::Writes,
5142 inner: std::sync::Arc::downgrade(&inner),
5143 });
5144 self.engine.set_emit_deltas(true);
5145 Ok(Subscription(inner))
5146 }
5147
5148 /// Subscribe to incremental Cypher query results.
5149 ///
5150 /// Parses and plans `cypher`; rejects the query if the plan is not in the
5151 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5152 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5153 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
5154 ///
5155 /// SKIP is not supported — it shifts the result window on every commit,
5156 /// causing spurious Added/Removed churn for rows whose data never changed.
5157 /// Multi-hop Expand chains are not supported; each additional MATCH clause
5158 /// widens scope beyond the documented single-scan / single-hop subset.
5159 ///
5160 /// After each successful commit, the plan is **fully re-executed** and the
5161 /// result is diffed against the previous run. Added rows produce
5162 /// [`DbEvent::QueryRowAdded`]; removed rows produce
5163 /// [`DbEvent::QueryRowRemoved`].
5164 ///
5165 /// **Full re-run per commit; use LIMIT to bound execution cost.**
5166 /// The existing 1 M intermediate-row cap applies. Differential evaluation
5167 /// is roadmap / Phase 5.
5168 ///
5169 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5170 /// as-of instances never commit, so `distribute_events` never runs and the
5171 /// subscription would never deliver events.
5172 ///
5173 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5174 /// or if the plan shape is not in the allowlist.
5175 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5176 if self.read_only {
5177 return Err(GraphError::ReadOnly);
5178 }
5179 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5180 detail: format!("lex: {e}"),
5181 })?;
5182 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5183 detail: format!("parse: {e}"),
5184 })?;
5185 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5186 detail: format!("plan: {e}"),
5187 })?;
5188 if !is_subscribable(&ops) {
5189 return Err(GraphError::QueryError {
5190 detail: "subscribe_query only supports allowlisted plan shapes: \
5191 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5192 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5193 Not supported: multi-hop Expand chains, SKIP (creates \
5194 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5195 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5196 Use LIMIT to bound re-execution cost."
5197 .to_string(),
5198 });
5199 }
5200 // Execute once to capture initial state (initial rows are not emitted as
5201 // events — the subscriber learns the baseline via the first query call).
5202 let empty_params = BTreeMap::new();
5203 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5204 GraphError::QueryError {
5205 detail: format!("execute: {e}"),
5206 }
5207 })?;
5208 let columns = initial.columns().to_vec();
5209 let prev_row_map = Self::result_to_row_map(&initial);
5210 let inner = SubInner::new(self.sub_capacity());
5211 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
5212 // or unrecognized leading scan → None (always re-execute).
5213 let scan_label = extract_scan_label(&ops, &mut self.syms);
5214 self.query_subscriptions.push(QuerySubEntry {
5215 ops,
5216 columns,
5217 prev_row_map,
5218 inner: std::sync::Arc::downgrade(&inner),
5219 scan_label,
5220 });
5221 Ok(Subscription(inner))
5222 }
5223
5224 /// Queue capacity used for new subscriptions.
5225 fn sub_capacity(&self) -> usize {
5226 self.sub_capacity
5227 }
5228
5229 /// Override per-subscriber queue capacity for subsequently created
5230 /// subscriptions on this db instance.
5231 ///
5232 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5233 /// value in tests to exercise the [`DbEvent::Lagged`] path without
5234 /// generating tens of thousands of events.
5235 ///
5236 /// This is a test-support escape hatch. Calling it in production reduces
5237 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5238 /// to discourage accidental production use.
5239 #[doc(hidden)]
5240 pub fn set_sub_capacity(&mut self, capacity: usize) {
5241 self.sub_capacity = capacity;
5242 }
5243
5244 // -----------------------------------------------------------------------
5245
5246 /// Start an atomic batch.
5247 ///
5248 /// The returned [`BatchBuilder`] borrows `self` mutably until
5249 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5250 /// validation, no WAL I/O. `commit` validates every queued op against
5251 /// live state plus preceding ops in this batch (duplicate key inside
5252 /// the batch is `Err`; an edge between two nodes created earlier in
5253 /// the batch is valid; `delete_node` then insert of the same key is a
5254 /// fresh identity). Validation never mutates the database. Any failure
5255 /// leaves WAL bytes and in-memory state identical to before `commit`.
5256 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5257 /// and each inner record is applied in order so rules fire per record.
5258 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5259 ///
5260 /// **Rule-window limitation:** batch validation cannot see edges that a
5261 /// rule created earlier in the *same* batch will derive at apply time, so
5262 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5263 /// where sequential calls would return `Err(RuleOwned)`. State integrity
5264 /// is unaffected (idempotent apply, provenance intact). Create rules in
5265 /// their own batch, or sequentially, when later ops may touch derived
5266 /// edges.
5267 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5268 BatchBuilder {
5269 db: self,
5270 ops: Vec::new(),
5271 }
5272 }
5273
5274 /// Closure-style atomic write batch.
5275 ///
5276 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5277 /// then committing. All ops queued inside `build` are validated in order and
5278 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5279 /// once per inner record, in order, after commit — semantically identical to
5280 /// sequential single-op writes.
5281 ///
5282 /// **Error semantics — validate-then-apply.** `build` queues ops without
5283 /// touching the database. [`BatchBuilder::commit`] validates every op against
5284 /// live state plus earlier ops in this batch before writing anything. If op N
5285 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5286 /// entire batch is rejected: no WAL bytes are written and no in-memory state
5287 /// changes. The database is identical to its state before `write_batch` was
5288 /// called.
5289 ///
5290 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5291 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5292 /// either fully applied or not at all. However, while applying a committed
5293 /// batch, concurrent readers may observe intermediate states as ops are applied
5294 /// sequentially in memory. There is no interactive transaction isolation in v1.
5295 /// This is documented as "crash-atomic write batches; no interactive
5296 /// transactions or read isolation."
5297 ///
5298 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5299 /// writes zero WAL bytes and returns `(0, 0)`.
5300 ///
5301 /// # Example
5302 ///
5303 /// ```rust,ignore
5304 /// let (nodes, edges) = db.write_batch(|b| {
5305 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5306 /// b.insert_node("Person", "bob", vec![]);
5307 /// b.insert_edge("KNOWS", "alice", "bob");
5308 /// b.set_prop("alice", "role", Value::Str("admin".into()));
5309 /// b.delete_node("old_key");
5310 /// })?;
5311 /// // One fsync; on crash replay: all five ops land or none do.
5312 /// ```
5313 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5314 where
5315 C: FnOnce(&mut BatchBuilder<'_, F>),
5316 {
5317 let mut b = self.batch();
5318 build(&mut b);
5319 b.commit()
5320 }
5321
5322 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5323 /// auto-declared KeyMatch rules (if any) first, then the accepted node
5324 /// inserts, so incremental fire sees the new rules. Per-row key problems
5325 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5326 /// `Err` means nothing was applied.
5327 ///
5328 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5329 /// distinct source labels sharing an FK field each get their own rule.
5330 pub fn ingest(
5331 &mut self,
5332 label: &str,
5333 rows: Vec<BTreeMap<String, Value>>,
5334 opts: &IngestOptions,
5335 ) -> Result<IngestReport> {
5336 self.ingest_with_edges(label, rows, opts, &[])
5337 }
5338
5339 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5340 /// A failing edge rejects the whole request; nothing is applied.
5341 pub fn ingest_with_edges(
5342 &mut self,
5343 label: &str,
5344 rows: Vec<BTreeMap<String, Value>>,
5345 opts: &IngestOptions,
5346 edges: &[(String, String, String)],
5347 ) -> Result<IngestReport> {
5348 crate::ingest::run(self, label, rows, opts, edges)
5349 }
5350
5351 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5352 ///
5353 /// JSON `null` fields are silently omitted (not stored, not a row error).
5354 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5355 /// Parse failures and a top-level value that is not an array of objects
5356 /// return [`GraphError::IngestError`].
5357 pub fn ingest_json(
5358 &mut self,
5359 label: &str,
5360 json: &str,
5361 opts: &IngestOptions,
5362 ) -> Result<IngestReport> {
5363 crate::ingest::run_json(self, label, json, opts)
5364 }
5365
5366 fn commit_logged_batch(
5367 &mut self,
5368 ops: Vec<BatchOp>,
5369 ingest: Option<(String, usize)>,
5370 // Two-source rule: write_batch_authz threads authz here directly (never
5371 // touches pending_write_authz); query_write_authz sets the field instead
5372 // and passes None. Only one source is non-None per call.
5373 param_authz: Option<WriteAuthz>,
5374 ) -> Result<(usize, usize)> {
5375 // Read-only guard: catches empty-batch calls before the early-return
5376 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5377 if self.read_only {
5378 return Err(GraphError::ReadOnly);
5379 }
5380 // Ensure provenance is decoded before MutPreview accesses it
5381 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5382 self.engine.ensure_provenance_loaded_mut();
5383
5384 // ── Authz pre-check ──────────────────────────────────────────────────
5385 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5386 // produces no WAL frame (all-or-nothing at the authz boundary extends
5387 // the existing validate-then-apply contract to role-scope checks).
5388 //
5389 // `batch_created` tracks key→label for nodes created by earlier ops in
5390 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5391 // as visible without needing to call `self.ids.get` on not-yet-committed
5392 // keys (they won't be there yet).
5393 //
5394 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5395 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5396 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5397 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5398 if let Some(ref authz) = authz_opt {
5399 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5400 for op in &ops {
5401 self.check_single_op_authz(authz, op, &batch_created)?;
5402 // Update batch_created after a passing authz check so that
5403 // subsequent ops in this batch see the nodes as "about to exist".
5404 match op {
5405 BatchOp::InsertNode { label, key, .. } => {
5406 // Only track genuinely new nodes (absent from the
5407 // snapshot at authz-check time). A pre-existing visible
5408 // key would be a DuplicateKey — not a real creation —
5409 // so MutPreview handles it. Letting it into batch_created
5410 // would allow a later SetProp to bypass update_labels
5411 // via the "batch-created → always updatable" ruling
5412 // (delete+recreate exploit, fix for I1 review round 2).
5413 //
5414 // Accepted edge: for a delete+recreate-with-different-
5415 // label batch, node_status resolves the pre-delete
5416 // (store) label for any subsequent update checks. This
5417 // grants no net-new capability — a role that can delete+
5418 // create can already place arbitrary props via
5419 // InsertNode's own props field.
5420 if self.ids.get(key.as_str()).is_none() {
5421 batch_created.insert(key.clone(), label.clone());
5422 }
5423 }
5424 BatchOp::InsertEdgeUpsert {
5425 placeholder_label,
5426 src_key,
5427 dst_key,
5428 ..
5429 } => {
5430 // Both endpoints will be created if not already in store.
5431 for ep_key in [src_key, dst_key] {
5432 if self.ids.get(ep_key.as_str()).is_none()
5433 && !batch_created.contains_key(ep_key.as_str())
5434 {
5435 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5436 }
5437 }
5438 }
5439 _ => {}
5440 }
5441 }
5442 }
5443
5444 let recs = {
5445 let mut preview = MutPreview::new(self);
5446 let mut recs = Vec::with_capacity(ops.len());
5447 for op in ops {
5448 match op {
5449 BatchOp::InsertNode { label, key, props } => {
5450 preview.check_insert_node(&key)?;
5451 preview.note_insert_node(&key, &props);
5452 recs.push(WalRecord::InsertNode { label, key, props });
5453 }
5454 BatchOp::InsertEdge {
5455 edge_type,
5456 src_key,
5457 dst_key,
5458 } => {
5459 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5460 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5461 recs.push(WalRecord::InsertEdge {
5462 edge_type,
5463 src_key,
5464 dst_key,
5465 });
5466 }
5467 }
5468 BatchOp::SetProp { key, field, value } => {
5469 preview.check_live_key(&key)?;
5470 preview.note_set_prop(&key, &field, &value);
5471 recs.push(WalRecord::SetProp { key, field, value });
5472 }
5473 BatchOp::RemoveProp { key, field } => {
5474 if preview.prepare_remove_prop(&key, &field)? {
5475 preview.note_remove_prop(&key, &field);
5476 recs.push(WalRecord::RemoveProp { key, field });
5477 }
5478 }
5479 BatchOp::DeleteEdge {
5480 edge_type,
5481 src_key,
5482 dst_key,
5483 } => {
5484 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5485 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5486 recs.push(WalRecord::DeleteEdge {
5487 edge_type,
5488 src_key,
5489 dst_key,
5490 });
5491 }
5492 }
5493 BatchOp::DeleteNode { key } => {
5494 preview.check_live_key(&key)?;
5495 preview.note_delete_node(&key);
5496 recs.push(WalRecord::DeleteNode { key });
5497 }
5498 BatchOp::CreateRule(def) => {
5499 preview.check_create_rule(&def)?;
5500 let def_bytes =
5501 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5502 detail: format!("serialize rule: {e}"),
5503 })?;
5504 preview.note_create_rule(&def);
5505 recs.push(WalRecord::CreateRule { def_bytes });
5506 }
5507 BatchOp::DeleteRule { name } => {
5508 preview.check_delete_rule(&name)?;
5509 preview.note_delete_rule(&name);
5510 recs.push(WalRecord::DeleteRule { name });
5511 }
5512 BatchOp::RenameNode { old_key, new_key } => {
5513 preview.check_rename_node(&old_key, &new_key)?;
5514 preview.note_rename_node(&old_key, &new_key);
5515 recs.push(WalRecord::RenameNode { old_key, new_key });
5516 }
5517 BatchOp::InsertEdgeUpsert {
5518 edge_type,
5519 src_key,
5520 dst_key,
5521 placeholder_label,
5522 } => {
5523 // Auto-create any missing endpoints as plain InsertNode ops.
5524 // Rules fire and last-change is updated for each created node.
5525 for key in [&src_key, &dst_key] {
5526 if !preview.has_key(key) {
5527 preview.check_insert_node(key)?;
5528 preview.note_insert_node(key, &[]);
5529 recs.push(WalRecord::InsertNode {
5530 label: placeholder_label.clone(),
5531 key: key.clone(),
5532 props: vec![],
5533 });
5534 }
5535 }
5536 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5537 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5538 recs.push(WalRecord::InsertEdge {
5539 edge_type,
5540 src_key,
5541 dst_key,
5542 });
5543 }
5544 }
5545 }
5546 }
5547 recs
5548 };
5549 if recs.is_empty() {
5550 return Ok((0, 0));
5551 }
5552 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5553 // *Id form, so only the dense variants can appear in `recs` here.
5554 let recs = self.rewrite_wal_dense(recs)?;
5555 // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5556 // namespace the node is already in is a no-op and is dropped there. An
5557 // empty `Batch` frame would still take a commit sequence and a WAL
5558 // record, so a batch that turns out to be nothing writes nothing.
5559 if recs.is_empty() {
5560 return Ok((0, 0));
5561 }
5562 let nodes_inserted = recs
5563 .iter()
5564 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5565 .count();
5566 let edges_inserted = recs
5567 .iter()
5568 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5569 .count();
5570 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5571 // under Strict. Pass self.fsync directly so Strict stays Strict —
5572 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5573 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5574 // short-circuit on single-op batches and silently skip the fsync.
5575 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5576 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5577 Ok((nodes_inserted, edges_inserted))
5578 }
5579
5580 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5581 self.commit_logged_batch(ops, None, None)
5582 }
5583
5584 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5585 /// and the group-commit drain thread, which do a single group fsync later.
5586 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5587 // Restore fsync policy even on panic via a raw-pointer drop guard.
5588 // A panic here would poison the RwLock anyway, but the correct policy
5589 // must be in place if the guard is ever unwrapped.
5590 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5591 impl Drop for RestoreFsync {
5592 fn drop(&mut self) {
5593 // SAFETY: the pointer is valid for the full duration of
5594 // commit_batch_nosync; the guard is dropped before the frame
5595 // returns, and GraphDb outlives this frame.
5596 unsafe {
5597 *self.0 = self.1;
5598 }
5599 }
5600 }
5601 let saved = self.fsync;
5602 // SAFETY: raw pointer into self; guard dropped within this frame.
5603 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5604 self.fsync = FsyncPolicy::Relaxed;
5605 self.commit_logged_batch(ops, None, None)
5606 }
5607
5608 /// Commit multiple op-batches as a **group**: each submission gets its own
5609 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5610 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5611 ///
5612 /// # Durability semantics
5613 ///
5614 /// A crash before the group fsync may lose **all** submissions in the group.
5615 /// A crash after the group fsync preserves all of them. No submission is
5616 /// ever torn: each WAL frame is either fully applied on replay or dropped
5617 /// in its entirety (CRC-protected frame boundaries).
5618 ///
5619 /// Events and subscription notifications fire per-submission immediately
5620 /// after apply, which may be before the group fsync. From a subscriber's
5621 /// perspective this is equivalent to the `Relaxed` durability window.
5622 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5623 /// fsync, so from their perspective durability is fully guaranteed.
5624 ///
5625 /// # MVCC interplay
5626 ///
5627 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5628 /// increments per submission (not per group), preserving existing reader
5629 /// snapshot semantics.
5630 ///
5631 /// # Returns
5632 ///
5633 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5634 /// in order. Failures are per-submission (validation errors); the group
5635 /// fsync error (if any) is returned as the second tuple element.
5636 pub fn commit_group(
5637 &mut self,
5638 groups: Vec<Vec<BatchOp>>,
5639 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5640 let mut results = Vec::with_capacity(groups.len());
5641 for ops in groups {
5642 results.push(self.commit_batch_nosync(ops));
5643 }
5644 let any_ok = results.iter().any(|r| r.is_ok());
5645 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5646 self.fs
5647 .sync(core_storage::fs::FileId::Wal)
5648 .map_err(GraphError::Io)
5649 .err()
5650 } else {
5651 None
5652 };
5653 (results, sync_err)
5654 }
5655
5656 /// Like [`commit_group`] but skips the group fsync entirely.
5657 ///
5658 /// Used by the drain thread to apply submissions under the write lock and
5659 /// then perform the single fsync OUTSIDE the lock (via
5660 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5661 /// to concurrent readers.
5662 pub fn commit_group_nosync(
5663 &mut self,
5664 groups: Vec<Vec<BatchOp>>,
5665 ) -> Vec<Result<(usize, usize)>> {
5666 let mut results = Vec::with_capacity(groups.len());
5667 for ops in groups {
5668 results.push(self.commit_batch_nosync(ops));
5669 }
5670 results
5671 }
5672
5673 pub fn insert_node(
5674 &mut self,
5675 label: &str,
5676 key: &str,
5677 props: Vec<(String, Value)>,
5678 ) -> Result<()> {
5679 if self.read_only {
5680 return Err(GraphError::ReadOnly);
5681 }
5682 MutPreview::new(self).check_insert_node(key)?;
5683 self.log_dense(vec![WalRecord::InsertNode {
5684 label: label.into(),
5685 key: key.into(),
5686 props,
5687 }])
5688 }
5689
5690 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5691 if self.read_only {
5692 return Err(GraphError::ReadOnly);
5693 }
5694 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5695 return Ok(false);
5696 }
5697 self.log_dense(vec![WalRecord::InsertEdge {
5698 edge_type: edge_type.into(),
5699 src_key: src_key.into(),
5700 dst_key: dst_key.into(),
5701 }])?;
5702 Ok(true)
5703 }
5704
5705 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5706 if self.read_only {
5707 return Err(GraphError::ReadOnly);
5708 }
5709 if let Some(view_name) = self.view_store.view_for_prop(field) {
5710 return Err(GraphError::ViewPropReadOnly {
5711 view_name: view_name.to_string(),
5712 });
5713 }
5714 MutPreview::new(self).check_live_key(key)?;
5715 self.log_dense(vec![WalRecord::SetProp {
5716 key: key.into(),
5717 field: field.into(),
5718 value,
5719 }])
5720 }
5721
5722 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5723 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5724 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5725 if self.read_only {
5726 return Err(GraphError::ReadOnly);
5727 }
5728 if let Some(view_name) = self.view_store.view_for_prop(field) {
5729 return Err(GraphError::ViewPropReadOnly {
5730 view_name: view_name.to_string(),
5731 });
5732 }
5733 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5734 return Ok(false);
5735 }
5736 self.log_then_apply(WalRecord::RemoveProp {
5737 key: key.into(),
5738 field: field.into(),
5739 })?;
5740 Ok(true)
5741 }
5742
5743 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5744 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5745 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5746 /// (the rule would just put the edge back; delete or change the rule).
5747 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5748 if self.read_only {
5749 return Err(GraphError::ReadOnly);
5750 }
5751 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5752 return Ok(false);
5753 }
5754 self.log_then_apply(WalRecord::DeleteEdge {
5755 edge_type: edge_type.into(),
5756 src_key: src_key.into(),
5757 dst_key: dst_key.into(),
5758 })?;
5759 Ok(true)
5760 }
5761
5762 /// Delete a live node. Unknown or already-tombstoned keys are
5763 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5764 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5765 /// (crash window) is a clean no-op.
5766 ///
5767 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5768 /// removed (computed from live state before the deletion is applied).
5769 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5770 if self.read_only {
5771 return Err(GraphError::ReadOnly);
5772 }
5773 // Provenance must be loaded before we query provenance_touching.
5774 self.engine.ensure_provenance_loaded_mut();
5775 let id = self
5776 .ids
5777 .get(key)
5778 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5779
5780 // Count edges before the delete is applied so we can report counts.
5781 let derived_set: BTreeSet<(u32, u32, u32)> = self
5782 .engine
5783 .provenance_touching(id)
5784 .map(|(_, etype, src, dst)| (etype, src, dst))
5785 .collect();
5786 let derived_edges = derived_set.len() as u64;
5787
5788 let mut total_topo = 0u64;
5789 let tv = self.topo_view();
5790 for et in tv.etypes() {
5791 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5792 + tv.neighbors(et, Direction::In, id).len() as u64;
5793 }
5794 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5795 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5796 // The subtraction remains correct because both counts include both directions.
5797 let manual_edges = total_topo.saturating_sub(derived_edges);
5798
5799 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5800 Ok(DeleteReport {
5801 manual_edges,
5802 derived_edges,
5803 })
5804 }
5805
5806 /// Rename a live node's key. The dense id (and therefore all edges,
5807 /// props, history, and last-change tracking) is unaffected.
5808 ///
5809 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5810 /// Returns `Err(DuplicateKey)` if `new` is already live.
5811 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5812 if self.read_only {
5813 return Err(GraphError::ReadOnly);
5814 }
5815 MutPreview::new(self).check_rename_node(old, new)?;
5816 self.log_then_apply(WalRecord::RenameNode {
5817 old_key: old.into(),
5818 new_key: new.into(),
5819 })
5820 }
5821
5822 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5823 /// `None` if the rule does not exist or is not approximate.
5824 ///
5825 /// The drift counter increments on IVF insert/remove after the last fit.
5826 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5827 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5828 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5829 // SideIvfExport = (centroids, node→cluster, drift)
5830 self.engine
5831 .export_ivf_state()
5832 .remove(rule)
5833 .map(|(_src, dst)| dst.2)
5834 }
5835
5836 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5837 /// Validation and duplicate-name check run before logging so invalid rules
5838 /// never enter the WAL.
5839 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5840 if self.read_only {
5841 return Err(GraphError::ReadOnly);
5842 }
5843 MutPreview::new(self).check_create_rule(&def)?;
5844 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5845 detail: format!("serialize rule: {e}"),
5846 })?;
5847 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5848 }
5849
5850 /// Override this handle's HNSW build-slice size, or `None` to restore
5851 /// [`core_rules::HNSW_BUILD_BATCH`].
5852 ///
5853 /// Exposed for tests that need a small slice without a large corpus; not
5854 /// part of the stable surface.
5855 #[doc(hidden)]
5856 pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
5857 self.engine.set_hnsw_build_batch(batch);
5858 }
5859
5860 /// Rules whose vector index is still being built, in name order.
5861 ///
5862 /// The same list [`GraphDb::stats`] reports per rule in `building`.
5863 pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
5864 self.engine.builds_in_progress()
5865 }
5866
5867 /// Advance any vector index still building and backfill each rule that
5868 /// finishes. Returns what is still outstanding.
5869 ///
5870 /// A map lookup when nothing is pending, so it is cheap to call on a timer.
5871 /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
5872 /// inserts per pending rule per call, so a caller can drive a large build
5873 /// to completion without ever holding the lock for more than a slice.
5874 ///
5875 /// A rule that finishes here is backfilled through the same
5876 /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
5877 /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
5878 /// and appear all at once.
5879 ///
5880 /// Every ordinary write pumps one slice on its own (see the post-commit
5881 /// hook in `log_then_apply_with`), so this is for quiescent stores and for
5882 /// operators who want the build finished before traffic arrives.
5883 pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
5884 Ok(self.pump_index_build_reporting()?.1)
5885 }
5886
5887 /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
5888 /// call finished, so a progress display can say so.
5889 ///
5890 /// A build can be registered and completed inside a single call — that is
5891 /// what a mid-build snapshot looks like on reopen, where the index scan
5892 /// finishes the graph and only the backfill is outstanding — and the
5893 /// outstanding list alone cannot show that anything happened.
5894 pub fn pump_index_build_reporting(
5895 &mut self,
5896 ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
5897 // A read-only handle cannot issue the `RebuildRule` a finished build
5898 // needs, so it would advance the index and then silently fail to
5899 // produce the edges. Refusing is the honest answer.
5900 if self.read_only {
5901 return Err(GraphError::ReadOnly);
5902 }
5903 let finished = self.pump_one_slice();
5904 for done in &finished {
5905 // The index is whole but the rule still owns no edges. A failed
5906 // second commit must leave the rule re-pumpable rather than
5907 // silently edge-less, so the error is surfaced here — unlike the
5908 // post-commit hook, this call is not riding someone else's commit.
5909 self.log_then_apply(WalRecord::RebuildRule {
5910 name: done.rule.clone(),
5911 })?;
5912 }
5913 Ok((finished, self.engine.builds_in_progress()))
5914 }
5915
5916 /// Run the deferred candidate-index build, if it is still owed, against the
5917 /// graph as it stands *now* — before the caller applies anything.
5918 ///
5919 /// A no-op bool test once the indexes are populated, which is after the
5920 /// first write of the handle's life, and for a store with no rules at all.
5921 fn populate_indexes_before_write(&mut self) {
5922 if !self.engine.needs_index_population() {
5923 return;
5924 }
5925 // The retained snapshot blobs arrive with the V8 base sections; without
5926 // them the scan would rebuild every graph the snapshot already holds.
5927 self.ensure_v8_base_sections_loaded();
5928 if !self.engine.needs_index_population() {
5929 return;
5930 }
5931 let mut eng = std::mem::take(&mut self.engine);
5932 {
5933 let gm = make_graph_mut(
5934 &self.ids,
5935 &mut self.syms,
5936 &self.labels,
5937 build_props_view(&self.props, &self.base),
5938 &mut self.topo,
5939 &self.base,
5940 &mut self.edge_props,
5941 );
5942 eng.populate_indexes(&gm);
5943 }
5944 self.engine = eng;
5945 }
5946
5947 /// One slice of build work for every pending rule. Returns the rules whose
5948 /// index just became whole, which the caller must `RebuildRule`.
5949 ///
5950 /// Goes through the engine even with nothing pending when the indexes have
5951 /// not been populated yet: that call is what re-derives a build a mid-build
5952 /// snapshot left behind, and a fresh handle has no other way to learn of it.
5953 fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
5954 // The retained snapshot blobs — and the id count an interrupted build
5955 // is recognised against — arrive with the V8 base sections, which a
5956 // clean open reads lazily. Without this a freshly opened handle pumps
5957 // against empty retained state and concludes there is nothing to do,
5958 // which is precisely the store `build-index` exists for.
5959 self.ensure_v8_base_sections_loaded();
5960 let mut eng = std::mem::take(&mut self.engine);
5961 let finished = {
5962 let mut gm = make_graph_mut(
5963 &self.ids,
5964 &mut self.syms,
5965 &self.labels,
5966 build_props_view(&self.props, &self.base),
5967 &mut self.topo,
5968 &self.base,
5969 &mut self.edge_props,
5970 );
5971 eng.pump_index_build(&mut gm)
5972 };
5973 self.engine = eng;
5974 finished
5975 }
5976
5977 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5978 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5979 if self.read_only {
5980 return Err(GraphError::ReadOnly);
5981 }
5982 MutPreview::new(self).check_delete_rule(name)?;
5983 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5984 }
5985
5986 /// Return a snapshot of all registered rules.
5987 pub fn rules(&self) -> Vec<RuleDef> {
5988 self.engine.rules().cloned().collect()
5989 }
5990
5991 // -----------------------------------------------------------------------
5992 // Rule suggestion API
5993 // -----------------------------------------------------------------------
5994
5995 /// Profile the database and suggest linking rules with previewed edge counts.
5996 ///
5997 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5998 /// sampling. Suggestions are sorted by estimated edge count (descending).
5999 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6000 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6001 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6002 }
6003
6004 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6005 /// reproducibility. Same seed + same data = identical output.
6006 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6007 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6008 .suggestions
6009 }
6010
6011 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6012 ///
6013 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6014 /// and a `truncated` flag indicating whether the global budget fired before all
6015 /// candidates were evaluated.
6016 pub fn suggest_rules_with_config(
6017 &self,
6018 config: &core_rules::suggest::SuggestConfig,
6019 seed: u64,
6020 ) -> core_rules::SuggestReport {
6021 use std::collections::BTreeMap;
6022
6023 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6024 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6025 for id in 0..self.ids.len() as u32 {
6026 let Some(key) = self.ids.key_of(id) else {
6027 continue;
6028 };
6029 let Some(&sym) = self.labels.get(id as usize) else {
6030 continue;
6031 };
6032 if sym == u32::MAX {
6033 continue; // tombstoned
6034 }
6035 let Some(label) = self.syms.resolve(sym) else {
6036 continue;
6037 };
6038 label_nodes
6039 .entry(label.to_string())
6040 .or_default()
6041 .push((id, key.to_string()));
6042 }
6043
6044 let existing = self.rules();
6045 let pv = build_props_view(&self.props, &self.base);
6046 let all_fields: Vec<String> = pv.field_names();
6047
6048 core_rules::suggest::suggest_rules(
6049 &label_nodes,
6050 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6051 &all_fields,
6052 &existing,
6053 config,
6054 seed,
6055 )
6056 }
6057
6058 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6059 /// plus later mutations replay identically (rebuild is a pure function
6060 /// of state).
6061 ///
6062 /// Only exit from the tripped latch: if the full desired set fits the
6063 /// budget, it is applied completely and `tripped` clears; if it still
6064 /// exceeds the budget, provenance is left untouched and `tripped` stays
6065 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6066 /// Unknown rule → `RuleNotFound`, nothing logged.
6067 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6068 if self.read_only {
6069 return Err(GraphError::ReadOnly);
6070 }
6071 if !self.engine.rules().any(|r| r.name == name) {
6072 return Err(GraphError::RuleNotFound { name: name.into() });
6073 }
6074 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6075 }
6076
6077 // -----------------------------------------------------------------------
6078 // Materialized view API
6079 // -----------------------------------------------------------------------
6080
6081 /// Register a new materialized property view, backfill its values for all
6082 /// existing nodes, and WAL-log the definition.
6083 ///
6084 /// # Errors
6085 /// - `ReadOnly`: called on an as-of instance.
6086 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6087 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6088 if self.read_only {
6089 return Err(GraphError::ReadOnly);
6090 }
6091 // Pre-validate before WAL write.
6092 def.validate()
6093 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6094 if self.view_store.has_view(&def.name) {
6095 return Err(GraphError::RuleInvalid {
6096 detail: format!("view {:?} already exists", def.name),
6097 });
6098 }
6099 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6100 return Err(GraphError::RuleInvalid {
6101 detail: format!(
6102 "view_prop {:?} is already used by view {:?}",
6103 def.view_prop, existing
6104 ),
6105 });
6106 }
6107 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6108 detail: format!("serialize view: {e}"),
6109 })?;
6110 // Enable delta accumulation before the view is registered so subsequent
6111 // incremental edge events reach view maintenance from this point onward.
6112 // (The backfill inside create_view reads topo directly; it does not rely
6113 // on pending deltas.)
6114 self.engine.set_emit_deltas(true);
6115 self.log_then_apply(WalRecord::CreateView { def_bytes })
6116 }
6117
6118 /// Remove a named view and delete its values from every node.
6119 ///
6120 /// # Errors
6121 /// - `ReadOnly`: called on an as-of instance.
6122 /// - `RuleNotFound`: view does not exist.
6123 pub fn delete_view(&mut self, name: &str) -> Result<()> {
6124 if self.read_only {
6125 return Err(GraphError::ReadOnly);
6126 }
6127 if !self.view_store.has_view(name) {
6128 return Err(GraphError::RuleNotFound { name: name.into() });
6129 }
6130 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6131 // After deletion, disable accumulation if no listeners remain.
6132 if !self.needs_emit_deltas() {
6133 self.engine.set_emit_deltas(false);
6134 }
6135 result
6136 }
6137
6138 /// Snapshot of all registered view definitions.
6139 pub fn views(&self) -> Vec<ViewDef> {
6140 self.view_store.views().cloned().collect()
6141 }
6142
6143 // -----------------------------------------------------------------------
6144 // Full-text-lite API
6145 // -----------------------------------------------------------------------
6146
6147 /// Enable full-text indexing for all nodes of `label` on property `field`.
6148 ///
6149 /// After this call, every subsequent write to `(label, field)` is reflected
6150 /// in the index incrementally. Existing nodes are backfilled immediately.
6151 /// The declaration is persisted as a WAL record; the index itself is rebuilt
6152 /// from scratch on re-open (no snapshot format changes).
6153 ///
6154 /// # Errors
6155 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6156 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6157 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6158 if self.read_only {
6159 return Err(GraphError::ReadOnly);
6160 }
6161 if self.fulltext.is_enabled(label, field) {
6162 return Err(GraphError::RuleInvalid {
6163 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6164 });
6165 }
6166 self.log_then_apply(WalRecord::EnableFulltext {
6167 label: label.into(),
6168 field: field.into(),
6169 })
6170 }
6171
6172 /// Disable full-text indexing for `(label, field)` and drop its postings.
6173 ///
6174 /// # Errors
6175 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6176 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6177 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6178 if self.read_only {
6179 return Err(GraphError::ReadOnly);
6180 }
6181 if !self.fulltext.is_enabled(label, field) {
6182 return Err(GraphError::RuleNotFound {
6183 name: format!("fulltext({label},{field})"),
6184 });
6185 }
6186 self.log_then_apply(WalRecord::DisableFulltext {
6187 label: label.into(),
6188 field: field.into(),
6189 })
6190 }
6191
6192 /// Whether `(label, field)` is currently indexed for full-text search.
6193 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6194 self.fulltext.is_enabled(label, field)
6195 }
6196
6197 /// Every `(label, field)` pair with a live full-text index, sorted.
6198 ///
6199 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6200 /// declares which nodes are *indexed*, so callers that want to search
6201 /// everything indexed should query each distinct field once.
6202 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6203 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6204 v.sort();
6205 v
6206 }
6207
6208 /// Enable an equality index for all nodes of `label` on scalar property
6209 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6210 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6211 /// declaration persists via WAL and the postings rebuild on re-open.
6212 ///
6213 /// # Errors
6214 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6215 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6216 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6217 if self.read_only {
6218 return Err(GraphError::ReadOnly);
6219 }
6220 if self.prop_index.is_enabled(label, field) {
6221 return Err(GraphError::RuleInvalid {
6222 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6223 });
6224 }
6225 self.log_then_apply(WalRecord::EnableIndex {
6226 label: label.into(),
6227 field: field.into(),
6228 })
6229 }
6230
6231 /// Disable the equality index for `(label, field)` and drop its postings.
6232 ///
6233 /// # Errors
6234 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6235 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6236 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6237 if self.read_only {
6238 return Err(GraphError::ReadOnly);
6239 }
6240 if !self.prop_index.is_enabled(label, field) {
6241 return Err(GraphError::RuleNotFound {
6242 name: format!("index({label},{field})"),
6243 });
6244 }
6245 self.log_then_apply(WalRecord::DisableIndex {
6246 label: label.into(),
6247 field: field.into(),
6248 })
6249 }
6250
6251 /// Whether `(label, field)` currently has an equality index.
6252 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6253 self.prop_index.is_enabled(label, field)
6254 }
6255
6256 /// Search a full-text-indexed field.
6257 ///
6258 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6259 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
6260 ///
6261 /// **Query syntax:**
6262 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6263 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6264 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6265 /// - `AND` keyword is accepted explicitly and is the default.
6266 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6267 ///
6268 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6269 /// Pin: this is the documented, tested, stable behavior for v1.
6270 ///
6271 /// **Memory / performance:** O(postings) lookup; no scan. The index is
6272 /// in-memory and proportional to total indexed text across all enabled fields.
6273 ///
6274 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6275 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6276 /// key ascending for deterministic tiebreaking.
6277 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6278 // Resolve node_ids to keys (excluding tombstones) then re-sort by
6279 // (score DESC, key ASC) to give a deterministic, key-lexicographic
6280 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
6281 // which diverges from key order when nodes were not inserted in key-lex order.
6282 let mut results: Vec<(String, f64)> = self
6283 .fulltext
6284 .search(field, query, 0)
6285 .into_iter()
6286 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6287 .collect();
6288 results.sort_by(|a, b| {
6289 b.1.partial_cmp(&a.1)
6290 .unwrap_or(std::cmp::Ordering::Equal)
6291 .then(a.0.cmp(&b.0))
6292 });
6293 results
6294 }
6295
6296 /// [`search`](Self::search), stopping at the `k` best hits.
6297 ///
6298 /// Same ranking and the same deterministic tiebreak, but the index drops
6299 /// everything past `k` before any key is resolved, so a caller that wants
6300 /// the top few out of a field that matched thousands does not pay to
6301 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6302 /// [`search`](Self::search) behaves.
6303 ///
6304 /// The BM25 scoring itself is not bounded by `k` — every candidate is
6305 /// scored either way — so this trims the resolve and the sort, not the
6306 /// search.
6307 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6308 // A tombstoned id resolves to nothing, so asking the index for exactly
6309 // `k` could return fewer. Over-fetching a little and truncating after
6310 // the filter keeps the count right without unbounding the call.
6311 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6312 let mut results: Vec<(String, f64)> = self
6313 .fulltext
6314 .search(field, query, want)
6315 .into_iter()
6316 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6317 .collect();
6318 results.sort_by(|a, b| {
6319 b.1.partial_cmp(&a.1)
6320 .unwrap_or(std::cmp::Ordering::Equal)
6321 .then(a.0.cmp(&b.0))
6322 });
6323 if k > 0 {
6324 results.truncate(k);
6325 }
6326 results
6327 }
6328
6329 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6330 ///
6331 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6332 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6333 /// them with RRF using a fixed constant of 60.
6334 ///
6335 /// ```text
6336 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
6337 /// ```
6338 ///
6339 /// Returns the top `k` nodes by fused score, ties broken by node key
6340 /// ascending (deterministic).
6341 ///
6342 /// # Vector leg fallback
6343 ///
6344 /// When `query_vec` is empty the vector leg is skipped entirely and
6345 /// results are ranked by the text list alone through the same RRF path
6346 /// (each text result scores `1/(60 + rank)` from that single list).
6347 ///
6348 /// When `label` is `None`, the vector leg **always** returns empty results.
6349 /// Internally `label` is mapped to `""`, which does not match any rule-created
6350 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6351 /// the brute-force fallback finds no nodes with an empty label. The fused
6352 /// ranking is therefore text-only in this case.
6353 pub fn search_hybrid(
6354 &self,
6355 text_field: &str,
6356 query_text: &str,
6357 vector_field: &str,
6358 query_vec: &[f64],
6359 label: Option<&str>,
6360 k: usize,
6361 ) -> Vec<(String, f64)> {
6362 use std::collections::HashMap;
6363
6364 const RRF_K: f64 = 60.0;
6365 let pool = 4 * k;
6366
6367 // Accumulate per-node RRF scores.
6368 let mut scores: HashMap<String, f64> = HashMap::new();
6369
6370 // Text leg.
6371 let text_hits = self.search(text_field, query_text);
6372 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6373 let rank = (rank0 + 1) as f64;
6374 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6375 }
6376
6377 // Vector leg (skipped when query_vec is empty).
6378 if !query_vec.is_empty() {
6379 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6380 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6381 let rank = (rank0 + 1) as f64;
6382 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6383 }
6384 }
6385
6386 // Sort: score DESC, then key ASC for deterministic tie-breaking.
6387 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6388 ranked.sort_by(|a, b| {
6389 b.1.partial_cmp(&a.1)
6390 .unwrap_or(std::cmp::Ordering::Equal)
6391 .then(a.0.cmp(&b.0))
6392 });
6393 ranked.truncate(k);
6394 ranked
6395 }
6396
6397 /// For DST/testing: scratch BM25 search over live nodes without the index.
6398 /// Walks every live node, re-stems field tokens, computes corpus stats, and
6399 /// returns BM25-ranked results.
6400 ///
6401 /// The oracle: the ordered key list of `search(field, q)` must equal that of
6402 /// `scratch_search(field, q)` at every quiescent state.
6403 #[doc(hidden)]
6404 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6405 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6406 use std::collections::BTreeMap;
6407
6408 let groups = parse_query(query);
6409 if groups.is_empty() {
6410 return vec![];
6411 }
6412
6413 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6414 struct NodeData {
6415 key: String,
6416 /// stemmed_token → positions (sorted)
6417 tokens: BTreeMap<String, Vec<u32>>,
6418 dl: u32,
6419 }
6420
6421 let mut nodes: Vec<NodeData> = Vec::new();
6422 for id in 0..self.ids.len() as u32 {
6423 let Some(key) = self.ids.key_of(id) else {
6424 continue;
6425 };
6426 let Some(&sym) = self.labels.get(id as usize) else {
6427 continue;
6428 };
6429 if sym == u32::MAX {
6430 continue;
6431 }
6432 let label = match self.syms.resolve(sym) {
6433 Some(l) => l,
6434 None => continue,
6435 };
6436 if !self.fulltext.is_enabled(label, field) {
6437 continue;
6438 }
6439 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6440 continue;
6441 };
6442 // Use value_tokens_stemmed_with_positions so list elements are
6443 // separated by POSITION_GAP — identical to the index path, which
6444 // prevents phrase queries from matching across element boundaries.
6445 let stemmed_with_pos = match &value {
6446 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6447 _ => continue,
6448 };
6449 let dl = stemmed_with_pos.len() as u32;
6450 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6451 for (tok, pos) in stemmed_with_pos {
6452 tok_map.entry(tok).or_default().push(pos);
6453 }
6454 nodes.push(NodeData {
6455 key: key.to_string(),
6456 tokens: tok_map,
6457 dl,
6458 });
6459 }
6460
6461 if nodes.is_empty() {
6462 return vec![];
6463 }
6464
6465 // --- BM25 corpus stats ---
6466 let n = nodes.len() as f64;
6467 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6468 // df per stemmed token across all live indexed nodes.
6469 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6470 for nd in &nodes {
6471 for tok in nd.tokens.keys() {
6472 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6473 }
6474 }
6475
6476 const K1: f64 = 1.2;
6477 const B: f64 = 0.75;
6478
6479 // --- Pass 2: score each node against each OR-group ---
6480 let mut results: Vec<(String, f64)> = Vec::new();
6481 for nd in &nodes {
6482 let dl = nd.dl as f64;
6483 let mut total_score = 0.0f64;
6484
6485 'group: for group in &groups {
6486 let mut group_score = 0.0f64;
6487
6488 for term in group {
6489 if term.negated {
6490 // Negated: if doc has this stemmed token → group fails.
6491 let present = if term.prefix {
6492 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6493 } else {
6494 nd.tokens.contains_key(term.token.as_str())
6495 };
6496 if present {
6497 continue 'group;
6498 }
6499 continue;
6500 }
6501 if term.prefix {
6502 // Prefix: sum BM25 for all matching stemmed tokens.
6503 let mut prefix_matched = false;
6504 for (tok, positions) in &nd.tokens {
6505 if tok.starts_with(term.token.as_str()) {
6506 let tf = positions.len() as f64;
6507 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6508 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6509 let tf_norm =
6510 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6511 group_score += idf * tf_norm;
6512 prefix_matched = true;
6513 }
6514 }
6515 if !prefix_matched {
6516 continue 'group;
6517 }
6518 } else {
6519 // term.token is already stemmed by parse_query; use directly.
6520 match nd.tokens.get(term.token.as_str()) {
6521 None => continue 'group,
6522 Some(positions) => {
6523 let tf = positions.len() as f64;
6524 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6525 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6526 let tf_norm =
6527 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6528 group_score += idf * tf_norm;
6529 }
6530 }
6531 }
6532 }
6533
6534 if group_score > 0.0 {
6535 total_score += group_score;
6536 }
6537 }
6538
6539 if total_score > 0.0 {
6540 results.push((nd.key.clone(), total_score));
6541 }
6542 }
6543
6544 results.sort_by(|a, b| {
6545 b.1.partial_cmp(&a.1)
6546 .unwrap_or(std::cmp::Ordering::Equal)
6547 .then(a.0.cmp(&b.0))
6548 });
6549 results
6550 }
6551
6552 /// Return the current view-maintained value of `view_prop` for node `key`.
6553 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6554 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6555 let id = self.ids.get(key)?;
6556 self.props_view()
6557 .get(id, view_prop)
6558 .map(|vr| vr.into_value())
6559 }
6560
6561 /// For testing / DST oracle: scratch recompute of a view value for one node.
6562 ///
6563 /// Returns `None` if the node does not exist, the view does not exist, or
6564 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6565 #[doc(hidden)]
6566 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6567 let node = self.ids.get(key)?;
6568 let def = self.view_store.views().find(|v| v.name == view_name)?;
6569 // Use TopologyView so that NeighborAgg sees base + overlay edges
6570 // without materialising a temporary Topology (I1).
6571 let topo_view = self.topo_view();
6572 core_rules::views::compute_view_value(
6573 def,
6574 node,
6575 self.props_view(),
6576 &topo_view,
6577 &self.ids,
6578 &self.syms,
6579 &self.labels,
6580 )
6581 }
6582
6583 // -----------------------------------------------------------------------
6584 // Graph algorithm API
6585 // -----------------------------------------------------------------------
6586
6587 /// Run PageRank over the unified topology (manual + derived edges).
6588 ///
6589 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6590 /// ascending). Set `config.edge_type` to restrict to one edge type.
6591 /// `config.converged` is `true` only when the power iteration converged
6592 /// within `config.max_iters` and within any time budget.
6593 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6594 let topo = build_topo_view(&self.topo, &self.base);
6595 let edge_props = self.edge_props_view();
6596 crate::algo::pagerank(
6597 &topo,
6598 &self.ids,
6599 &self.syms,
6600 &self.labels,
6601 &edge_props,
6602 config,
6603 )
6604 }
6605
6606 /// Weakly-connected components over the unified topology (treated as
6607 /// undirected regardless of how edges were inserted).
6608 ///
6609 /// Component IDs are the key of the smallest member in the component
6610 /// (deterministic). Result sorted by (component_id, key).
6611 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6612 let topo = build_topo_view(&self.topo, &self.base);
6613 let edge_props = self.edge_props_view();
6614 crate::algo::wcc(
6615 &topo,
6616 &self.ids,
6617 &self.syms,
6618 &self.labels,
6619 &edge_props,
6620 config,
6621 )
6622 }
6623
6624 /// Degree centrality for every live node.
6625 ///
6626 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6627 /// `AlgoDir::Both` = out + in (total directed degree).
6628 ///
6629 /// For one-shot ranking use this; for a live property updated on every
6630 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6631 pub fn degree_centrality(
6632 &self,
6633 config: &crate::algo::DegreeConfig,
6634 ) -> crate::algo::DegreeReport {
6635 let topo = build_topo_view(&self.topo, &self.base);
6636 let edge_props = self.edge_props_view();
6637 crate::algo::degree_centrality(
6638 &topo,
6639 &self.ids,
6640 &self.syms,
6641 &self.labels,
6642 &edge_props,
6643 config,
6644 )
6645 }
6646
6647 /// Louvain community detection over the unified topology (undirected).
6648 ///
6649 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6650 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6651 /// result (communities sorted size-desc, then smallest member key asc).
6652 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6653 let topo = build_topo_view(&self.topo, &self.base);
6654 let edge_props = self.edge_props_view();
6655 crate::algo::louvain(
6656 &topo,
6657 &self.ids,
6658 &self.syms,
6659 &self.labels,
6660 &edge_props,
6661 config,
6662 )
6663 }
6664
6665 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6666 /// atomically via a single write-batch (one WAL frame, one fsync).
6667 ///
6668 /// # Errors
6669 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6670 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6671 /// (collision check mirrors `create_view`).
6672 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6673 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6674 if self.read_only {
6675 return Err(GraphError::ReadOnly);
6676 }
6677 // Collision check: refuse if prop_name is view-managed.
6678 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6679 return Err(GraphError::RuleInvalid {
6680 detail: format!(
6681 "prop {:?} is managed by view {:?} and cannot be written as scores",
6682 prop_name, view_name
6683 ),
6684 });
6685 }
6686 // Refuse if prop_name is a view name itself (confusing namespace collision).
6687 if self.view_store.has_view(prop_name) {
6688 return Err(GraphError::RuleInvalid {
6689 detail: format!(
6690 "prop_name {:?} collides with an existing view name",
6691 prop_name
6692 ),
6693 });
6694 }
6695 // Write all scores in a single crash-atomic batch.
6696 self.write_batch(|b| {
6697 for (key, score) in scores {
6698 b.set_prop(key, prop_name, Value::Float(*score));
6699 }
6700 })?;
6701 Ok(())
6702 }
6703
6704 /// Return the value of `field` for the node with key `key`, or `None` if
6705 /// the node or field is absent. Reads through the overlay-over-base
6706 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6707 /// overlay hits; one clone per base hit).
6708 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6709 let id = self.ids.get(key)?;
6710 self.props_view().get(id, field).map(|vr| vr.into_value())
6711 }
6712
6713 pub fn has_node(&self, key: &str) -> bool {
6714 self.ids.get(key).is_some()
6715 }
6716
6717 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6718 pub(crate) fn ids(&self) -> &IdMap {
6719 &self.ids
6720 }
6721
6722 // -----------------------------------------------------------------------
6723 // Namespaces
6724 // -----------------------------------------------------------------------
6725
6726 /// The index `name` already has in `ns_names`, if any.
6727 fn ns_index_of(&self, name: &str) -> Option<u32> {
6728 self.ns_names
6729 .iter()
6730 .position(|n| n == name)
6731 .map(|i| i as u32)
6732 }
6733
6734 /// The index for `name`, appending it to `ns_names` when it is new.
6735 ///
6736 /// The table holds one entry per distinct namespace in the store — a
6737 /// tenant count, not a node count — so the linear scan is cheaper than a
6738 /// map and keeps `namespaces()` allocation-free of a second index.
6739 fn ns_index_for(&mut self, name: &str) -> u32 {
6740 match self.ns_index_of(name) {
6741 Some(i) => i,
6742 None => {
6743 self.ns_names.push(name.to_string());
6744 (self.ns_names.len() - 1) as u32
6745 }
6746 }
6747 }
6748
6749 /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6750 /// does not know (unreachable; the default is the narrowing answer).
6751 fn ns_name(&self, idx: u32) -> &str {
6752 self.ns_names
6753 .get(idx as usize)
6754 .map(String::as_str)
6755 .unwrap_or(NS_DEFAULT)
6756 }
6757
6758 /// The namespace index of dense node `id`, defaulting for an id with no
6759 /// entry (a node inserted before this handle rebuilt the array cannot
6760 /// exist: every insert path maintains it).
6761 fn node_ns_idx(&self, id: u32) -> u32 {
6762 self.node_ns
6763 .get(id as usize)
6764 .copied()
6765 .unwrap_or(NS_DEFAULT_IDX)
6766 }
6767
6768 /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6769 /// grows. Called from `apply` for every node insert, live and replayed.
6770 fn set_node_ns(&mut self, id: u32, name: &str) {
6771 let idx = if name == NS_DEFAULT {
6772 NS_DEFAULT_IDX
6773 } else {
6774 self.ns_index_for(name)
6775 };
6776 if self.node_ns.len() <= id as usize {
6777 self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6778 }
6779 self.node_ns[id as usize] = idx;
6780 }
6781
6782 /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6783 /// open or a reload, after the snapshot is restored and the WAL replayed.
6784 ///
6785 /// A store with no `ns` column reads nothing: the column-name check fails
6786 /// and the vector is filled with one constant.
6787 fn rebuild_node_ns(&mut self) {
6788 let total = self.ids.len();
6789 self.ns_names.truncate(1);
6790 self.node_ns.clear();
6791 self.node_ns.resize(total, NS_DEFAULT_IDX);
6792 let has_ns_column = {
6793 let cv = self.props_view();
6794 cv.field_names().iter().any(|f| f == NS_PROP)
6795 };
6796 if !has_ns_column {
6797 return;
6798 }
6799 // Collected first so the props view is released before `ns_index_for`
6800 // takes `&mut self`.
6801 let named: Vec<(u32, String)> = {
6802 let cv = self.props_view();
6803 (0..total as u32)
6804 .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
6805 Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
6806 _ => None,
6807 })
6808 .collect()
6809 };
6810 for (id, name) in named {
6811 let idx = self.ns_index_for(&name);
6812 self.node_ns[id as usize] = idx;
6813 }
6814 }
6815
6816 /// Every namespace with at least one live node, in name order.
6817 ///
6818 /// `["default"]` on any store that has never named a namespace, including
6819 /// an empty one: a store is always at least its default namespace.
6820 pub fn namespaces(&self) -> Vec<String> {
6821 let mut out: BTreeSet<&str> = BTreeSet::new();
6822 out.insert(NS_DEFAULT);
6823 for (id, &idx) in self.node_ns.iter().enumerate() {
6824 if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
6825 continue;
6826 }
6827 out.insert(self.ns_name(idx));
6828 }
6829 out.into_iter().map(str::to_string).collect()
6830 }
6831
6832 /// The namespace of `key`, or `None` when the key names no live node.
6833 pub fn namespace_of(&self, key: &str) -> Option<String> {
6834 let id = self.ids.get(key)?;
6835 if !self.is_live_node(id) {
6836 return None;
6837 }
6838 Some(self.ns_name(self.node_ns_idx(id)).to_string())
6839 }
6840
6841 /// Every live node in `namespace`, as a visibility mask.
6842 ///
6843 /// Built off `node_ns` on whichever handle this is, so on a temporal handle
6844 /// it is the namespace's membership at that commit. A name no node uses
6845 /// gives an empty mask — a namespace scope never widens.
6846 pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
6847 let Some(idx) = self.ns_index_of(namespace) else {
6848 return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
6849 };
6850 let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
6851 .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
6852 .collect();
6853 crate::mask::NodeMask::from_ids(visible)
6854 }
6855
6856 /// Live-node test used by the namespace accessors: a deleted node keeps its
6857 /// dense id and its `node_ns` slot, and the label sentinel is what marks it
6858 /// gone — the same test `mask_for_role`'s label leg applies implicitly.
6859 fn is_live_node(&self, id: u32) -> bool {
6860 self.labels
6861 .get(id as usize)
6862 .is_some_and(|&sym| sym != u32::MAX)
6863 && self.ids.key_of(id).is_some()
6864 }
6865
6866 /// Per-namespace live node counts for [`Stats`], in name order.
6867 fn namespace_stats(&self) -> Vec<NamespaceStats> {
6868 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
6869 counts.insert(NS_DEFAULT, 0);
6870 for id in 0..self.ids.len() as u32 {
6871 if !self.is_live_node(id) {
6872 continue;
6873 }
6874 *counts
6875 .entry(self.ns_name(self.node_ns_idx(id)))
6876 .or_insert(0) += 1;
6877 }
6878 counts
6879 .into_iter()
6880 .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
6881 .map(|(name, nodes_live)| NamespaceStats {
6882 name: name.to_string(),
6883 nodes_live,
6884 })
6885 .collect()
6886 }
6887
6888 /// The namespace a create-class op would put its node in: the `ns` entry of
6889 /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
6890 fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
6891 Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
6892 }
6893
6894 /// The one `ns` entry in a node's props, or `None` when it carries none.
6895 ///
6896 /// A props list naming `ns` twice is refused. Without that refusal the
6897 /// write path and the authorisation path can read the same list
6898 /// differently — one taking the first entry, the other the last — and
6899 /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
6900 /// the role was checked against the other of. One entry is the only shape
6901 /// where "the node's namespace" is a single fact, so it is the only shape
6902 /// accepted, and every reader of it agrees by construction.
6903 fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
6904 let mut found: Option<&'a Value> = None;
6905 for (field, value) in props {
6906 if field != NS_PROP {
6907 continue;
6908 }
6909 if found.is_some() {
6910 return Err(GraphError::RuleInvalid {
6911 detail: format!(
6912 "node {key}: {NS_PROP} is given more than once; a node has exactly \
6913 one namespace"
6914 ),
6915 });
6916 }
6917 found = Some(value);
6918 }
6919 Ok(found)
6920 }
6921
6922 /// The definition of the role a write authorisation names.
6923 ///
6924 /// `None` when `roles.json` was corrupt at open or the role has since been
6925 /// removed — neither can reach a write, because the authorisation carries a
6926 /// mask `mask_for_role` already resolved for that name.
6927 fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
6928 self.roles.as_ref()?.iter().find(|r| r.name == role)
6929 }
6930
6931 /// Validate the `ns` entry of a node's props and drop an explicit default.
6932 ///
6933 /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
6934 /// a record that reached the WAL was already accepted here.
6935 fn normalise_insert_ns(
6936 key: &str,
6937 props: Vec<(String, Value)>,
6938 ) -> Result<(Vec<(String, Value)>, String)> {
6939 // One `ns` or none: this is where that is enforced, so every later
6940 // reader of the list — the authorisation gate, the two `apply` arms,
6941 // `node_ns` — is looking at a single entry and cannot disagree about
6942 // which one counts.
6943 Self::sole_ns_entry(key, &props)?;
6944 let mut name = NS_DEFAULT.to_string();
6945 let mut out = Vec::with_capacity(props.len());
6946 for (field, value) in props {
6947 if field != NS_PROP {
6948 out.push((field, value));
6949 continue;
6950 }
6951 let Value::Str(ref s) = value else {
6952 return Err(GraphError::RuleInvalid {
6953 detail: format!(
6954 "node {key}: {NS_PROP} must be a string naming a namespace, \
6955 got {value:?}"
6956 ),
6957 });
6958 };
6959 if !valid_namespace(s) {
6960 return Err(GraphError::RuleInvalid {
6961 detail: format!(
6962 "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
6963 characters of [A-Za-z0-9_.-]"
6964 ),
6965 });
6966 }
6967 name = s.clone();
6968 // An explicit default stores nothing, so a single-tenant store
6969 // never grows an `ns` column.
6970 if name != NS_DEFAULT {
6971 out.push((field, value));
6972 }
6973 }
6974 Ok((out, name))
6975 }
6976
6977 // -----------------------------------------------------------------------
6978 // RBAC role resolution
6979 // -----------------------------------------------------------------------
6980
6981 /// Parse `roles.json` bytes from `fs`.
6982 ///
6983 /// Return values:
6984 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6985 /// and valid; in both cases `mask_for_role` uses the
6986 /// list normally (an absent file means no roles defined).
6987 /// `Ok(None)` — file present but corrupt or unrecognised version
6988 /// → poisoned state; `mask_for_role` returns `Err` for
6989 /// any role name until the file is fixed and the DB
6990 /// re-opened (or `apply_schema` is called to repair it).
6991 ///
6992 /// Note: `None` signals corruption, not absence — the opposite of what an
6993 /// optional "file missing" convention would suggest. The open path stores
6994 /// this result on `db.roles` directly.
6995 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6996 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6997 if bytes.is_empty() {
6998 // Empty bytes means either the file is absent or zero-byte — both
6999 // are treated identically as "no roles defined". A zero-byte
7000 // roles.json does NOT widen access: an absent file and a zero-byte
7001 // file both resolve to an empty role list (sees nothing by default).
7002 return Ok(Some(vec![]));
7003 }
7004 match serde_json::from_slice::<RolesFile>(&bytes) {
7005 Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7006 // Corrupt or unrecognised version (>4): poison the roles state.
7007 // Never widen: a version this binary does not know may carry a
7008 // narrowing this binary would not apply.
7009 _ => Ok(None),
7010 }
7011 }
7012
7013 /// Resolve a role to a node-visibility mask against the current graph state.
7014 ///
7015 /// Returns `Err` when:
7016 /// - `roles.json` was present but corrupt at open (poisoned state), or
7017 /// - `role` does not match any defined role name.
7018 ///
7019 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7020 /// all live nodes carrying any label in `labels` that also pass the role's
7021 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7022 /// has one. Label resolution is live — new nodes of an allowed label are
7023 /// visible without re-applying the schema, and a property edited out of the
7024 /// predicate takes its node out of the mask on the next read. An empty
7025 /// union = empty mask = sees nothing.
7026 ///
7027 /// This is the one resolver every read path calls, live and as-of alike, so
7028 /// the predicate applies everywhere at once. On an as-of handle the role
7029 /// *definition* is the current one and the graph is the historical one: the
7030 /// predicate is evaluated against the property values at the commit being
7031 /// read.
7032 ///
7033 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7034 /// between two writes resolves the role once. See
7035 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7036 /// stale.
7037 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7038 self.role_masks
7039 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7040 .map(|m| (*m).clone())
7041 }
7042
7043 /// The mask an [`AsOfScope`] names, resolved against this handle.
7044 ///
7045 /// Shared by [`GraphDb::query_at_scoped`] and
7046 /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7047 /// however the namespace leg is added.
7048 fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7049 // One resolver answers "what may this role see" — `mask_for_role` — and
7050 // it runs against this handle, so on a temporal one the answer is the
7051 // as-of one.
7052 Ok(match scope {
7053 AsOfScope::Role(role) => self.mask_for_role(role)?,
7054 AsOfScope::Keys(keys) => {
7055 crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7056 }
7057 AsOfScope::RoleAndKeys(role, keys) => {
7058 self.mask_for_role(role)?
7059 .intersect(&crate::mask::NodeMask::from_keys(
7060 self,
7061 keys.iter().map(String::as_str),
7062 ))
7063 }
7064 AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7065 })
7066 }
7067
7068 /// Resolve `role` against the current graph, ignoring the memo.
7069 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7070 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7071 detail:
7072 "roles.json was corrupt at open; fix the file and re-open to restore role access"
7073 .into(),
7074 })?;
7075 let def = roles
7076 .iter()
7077 .find(|r| r.name == role)
7078 .ok_or_else(|| GraphError::KeyNotFound {
7079 key: format!("role:{role}"),
7080 })?;
7081
7082 let mut visible = std::collections::HashSet::new();
7083
7084 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7085 // An administrative grant, never narrowed by the predicate.
7086 for key in &def.keys {
7087 if let Some(id) = self.ids.get(key) {
7088 visible.insert(id);
7089 }
7090 }
7091
7092 // Label leg: live scan — iterate labels vec for matching symbol, and
7093 // when the role carries a predicate, test the property as well. The
7094 // property comes from the store's own merged view (overlay over the
7095 // mmap'd base), so an as-of handle reads the values of its own commit.
7096 let props = def.visible_where.as_ref().map(|_| self.props_view());
7097 for label_name in &def.labels {
7098 if let Some(sym) = self.syms.get(label_name) {
7099 for (i, &s) in self.labels.iter().enumerate() {
7100 if s != sym {
7101 continue;
7102 }
7103 let id = i as u32;
7104 match (&def.visible_where, &props) {
7105 (Some(pred), Some(view)) => {
7106 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7107 if pred.holds(value.as_ref()) {
7108 visible.insert(id);
7109 }
7110 }
7111 _ => {
7112 visible.insert(id);
7113 }
7114 }
7115 }
7116 }
7117 }
7118
7119 // Namespace leg: an intersection over the whole union, the key leg
7120 // included. A namespace is a tenancy boundary, so a key naming a node in
7121 // another tenant's namespace is not an administrative grant — and
7122 // `apply_schema` has already refused that role, so this only has to be
7123 // right about the node that moved into existence afterwards.
7124 if def.namespaces.is_some() {
7125 visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7126 }
7127
7128 Ok(crate::mask::NodeMask::from_ids(visible))
7129 }
7130
7131 /// Return the current list of role definitions.
7132 ///
7133 /// Returns an empty list when no roles are defined or when `roles.json`
7134 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7135 /// the fail-loud error in that case).
7136 pub fn roles(&self) -> Vec<RoleDef> {
7137 self.roles.as_deref().unwrap_or(&[]).to_vec()
7138 }
7139
7140 // ── Role-scoped write authz ───────────────────────────────────────────────
7141
7142 /// Execute `ops` with optional role-scoped write authorization.
7143 ///
7144 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7145 /// (zero-cost bypass of all authz checks).
7146 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7147 /// record is built. A denial returns an error with no WAL frame written
7148 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7149 ///
7150 /// See the plan's "authz decision table" section for the full semantics.
7151 pub fn write_batch_authz(
7152 &mut self,
7153 authz: Option<&WriteAuthz>,
7154 ops: Vec<BatchOp>,
7155 ) -> Result<(usize, usize)> {
7156 // Thread authz as a direct parameter — never touches pending_write_authz.
7157 self.commit_logged_batch(ops, None, authz.cloned())
7158 }
7159
7160 /// Execute a Cypher write statement with role-scoped write authorization.
7161 ///
7162 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7163 /// lifetime as execution, satisfying §5 lock discipline). The resolved
7164 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7165 /// call so that all inner `batch.commit()` calls are authz-checked.
7166 ///
7167 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7168 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7169 /// timing-oracle item (hidden ≡ absent for unscoped roles).
7170 ///
7171 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7172 /// "this endpoint is not permitted".
7173 pub fn query_write_authz(
7174 &mut self,
7175 role: &str,
7176 cypher: &str,
7177 params: &BTreeMap<String, Value>,
7178 ) -> Result<ResultSet> {
7179 // Resolve scope (fails fast if role has no write scope).
7180 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7181 let scope =
7182 {
7183 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7184 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7185 })?;
7186 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7187 GraphError::KeyNotFound {
7188 key: format!("role:{role}"),
7189 }
7190 })?;
7191 def.write
7192 .clone()
7193 .ok_or_else(|| GraphError::RoleWriteDenied {
7194 reason: "role-bound token: writes are not permitted".into(),
7195 })?
7196 };
7197 // Resolve mask inside the call (same guard, §5 coherence).
7198 let mask = self.mask_for_role(role)?;
7199 self.pending_write_authz = Some(WriteAuthz {
7200 role: role.into(),
7201 scope,
7202 mask,
7203 });
7204 // RAII guard: always clears pending_write_authz on scope exit, including
7205 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7206 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7207 impl Drop for ClearPendingAuthzOnDrop {
7208 fn drop(&mut self) {
7209 // SAFETY: pointer into the owning GraphDb; guard is dropped
7210 // within this function's frame before it returns.
7211 unsafe { *self.0 = None };
7212 }
7213 }
7214 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7215 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7216 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7217 detail: format!("lex: {e}"),
7218 })?;
7219 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7220 detail: format!("parse: {e}"),
7221 })?;
7222 self.exec_write_stmt(stmt, params)
7223 }
7224
7225 /// Execute `ops` with optional role-scoped write authorization, suppressing
7226 /// fsync (for use inside the group-commit drain thread, which performs one
7227 /// group fsync after releasing the write lock).
7228 ///
7229 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7230 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7231 /// contract established by [`commit_batch_nosync`].
7232 pub(crate) fn write_batch_authz_nosync(
7233 &mut self,
7234 authz: Option<&WriteAuthz>,
7235 ops: Vec<BatchOp>,
7236 ) -> Result<(usize, usize)> {
7237 let saved = self.fsync;
7238 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7239 impl Drop for RestoreFsync {
7240 fn drop(&mut self) {
7241 // SAFETY: pointer into the owning GraphDb; guard is dropped
7242 // within the enclosing function's frame before it returns.
7243 unsafe { *self.0 = self.1 };
7244 }
7245 }
7246 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7247 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7248 self.fsync = FsyncPolicy::Relaxed;
7249 self.commit_logged_batch(ops, None, authz.cloned())
7250 }
7251
7252 /// Execute a `/ingest` request with role-scoped write authorization.
7253 ///
7254 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7255 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7256 /// Sets `pending_write_authz` for the duration of the call so that the
7257 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7258 /// and evaluates the decision table per-op before any WAL write.
7259 ///
7260 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7261 /// denied by the decision table with the appropriate §4.3 scope reason;
7262 /// no special HTTP-layer check is needed.
7263 ///
7264 /// Roles with `write: None` return `RoleWriteDenied` with
7265 /// "writes are not permitted" (byte-identical to v1 blanket 403).
7266 pub fn ingest_with_edges_authz(
7267 &mut self,
7268 role: &str,
7269 label: &str,
7270 rows: Vec<std::collections::BTreeMap<String, Value>>,
7271 opts: &crate::ingest::IngestOptions,
7272 edges: &[(String, String, String)],
7273 ) -> Result<crate::ingest::IngestReport> {
7274 // Resolve scope (fails fast if role has no write scope).
7275 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7276 let scope =
7277 {
7278 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7279 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7280 })?;
7281 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7282 GraphError::KeyNotFound {
7283 key: format!("role:{role}"),
7284 }
7285 })?;
7286 def.write
7287 .clone()
7288 .ok_or_else(|| GraphError::RoleWriteDenied {
7289 reason: "role-bound token: writes are not permitted".into(),
7290 })?
7291 };
7292 let mask = self.mask_for_role(role)?;
7293 self.pending_write_authz = Some(WriteAuthz {
7294 role: role.into(),
7295 scope,
7296 mask,
7297 });
7298 // RAII guard: always clears pending_write_authz on scope exit, including
7299 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7300 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7301 impl Drop for ClearPendingAuthzOnDrop {
7302 fn drop(&mut self) {
7303 // SAFETY: pointer into the owning GraphDb; guard is dropped
7304 // within this function's frame before it returns.
7305 unsafe { *self.0 = None };
7306 }
7307 }
7308 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7309 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7310 self.ingest_with_edges(label, rows, opts, edges)
7311 }
7312
7313 /// Evaluate the write-authz decision table for one `BatchOp`.
7314 ///
7315 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7316 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
7317 /// the remaining ops are not evaluated and no WAL frame is written.
7318 ///
7319 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7320 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
7321 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7322 /// batch creates counts as visible if its label passed the create-class gate").
7323 fn check_single_op_authz(
7324 &self,
7325 authz: &WriteAuthz,
7326 op: &BatchOp,
7327 batch_created: &BTreeMap<String, String>,
7328 ) -> Result<()> {
7329 // Helper: 3-way node status under the authz mask.
7330 //
7331 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7332 // as Visible with their recorded label — their create gate already passed
7333 // and they are not yet in self.ids (not committed). This fixes the
7334 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7335 // the SetProp must not see the node as Absent.
7336 let node_status = |key: &str| -> NodeAuthzStatus {
7337 if let Some(label) = batch_created.get(key) {
7338 return NodeAuthzStatus::Visible(label.clone());
7339 }
7340 match self.ids.get(key) {
7341 None => NodeAuthzStatus::Absent,
7342 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7343 Some(id) => {
7344 let label = self
7345 .labels
7346 .get(id as usize)
7347 .and_then(|&sym| {
7348 if sym == u32::MAX {
7349 None
7350 } else {
7351 self.syms.resolve(sym).map(str::to_string)
7352 }
7353 })
7354 .unwrap_or_default();
7355 NodeAuthzStatus::Visible(label)
7356 }
7357 }
7358 };
7359
7360 // Helper: is an InsertEdgeUpsert endpoint visible?
7361 // A same-batch placeholder counts as visible if its label passed
7362 // the create-class gate (spec "upsert placeholder-counts-as-visible").
7363 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7364 // In store and visible?
7365 if let Some(id) = self.ids.get(ep_key) {
7366 return authz.mask.contains_id(id);
7367 }
7368 // Created by an earlier op in this batch?
7369 if let Some(created_label) = batch_created.get(ep_key) {
7370 return authz.scope.create_labels.contains(created_label);
7371 }
7372 // Will be created by THIS InsertEdgeUpsert: placeholder_label
7373 // must pass the create-class gate.
7374 authz
7375 .scope
7376 .create_labels
7377 .contains(&placeholder_label.to_string())
7378 };
7379
7380 match op {
7381 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7382 // These ops are never routed to role-scoped paths by the HTTP layer,
7383 // but we 403 them here to close any future bypass route.
7384 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7385 return Err(GraphError::RoleWriteDenied {
7386 reason: "role-bound token: this endpoint is not permitted".into(),
7387 });
7388 }
7389
7390 // ── CREATE-class: InsertNode ─────────────────────────────────────
7391 //
7392 // Decision table row 1 (scope-before-lookup): check label in
7393 // create_labels BEFORE any key lookup. This is the structural
7394 // closure of the §6.2 timing-oracle item — the denial fires even
7395 // when the store is EMPTY (see test_create_scope_denied_empty_store).
7396 BatchOp::InsertNode { label, key, props } => {
7397 if !authz.scope.create_labels.contains(label) {
7398 return Err(GraphError::RoleWriteDenied {
7399 reason: format!(
7400 "role-bound token: label '{}' not in write scope (create_labels)",
7401 label
7402 ),
7403 });
7404 }
7405 // A role bound to namespaces may only create inside them. The
7406 // never-widen rule is about what a write makes visible to *any*
7407 // party, not only to the writer: a node this role could never
7408 // read back is a write into somebody else's tenancy. Also a
7409 // scope check, so it runs before the key lookup — it discloses
7410 // nothing about the store. Covers Cypher `CREATE` and the node
7411 // `MERGE` creates, both of which arrive as this op.
7412 // Resolved before the role lookup so a props list naming `ns`
7413 // twice is refused for every role, scoped or not: it is the same
7414 // malformed write the seam refuses, and leaving it to the seam
7415 // would mean the gate had already read one of the two.
7416 let target = Self::created_namespace(key, props)?;
7417 if let Some(def) = self.role_def_for(&authz.role) {
7418 if !def.sees_namespace(target) {
7419 return Err(GraphError::RoleWriteDenied {
7420 reason: format!(
7421 "role-bound token: namespace '{target}' not in the role's \
7422 namespaces"
7423 ),
7424 });
7425 }
7426 }
7427 // Row 2/3: key lookup.
7428 match self.ids.get(key.as_str()) {
7429 Some(id) if authz.mask.contains_id(id) => {
7430 // Visible: DuplicateKey — let MutPreview handle this.
7431 }
7432 Some(_) => {
7433 // Hidden: indistinguishable from absent to the role.
7434 return Err(GraphError::RoleWriteDenied {
7435 reason: "role-bound token: target node not visible".into(),
7436 });
7437 }
7438 None => {
7439 // Absent: proceed (create).
7440 }
7441 }
7442 }
7443
7444 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7445 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7446 if batch_created.contains_key(key.as_str()) {
7447 // Batch-created node: create gate already passed this batch.
7448 // Updating it in the same batch is always allowed, regardless
7449 // of update_labels (ruling §3.5: "writer just created it").
7450 } else {
7451 let label = match node_status(key) {
7452 NodeAuthzStatus::Visible(lbl) => lbl,
7453 _ => {
7454 return Err(GraphError::RoleWriteDenied {
7455 reason: "role-bound token: target node not visible".into(),
7456 });
7457 }
7458 };
7459 if !authz.scope.update_labels.contains(&label) {
7460 return Err(GraphError::RoleWriteDenied {
7461 reason: format!(
7462 "role-bound token: label '{}' not in write scope (update_labels)",
7463 label
7464 ),
7465 });
7466 }
7467 }
7468 }
7469
7470 // ── DELETE-class: DeleteNode ─────────────────────────────────────
7471 BatchOp::DeleteNode { key } => {
7472 let label = match node_status(key) {
7473 NodeAuthzStatus::Visible(lbl) => lbl,
7474 _ => {
7475 return Err(GraphError::RoleWriteDenied {
7476 reason: "role-bound token: target node not visible".into(),
7477 });
7478 }
7479 };
7480 if !authz.scope.delete_labels.contains(&label) {
7481 return Err(GraphError::RoleWriteDenied {
7482 reason: format!(
7483 "role-bound token: label '{}' not in write scope (delete_labels)",
7484 label
7485 ),
7486 });
7487 }
7488 }
7489
7490 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7491 //
7492 // Derived-edge rejection runs BEFORE the delete_edge_types scope
7493 // check (spec §3.5: "existing derived-edge rejection precedes
7494 // delete_edge_types check").
7495 BatchOp::DeleteEdge {
7496 edge_type,
7497 src_key,
7498 dst_key,
7499 } => {
7500 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7501 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7502 self.ids.get(src_key.as_str()),
7503 self.ids.get(dst_key.as_str()),
7504 self.syms.get(edge_type.as_str()),
7505 ) {
7506 if self.engine.is_owned(et_sym, src_id, dst_id) {
7507 return Err(GraphError::RuleOwned {
7508 detail: format!(
7509 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7510 delete or change the owning rule"
7511 ),
7512 });
7513 }
7514 // Also check would_derive via MutPreview (empty overlay, pre-batch).
7515 let preview = MutPreview::new(self);
7516 if preview.would_derive(edge_type, src_key, dst_key) {
7517 return Err(GraphError::RuleOwned {
7518 detail: format!(
7519 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7520 delete or change the owning rule, or a live rule would \
7521 re-derive it"
7522 ),
7523 });
7524 }
7525 }
7526 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7527 if !authz.scope.delete_edge_types.contains(edge_type) {
7528 return Err(GraphError::RoleWriteDenied {
7529 reason: format!(
7530 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7531 edge_type
7532 ),
7533 });
7534 }
7535 // Both endpoints must be visible.
7536 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7537 match self.ids.get(ep_key) {
7538 None => {
7539 return Err(GraphError::RoleWriteDenied {
7540 reason: "role-bound token: edge endpoint not visible".into(),
7541 });
7542 }
7543 Some(id) if !authz.mask.contains_id(id) => {
7544 return Err(GraphError::RoleWriteDenied {
7545 reason: "role-bound token: edge endpoint not visible".into(),
7546 });
7547 }
7548 _ => {}
7549 }
7550 }
7551 }
7552
7553 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7554 //
7555 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7556 BatchOp::InsertEdge {
7557 edge_type,
7558 src_key,
7559 dst_key,
7560 } => {
7561 if !authz.scope.create_edge_types.contains(edge_type) {
7562 return Err(GraphError::RoleWriteDenied {
7563 reason: format!(
7564 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7565 edge_type
7566 ),
7567 });
7568 }
7569 // Both endpoints must be visible. A node created by an earlier
7570 // InsertNode in the same batch (tracked in batch_created) counts
7571 // as visible if its label passed the create-class gate.
7572 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7573 if batch_created.contains_key(ep_key) {
7574 // Created earlier this batch — already scope-checked.
7575 continue;
7576 }
7577 match self.ids.get(ep_key) {
7578 None => {
7579 return Err(GraphError::RoleWriteDenied {
7580 reason: "role-bound token: edge endpoint not visible".into(),
7581 });
7582 }
7583 Some(id) if !authz.mask.contains_id(id) => {
7584 return Err(GraphError::RoleWriteDenied {
7585 reason: "role-bound token: edge endpoint not visible".into(),
7586 });
7587 }
7588 _ => {}
7589 }
7590 }
7591 }
7592
7593 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7594 //
7595 // Scope check first; then endpoint visibility using same-batch
7596 // placeholder awareness (spec: "a placeholder endpoint the SAME
7597 // batch creates counts as visible if its label passed the
7598 // create-class gate").
7599 BatchOp::InsertEdgeUpsert {
7600 edge_type,
7601 src_key,
7602 dst_key,
7603 placeholder_label,
7604 } => {
7605 if !authz.scope.create_edge_types.contains(edge_type) {
7606 return Err(GraphError::RoleWriteDenied {
7607 reason: format!(
7608 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7609 edge_type
7610 ),
7611 });
7612 }
7613 // Check placeholder label against create_labels (create-class gate).
7614 // This ensures the auto-created endpoints are scope-allowed.
7615 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7616 if !upsert_ep_visible(ep_key, placeholder_label) {
7617 return Err(GraphError::RoleWriteDenied {
7618 reason: "role-bound token: edge endpoint not visible".into(),
7619 });
7620 }
7621 }
7622 // A placeholder is created with no props, so it lands in the
7623 // default namespace. A role that cannot read `default` must not
7624 // create one there, for the same reason it may not create a node
7625 // there outright.
7626 //
7627 // The refusal is byte-identical to the hidden-endpoint one above,
7628 // and deliberately so: this arm fires only for an endpoint that
7629 // does **not** exist, and the one above only for an endpoint that
7630 // does. Two different strings would make the pair an existence
7631 // oracle — ask for an upsert and read off whether the key is
7632 // taken. Hidden ≡ absent is the rule everywhere else in this
7633 // table and it holds here too.
7634 if let Some(def) = self.role_def_for(&authz.role) {
7635 if !def.sees_namespace(NS_DEFAULT) {
7636 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7637 if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7638 {
7639 return Err(GraphError::RoleWriteDenied {
7640 reason: "role-bound token: edge endpoint not visible".into(),
7641 });
7642 }
7643 }
7644 }
7645 }
7646 }
7647 }
7648 Ok(())
7649 }
7650
7651 /// Write `roles` to `roles.json` atomically and update the in-memory list.
7652 ///
7653 /// Called by `apply_schema` when roles change. Never called on unchanged
7654 /// re-apply — this preserves byte-identical idempotency.
7655 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7656 let file = RolesFile::new_versioned(roles.clone());
7657 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7658 detail: format!("roles serialization: {e}"),
7659 })?;
7660 self.fs
7661 .write_atomic(FileId::Roles, &bytes)
7662 .map_err(GraphError::Io)?;
7663 self.roles = Some(roles);
7664 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7665 // and a memoised mask would still match its version. Install a fresh
7666 // cache instead of clearing the shared one: a reader snapshot frozen
7667 // against the old definitions keeps the old `Arc` to itself and can
7668 // never publish an answer this handle would read back.
7669 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7670 // Refresh the MVCC frozen overlay so that reader() immediately sees the
7671 // updated role definitions without waiting for the next K-commit fold.
7672 self.fold_now();
7673 Ok(())
7674 }
7675
7676 fn view(&self) -> GraphView<'_> {
7677 GraphView {
7678 ids: &self.ids,
7679 syms: &self.syms,
7680 labels: &self.labels,
7681 props: self.props_view(),
7682 topo: self.topo_view(),
7683 edge_props: self.edge_props_view(),
7684 mask: None,
7685 prop_index: Some(&self.prop_index),
7686 }
7687 }
7688
7689 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7690 GraphView {
7691 ids: &self.ids,
7692 syms: &self.syms,
7693 labels: &self.labels,
7694 props: self.props_view(),
7695 topo: self.topo_view(),
7696 edge_props: self.edge_props_view(),
7697 mask: Some(&mask.visible),
7698 prop_index: Some(&self.prop_index),
7699 }
7700 }
7701
7702 /// Execute a read-only Cypher query with a node visibility mask.
7703 ///
7704 /// Only nodes whose key is in `mask` are accessible: label scans, key
7705 /// lookups, and neighbor expansions all respect the mask. Edges where
7706 /// either endpoint is hidden are silently dropped.
7707 ///
7708 /// Returns `Err` with a "masked queries are read-only" message when
7709 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7710 pub fn query_masked(
7711 &self,
7712 cypher: &str,
7713 params: &std::collections::BTreeMap<String, Value>,
7714 mask: &crate::mask::NodeMask,
7715 ) -> Result<ResultSet> {
7716 // Reject write statements up front.
7717 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7718 detail: format!("lex: {e}"),
7719 })?;
7720 if is_write_tokens(&tokens) {
7721 return Err(GraphError::MaskedReadOnly);
7722 }
7723 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7724 detail: format!("parse: {e}"),
7725 })?;
7726 // Each UNION part executes against the same masked view, so the mask
7727 // applies uniformly across the chain.
7728 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7729 GraphError::QueryError {
7730 detail: format!("execute: {e}"),
7731 }
7732 })
7733 }
7734
7735 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7736 let id = self.ids.get(key)?;
7737 Some(NodeRef { db: self, id })
7738 }
7739
7740 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7741 ///
7742 /// Hidden nodes are never used as traversal intermediaries in either
7743 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7744 /// only through a hidden node will not appear in results.
7745 ///
7746 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7747 /// a visited visible node are appended to the result as stub rows
7748 /// (`label` column is `null`, same key+depth columns as visible rows).
7749 /// They are NOT added to the BFS frontier.
7750 ///
7751 /// Returns `None` when `key` does not exist (caller should 404).
7752 ///
7753 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7754 /// stub rows are never produced on the role path.
7755 pub fn neighborhood_masked(
7756 &self,
7757 key: &str,
7758 depth: u32,
7759 edge_types: Option<&[&str]>,
7760 dir: Dir,
7761 mask: &crate::mask::NodeMask,
7762 ) -> Option<ResultSet> {
7763 let start_id = self.ids.get(key)?;
7764 let view = self.view_masked(mask);
7765 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7766 names
7767 .iter()
7768 .filter_map(|name| view.syms.get(name))
7769 .collect()
7770 });
7771 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7772 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7773 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7774 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7775 visited.push((start_id, 0));
7776 for (nid, d) in &nb.nodes {
7777 let k = view.key_of(*nid);
7778 let label = view
7779 .label_of(*nid)
7780 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7781 rs.push_row(vec![
7782 Some(Value::Str(k.to_string())),
7783 Some(Value::Str(label.to_string())),
7784 Some(Value::Int(*d as i64)),
7785 ]);
7786 visited.push((*nid, *d));
7787 }
7788 // Stub mode: add hidden direct neighbours of each visited node as stubs.
7789 // Hidden nodes are edge-endpoints only — they are not added to the BFS
7790 // frontier, so the BFS never expands through them.
7791 if mask.mode() == crate::mask::MaskMode::Stub {
7792 let raw_view = self.view();
7793 let mut seen: std::collections::HashSet<u32> =
7794 visited.iter().map(|(id, _)| *id).collect();
7795 for (node_id, node_depth) in &visited {
7796 if *node_depth >= depth {
7797 continue;
7798 }
7799 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7800 let nbr = if e.src == *node_id { e.dst } else { e.src };
7801 if !mask.contains_id(nbr) && seen.insert(nbr) {
7802 if let Some(k) = self.ids.key_of(nbr) {
7803 rs.push_row(vec![
7804 Some(Value::Str(k.to_string())),
7805 None,
7806 Some(Value::Int((*node_depth + 1) as i64)),
7807 ]);
7808 }
7809 }
7810 }
7811 }
7812 }
7813 Some(rs)
7814 }
7815
7816 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7817 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7818 let n = self.node_ref(key)?;
7819 Some(NodeInfo {
7820 key: n.key().to_string(),
7821 label: n.label().to_string(),
7822 props: n.props(),
7823 })
7824 }
7825
7826 /// Look up a node with mask awareness.
7827 ///
7828 /// | Key state | Omit mode | Stub mode |
7829 /// |-------------------|-----------------|------------------------|
7830 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
7831 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
7832 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
7833 ///
7834 /// **SECURITY**: only call from client-mask (full-token) paths.
7835 /// Role-token paths must use [`node_info`] after an explicit visibility check.
7836 pub fn node_info_masked(
7837 &self,
7838 key: &str,
7839 mask: &crate::mask::NodeMask,
7840 ) -> Option<MaskedNodeResult> {
7841 let id = self.ids.get(key)?;
7842 if mask.contains_id(id) {
7843 Some(MaskedNodeResult::Visible(self.node_info(key)?))
7844 } else {
7845 match mask.mode() {
7846 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7847 crate::mask::MaskMode::Omit => None,
7848 }
7849 }
7850 }
7851
7852 /// Get edges for `key` with mask-aware hidden-endpoint handling.
7853 ///
7854 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7855 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7856 /// is `true` for each hidden endpoint.
7857 ///
7858 /// Unknown key → [`GraphError::KeyNotFound`].
7859 ///
7860 /// **SECURITY**: only call from client-mask (full-token) paths.
7861 pub fn node_edges_masked(
7862 &self,
7863 key: &str,
7864 mask: &crate::mask::NodeMask,
7865 ) -> Result<Vec<MaskedEdge>> {
7866 self.ensure_v8_base_sections_loaded();
7867 let id = self
7868 .ids
7869 .get(key)
7870 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7871 let derived: BTreeSet<(u32, u32, u32)> = self
7872 .engine
7873 .provenance_touching(id)
7874 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7875 .collect();
7876 let mut edges = Vec::new();
7877 let tv = self.topo_view();
7878 for etype in tv.etypes() {
7879 // etype comes from the archived CSR (access_unchecked, no eager CRC).
7880 // A bit-flip in the large TOPOLOGY section can produce an etype id
7881 // that is not in the interner. Return Corrupt rather than panic.
7882 let edge_type = self
7883 .syms
7884 .resolve(etype)
7885 .ok_or_else(|| GraphError::Corrupt {
7886 detail: format!("v8: topology etype {etype} not in interner"),
7887 })?
7888 .to_string();
7889 for dir in [Direction::Out, Direction::In] {
7890 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7891 let nbr_restricted = !mask.contains_id(nbr);
7892 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7893 continue;
7894 }
7895 let nbr_key = self
7896 .ids
7897 .key_of(nbr)
7898 .ok_or_else(|| GraphError::Corrupt {
7899 detail: format!("topology id {nbr} has no key"),
7900 })?
7901 .to_string();
7902 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7903 match dir {
7904 Direction::Out => {
7905 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7906 }
7907 Direction::In => {
7908 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7909 }
7910 };
7911 edges.push(MaskedEdge {
7912 edge_type: edge_type.clone(),
7913 src_key,
7914 src_restricted,
7915 dst_key,
7916 dst_restricted,
7917 derived: derived.contains(&(etype, src_id, dst_id)),
7918 });
7919 }
7920 }
7921 }
7922 edges.sort_by(|a, b| {
7923 a.edge_type
7924 .cmp(&b.edge_type)
7925 .then(a.src_key.cmp(&b.src_key))
7926 .then(a.dst_key.cmp(&b.dst_key))
7927 });
7928 edges.dedup_by(|a, b| {
7929 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7930 });
7931 Ok(edges)
7932 }
7933
7934 /// Every directed edge incident on `key`, both directions, every etype.
7935 ///
7936 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7937 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7938 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7939 /// Unknown key → [`GraphError::KeyNotFound`].
7940 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7941 self.ensure_v8_base_sections_loaded();
7942 let id = self
7943 .ids
7944 .get(key)
7945 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7946 let derived: BTreeSet<(u32, u32, u32)> = self
7947 .engine
7948 .provenance_touching(id)
7949 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7950 .collect();
7951 let mut edges = Vec::new();
7952 let tv = self.topo_view();
7953 for etype in tv.etypes() {
7954 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7955 let edge_type = self
7956 .syms
7957 .resolve(etype)
7958 .ok_or_else(|| GraphError::Corrupt {
7959 detail: format!("v8: topology etype {etype} not in interner"),
7960 })?
7961 .to_string();
7962 for dir in [Direction::Out, Direction::In] {
7963 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7964 let (src, dst, src_key, dst_key) = match dir {
7965 Direction::Out => (
7966 id,
7967 nbr,
7968 key.to_string(),
7969 self.ids
7970 .key_of(nbr)
7971 .ok_or_else(|| GraphError::Corrupt {
7972 detail: format!("topology id {nbr} has no key"),
7973 })?
7974 .to_string(),
7975 ),
7976 Direction::In => (
7977 nbr,
7978 id,
7979 self.ids
7980 .key_of(nbr)
7981 .ok_or_else(|| GraphError::Corrupt {
7982 detail: format!("topology id {nbr} has no key"),
7983 })?
7984 .to_string(),
7985 key.to_string(),
7986 ),
7987 };
7988 edges.push(EdgeInfo {
7989 edge_type: edge_type.clone(),
7990 src_key,
7991 dst_key,
7992 derived: derived.contains(&(etype, src, dst)),
7993 });
7994 }
7995 }
7996 }
7997 edges.sort_by(|a, b| {
7998 a.edge_type
7999 .cmp(&b.edge_type)
8000 .then(a.src_key.cmp(&b.src_key))
8001 .then(a.dst_key.cmp(&b.dst_key))
8002 });
8003 // Self-loops appear in both Out and In; sort makes the pair adjacent
8004 // (sort key matches PartialEq for this case) so one pass drops the dup.
8005 edges.dedup();
8006 Ok(edges)
8007 }
8008
8009 // ── Backup ────────────────────────────────────────────────────────────────
8010
8011 /// Copy this store to `dest` as a consistent, verified snapshot.
8012 ///
8013 /// Copies every durable file in the database directory — `snapshot.bin`,
8014 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8015 /// `roles.json` — into a freshly created `dest` directory using OS-level
8016 /// `copy` calls (no large in-process buffers).
8017 ///
8018 /// # Consistency guarantee
8019 ///
8020 /// The guarantee is **process-local**: the caller holds `&self`, which
8021 /// prevents any concurrent writer in the **same process** from modifying
8022 /// the files during the copy. Running `mushroomdb backup` against a
8023 /// directory that is **concurrently being written by another process** (e.g.
8024 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
8025 /// `verified: true` result reduces but does not eliminate the risk of a
8026 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8027 /// consistent mid-write snapshot).
8028 ///
8029 /// **The safe path for a live-served store is `POST /backup` on the HTTP
8030 /// server.** That handler acquires the read lock on the shared database
8031 /// before calling this method, which is the correct cross-process
8032 /// synchronisation point because the server is the single process writing
8033 /// the files.
8034 ///
8035 /// After copying, opens the destination read-only and runs the CRC section
8036 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8037 /// `BackupReport::verified` reflects whether both checks passed.
8038 ///
8039 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8040 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8041 // Derive source directory from snapshot_path (RealFs only).
8042 let src_dir = match self.fs.snapshot_path() {
8043 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8044 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8045 })?,
8046 None => {
8047 return Err(GraphError::Io(std::io::Error::other(
8048 "backup_to requires a real filesystem (RealFs)",
8049 )))
8050 }
8051 };
8052
8053 std::fs::create_dir_all(dest)?;
8054
8055 let mut files: Vec<String> = Vec::new();
8056 let mut bytes: u64 = 0;
8057
8058 // Helper: copy src_dir/name → dest/name if the file exists.
8059 let mut try_copy = |name: &str| -> std::io::Result<()> {
8060 let src_path = src_dir.join(name);
8061 if src_path.exists() {
8062 let n = std::fs::copy(&src_path, dest.join(name))?;
8063 bytes += n;
8064 files.push(name.to_string());
8065 }
8066 Ok(())
8067 };
8068
8069 try_copy("snapshot.bin")?;
8070 try_copy("snapshot.bin.bak")?;
8071 try_copy("wal.bin")?;
8072 try_copy("wal.floor")?;
8073 try_copy("wal.genesis")?;
8074 try_copy("roles.json")?;
8075
8076 // Copy WAL archives.
8077 let archives = self.fs.list_archives()?;
8078 for n in &archives {
8079 let name = format!("wal.{n}.archive");
8080 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8081 bytes += n_bytes;
8082 files.push(name);
8083 }
8084
8085 files.sort();
8086
8087 // Post-copy verification: open dest and run CRC checks.
8088 let snap_in_dest = dest.join("snapshot.bin").exists();
8089 let crc_ok = if snap_in_dest {
8090 crate::verify_snapshot(dest)
8091 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8092 .unwrap_or(false)
8093 } else {
8094 true // WAL-only store: nothing to CRC-check in snapshot
8095 };
8096 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8097 let verified = crc_ok && opens_ok;
8098
8099 Ok(BackupReport {
8100 files,
8101 bytes,
8102 verified,
8103 })
8104 }
8105
8106 // ── Export helpers ────────────────────────────────────────────────────────
8107
8108 /// All live nodes, sorted by key (deterministic).
8109 ///
8110 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8111 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8112 self.ensure_v8_base_sections_loaded();
8113 let pv = self.props_view();
8114 let mut nodes = Vec::new();
8115 for id in 0..self.ids.len() as u32 {
8116 let Some(key) = self.ids.key_of(id) else {
8117 continue;
8118 };
8119 let Some(&sym) = self.labels.get(id as usize) else {
8120 continue;
8121 };
8122 if sym == u32::MAX {
8123 continue; // tombstoned
8124 }
8125 let Some(label) = self.syms.resolve(sym) else {
8126 continue;
8127 };
8128 let mut props = BTreeMap::new();
8129 for field in pv.field_names() {
8130 if let Some(vr) = pv.get(id, &field) {
8131 props.insert(field, vr.into_value());
8132 }
8133 }
8134 nodes.push(NodeInfo {
8135 key: key.to_string(),
8136 label: label.to_string(),
8137 props,
8138 });
8139 }
8140 nodes.sort_by(|a, b| a.key.cmp(&b.key));
8141 nodes
8142 }
8143
8144 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8145 ///
8146 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8147 /// Manual edges carry `derived: false` and `rule: None`.
8148 /// `weight` is the creating rule's `weight_prop` value read off the edge
8149 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8150 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8151 /// store state.
8152 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8153 self.ensure_v8_base_sections_loaded();
8154
8155 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8156 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8157 for (rule_name, triples) in self.engine.provenance() {
8158 for &(etype, src, dst) in triples {
8159 prov.insert((etype, src, dst), rule_name.clone());
8160 }
8161 }
8162
8163 // rule_name → weight_prop, for O(1) lookup per derived edge.
8164 let weight_props: HashMap<&str, Option<&str>> = self
8165 .engine
8166 .rules()
8167 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8168 .collect();
8169
8170 let tv = self.topo_view();
8171 let ep = self.edge_props_view();
8172 let mut edges = Vec::new();
8173
8174 for id in 0..self.ids.len() as u32 {
8175 let Some(key) = self.ids.key_of(id) else {
8176 continue;
8177 };
8178 let Some(&lsym) = self.labels.get(id as usize) else {
8179 continue;
8180 };
8181 if lsym == u32::MAX {
8182 continue; // tombstoned
8183 }
8184
8185 for etype_sym in tv.etypes() {
8186 // etype from archived CSR (access_unchecked, no eager CRC).
8187 // Skip edges whose etype is not in the interner; this can only
8188 // occur with a corrupt large TOPOLOGY section (bit-flip on an
8189 // etype field in the archived data). The function returns Vec,
8190 // not Result, so we continue rather than propagate.
8191 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8192 continue;
8193 };
8194 let edge_type = edge_type.to_string();
8195 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8196 let Some(dst_key) = self.ids.key_of(nbr) else {
8197 continue; // skip corrupt entries
8198 };
8199 let prov_key = (etype_sym, id, nbr);
8200 let rule = prov.get(&prov_key).cloned();
8201 let derived = rule.is_some();
8202 let weight = rule
8203 .as_deref()
8204 .and_then(|rn| weight_props.get(rn).copied().flatten())
8205 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8206 Some(Value::Float(f)) => Some(f),
8207 Some(Value::Int(i)) => Some(i as f64),
8208 _ => None,
8209 });
8210 edges.push(ExportEdge {
8211 edge_type: edge_type.clone(),
8212 src: key.to_string(),
8213 dst: dst_key.to_string(),
8214 derived,
8215 rule,
8216 weight,
8217 });
8218 }
8219 }
8220 }
8221
8222 edges.sort_by(|a, b| {
8223 a.edge_type
8224 .cmp(&b.edge_type)
8225 .then(a.src.cmp(&b.src))
8226 .then(a.dst.cmp(&b.dst))
8227 });
8228 edges
8229 }
8230
8231 /// What each edge type *is*, without building one record per edge.
8232 ///
8233 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8234 /// question by materialising every edge — three `String`s apiece, a
8235 /// provenance `HashMap` over every derived edge, and a final sort. That is
8236 /// the right shape for an export, and the wrong one for a summary: on a
8237 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8238 /// produce nine lines. This walks the topology instead, summing neighbour
8239 /// slice lengths and collecting *label symbols* rather than label strings,
8240 /// so the per-edge cost is an integer add and a set insert on a set with
8241 /// as many members as the store has labels.
8242 ///
8243 /// The rule names come off the rule *definitions*, which each declare the
8244 /// `edge_type` they derive, so naming them costs one pass over the rules
8245 /// rather than one provenance lookup per edge. That is also why `rules`
8246 /// is a list: two rules may derive the same type — the association store
8247 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8248 /// talent→job rule — and naming only one of them would be a half-truth.
8249 /// A type with no rules is one written by hand.
8250 ///
8251 /// `sample` is the first edge of the type in the store's own id order,
8252 /// which is insertion order: deterministic for a given store, and not the
8253 /// same as key order, which cannot be had without resolving a key per
8254 /// edge. Sorted by `edge_type`.
8255 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8256 self.ensure_v8_base_sections_loaded();
8257
8258 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8259 for r in self.engine.rules() {
8260 rules_by_type
8261 .entry(r.edge_type.as_str())
8262 .or_default()
8263 .insert(r.name.as_str());
8264 }
8265
8266 let tv = self.topo_view();
8267 let node_count = self.ids.len() as u32;
8268 let mut out = Vec::new();
8269 for etype_sym in tv.etypes() {
8270 // An etype the interner cannot resolve means a corrupt TOPOLOGY
8271 // section; skip it rather than name it, as `all_edges_for_export`
8272 // does for the same reason.
8273 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8274 continue;
8275 };
8276 let mut edges: u64 = 0;
8277 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8278 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8279 let mut sample: Option<(u32, u32)> = None;
8280 for id in 0..node_count {
8281 let Some(&lsym) = self.labels.get(id as usize) else {
8282 continue;
8283 };
8284 if lsym == u32::MAX {
8285 continue; // tombstoned
8286 }
8287 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8288 let nbrs = nbrs.as_ref();
8289 if nbrs.is_empty() {
8290 continue;
8291 }
8292 edges += nbrs.len() as u64;
8293 src_syms.insert(lsym);
8294 for &nbr in nbrs {
8295 if let Some(&dsym) = self.labels.get(nbr as usize) {
8296 if dsym != u32::MAX {
8297 dst_syms.insert(dsym);
8298 }
8299 }
8300 }
8301 if sample.is_none() {
8302 sample = Some((id, nbrs[0]));
8303 }
8304 }
8305 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8306 syms.iter()
8307 .filter_map(|&s| self.syms.resolve(s))
8308 .map(ToString::to_string)
8309 .collect()
8310 };
8311 out.push(EdgeTypeCensus {
8312 edge_type: edge_type.to_string(),
8313 edges,
8314 src_labels: resolve(&src_syms),
8315 dst_labels: resolve(&dst_syms),
8316 rules: rules_by_type
8317 .get(edge_type)
8318 .map(|rs| rs.iter().map(ToString::to_string).collect())
8319 .unwrap_or_default(),
8320 sample: sample.and_then(|(s, d)| {
8321 Some((
8322 self.ids.key_of(s)?.to_string(),
8323 self.ids.key_of(d)?.to_string(),
8324 ))
8325 }),
8326 });
8327 }
8328 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8329 out
8330 }
8331
8332 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8333 /// on each edge when given.
8334 ///
8335 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8336 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8337 /// `None` — callers that want a default weight (e.g. `1.0` for missing
8338 /// props) apply it themselves, matching the convention used internally
8339 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8340 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8341 ///
8342 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8343 /// (manual + rule-derived edges). An unknown `edge_type` returns an
8344 /// empty vec.
8345 pub fn weighted_edges(
8346 &self,
8347 edge_type: &str,
8348 weight_prop: Option<&str>,
8349 ) -> Vec<(String, String, Option<f64>)> {
8350 let Some(etype_sym) = self.syms.get(edge_type) else {
8351 return Vec::new();
8352 };
8353 let tv = self.topo_view();
8354 let ep = self.edge_props_view();
8355 let mut out = Vec::new();
8356 for id in 0..self.ids.len() as u32 {
8357 let Some(key) = self.ids.key_of(id) else {
8358 continue;
8359 };
8360 let Some(&sym) = self.labels.get(id as usize) else {
8361 continue;
8362 };
8363 if sym == u32::MAX {
8364 continue; // tombstoned
8365 }
8366 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8367 let Some(dst_key) = self.ids.key_of(nbr) else {
8368 continue;
8369 };
8370 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8371 Some(Value::Float(f)) => Some(f),
8372 Some(Value::Int(i)) => Some(i as f64),
8373 _ => None,
8374 });
8375 out.push((key.to_string(), dst_key.to_string(), weight));
8376 }
8377 }
8378 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8379 out
8380 }
8381
8382 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8383 self.view()
8384 .nodes_with_label(label)
8385 .into_iter()
8386 .map(|id| NodeRef { db: self, id })
8387 .collect()
8388 }
8389
8390 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8391 let view = self.view();
8392 view.nodes_with_label(label)
8393 .into_iter()
8394 .filter(|&id| {
8395 eval_filter(filter, &|field| {
8396 view.prop(id, field).map(|vr| vr.into_value())
8397 })
8398 })
8399 .map(|id| NodeRef { db: self, id })
8400 .collect()
8401 }
8402
8403 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8404 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
8405 /// with `label = None` will use the native ANN path rather than the O(n)
8406 /// brute-force scan.
8407 pub fn has_vector_rule(&self, field: &str) -> bool {
8408 self.engine.hnsw_has_rule(field)
8409 }
8410
8411 /// How many HNSW graphs this handle has built from scratch since it was
8412 /// opened (one per side of an approximate rule).
8413 ///
8414 /// An open that restored every graph from the snapshot reports `0`.
8415 /// Exposed for tests that assert the open path reuses the persisted index
8416 /// rather than rebuilding it; not part of the stable surface.
8417 #[doc(hidden)]
8418 pub fn hnsw_build_count(&self) -> u64 {
8419 self.engine.hnsw_build_count()
8420 }
8421
8422 /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8423 ///
8424 /// Zero before the first ANN query on a clean open, and again once the
8425 /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8426 /// Exposed for tests that assert the lazy copies are released; not part of
8427 /// the stable surface.
8428 #[doc(hidden)]
8429 pub fn lazy_hnsw_len(&self) -> usize {
8430 self.engine.lazy_hnsw_len()
8431 }
8432
8433 /// Find nodes whose `field` vector is most similar to `q` (cosine
8434 /// similarity), returning up to `k` results with similarity ≥ `min`,
8435 /// sorted descending.
8436 ///
8437 /// When `label` is `None` the search spans all labels (via
8438 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8439 /// `Some(lbl)` it restricts to nodes with that label.
8440 ///
8441 /// Uses the HNSW index when one is available (fast path); otherwise falls
8442 /// back to an O(n) brute-force scan.
8443 ///
8444 /// **The index supplies candidates, never scores.** Its own distances are
8445 /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8446 /// every candidate is re-scored from the `f64` property vectors by
8447 /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8448 /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8449 /// the re-ordering cannot drop a true top-`k` member; see that constant for
8450 /// the rule. The score a caller receives is therefore the same number the
8451 /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8452 /// finds an exact duplicate.
8453 pub fn find_similar_vector(
8454 &self,
8455 field: &str,
8456 label: Option<&str>,
8457 q: &[f64],
8458 k: usize,
8459 min: f64,
8460 ) -> Vec<(String, f64)> {
8461 // Ensure any HNSW blobs retained from the snapshot are deserialized
8462 // before the first ANN query on a clean-open (no-WAL) path. The
8463 // section read has to come first: on a clean open nothing else has
8464 // called it, so without it `retained_hnsw_blobs` is empty,
8465 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8466 // approximate query on the handle runs brute force — correct results,
8467 // silently off the index. Both calls are idempotent and cheap once hot.
8468 self.ensure_v8_base_sections_loaded();
8469 self.engine.ensure_hnsw_loaded();
8470 // L2-normalise query for cosine via dot product.
8471 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8472 if norm == 0.0 {
8473 return vec![];
8474 }
8475 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8476
8477 // Try HNSW fast path.
8478 // `None` label searches across all VectorSimilar rules covering `field`
8479 // (merging their results); `Some(lbl)` restricts to rules whose
8480 // dst_label matches. Returns `None` when no populated HNSW index
8481 // covers the request — the O(n) brute-force fallback handles that case.
8482 let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8483 let hnsw_hits = match label {
8484 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
8485 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
8486 };
8487 if let Some(hits) = hnsw_hits {
8488 // Candidates only: the index's `f32` similarity is discarded and
8489 // each hit is re-scored against the `f64` vectors.
8490 let view = self.view();
8491 let mut out: Vec<(String, f64)> = hits
8492 .into_iter()
8493 .filter_map(|(id, _)| {
8494 let sim = exact_vector_similarity(&view, id, field, &q_unit)?;
8495 if sim < min {
8496 return None;
8497 }
8498 Some((self.ids.key_of(id)?.to_string(), sim))
8499 })
8500 .collect();
8501 out.sort_by(|a, b| {
8502 b.1.partial_cmp(&a.1)
8503 .unwrap_or(std::cmp::Ordering::Equal)
8504 .then_with(|| a.0.cmp(&b.0))
8505 });
8506 out.truncate(k);
8507 return out;
8508 }
8509
8510 // Brute-force fallback: O(n) scan (only reached when no HNSW index
8511 // covers the request).
8512 let view = self.view();
8513 let candidate_ids: Vec<u32> = match label {
8514 Some(lbl) => view.nodes_with_label(lbl),
8515 None => view.nodes_all(),
8516 };
8517 let mut scored: Vec<(String, f64)> = candidate_ids
8518 .into_iter()
8519 .filter_map(|id| {
8520 let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8521 if dot < min {
8522 return None;
8523 }
8524 let key = self.ids.key_of(id)?.to_string();
8525 Some((key, dot))
8526 })
8527 .collect();
8528 scored.sort_by(|a, b| {
8529 b.1.partial_cmp(&a.1)
8530 .unwrap_or(std::cmp::Ordering::Equal)
8531 .then_with(|| a.0.cmp(&b.0))
8532 });
8533 scored.truncate(k);
8534 scored
8535 }
8536
8537 /// Like [`find_similar_vector`] but restricts results to nodes visible in
8538 /// `mask`. Hidden nodes never appear in results; the mask is applied
8539 /// **before** k-truncation so a caller still receives up to `k` visible
8540 /// hits.
8541 ///
8542 /// # HNSW path (over-fetch policy)
8543 ///
8544 /// When an HNSW index covers the request, this function fetches
8545 /// `4 * k + VECTOR_RESCORE_MARGIN` candidates from the index and discards
8546 /// hidden nodes in the post-filter step. If fewer than `k` visible nodes
8547 /// remain after filtering the caller receives whatever is available — we do
8548 /// not re-query the index. Every surviving candidate is re-scored from the
8549 /// `f64` property vectors, exactly as [`find_similar_vector`] does and for
8550 /// the same reason. The 4×
8551 /// multiplier is a heuristic suited for sparsely masked graphs; callers
8552 /// operating under a very selective mask should register a VectorSimilar
8553 /// rule with a non-approximate index, or use the brute-force path (no HNSW
8554 /// rule) which exhaustively filters through the masked [`GraphView`].
8555 ///
8556 /// # Brute-force path
8557 ///
8558 /// When no HNSW index covers the request the function builds a masked
8559 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
8560 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
8561 /// fewer than `k` exist).
8562 pub fn find_similar_vector_masked(
8563 &self,
8564 field: &str,
8565 label: Option<&str>,
8566 q: &[f64],
8567 k: usize,
8568 min: f64,
8569 mask: &crate::mask::NodeMask,
8570 ) -> Vec<(String, f64)> {
8571 // Section read before the blob decode — see `find_similar_vector`.
8572 self.ensure_v8_base_sections_loaded();
8573 self.engine.ensure_hnsw_loaded();
8574 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8575 if norm == 0.0 {
8576 return vec![];
8577 }
8578 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8579
8580 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
8581 // visible hits. See doc comment above for the policy rationale.
8582 let over_k = k
8583 .saturating_mul(4)
8584 .max(k + 1)
8585 .saturating_add(VECTOR_RESCORE_MARGIN);
8586 let hnsw_hits = match label {
8587 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
8588 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
8589 };
8590 if let Some(hits) = hnsw_hits {
8591 // Candidates only — re-scored from the `f64` vectors before `min`,
8592 // the ordering or the reported score. The mask is applied first so a
8593 // hidden node is never scored.
8594 let view = self.view_masked(mask);
8595 let mut out: Vec<(String, f64)> = hits
8596 .into_iter()
8597 .filter(|&(id, _)| mask.visible.contains(&id))
8598 .filter_map(|(id, _)| {
8599 let sim = exact_vector_similarity(&view, id, field, &q_unit)?;
8600 if sim < min {
8601 return None;
8602 }
8603 Some((self.ids.key_of(id)?.to_string(), sim))
8604 })
8605 .collect();
8606 out.sort_by(|a, b| {
8607 b.1.partial_cmp(&a.1)
8608 .unwrap_or(std::cmp::Ordering::Equal)
8609 .then_with(|| a.0.cmp(&b.0))
8610 });
8611 out.truncate(k);
8612 return out;
8613 }
8614
8615 // Brute-force fallback — masked view ensures only visible nodes are
8616 // enumerated by nodes_all(); nodes_with_label() does not filter by
8617 // mask so we apply view.visible() explicitly for the labeled case.
8618 let view = self.view_masked(mask);
8619 let candidate_ids: Vec<u32> = match label {
8620 Some(lbl) => view
8621 .nodes_with_label(lbl)
8622 .into_iter()
8623 .filter(|&id| view.visible(id))
8624 .collect(),
8625 None => view.nodes_all(),
8626 };
8627 let mut scored: Vec<(String, f64)> = candidate_ids
8628 .into_iter()
8629 .filter_map(|id| {
8630 let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8631 if dot < min {
8632 return None;
8633 }
8634 let key = self.ids.key_of(id)?.to_string();
8635 Some((key, dot))
8636 })
8637 .collect();
8638 scored.sort_by(|a, b| {
8639 b.1.partial_cmp(&a.1)
8640 .unwrap_or(std::cmp::Ordering::Equal)
8641 .then_with(|| a.0.cmp(&b.0))
8642 });
8643 scored.truncate(k);
8644 scored
8645 }
8646
8647 /// Read a single property from an edge.
8648 ///
8649 /// Returns `None` when the edge does not exist, the field is absent, or any
8650 /// of the string keys cannot be resolved to interned ids. Only edge props
8651 /// written by rules (weight fields) are accessible without a `set_edge_prop`
8652 /// binding; topology-only edges (no props set) return `None` for every field.
8653 pub fn get_edge_prop(
8654 &self,
8655 edge_type: &str,
8656 src_key: &str,
8657 dst_key: &str,
8658 field: &str,
8659 ) -> Option<Value> {
8660 let etype = self.syms.get(edge_type)?;
8661 let src = self.ids.get(src_key)?;
8662 let dst = self.ids.get(dst_key)?;
8663 self.edge_props_view().get(etype, src, dst, field)
8664 }
8665
8666 /// Lex → parse → plan → execute `cypher` over a read-only view.
8667 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
8668 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
8669 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
8670 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8671 detail: format!("lex: {e}"),
8672 })?;
8673 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
8674 detail: format!("parse: {e}"),
8675 })?;
8676 let t0 = std::time::Instant::now();
8677 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
8678 GraphError::QueryError {
8679 detail: format!("execute: {e}"),
8680 }
8681 });
8682 let elapsed_ms = t0.elapsed().as_millis() as u64;
8683 let threshold = self.slow_query_threshold_ms;
8684 if threshold > 0 && elapsed_ms >= threshold {
8685 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
8686 let entry = SlowQueryEntry {
8687 ms: elapsed_ms,
8688 query: cypher.to_string(),
8689 at_commit: self.commit_seq,
8690 };
8691 if let Ok(mut log) = self.slow_queries.lock() {
8692 if log.entries.len() == SLOW_QUERY_RING_CAP {
8693 log.entries.pop_front();
8694 }
8695 log.entries.push_back(entry);
8696 log.total += 1;
8697 }
8698 }
8699 result
8700 }
8701
8702 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
8703 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
8704 /// calling [`GraphDb::query`].
8705 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
8706 let map: BTreeMap<String, Value> = params
8707 .iter()
8708 .map(|(k, v)| (k.to_string(), v.clone()))
8709 .collect();
8710 self.query(cypher, &map)
8711 }
8712
8713 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
8714 ///
8715 /// All mutations flow through the same `insert_node` / `set_prop` /
8716 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
8717 /// fires and the WAL captures everything with one fsync per statement.
8718 ///
8719 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
8720 /// and `deleted` matching the write-result contract.
8721 ///
8722 /// **Mutation routing**: mutations are collected into a single
8723 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
8724 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
8725 /// over `self.view()` — the borrow is dropped before the batch is opened.
8726 ///
8727 /// **Limitations (v1)**:
8728 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
8729 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
8730 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
8731 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
8732 /// - Deleting a derived edge → named error "cannot delete derived edge".
8733 pub fn query_write(
8734 &mut self,
8735 cypher: &str,
8736 params: &BTreeMap<String, Value>,
8737 ) -> Result<ResultSet> {
8738 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8739 detail: format!("lex: {e}"),
8740 })?;
8741 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8742 detail: format!("parse: {e}"),
8743 })?;
8744 self.exec_write_stmt(stmt, params)
8745 }
8746
8747 fn exec_write_stmt(
8748 &mut self,
8749 stmt: WriteStatement,
8750 params: &BTreeMap<String, Value>,
8751 ) -> Result<ResultSet> {
8752 match stmt {
8753 WriteStatement::Create(s) => self.exec_create(s, params),
8754 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
8755 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
8756 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
8757 WriteStatement::Merge(s) => self.exec_merge(s, params),
8758 }
8759 }
8760
8761 fn exec_create(
8762 &mut self,
8763 stmt: core_query::cypher::CreateStmt,
8764 params: &BTreeMap<String, Value>,
8765 ) -> Result<ResultSet> {
8766 // Extract the node key from props: require a string-valued `id` field.
8767 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
8768 for node in &stmt.nodes {
8769 let var = node.var.as_deref().unwrap_or("_cn0");
8770 let key = node
8771 .props
8772 .iter()
8773 .find(|(f, _)| f == "id")
8774 .and_then(|(_, v)| {
8775 if let Value::Str(s) = v {
8776 Some(s.clone())
8777 } else {
8778 None
8779 }
8780 })
8781 .ok_or_else(|| GraphError::QueryError {
8782 detail: format!(
8783 "CREATE node ({}:{}) requires a string 'id' property",
8784 var, node.label
8785 ),
8786 })?;
8787 var_to_key.insert(var.to_string(), key);
8788 }
8789
8790 let mut batch = self.batch();
8791 let mut created: usize = 0;
8792 for node in &stmt.nodes {
8793 let var = node.var.as_deref().unwrap_or("_cn0");
8794 let key = &var_to_key[var];
8795 batch.insert_node(&node.label, key, node.props.clone());
8796 created += 1;
8797 }
8798 for edge in &stmt.edges {
8799 let src_key = var_to_key
8800 .get(&edge.src_var)
8801 .ok_or_else(|| GraphError::QueryError {
8802 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
8803 })?;
8804 let dst_key = var_to_key
8805 .get(&edge.dst_var)
8806 .ok_or_else(|| GraphError::QueryError {
8807 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
8808 })?;
8809 batch.insert_edge(&edge.etype, src_key, dst_key);
8810 }
8811 batch.commit()?;
8812
8813 // Optional RETURN clause: project created bindings as a read result.
8814 if let Some(returns) = stmt.returns {
8815 // Each created node is looked up by its key via a separate MATCH pattern.
8816 // Multiple single-node patterns cross-join to produce 1 output row with
8817 // all variables bound (each pattern returns exactly 1 row).
8818 let patterns: Vec<Pattern> = stmt
8819 .nodes
8820 .iter()
8821 .map(|node| {
8822 let var = node.var.as_deref().unwrap_or("_cn0");
8823 let key = var_to_key[var].clone();
8824 Pattern {
8825 start: NodePat {
8826 var: Some(var.to_string()),
8827 label: Some(node.label.clone()),
8828 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
8829 },
8830 chain: vec![],
8831 shortest: false,
8832 }
8833 })
8834 .collect();
8835 let q = Query {
8836 matches: patterns,
8837 optional_clauses: vec![],
8838 where_expr: None,
8839 unwinds: vec![],
8840 post_unwind_where: None,
8841 stages: vec![],
8842 returns,
8843 distinct: false,
8844 order_by: vec![],
8845 skip: None,
8846 limit: None,
8847 };
8848 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8849 detail: format!("plan: {e}"),
8850 })?;
8851 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
8852 GraphError::QueryError {
8853 detail: format!("execute: {e}"),
8854 }
8855 });
8856 }
8857
8858 let mut rs = write_result_set();
8859 rs.push_row(vec![
8860 Some(Value::Int(created as i64)),
8861 Some(Value::Int(0)),
8862 Some(Value::Int(0)),
8863 ]);
8864 Ok(rs)
8865 }
8866
8867 fn exec_match_set(
8868 &mut self,
8869 stmt: core_query::cypher::MatchSetStmt,
8870 params: &BTreeMap<String, Value>,
8871 ) -> Result<ResultSet> {
8872 let project_returns = stmt.returns.clone();
8873 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8874 // so the post-write projection can look them up by key.
8875 let mut set_vars: Vec<String> = Vec::new();
8876 for s in &stmt.sets {
8877 if !set_vars.contains(&s.var) {
8878 set_vars.push(s.var.clone());
8879 }
8880 }
8881 let rel_vars = pattern_rel_vars(&stmt.matches);
8882 let mut lookup_vars = set_vars.clone();
8883 for v in pattern_node_vars(&stmt.matches) {
8884 add_var(&mut lookup_vars, &v);
8885 }
8886 if let Some(ref returns) = project_returns {
8887 for v in ret_node_vars(returns) {
8888 if !rel_vars.iter().any(|r| r == &v) {
8889 add_var(&mut lookup_vars, &v);
8890 }
8891 }
8892 }
8893
8894 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8895 // SET values are projected as ScalarExpr items so that arithmetic expressions
8896 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8897 let mut set_returns: Vec<RetItem> = lookup_vars
8898 .iter()
8899 .map(|v| RetItem {
8900 value: RetVal::Var(v.clone()),
8901 alias: None,
8902 })
8903 .collect();
8904 // One computed column per SET clause; alias is `__sv_<i>`.
8905 let set_val_cols: Vec<String> = stmt
8906 .sets
8907 .iter()
8908 .enumerate()
8909 .map(|(i, _)| format!("__sv_{i}"))
8910 .collect();
8911 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8912 set_returns.push(RetItem {
8913 value: RetVal::ScalarExpr(sc.value.clone()),
8914 alias: Some(col.clone()),
8915 });
8916 }
8917 // Capture relationship types while r is bound; SET does not change them.
8918 for r in &rel_vars {
8919 set_returns.push(RetItem {
8920 value: RetVal::FuncCall {
8921 name: "type".into(),
8922 args: vec![Operand::Var(r.clone())],
8923 },
8924 alias: Some(rel_type_alias(r)),
8925 });
8926 }
8927
8928 let read_q = Query {
8929 matches: stmt.matches.clone(),
8930 optional_clauses: vec![],
8931 where_expr: stmt.where_expr.clone(),
8932 unwinds: vec![],
8933 post_unwind_where: None,
8934 stages: vec![],
8935 returns: set_returns,
8936 distinct: false,
8937 order_by: vec![],
8938 skip: None,
8939 limit: None,
8940 };
8941 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8942 detail: format!("plan: {e}"),
8943 })?;
8944 // MATCH phase is read-only; borrow ends before batch opens.
8945 //
8946 // When a role-scoped write is in flight, run the MATCH read through
8947 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8948 // zero-rows (no SetProp ops generated, no existence-oracle 403).
8949 // Full-authority writes (pending_write_authz=None) keep view().
8950 let match_rs = {
8951 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8952 if let Some(ref mask) = mask_opt {
8953 execute(&self.view_masked(mask), &ops, &Params(params))
8954 } else {
8955 execute(&self.view(), &ops, &Params(params))
8956 }
8957 }
8958 .map_err(|e| GraphError::QueryError {
8959 detail: format!("execute: {e}"),
8960 })?;
8961
8962 // Collect (key, field, value) for each matched row × each SET clause.
8963 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8964 for row_i in 0..match_rs.len() {
8965 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8966 let key = match match_rs.get(row_i, &sc.var) {
8967 Some(Value::Str(k)) => k.clone(),
8968 _ => {
8969 return Err(GraphError::QueryError {
8970 detail: format!(
8971 "SET variable '{}' did not resolve to a node key",
8972 sc.var
8973 ),
8974 })
8975 }
8976 };
8977 // The SET value was already evaluated by the executor.
8978 let value = match match_rs.get(row_i, col) {
8979 Some(v) => v.clone(),
8980 None => {
8981 return Err(GraphError::QueryError {
8982 detail: format!(
8983 "SET value for {}.{} evaluated to null",
8984 sc.var, sc.field
8985 ),
8986 })
8987 }
8988 };
8989 set_ops.push((key, sc.field.clone(), value));
8990 }
8991 }
8992
8993 // Apply as one atomic batch.
8994 let props_set = set_ops.len();
8995 let mut batch = self.batch();
8996 for (key, field, value) in set_ops {
8997 batch.set_prop(&key, &field, value);
8998 }
8999 batch.commit()?;
9000
9001 if let Some(returns) = project_returns {
9002 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9003 }
9004
9005 let mut rs = write_result_set();
9006 rs.push_row(vec![
9007 Some(Value::Int(0)),
9008 Some(Value::Int(props_set as i64)),
9009 Some(Value::Int(0)),
9010 ]);
9011 Ok(rs)
9012 }
9013
9014 fn exec_match_delete(
9015 &mut self,
9016 stmt: core_query::cypher::MatchDeleteStmt,
9017 params: &BTreeMap<String, Value>,
9018 ) -> Result<ResultSet> {
9019 // Collect unique node vars needed to identify edge endpoints.
9020 let mut node_vars: Vec<String> = Vec::new();
9021 for ed in &stmt.deletes {
9022 if !node_vars.contains(&ed.src_var) {
9023 node_vars.push(ed.src_var.clone());
9024 }
9025 if !node_vars.contains(&ed.dst_var) {
9026 node_vars.push(ed.dst_var.clone());
9027 }
9028 }
9029
9030 // Synthesize read query.
9031 let returns: Vec<RetItem> = node_vars
9032 .iter()
9033 .map(|v| RetItem {
9034 value: RetVal::Var(v.clone()),
9035 alias: None,
9036 })
9037 .collect();
9038 let read_q = Query {
9039 matches: stmt.matches,
9040 optional_clauses: vec![],
9041 where_expr: stmt.where_expr,
9042 unwinds: vec![],
9043 post_unwind_where: None,
9044 stages: vec![],
9045 returns,
9046 distinct: false,
9047 order_by: vec![],
9048 skip: None,
9049 limit: None,
9050 };
9051 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9052 detail: format!("plan: {e}"),
9053 })?;
9054 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9055 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9056 let match_rs = {
9057 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9058 if let Some(ref mask) = mask_opt {
9059 execute(&self.view_masked(mask), &ops, &Params(params))
9060 } else {
9061 execute(&self.view(), &ops, &Params(params))
9062 }
9063 }
9064 .map_err(|e| GraphError::QueryError {
9065 detail: format!("execute: {e}"),
9066 })?;
9067
9068 // Collect (etype, src_key, dst_key) for each row × each delete target.
9069 let mut del_ops: Vec<(String, String, String)> = Vec::new();
9070 for row_i in 0..match_rs.len() {
9071 for ed in &stmt.deletes {
9072 let src_key = match match_rs.get(row_i, &ed.src_var) {
9073 Some(Value::Str(k)) => k.clone(),
9074 _ => {
9075 return Err(GraphError::QueryError {
9076 detail: format!(
9077 "DELETE src variable '{}' did not resolve to a node key",
9078 ed.src_var
9079 ),
9080 })
9081 }
9082 };
9083 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9084 Some(Value::Str(k)) => k.clone(),
9085 _ => {
9086 return Err(GraphError::QueryError {
9087 detail: format!(
9088 "DELETE dst variable '{}' did not resolve to a node key",
9089 ed.dst_var
9090 ),
9091 })
9092 }
9093 };
9094 del_ops.push((ed.etype.clone(), src_key, dst_key));
9095 }
9096 }
9097
9098 // Apply as one atomic batch.
9099 let deleted = del_ops.len();
9100 let mut batch = self.batch();
9101 for (etype, src_key, dst_key) in del_ops {
9102 batch.delete_edge(&etype, &src_key, &dst_key);
9103 }
9104 batch.commit().map_err(|e| match e {
9105 GraphError::RuleOwned { .. } => GraphError::QueryError {
9106 detail: "cannot delete derived edge; retract via the rule or change the property"
9107 .to_string(),
9108 },
9109 other => other,
9110 })?;
9111
9112 let mut rs = write_result_set();
9113 rs.push_row(vec![
9114 Some(Value::Int(0)),
9115 Some(Value::Int(0)),
9116 Some(Value::Int(deleted as i64)),
9117 ]);
9118 Ok(rs)
9119 }
9120
9121 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9122 ///
9123 /// Collects the matching node keys via an ephemeral read query, then calls
9124 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
9125 /// the executor first checks that the node has no incident edges; if any
9126 /// remain it returns a named error matching openCypher semantics.
9127 fn exec_match_delete_node(
9128 &mut self,
9129 stmt: MatchDeleteNodeStmt,
9130 params: &BTreeMap<String, Value>,
9131 ) -> Result<ResultSet> {
9132 // Build a read query returning only the node keys we need.
9133 let returns: Vec<RetItem> = stmt
9134 .node_vars
9135 .iter()
9136 .map(|v| RetItem {
9137 value: RetVal::Var(v.clone()),
9138 alias: None,
9139 })
9140 .collect();
9141 let read_q = Query {
9142 matches: stmt.matches,
9143 optional_clauses: vec![],
9144 where_expr: stmt.where_expr,
9145 unwinds: vec![],
9146 post_unwind_where: None,
9147 stages: vec![],
9148 returns,
9149 distinct: false,
9150 order_by: vec![],
9151 skip: None,
9152 limit: None,
9153 };
9154 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9155 detail: format!("plan: {e}"),
9156 })?;
9157 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9158 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9159 let match_rs = {
9160 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9161 if let Some(ref mask) = mask_opt {
9162 execute(&self.view_masked(mask), &ops, &Params(params))
9163 } else {
9164 execute(&self.view(), &ops, &Params(params))
9165 }
9166 }
9167 .map_err(|e| GraphError::QueryError {
9168 detail: format!("execute: {e}"),
9169 })?;
9170
9171 // Collect unique node keys to delete (deduplicate across rows × vars).
9172 let mut keys: Vec<String> = Vec::new();
9173 for row_i in 0..match_rs.len() {
9174 for var in &stmt.node_vars {
9175 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9176 if !keys.contains(k) {
9177 keys.push(k.clone());
9178 }
9179 }
9180 }
9181 }
9182
9183 if !stmt.detach {
9184 // openCypher bare DELETE: error if any matched node has incident edges.
9185 for key in &keys {
9186 if let Some(id) = self.ids.get(key) {
9187 let tv = self.topo_view();
9188 let has_edges = tv.etypes().any(|et| {
9189 !tv.neighbors(et, Direction::Out, id).is_empty()
9190 || !tv.neighbors(et, Direction::In, id).is_empty()
9191 });
9192 if has_edges {
9193 return Err(GraphError::QueryError {
9194 detail: format!(
9195 "Cannot delete node `{key}` because it still has incident edges. \
9196 Use DETACH DELETE to remove the node and all its edges."
9197 ),
9198 });
9199 }
9200 }
9201 }
9202 }
9203
9204 let mut nodes_deleted = 0i64;
9205 let mut edges_deleted = 0i64;
9206 for key in keys {
9207 match self.delete_node(&key) {
9208 Ok(report) => {
9209 nodes_deleted += 1;
9210 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9211 }
9212 Err(GraphError::KeyNotFound { .. }) => {
9213 // Node may have been deleted by an earlier iteration (e.g., via
9214 // multiple MATCH rows for the same node). Safe to skip.
9215 }
9216 Err(e) => return Err(e),
9217 }
9218 }
9219
9220 let mut rs = write_result_set();
9221 rs.push_row(vec![
9222 Some(Value::Int(0)),
9223 Some(Value::Int(0)),
9224 Some(Value::Int(nodes_deleted + edges_deleted)),
9225 ]);
9226 Ok(rs)
9227 }
9228
9229 fn exec_merge(
9230 &mut self,
9231 stmt: core_query::cypher::MergeStmt,
9232 params: &BTreeMap<String, Value>,
9233 ) -> Result<ResultSet> {
9234 // MERGE: check if a node with the given key already exists.
9235 let key = match &stmt.key_value {
9236 Value::Str(s) => s.clone(),
9237 _ => {
9238 return Err(GraphError::QueryError {
9239 detail: format!(
9240 "MERGE key value must be a string (got {:?})",
9241 stmt.key_value
9242 ),
9243 })
9244 }
9245 };
9246
9247 if let Some(var) = stmt.var.as_deref() {
9248 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9249 if sc.var != var {
9250 return Err(GraphError::QueryError {
9251 detail: format!(
9252 "SET variable '{}' does not match MERGE variable '{var}'",
9253 sc.var
9254 ),
9255 });
9256 }
9257 }
9258 }
9259
9260 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9261 //
9262 // MERGE scope precondition: check create OR update scope for the
9263 // declared label BEFORE calling `has_node` (timing-oracle closure,
9264 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9265 // unscoped roles — the scope denial fires without touching the key store).
9266 //
9267 // Clone to avoid holding a borrow on `self.pending_write_authz` while
9268 // also calling `self.ids.get(key)`.
9269 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9270 let has_create = authz.scope.create_labels.contains(&stmt.label);
9271 let has_update = authz.scope.update_labels.contains(&stmt.label);
9272 if !has_create && !has_update {
9273 // Scope-before-lookup: 403 without has_node call (timing oracle
9274 // closure — see test_merge_unscoped_no_key_lookup).
9275 return Err(GraphError::RoleWriteDenied {
9276 reason: format!(
9277 "role-bound token: label '{}' not in write scope (create_labels)",
9278 stmt.label
9279 ),
9280 });
9281 }
9282 // Key lookup under mask.
9283 match self.ids.get(key.as_str()) {
9284 Some(id) if authz.mask.contains_id(id) => {
9285 // Visible: must have update scope to proceed to match arm.
9286 if !has_update {
9287 return Err(GraphError::RoleWriteDenied {
9288 reason: format!(
9289 "role-bound token: label '{}' not in write scope (update_labels)",
9290 stmt.label
9291 ),
9292 });
9293 }
9294 true // existed = true → match arm
9295 }
9296 Some(_) => {
9297 // Hidden: same error as absent to the role (spec §3.1/§3.3).
9298 return Err(GraphError::RoleWriteDenied {
9299 reason: "role-bound token: target node not visible".into(),
9300 });
9301 }
9302 None => {
9303 // Absent: must have create scope to proceed to the create arm.
9304 //
9305 // Update-only roles (create_labels empty, update_labels set):
9306 // return the SAME "not visible" error as the hidden-key branch
9307 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9308 // "confirm existence of hidden nodes: No").
9309 //
9310 // Create-scoped roles (has_create=true): absent → create arm
9311 // as before. The accepted structural key-existence disclosure
9312 // (§THREAT-MODEL) applies only when the role holds create scope.
9313 if !has_create {
9314 return Err(GraphError::RoleWriteDenied {
9315 reason: "role-bound token: target node not visible".into(),
9316 });
9317 }
9318 false // existed = false → create arm
9319 }
9320 }
9321 } else {
9322 // Full authority: use the existing non-masked has_node check.
9323 self.has_node(&key)
9324 };
9325
9326 let existed = merge_existed;
9327 let mut created = 0i64;
9328 if !existed || !stmt.on_match.is_empty() {
9329 let mut batch = self.batch();
9330 if !existed {
9331 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
9332 batch.insert_node(&stmt.label, &key, props);
9333 for sc in &stmt.on_create {
9334 let value = resolve_merge_set_value(&sc.value, params)?;
9335 batch.set_prop(&key, &sc.field, value);
9336 }
9337 created = 1;
9338 } else {
9339 for sc in &stmt.on_match {
9340 let value = resolve_merge_set_value(&sc.value, params)?;
9341 batch.set_prop(&key, &sc.field, value);
9342 }
9343 }
9344 batch.commit()?;
9345 }
9346
9347 // Refresh the role mask so the just-created node is visible to this
9348 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9349 // (apply_schema subset rule), so the new node's label is already in the
9350 // role's read scope — this never widens beyond the role's declared labels.
9351 if !existed {
9352 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9353 let new_mask = self.mask_for_role(&role)?;
9354 if let Some(a) = self.pending_write_authz.as_mut() {
9355 a.mask = new_mask;
9356 }
9357 }
9358 }
9359
9360 // Optional RETURN clause: project the node (created or matched) as a read result.
9361 if let Some(returns) = stmt.returns {
9362 let var = stmt.var.as_deref().unwrap_or("_mn0");
9363 let q = Query {
9364 matches: vec![Pattern {
9365 start: NodePat {
9366 var: Some(var.to_string()),
9367 label: Some(stmt.label.clone()),
9368 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9369 },
9370 chain: vec![],
9371 shortest: false,
9372 }],
9373 optional_clauses: vec![],
9374 where_expr: None,
9375 unwinds: vec![],
9376 post_unwind_where: None,
9377 stages: vec![],
9378 returns,
9379 distinct: false,
9380 order_by: vec![],
9381 skip: None,
9382 limit: None,
9383 };
9384 let ops = plan(&q).map_err(|e| GraphError::QueryError {
9385 detail: format!("plan: {e}"),
9386 })?;
9387 // Use view_masked when a role-scoped write is in flight so the
9388 // post-merge projection is consistent with the masked read phase.
9389 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9390 return (if let Some(ref mask) = mask_opt {
9391 execute(&self.view_masked(mask), &ops, &Params(params))
9392 } else {
9393 execute(&self.view(), &ops, &Params(params))
9394 })
9395 .map_err(|e| GraphError::QueryError {
9396 detail: format!("execute: {e}"),
9397 });
9398 }
9399
9400 let mut rs = write_result_set();
9401 rs.push_row(vec![
9402 Some(Value::Int(created)),
9403 Some(Value::Int(0)),
9404 Some(Value::Int(0)),
9405 ]);
9406 Ok(rs)
9407 }
9408
9409 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9410 /// annotated with rule name, edge type, direction, and weight.
9411 /// Results are sorted by (rule, edge_type).
9412 /// Returns `Err(KeyNotFound)` if either key is unknown.
9413 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9414 self.ensure_v8_base_sections_loaded();
9415 let id_a = self
9416 .ids
9417 .get(key_a)
9418 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9419 let id_b = self
9420 .ids
9421 .get(key_b)
9422 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9423
9424 let mut results = Vec::new();
9425
9426 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9427 // rather than O(total provenance).
9428 let scan = if self.engine.provenance_touching_len(id_a)
9429 <= self.engine.provenance_touching_len(id_b)
9430 {
9431 id_a
9432 } else {
9433 id_b
9434 };
9435 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9436 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9437 continue;
9438 }
9439 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9440 continue;
9441 };
9442 let edge_type = match self.syms.resolve(etype) {
9443 Some(s) => s.to_string(),
9444 None => continue,
9445 };
9446 // Provenance (src, dst) ids come from the archived PROVENANCE section
9447 // (large, no eager CRC). A corrupt section can produce ids that are
9448 // out of range; return Corrupt rather than panic.
9449 let src_key = self
9450 .ids
9451 .key_of(src)
9452 .ok_or_else(|| GraphError::Corrupt {
9453 detail: format!("v8: provenance src id {src} not in id table"),
9454 })?
9455 .to_string();
9456 let dst_key = self
9457 .ids
9458 .key_of(dst)
9459 .ok_or_else(|| GraphError::Corrupt {
9460 detail: format!("v8: provenance dst id {dst} not in id table"),
9461 })?
9462 .to_string();
9463 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9464 self.edge_props_view()
9465 .get(etype, src, dst, prop)
9466 .and_then(|v| {
9467 if let Value::Float(f) = v {
9468 Some(f)
9469 } else {
9470 None
9471 }
9472 })
9473 });
9474 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9475 // still have a score: recompute it from the predicate so explain
9476 // never reports "no score" for an edge the engine scored. Via-hop
9477 // rules score over their via set, not over (src, dst), so leave
9478 // those None rather than report a number the rule did not produce.
9479 let weight = stored.or_else(|| {
9480 if rule_def.via_edge.is_some() {
9481 return None;
9482 }
9483 let props_view = build_props_view(&self.props, &self.base);
9484 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9485 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9486 let src_view = NodeView {
9487 key: &src_key,
9488 props: &src_get,
9489 };
9490 let dst_view = NodeView {
9491 key: &dst_key,
9492 props: &dst_get,
9493 };
9494 evaluate(&rule_def.predicate, &src_view, &dst_view)
9495 });
9496 results.push(Explanation {
9497 rule: rule_name.to_string(),
9498 edge_type,
9499 src_key,
9500 dst_key,
9501 weight,
9502 predicate: PredicateSummary {
9503 approximate: rule_def.approximate,
9504 ..PredicateSummary::from(&rule_def.predicate)
9505 },
9506 via_edge: rule_def.via_edge.clone(),
9507 });
9508 }
9509
9510 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
9511 Ok(results)
9512 }
9513
9514 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
9515 let id = self
9516 .ids
9517 .get(key)
9518 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9519 let Some(sym) = self.syms.get(edge_type) else {
9520 return Ok(Vec::new());
9521 };
9522 self.topo_view()
9523 .neighbors(sym, dir, id)
9524 .iter()
9525 .map(|&n| {
9526 self.ids
9527 .key_of(n)
9528 .map(|k| k.to_string())
9529 .ok_or_else(|| GraphError::Corrupt {
9530 detail: format!("topology id {n} has no key"),
9531 })
9532 })
9533 .collect::<Result<Vec<_>>>()
9534 }
9535
9536 /// Return the last-change commit sequence for `key`, or `None` if the node
9537 /// does not exist or has never been mutated since the last V5-V7 snapshot
9538 /// (horizon-bounded for legacy stores).
9539 ///
9540 /// The returned sequence is a monotonically increasing counter that starts
9541 /// at 1 for the first commit after `open` and increments with every
9542 /// successful write. WAL replay at open also assigns sequences (1..N for N
9543 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
9544 ///
9545 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
9546 /// in the snapshot but not touched by any WAL frame will return `None`
9547 /// (horizon-bounded: CAS against such nodes is only safe after the first
9548 /// V8 snapshot or after the node is next mutated).
9549 pub fn last_changed(&self, key: &str) -> Option<u64> {
9550 let id = self.ids.get(key)?;
9551 self.last_change.get(&id).copied()
9552 }
9553
9554 /// The current commit sequence (number of successful commits since open,
9555 /// including WAL replay frames). Useful for recording a baseline before
9556 /// a read-modify-write cycle.
9557 pub fn commit_seq(&self) -> u64 {
9558 self.commit_seq
9559 }
9560
9561 /// Check that all `preconds` are satisfied against the current db state.
9562 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
9563 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
9564 for precond in preconds {
9565 match precond {
9566 Precondition::NodeUnchangedSince { key, expected } => {
9567 // Missing entry means the node predates the WAL window or
9568 // does not exist; treat as 0 (before any commit).
9569 let actual = self.last_changed(key).unwrap_or_default();
9570 if actual != *expected {
9571 return Err(GraphError::CasConflict {
9572 key: key.clone(),
9573 expected: *expected,
9574 actual,
9575 });
9576 }
9577 }
9578 Precondition::NodeAbsent { key } => {
9579 // Node must not exist (not live).
9580 if self.ids.get(key).is_some() {
9581 let actual = self.last_changed(key).unwrap_or(0);
9582 return Err(GraphError::CasConflict {
9583 key: key.clone(),
9584 expected: u64::MAX,
9585 actual,
9586 });
9587 }
9588 }
9589 }
9590 }
9591 Ok(())
9592 }
9593
9594 /// Apply a batch of mutations with compare-and-set preconditions.
9595 ///
9596 /// All preconditions are checked atomically before any operation is applied.
9597 /// If any precondition fails, the entire batch is rejected with
9598 /// [`GraphError::CasConflict`] and no WAL frame is written.
9599 ///
9600 /// # Returns
9601 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
9602 ///
9603 /// # Errors
9604 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
9605 /// - Any error that [`write_batch`] would return for the ops themselves.
9606 pub fn write_batch_cas(
9607 &mut self,
9608 preconds: Vec<Precondition>,
9609 ops: Vec<BatchOp>,
9610 ) -> Result<(usize, usize)> {
9611 self.check_preconditions(&preconds)?;
9612 self.commit_logged_batch(ops, None, None)
9613 }
9614
9615 /// Update the per-node last-change map for a WAL record at commit `seq`.
9616 ///
9617 /// Called after a successful apply to record which nodes were touched.
9618 /// For replay, called with the WAL-frame's replayed seq.
9619 ///
9620 /// Touch definition (see [`Precondition`] doc):
9621 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
9622 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
9623 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
9624 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
9625 /// - Batch → recurse into inner records.
9626 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
9627 match rec {
9628 WalRecord::InsertNode { key, .. }
9629 | WalRecord::SetProp { key, .. }
9630 | WalRecord::RemoveProp { key, .. } => {
9631 if let Some(id) = self.ids.get(key) {
9632 self.last_change.insert(id, seq);
9633 }
9634 }
9635 WalRecord::InsertNodeId { key, .. } => {
9636 if let Some(id) = self.ids.get(key) {
9637 self.last_change.insert(id, seq);
9638 }
9639 }
9640 WalRecord::SetPropId { id, .. } => {
9641 self.last_change.insert(*id, seq);
9642 }
9643 WalRecord::InsertEdge {
9644 src_key, dst_key, ..
9645 }
9646 | WalRecord::DeleteEdge {
9647 src_key, dst_key, ..
9648 } => {
9649 if let Some(src_id) = self.ids.get(src_key) {
9650 self.last_change.insert(src_id, seq);
9651 }
9652 if let Some(dst_id) = self.ids.get(dst_key) {
9653 self.last_change.insert(dst_id, seq);
9654 }
9655 }
9656 WalRecord::InsertEdgeId { src, dst, .. } => {
9657 self.last_change.insert(*src, seq);
9658 self.last_change.insert(*dst, seq);
9659 }
9660 // DeleteNode: node is tombstoned; last_changed(key) returns None for
9661 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
9662 // History markers: state no-ops; the underlying mutation already
9663 // touched the relevant nodes' last_change entries.
9664 WalRecord::DeleteNode { .. }
9665 | WalRecord::DerivedEdgeAdded { .. }
9666 | WalRecord::DerivedEdgeRetracted { .. }
9667 | WalRecord::Intern { .. }
9668 | WalRecord::CreateRule { .. }
9669 | WalRecord::DeleteRule { .. }
9670 | WalRecord::RebuildRule { .. }
9671 | WalRecord::CreateView { .. }
9672 | WalRecord::DeleteView { .. }
9673 | WalRecord::EnableFulltext { .. }
9674 | WalRecord::DisableFulltext { .. }
9675 | WalRecord::EnableIndex { .. }
9676 | WalRecord::DisableIndex { .. } => {}
9677 // RenameNode: node id is stable; update last_change via the new key.
9678 // Called after apply(), so ids already reflects new_key.
9679 WalRecord::RenameNode { new_key, .. } => {
9680 if let Some(id) = self.ids.get(new_key) {
9681 self.last_change.insert(id, seq);
9682 }
9683 }
9684 WalRecord::Batch(inner) => {
9685 for inner_rec in inner {
9686 self.update_last_change_from_rec(inner_rec, seq);
9687 }
9688 }
9689 }
9690 }
9691
9692 pub fn node_count(&self) -> usize {
9693 self.ids.len()
9694 }
9695
9696 /// Configure archive retention: keep the `N` newest WAL archives at each
9697 /// [`snapshot_with`] call when `archive_wal: true`.
9698 ///
9699 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
9700 /// `Some(0)` or `None` → unlimited (no pruning).
9701 ///
9702 /// Pruning only ever happens inside [`snapshot_with`]; this method only
9703 /// stores the policy. Archives below the retention limit are deleted
9704 /// oldest-first. The horizon floor is updated so that
9705 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
9706 /// in pruned archives rather than silently returning wrong data.
9707 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
9708 self.wal_archive_retention = keep;
9709 }
9710
9711 /// Delete any WAL archives that are fully below the current horizon floor.
9712 ///
9713 /// Orphaned archives arise when the floor is written first during retention
9714 /// pruning and then a crash interrupts the archive-delete sequence. The
9715 /// opening cleanup ensures no subsequent read path sees stale data.
9716 ///
9717 /// Under the monotonic naming scheme, the archive name N equals the
9718 /// cumulative end-frame index of the archive in global commit space (i.e.
9719 /// the archive covers global frames `[prev_n, N)`). An archive is
9720 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
9721 /// below the floor and have already been counted in it.
9722 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
9723 if self.wal_horizon_floor == 0 {
9724 // Floor at 0 means no pruning has ever occurred; nothing to clean.
9725 return Ok(());
9726 }
9727 let archive_ns = self.fs.list_archives()?;
9728 for n in archive_ns {
9729 if n <= self.wal_horizon_floor {
9730 // Archive N ends at global frame N; all its frames are below
9731 // the floor (floor already accounts for them) → orphaned.
9732 self.fs.delete_archive(n).map_err(GraphError::Io)?;
9733 } else {
9734 // Archives are sorted ascending; first one above floor stops scan.
9735 break;
9736 }
9737 }
9738 Ok(())
9739 }
9740
9741 /// Collect all WAL frames from surviving archives (oldest-first) then the
9742 /// live WAL into one flat list, and return the total along with the number
9743 /// of archive frames at the front of the list.
9744 ///
9745 /// Commit indices into the returned list are LOCAL (0 = first frame of
9746 /// oldest surviving archive). To obtain the GLOBAL index add
9747 /// `self.wal_horizon_floor`.
9748 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
9749 let archive_ns = self.fs.list_archives()?;
9750 let mut all: Vec<WalRecord> = Vec::new();
9751 for n in archive_ns {
9752 let bytes = self.fs.read_archive(n)?;
9753 let (frames, _) = decode_all(&bytes);
9754 all.extend(frames);
9755 }
9756 let archive_count = all.len() as u64;
9757 let live_bytes = self.fs.read(FileId::Wal)?;
9758 let (live_frames, _) = decode_all(&live_bytes);
9759 all.extend(live_frames);
9760 Ok((all, archive_count))
9761 }
9762
9763 /// Return the total number of committed WAL frames visible in the current
9764 /// horizon window, including frames in surviving WAL archives.
9765 ///
9766 /// This is the exclusive upper bound for valid `at_commit` indices in
9767 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
9768 ///
9769 /// Returns the horizon floor when all surviving history is empty.
9770 pub fn wal_total_commits(&self) -> Result<u64> {
9771 let (frames, _) = self.all_frames()?;
9772 Ok(self.wal_horizon_floor + frames.len() as u64)
9773 }
9774
9775 /// The global frame index of the first commit reachable through surviving
9776 /// archives (0 when no archives have been pruned).
9777 pub fn wal_horizon_floor(&self) -> u64 {
9778 self.wal_horizon_floor
9779 }
9780
9781 /// Return the per-node change history for `key` by scanning the on-disk WAL.
9782 ///
9783 /// ## Horizon
9784 ///
9785 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
9786 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
9787 /// zero-cost contract; a durable history log is out of scope.
9788 ///
9789 /// ## Derived edges
9790 ///
9791 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
9792 /// history. Only edges written directly by the application are recorded.
9793 ///
9794 /// ## Deleted nodes
9795 ///
9796 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
9797 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
9798 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
9799 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
9800 ///
9801 /// ## Dense-id edge entries and tombstoned partners
9802 ///
9803 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
9804 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
9805 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
9806 /// Build commit-bounded alias intervals for `queried_key`.
9807 ///
9808 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
9809 /// A record written under `key` at commit `c` matches the queried identity iff
9810 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
9811 ///
9812 /// Each alias entry carries both a lower and an upper bound so that key-reuse
9813 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
9814 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
9815 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
9816 /// only identity-2's events (commits 7–9 under "a") are in scope.
9817 ///
9818 /// Only **forward aliasing**: querying the *new* key surfaces events written
9819 /// under the *old* key. The reverse direction is not supported.
9820 fn build_key_alias_intervals(
9821 &self,
9822 frames: &[core_storage::wal::WalRecord],
9823 queried_key: &str,
9824 ) -> Vec<(String, u64, Option<u64>)> {
9825 use core_storage::wal::WalRecord;
9826
9827 // Pre-pass: build reverse_rename and key_starts maps.
9828 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
9829 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
9830
9831 for (local_i, frame) in frames.iter().enumerate() {
9832 let commit = self.wal_horizon_floor + local_i as u64;
9833 let records: &[WalRecord] = match frame {
9834 WalRecord::Batch(inner) => inner.as_slice(),
9835 single => std::slice::from_ref(single),
9836 };
9837 for rec in records {
9838 match rec {
9839 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
9840 key_starts.entry(key.clone()).or_default().push(commit);
9841 }
9842 WalRecord::RenameNode { old_key, new_key } => {
9843 // new_key came into existence at this commit.
9844 key_starts.entry(new_key.clone()).or_default().push(commit);
9845 // Record the reverse rename: new_key was introduced by renaming old_key.
9846 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
9847 }
9848 _ => {}
9849 }
9850 }
9851 }
9852
9853 // Build alias intervals by following the reverse rename chain.
9854 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
9855 let mut current_key = queried_key.to_string();
9856 let mut current_valid_until: Option<u64> = None;
9857
9858 loop {
9859 // valid_from: the most recent commit where current_key was assigned to this
9860 // identity. For aliases (valid_until = Some(vu)), find the last start event
9861 // for the key strictly before vu — this is where the alias's occupancy by
9862 // this identity began, correctly excluding prior identities that reused the key.
9863 let valid_from = if let Some(vu) = current_valid_until {
9864 key_starts
9865 .get(¤t_key)
9866 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9867 .unwrap_or(self.wal_horizon_floor)
9868 } else {
9869 // Queried key — no upper bound; may have been introduced at any commit.
9870 self.wal_horizon_floor
9871 };
9872
9873 result.push((current_key.clone(), valid_from, current_valid_until));
9874
9875 match reverse_rename.get(¤t_key) {
9876 Some((old_key, rename_commit)) => {
9877 current_valid_until = Some(*rename_commit);
9878 current_key = old_key.clone();
9879 }
9880 None => break,
9881 }
9882 }
9883
9884 result
9885 }
9886
9887 /// Returns true if `record_key` matches any alias interval that covers `commit`.
9888 fn aliases_match(
9889 intervals: &[(String, u64, Option<u64>)],
9890 record_key: &str,
9891 commit: u64,
9892 ) -> bool {
9893 intervals
9894 .iter()
9895 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9896 }
9897
9898 /// Return the change history of node `key` by scanning the on-disk WAL.
9899 ///
9900 /// ## Horizon
9901 ///
9902 /// History reaches back only as far as the retained WAL. The returned
9903 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
9904 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
9905 /// oldest commit still reachable). When `horizon > 0`, older events were
9906 /// pruned and are not in `items`.
9907 pub fn node_history(
9908 &self,
9909 key: &str,
9910 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
9911 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
9912 use core_storage::wal::WalRecord;
9913
9914 let (frames, _) = self.all_frames()?;
9915 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9916
9917 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9918 let alias_intervals = self.build_key_alias_intervals(&frames, key);
9919
9920 let mut out: Vec<HistoryEntry> = Vec::new();
9921
9922 for (local_i, frame) in frames.iter().enumerate() {
9923 let commit = self.wal_horizon_floor + local_i as u64;
9924 // Collect the inner records to process — Batch is one commit, single records are one commit.
9925 let records: &[WalRecord] = match frame {
9926 WalRecord::Batch(inner) => inner.as_slice(),
9927 single => std::slice::from_ref(single),
9928 };
9929
9930 for rec in records {
9931 let change = match rec {
9932 WalRecord::InsertNode { label, key: k, .. }
9933 if Self::aliases_match(&alias_intervals, k, commit) =>
9934 {
9935 Some(HistoryChange::NodeInserted {
9936 label: label.clone(),
9937 })
9938 }
9939 WalRecord::InsertNodeId { label, key: k, .. }
9940 if Self::aliases_match(&alias_intervals, k, commit) =>
9941 {
9942 let label_str = match self.syms.resolve(*label) {
9943 Some(s) => s.to_string(),
9944 None => continue,
9945 };
9946 Some(HistoryChange::NodeInserted { label: label_str })
9947 }
9948 WalRecord::SetProp {
9949 key: k,
9950 field,
9951 value,
9952 } if Self::aliases_match(&alias_intervals, k, commit) => {
9953 Some(HistoryChange::PropSet {
9954 field: field.clone(),
9955 value: value.clone(),
9956 })
9957 }
9958 WalRecord::SetPropId { id, field, value } => {
9959 // Use key_of_historical (not key_of) so a node's prop_set
9960 // events remain visible after the node is later deleted:
9961 // key_of returns None for a tombstoned id, which would
9962 // silently drop every PropSet between insert and delete.
9963 // Mirrors the InsertEdgeId arm below and edge_history's
9964 // own id-keyed arms.
9965 match self.ids.key_of_historical(*id) {
9966 // key_of_historical returns the last-known (possibly
9967 // post-rename, possibly post-delete) key; compare to queried key.
9968 Some(resolved) if resolved == key => {
9969 let field_str = match self.syms.resolve(*field) {
9970 Some(s) => s.to_string(),
9971 None => continue,
9972 };
9973 Some(HistoryChange::PropSet {
9974 field: field_str,
9975 value: value.clone(),
9976 })
9977 }
9978 _ => None,
9979 }
9980 }
9981 WalRecord::RemoveProp { key: k, field }
9982 if Self::aliases_match(&alias_intervals, k, commit) =>
9983 {
9984 Some(HistoryChange::PropRemoved {
9985 field: field.clone(),
9986 })
9987 }
9988 WalRecord::InsertEdge {
9989 edge_type,
9990 src_key,
9991 dst_key,
9992 } => {
9993 if Self::aliases_match(&alias_intervals, src_key, commit) {
9994 Some(HistoryChange::EdgeAdded {
9995 edge_type: edge_type.clone(),
9996 other: dst_key.clone(),
9997 outgoing: true,
9998 })
9999 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10000 Some(HistoryChange::EdgeAdded {
10001 edge_type: edge_type.clone(),
10002 other: src_key.clone(),
10003 outgoing: false,
10004 })
10005 } else {
10006 None
10007 }
10008 }
10009 WalRecord::InsertEdgeId { etype, src, dst } => {
10010 let etype_str = match self.syms.resolve(*etype) {
10011 Some(s) => s.to_string(),
10012 None => continue,
10013 };
10014 // key_of_historical (not key_of): an edge added before
10015 // either endpoint was later deleted must still resolve —
10016 // see the SetPropId arm above and edge_history's
10017 // InsertEdgeId arm, which use the same lookup for the
10018 // same reason.
10019 let src_key = self.ids.key_of_historical(*src);
10020 let dst_key = self.ids.key_of_historical(*dst);
10021 if src_key == Some(key) {
10022 let other = match dst_key {
10023 Some(s) => s.to_string(),
10024 None => continue,
10025 };
10026 Some(HistoryChange::EdgeAdded {
10027 edge_type: etype_str,
10028 other,
10029 outgoing: true,
10030 })
10031 } else if dst_key == Some(key) {
10032 let other = match src_key {
10033 Some(s) => s.to_string(),
10034 None => continue,
10035 };
10036 Some(HistoryChange::EdgeAdded {
10037 edge_type: etype_str,
10038 other,
10039 outgoing: false,
10040 })
10041 } else {
10042 None
10043 }
10044 }
10045 WalRecord::DeleteEdge {
10046 edge_type,
10047 src_key,
10048 dst_key,
10049 } => {
10050 if Self::aliases_match(&alias_intervals, src_key, commit) {
10051 Some(HistoryChange::EdgeRemoved {
10052 edge_type: edge_type.clone(),
10053 other: dst_key.clone(),
10054 outgoing: true,
10055 })
10056 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10057 Some(HistoryChange::EdgeRemoved {
10058 edge_type: edge_type.clone(),
10059 other: src_key.clone(),
10060 outgoing: false,
10061 })
10062 } else {
10063 None
10064 }
10065 }
10066 WalRecord::DeleteNode { key: k }
10067 if Self::aliases_match(&alias_intervals, k, commit) =>
10068 {
10069 Some(HistoryChange::NodeDeleted)
10070 }
10071 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10072 _ => None,
10073 };
10074
10075 if let Some(change) = change {
10076 out.push(HistoryEntry { commit, change });
10077 }
10078 }
10079 }
10080
10081 Ok(HistoryResult {
10082 items: out,
10083 total_commits,
10084 horizon: self.wal_horizon_floor,
10085 })
10086 }
10087
10088 /// Return the per-edge change history between nodes `a` and `b` by scanning
10089 /// the on-disk WAL.
10090 ///
10091 /// ## Horizon
10092 ///
10093 /// History reaches back only to the last WAL-truncating snapshot, exactly
10094 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10095 /// `total_commits` (= number of WAL frames), which is the exclusive upper
10096 /// bound for valid commit indices.
10097 ///
10098 /// ## Derived edges
10099 ///
10100 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10101 /// WAL markers written by `log_then_apply_with` after each rule-firing
10102 /// mutation. The `rule` field of those events carries the rule name.
10103 ///
10104 /// ## DeleteNode
10105 ///
10106 /// When a node is deleted, its manual incident edges are swept inline without
10107 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10108 /// events for either endpoint and synthesises `Retracted(rule:None)` events
10109 /// for each manual edge that was active at that point. Derived edges active at
10110 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10111 /// the engine appends immediately after the `DeleteNode` record; those events
10112 /// carry correct rule attribution and are emitted by the marker arm, not the
10113 /// synthetic sweep.
10114 ///
10115 /// ## Masks
10116 ///
10117 /// Like `node_history`, this method has no mask parameter and returns WAL
10118 /// history regardless of any role mask. For masked history semantics, apply
10119 /// the mask at the caller level.
10120 pub fn edge_history(
10121 &self,
10122 a: &str,
10123 b: &str,
10124 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10125 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10126 use core_storage::wal::WalRecord;
10127
10128 let (frames, _) = self.all_frames()?;
10129 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10130
10131 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10132 // Intervals are commit-bounded so recycled keys don't contaminate histories.
10133 let alias_a = self.build_key_alias_intervals(&frames, a);
10134 let alias_b = self.build_key_alias_intervals(&frames, b);
10135
10136 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10137 // The is_derived flag is used by the DeleteNode sweep: manual edges are
10138 // swept with a synthetic Retracted(rule:None); derived edges are skipped
10139 // because the engine writes a DerivedEdgeRetracted marker immediately after
10140 // the DeleteNode record, which carries the correct rule attribution.
10141 let mut active: Vec<(String, String, String, bool)> = Vec::new();
10142 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10143
10144 for (local_i, frame) in frames.iter().enumerate() {
10145 let commit = self.wal_horizon_floor + local_i as u64;
10146 let records: &[WalRecord] = match frame {
10147 WalRecord::Batch(inner) => inner.as_slice(),
10148 single => std::slice::from_ref(single),
10149 };
10150
10151 for rec in records {
10152 match rec {
10153 WalRecord::InsertEdge {
10154 edge_type,
10155 src_key,
10156 dst_key,
10157 } => {
10158 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10159 && Self::aliases_match(&alias_b, dst_key, commit);
10160 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10161 && Self::aliases_match(&alias_a, dst_key, commit);
10162 if is_ab || is_ba {
10163 active.push((
10164 edge_type.clone(),
10165 src_key.clone(),
10166 dst_key.clone(),
10167 false,
10168 ));
10169 out.push(EdgeHistoryEvent {
10170 edge_type: edge_type.clone(),
10171 commit,
10172 event: EdgeEvent::Added,
10173 rule: None,
10174 });
10175 }
10176 }
10177 WalRecord::InsertEdgeId { etype, src, dst } => {
10178 let etype_str = match self.syms.resolve(*etype) {
10179 Some(s) => s.to_string(),
10180 None => continue,
10181 };
10182 // Use key_of_historical so tombstoned nodes (deleted
10183 // later in the WAL) still resolve during the scan.
10184 let src_key = self.ids.key_of_historical(*src);
10185 let dst_key = self.ids.key_of_historical(*dst);
10186 let is_ab = src_key == Some(a) && dst_key == Some(b);
10187 let is_ba = src_key == Some(b) && dst_key == Some(a);
10188 if is_ab || is_ba {
10189 let src_str = src_key.unwrap().to_string();
10190 let dst_str = dst_key.unwrap().to_string();
10191 active.push((etype_str.clone(), src_str, dst_str, false));
10192 out.push(EdgeHistoryEvent {
10193 edge_type: etype_str,
10194 commit,
10195 event: EdgeEvent::Added,
10196 rule: None,
10197 });
10198 }
10199 }
10200 WalRecord::DeleteEdge {
10201 edge_type,
10202 src_key,
10203 dst_key,
10204 } => {
10205 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10206 && Self::aliases_match(&alias_b, dst_key, commit);
10207 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10208 && Self::aliases_match(&alias_a, dst_key, commit);
10209 if is_ab || is_ba {
10210 // Remove the first matching active entry (flag ignored).
10211 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10212 et == edge_type && s == src_key && d == dst_key
10213 }) {
10214 active.remove(pos);
10215 }
10216 out.push(EdgeHistoryEvent {
10217 edge_type: edge_type.clone(),
10218 commit,
10219 event: EdgeEvent::Retracted,
10220 rule: None,
10221 });
10222 }
10223 }
10224 WalRecord::DeleteNode { key: k }
10225 if Self::aliases_match(&alias_a, k, commit)
10226 || Self::aliases_match(&alias_b, k, commit) =>
10227 {
10228 // Sweep: implicitly retract only MANUAL active edges.
10229 // Derived active edges are skipped here because the rule
10230 // engine appends a DerivedEdgeRetracted marker immediately
10231 // after this DeleteNode record; that marker produces the
10232 // single correctly-attributed Retracted event. Derived
10233 // entries are dropped from `active` (the marker arm's
10234 // idempotent retain finds nothing to remove).
10235 for (et, _, _, is_derived) in active.drain(..) {
10236 if !is_derived {
10237 out.push(EdgeHistoryEvent {
10238 edge_type: et,
10239 commit,
10240 event: EdgeEvent::Retracted,
10241 rule: None,
10242 });
10243 }
10244 // Derived: drop silently; marker carries the Retracted event.
10245 }
10246 }
10247 WalRecord::DerivedEdgeAdded {
10248 rule,
10249 edge_type: et,
10250 src_key,
10251 dst_key,
10252 } => {
10253 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10254 && Self::aliases_match(&alias_b, dst_key, commit);
10255 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10256 && Self::aliases_match(&alias_a, dst_key, commit);
10257 if is_ab || is_ba {
10258 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10259 out.push(EdgeHistoryEvent {
10260 edge_type: et.clone(),
10261 commit,
10262 event: EdgeEvent::Added,
10263 rule: Some(rule.clone()),
10264 });
10265 }
10266 }
10267 WalRecord::DerivedEdgeRetracted {
10268 rule,
10269 edge_type: et,
10270 src_key,
10271 dst_key,
10272 } => {
10273 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10274 && Self::aliases_match(&alias_b, dst_key, commit);
10275 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10276 && Self::aliases_match(&alias_a, dst_key, commit);
10277 if is_ab || is_ba {
10278 // Push unconditionally: a derived edge whose Added marker
10279 // predates the history horizon has no `active` entry, but
10280 // the retraction is still a real in-window event.
10281 // Remove from active idempotently if present.
10282 active.retain(|(aet, s, d, _)| {
10283 !(aet == et && s == src_key && d == dst_key)
10284 });
10285 out.push(EdgeHistoryEvent {
10286 edge_type: et.clone(),
10287 commit,
10288 event: EdgeEvent::Retracted,
10289 rule: Some(rule.clone()),
10290 });
10291 }
10292 }
10293 // All other records (InsertNode, SetProp, CreateRule, etc.)
10294 // do not affect edges between a and b.
10295 _ => {}
10296 }
10297 }
10298 }
10299
10300 Ok(HistoryResult {
10301 items: out,
10302 total_commits,
10303 horizon: self.wal_horizon_floor,
10304 })
10305 }
10306
10307 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10308 /// (in either direction) at the WAL commit `at_commit`.
10309 ///
10310 /// ## Horizon
10311 ///
10312 /// Valid commit indices are `0..total_commits` where `total_commits` is the
10313 /// number of WAL frames. An `at_commit >= total_commits` is outside the
10314 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10315 ///
10316 /// ## Derived edges
10317 ///
10318 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10319 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10320 /// and therefore includes derived edges in its point-in-time evaluation,
10321 /// matching `edge_history`'s fidelity.
10322 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10323 use core_storage::wal::WalRecord;
10324
10325 let (frames, _) = self.all_frames()?;
10326 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10327
10328 // Horizon floor: commits in pruned archives are unreachable.
10329 if at_commit < self.wal_horizon_floor {
10330 return Err(GraphError::CommitOutOfRange {
10331 commit: at_commit,
10332 total: total_commits,
10333 floor: self.wal_horizon_floor,
10334 });
10335 }
10336 if at_commit >= total_commits {
10337 return Err(GraphError::CommitOutOfRange {
10338 commit: at_commit,
10339 total: total_commits,
10340 floor: self.wal_horizon_floor,
10341 });
10342 }
10343
10344 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10345 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10346 let alias_a = self.build_key_alias_intervals(&frames, a);
10347 let alias_b = self.build_key_alias_intervals(&frames, b);
10348
10349 // Local index into surviving frames (0 = first frame of oldest archive).
10350 let local_commit = at_commit - self.wal_horizon_floor;
10351
10352 // Replay local frames 0..=local_commit, tracking active edges.
10353 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10354
10355 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10356 let commit = self.wal_horizon_floor + local_i as u64;
10357 let records: &[WalRecord] = match frame {
10358 WalRecord::Batch(inner) => inner.as_slice(),
10359 single => std::slice::from_ref(single),
10360 };
10361
10362 for rec in records {
10363 match rec {
10364 WalRecord::InsertEdge {
10365 edge_type: et,
10366 src_key,
10367 dst_key,
10368 } => {
10369 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10370 && Self::aliases_match(&alias_b, dst_key, commit);
10371 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10372 && Self::aliases_match(&alias_a, dst_key, commit);
10373 if is_ab || is_ba {
10374 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10375 }
10376 }
10377 WalRecord::InsertEdgeId { etype, src, dst } => {
10378 let etype_str = match self.syms.resolve(*etype) {
10379 Some(s) => s.to_string(),
10380 None => continue,
10381 };
10382 // Use key_of_historical so tombstoned nodes resolve.
10383 let src_key = self.ids.key_of_historical(*src);
10384 let dst_key = self.ids.key_of_historical(*dst);
10385 let is_ab = src_key == Some(a) && dst_key == Some(b);
10386 let is_ba = src_key == Some(b) && dst_key == Some(a);
10387 if is_ab || is_ba {
10388 active.insert((
10389 etype_str,
10390 src_key.unwrap().to_string(),
10391 dst_key.unwrap().to_string(),
10392 ));
10393 }
10394 }
10395 WalRecord::DeleteEdge {
10396 edge_type: et,
10397 src_key,
10398 dst_key,
10399 } => {
10400 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10401 && Self::aliases_match(&alias_b, dst_key, commit);
10402 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10403 && Self::aliases_match(&alias_a, dst_key, commit);
10404 if is_ab || is_ba {
10405 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10406 }
10407 }
10408 WalRecord::DeleteNode { key: k }
10409 if Self::aliases_match(&alias_a, k, commit)
10410 || Self::aliases_match(&alias_b, k, commit) =>
10411 {
10412 // All edges touching the deleted node are gone.
10413 active.retain(|(_, s, d)| s != k && d != k);
10414 }
10415 WalRecord::DerivedEdgeAdded {
10416 edge_type: et,
10417 src_key,
10418 dst_key,
10419 ..
10420 } => {
10421 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10422 && Self::aliases_match(&alias_b, dst_key, commit);
10423 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10424 && Self::aliases_match(&alias_a, dst_key, commit);
10425 if is_ab || is_ba {
10426 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10427 }
10428 }
10429 WalRecord::DerivedEdgeRetracted {
10430 edge_type: et,
10431 src_key,
10432 dst_key,
10433 ..
10434 } => {
10435 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10436 && Self::aliases_match(&alias_b, dst_key, commit);
10437 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10438 && Self::aliases_match(&alias_a, dst_key, commit);
10439 if is_ab || is_ba {
10440 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10441 }
10442 }
10443 _ => {}
10444 }
10445 }
10446 }
10447
10448 Ok(active.iter().any(|(et, _, _)| et == edge_type))
10449 }
10450
10451 /// Every edge incident to `key` — either endpoint — that existed at WAL
10452 /// commit `commit`, from ONE scan of the WAL.
10453 ///
10454 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
10455 /// "what did K's relationships look like at commit C" with one call instead
10456 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
10457 /// The two agree edge for edge.
10458 ///
10459 /// Results are sorted by `(edge_type, src_key, dst_key)`.
10460 ///
10461 /// ## Horizon
10462 ///
10463 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
10464 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
10465 /// `was_linked`. An unknown key is not an error — it simply had no edges.
10466 ///
10467 /// ## Derived edges
10468 ///
10469 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
10470 /// attribution, so a rule-owned edge comes back with `derived: true` and
10471 /// `rule: Some(name)`.
10472 ///
10473 /// ## Renames
10474 ///
10475 /// `key` is matched through the same commit-bounded alias intervals
10476 /// `edge_history` uses, so querying a node's *current* key surfaces edges
10477 /// written under an earlier name. Endpoint keys in the result are reported
10478 /// under the name the node carries today, so they can be fed straight back
10479 /// into `node_info`, `explain` or another `edges_at`.
10480 ///
10481 /// ## Masks
10482 ///
10483 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
10484 /// any role mask. Apply masking at the caller level.
10485 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
10486 use core_storage::wal::WalRecord;
10487
10488 let (frames, _) = self.all_frames()?;
10489 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10490
10491 // Horizon floor: commits in pruned archives are unreachable.
10492 if commit < self.wal_horizon_floor || commit >= total_commits {
10493 return Err(GraphError::CommitOutOfRange {
10494 commit,
10495 total: total_commits,
10496 floor: self.wal_horizon_floor,
10497 });
10498 }
10499
10500 // Commit-bounded historical names of `key` (handles RenameNode).
10501 let alias = self.build_key_alias_intervals(&frames, key);
10502
10503 // Forward rename chain, for reporting endpoints under their current
10504 // names: old key → [(commit, new key)] in ascending commit order.
10505 // Built over the whole WAL, not just the prefix up to `commit`, because
10506 // a rename after `commit` still changes what the node is called today.
10507 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
10508 for (local_i, frame) in frames.iter().enumerate() {
10509 let c = self.wal_horizon_floor + local_i as u64;
10510 let records: &[WalRecord] = match frame {
10511 WalRecord::Batch(inner) => inner.as_slice(),
10512 single => std::slice::from_ref(single),
10513 };
10514 for rec in records {
10515 if let WalRecord::RenameNode { old_key, new_key } = rec {
10516 renames
10517 .entry(old_key.clone())
10518 .or_default()
10519 .push((c, new_key.clone()));
10520 }
10521 }
10522 }
10523
10524 // The name a node written as `k` at commit `from` carries today.
10525 // Follows the first rename at or after `from`, then keeps going. The
10526 // iteration cap bounds a rename cycle inside a single batch.
10527 let canon = |k: &str, from: u64| -> String {
10528 if renames.is_empty() {
10529 return k.to_string();
10530 }
10531 let mut cur = k.to_string();
10532 let mut at = from;
10533 for _ in 0..64 {
10534 match renames
10535 .get(&cur)
10536 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
10537 {
10538 Some((c, new)) => {
10539 at = *c;
10540 cur = new.clone();
10541 }
10542 None => break,
10543 }
10544 }
10545 cur
10546 };
10547
10548 let local_commit = commit - self.wal_horizon_floor;
10549 // (edge_type, src_key, dst_key) → (derived, rule)
10550 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
10551 BTreeMap::new();
10552
10553 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10554 let c = self.wal_horizon_floor + local_i as u64;
10555 let records: &[WalRecord] = match frame {
10556 WalRecord::Batch(inner) => inner.as_slice(),
10557 single => std::slice::from_ref(single),
10558 };
10559
10560 for rec in records {
10561 match rec {
10562 WalRecord::InsertEdge {
10563 edge_type,
10564 src_key,
10565 dst_key,
10566 } => {
10567 if Self::aliases_match(&alias, src_key, c)
10568 || Self::aliases_match(&alias, dst_key, c)
10569 {
10570 active.insert(
10571 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10572 (false, None),
10573 );
10574 }
10575 }
10576 WalRecord::InsertEdgeId { etype, src, dst } => {
10577 let Some(etype_str) = self.syms.resolve(*etype) else {
10578 continue;
10579 };
10580 // `key_of_historical` resolves tombstoned ids too, and
10581 // already returns the node's current key — no rename
10582 // canonicalisation needed on this arm.
10583 let (Some(src_key), Some(dst_key)) = (
10584 self.ids.key_of_historical(*src),
10585 self.ids.key_of_historical(*dst),
10586 ) else {
10587 continue;
10588 };
10589 if src_key == key || dst_key == key {
10590 active.insert(
10591 (
10592 etype_str.to_string(),
10593 src_key.to_string(),
10594 dst_key.to_string(),
10595 ),
10596 (false, None),
10597 );
10598 }
10599 }
10600 WalRecord::DeleteEdge {
10601 edge_type,
10602 src_key,
10603 dst_key,
10604 } => {
10605 if Self::aliases_match(&alias, src_key, c)
10606 || Self::aliases_match(&alias, dst_key, c)
10607 {
10608 active.remove(&(
10609 edge_type.clone(),
10610 canon(src_key, c),
10611 canon(dst_key, c),
10612 ));
10613 }
10614 }
10615 WalRecord::DeleteNode { key: k } => {
10616 if active.is_empty() {
10617 continue;
10618 }
10619 if Self::aliases_match(&alias, k, c) {
10620 // Our node is gone; every incident edge goes with it.
10621 active.clear();
10622 } else {
10623 // A partner is gone; its edges to us go with it.
10624 let ck = canon(k, c);
10625 active.retain(|(_, s, d), _| *s != ck && *d != ck);
10626 }
10627 }
10628 WalRecord::DerivedEdgeAdded {
10629 rule,
10630 edge_type,
10631 src_key,
10632 dst_key,
10633 } => {
10634 if Self::aliases_match(&alias, src_key, c)
10635 || Self::aliases_match(&alias, dst_key, c)
10636 {
10637 active.insert(
10638 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10639 (true, Some(rule.clone())),
10640 );
10641 }
10642 }
10643 WalRecord::DerivedEdgeRetracted {
10644 edge_type,
10645 src_key,
10646 dst_key,
10647 ..
10648 } => {
10649 if Self::aliases_match(&alias, src_key, c)
10650 || Self::aliases_match(&alias, dst_key, c)
10651 {
10652 active.remove(&(
10653 edge_type.clone(),
10654 canon(src_key, c),
10655 canon(dst_key, c),
10656 ));
10657 }
10658 }
10659 // InsertNode, SetProp, CreateRule, … do not move edges.
10660 _ => {}
10661 }
10662 }
10663 }
10664
10665 // BTreeMap iteration is already (edge_type, src, dst) order.
10666 Ok(active
10667 .into_iter()
10668 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
10669 edge_type,
10670 src_key,
10671 dst_key,
10672 derived,
10673 rule,
10674 })
10675 .collect())
10676 }
10677
10678 /// The derived edges that would be retracted and derived if `key.field`
10679 /// were set to `value` — computed WITHOUT writing anything.
10680 ///
10681 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
10682 /// provenance, its candidate indexes, the topology and the property columns
10683 /// are all cloned first, the change is applied to the clone, and the real
10684 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
10685 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
10686 /// it emits are the answer, so rule semantics — predicates, top-k,
10687 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
10688 ///
10689 /// Works on a read-only handle.
10690 ///
10691 /// **While a rule's vector index is still building** (`RuleStats::building`)
10692 /// the clone carries no pending-build state, so this reports the edges that
10693 /// rule would derive — which the live store will not derive until its
10694 /// backfill runs. Right about the end state, early about the timing.
10695 ///
10696 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
10697 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
10698 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
10699 /// (the node already holds `value`, or no rule watches `field`) returns
10700 /// empty lists.
10701 ///
10702 /// ## Cost
10703 ///
10704 /// One clone of the property columns, the topology overlay, the symbol
10705 /// interner, the edge properties and the provenance map, plus one candidate
10706 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
10707 /// directory, but it is not free — this is an interactive "what if", not a
10708 /// hot path.
10709 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
10710 // The engine's provenance, HNSW and IVF state live in the mmap'd base
10711 // until something asks for them. On a store opened cold from a snapshot
10712 // this is the first ask, and without it the clone below starts from an
10713 // empty provenance map: nothing to retract, so `lost` comes back empty.
10714 self.ensure_v8_base_sections_loaded();
10715
10716 let empty = WhatIf {
10717 lost: Vec::new(),
10718 gained: Vec::new(),
10719 };
10720
10721 if let Some(view_name) = self.view_store.view_for_prop(field) {
10722 return Err(GraphError::ViewPropReadOnly {
10723 view_name: view_name.to_string(),
10724 });
10725 }
10726 MutPreview::new(self).check_live_key(key)?;
10727 let id = self
10728 .ids
10729 .get(key)
10730 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10731
10732 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
10733 if rules.is_empty() {
10734 return Ok(empty);
10735 }
10736
10737 // No rule watches this field → no derivation can change.
10738 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
10739 return Ok(empty);
10740 }
10741
10742 let old_value = build_props_view(&self.props, &self.base)
10743 .get(id, field)
10744 .map(|vr| vr.into_value());
10745 if old_value.as_ref() == Some(&value) {
10746 return Ok(empty);
10747 }
10748
10749 // --- Clone every piece of state the re-derivation writes to. ---
10750 let mut props = self.props.clone();
10751 let mut topo = self.topo.clone();
10752 let mut syms = self.syms.clone();
10753 let mut edge_props = self.edge_props.clone();
10754
10755 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
10756 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
10757 for r in &rules {
10758 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
10759 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
10760 }
10761 // `provenance()` decodes retained snapshot bytes on first use; the
10762 // engine clone needs the real map, not an empty one.
10763 let provenance = self.engine.provenance().clone();
10764 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
10765
10766 // Build the candidate indexes from the state BEFORE the change, exactly
10767 // as apply() sees them: `on_node_changed` withdraws the node under its
10768 // old value and refiles it under the new one, so the index must not
10769 // already reflect the change.
10770 engine.reindex_all_load_state(
10771 &self.ids,
10772 &syms,
10773 &self.labels,
10774 build_props_view(&self.props, &self.base),
10775 self.engine.export_ivf_state(),
10776 self.engine.export_hnsw_state_passthrough(),
10777 );
10778 engine.set_emit_deltas(true);
10779
10780 // --- Apply the hypothetical change and re-derive. ---
10781 props.set(id, field, value);
10782 {
10783 let mut gm = make_graph_mut(
10784 &self.ids,
10785 &mut syms,
10786 &self.labels,
10787 build_props_view(&props, &self.base),
10788 &mut topo,
10789 &self.base,
10790 &mut edge_props,
10791 );
10792 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
10793 }
10794
10795 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
10796 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
10797 for d in engine.drain_deltas() {
10798 let edge = EdgeAt {
10799 edge_type: d.edge_type,
10800 src_key: d.src_key,
10801 dst_key: d.dst_key,
10802 derived: true,
10803 rule: Some(d.rule),
10804 };
10805 if d.fired {
10806 gained.insert(edge);
10807 } else {
10808 lost.insert(edge);
10809 }
10810 }
10811 // An edge retracted and re-derived within the same re-derivation (top-k
10812 // churn) is not a change the caller would see.
10813 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
10814 for e in churn {
10815 lost.remove(&e);
10816 gained.remove(&e);
10817 }
10818
10819 Ok(WhatIf {
10820 lost: lost.into_iter().collect(),
10821 gained: gained.into_iter().collect(),
10822 })
10823 }
10824
10825 pub fn edge_count(&self) -> u64 {
10826 self.topo_view().edge_count()
10827 }
10828
10829 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
10830 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
10831 pub fn stats(&self) -> Stats {
10832 self.ensure_v8_base_sections_loaded();
10833 let building = self.engine.builds_in_progress();
10834 let rules: Vec<RuleStats> = self
10835 .engine
10836 .rules()
10837 .map(|r| RuleStats {
10838 name: r.name.clone(),
10839 edges: self
10840 .engine
10841 .provenance()
10842 .get(&r.name)
10843 .map(|s| s.len() as u64)
10844 .unwrap_or(0),
10845 tripped: self.engine.is_tripped(&r.name),
10846 fires: self.engine.fire_count(&r.name),
10847 approximate: r.approximate,
10848 building: building.iter().find(|b| b.rule == r.name).cloned(),
10849 })
10850 .collect();
10851 Stats {
10852 nodes_live: self.ids.live_len(),
10853 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
10854 edges: self.topo_view().edge_count(),
10855 rules,
10856 chain_truncations: self.engine.chain_truncations(),
10857 history_floor: self.wal_horizon_floor,
10858 namespaces: self.namespace_stats(),
10859 }
10860 }
10861
10862 /// On-disk size of the WAL file in bytes.
10863 ///
10864 /// Reads file metadata without loading WAL contents. Returns `Err` for
10865 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
10866 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
10867 let path = self.fs.wal_path().ok_or_else(|| {
10868 std::io::Error::new(
10869 std::io::ErrorKind::Unsupported,
10870 "wal_path not available for this Fs implementation",
10871 )
10872 })?;
10873 Ok(std::fs::metadata(path)?.len())
10874 }
10875
10876 /// Set the slow-query threshold. Queries whose execution time equals or
10877 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
10878 ///
10879 /// Use this setter in tests — the environment variable
10880 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
10881 /// threads.
10882 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
10883 self.slow_query_threshold_ms = ms;
10884 }
10885
10886 /// Snapshot of the slow-query ring buffer and lifetime counter.
10887 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
10888 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
10889 SlowQuerySnapshot {
10890 threshold_ms: self.slow_query_threshold_ms,
10891 count: log.total,
10892 last: log.entries.iter().cloned().collect(),
10893 }
10894 }
10895
10896 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
10897 /// to compute uptime.
10898 pub fn started_at(&self) -> std::time::Instant {
10899 self.started_at
10900 }
10901
10902 /// On-disk snapshot format version this binary writes and reads.
10903 pub fn format_version() -> u16 {
10904 core_storage::snapshot::VERSION
10905 }
10906
10907 /// Test-support: total bytes appended (SimFs only usage).
10908 pub fn fs_total_appended(&self) -> usize
10909 where
10910 F: FsIntrospect,
10911 {
10912 self.fs.total_appended()
10913 }
10914
10915 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10916 pub fn fs_sync_count(&self) -> usize
10917 where
10918 F: FsIntrospect,
10919 {
10920 self.fs.sync_count()
10921 }
10922
10923 /// Consume the db, returning its fs (for crash simulation).
10924 pub fn into_fs(self) -> F {
10925 self.fs
10926 }
10927
10928 pub fn snapshot(&mut self) -> Result<()> {
10929 self.snapshot_with(SnapshotOptions::default())
10930 }
10931
10932 /// Snapshot with explicit options.
10933 ///
10934 /// # `keep_wal`
10935 ///
10936 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10937 /// - The WAL is replaced with a minimal baseline containing one
10938 /// `EnableFulltext` record per active declaration. All pre-snapshot
10939 /// history is discarded; `open_at` can only reach post-snapshot commits.
10940 ///
10941 /// When `keep_wal` is `true`:
10942 /// - The WAL is left intact. All pre-snapshot commits remain reachable
10943 /// via `open_at`. The existing WAL already contains the original
10944 /// `EnableFulltext` records, so no baseline re-write is needed; the
10945 /// recovery guards in `apply()` silently skip any duplicate records on
10946 /// replay.
10947 /// - Crash window: a crash after the snapshot write but before the next
10948 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
10949 /// snapshot is loaded and the WAL replayed idempotently over it — safe
10950 /// because every `apply()` arm is idempotent when replayed over an
10951 /// already-current snapshot.
10952 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10953 if self.read_only {
10954 return Err(GraphError::ReadOnly);
10955 }
10956 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10957 // appending ends up holding a descriptor on an unlinked inode and loses
10958 // commits it believes durable. Snapshotting therefore requires the
10959 // cross-process write lock, exactly as appending does. Unlike the WAL
10960 // append path this does not go through `log_then_apply_with`, so both
10961 // guards are repeated here.
10962 if self.degraded {
10963 return Err(GraphError::Io(std::io::Error::other(
10964 "database degraded after group-commit fsync failure; reopen required",
10965 )));
10966 }
10967 if self.lock_denied {
10968 return Err(GraphError::Busy { holder: None });
10969 }
10970 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10971 // Used by the archive path's conservative genesis-chain check: if a prior
10972 // snapshot exists but wal.truncated does not, we cannot distinguish a
10973 // legacy store (may have been truncated in an older code version) from a
10974 // new store that only used keep_wal=true. Conservative: refuse genesis in
10975 // both cases. Must be sampled here, before the snapshot write below.
10976 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10977 self.ensure_v8_base_sections_loaded();
10978 // Ensure provenance is decoded before to_persist() clones it.
10979 self.engine.ensure_provenance_loaded_mut();
10980 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10981 let rule_defs = rule_defs_typed
10982 .iter()
10983 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10984 .collect();
10985 // Collect HNSW state and IVF state. When indexes are not yet
10986 // populated (clean open, no mutation since open), pass the retained
10987 // raw bytes through directly so that migrate/snapshot does not
10988 // silently discard fitted approximate-rule indexes.
10989 let hnsw_state = self.engine.export_hnsw_state_passthrough();
10990 let ivf_bytes = if !self.engine.indexes_populated() {
10991 // Pass retained IVF bytes through unchanged (no re-encode).
10992 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10993 } else {
10994 // Indexes live: encode from current state.
10995 let raw_ivf = self.engine.export_ivf_state();
10996 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10997 .into_iter()
10998 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10999 (
11000 name,
11001 core_storage::snapshot::PerRuleIvfState {
11002 src: core_storage::snapshot::SideIvfState {
11003 centroids: sc,
11004 clusters: sa,
11005 drift: sd,
11006 },
11007 dst: core_storage::snapshot::SideIvfState {
11008 centroids: dc,
11009 clusters: da,
11010 drift: dd,
11011 },
11012 },
11013 )
11014 })
11015 .collect();
11016 if ivf_state_map.is_empty() {
11017 Vec::new()
11018 } else {
11019 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11020 }
11021 };
11022 let view_defs: Vec<Vec<u8>> = self
11023 .view_store
11024 .views()
11025 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11026 .collect();
11027 if self.base.is_some() {
11028 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11029 // write it atomically, remap it as the new base, then clear the overlay.
11030 let meta = V8Meta {
11031 labels: self.labels.clone(),
11032 edge_props: self.edge_props.clone(),
11033 rule_defs,
11034 provenance,
11035 rule_tripped,
11036 rule_fires,
11037 ivf_bytes,
11038 view_defs,
11039 wal_truncated: !opts.keep_wal,
11040 hnsw: hnsw_state,
11041 last_change: self.last_change.clone(),
11042 };
11043 let mut buf: Vec<u8> = Vec::new();
11044 {
11045 // Clone the Arc so the old base stays alive while we encode.
11046 // The borrow of archived_csr (into old_base's mmap) is released
11047 // at the end of this block, before we replace self.base.
11048 let old_base = self.base.clone().expect("is_some checked above");
11049 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11050 detail: format!("v8 snapshot: topology section: {e:?}"),
11051 })?;
11052 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11053 detail: format!("v8 snapshot: columns section: {e:?}"),
11054 })?;
11055 // `None` when the base predates V9 — the migration path: its
11056 // string columns still carry their own tables and this snapshot
11057 // is the rewrite that collapses them into section 12.
11058 let archived_strings =
11059 old_base
11060 .string_table()
11061 .transpose()
11062 .map_err(|e| GraphError::Corrupt {
11063 detail: format!("v8 snapshot: strings section: {e:?}"),
11064 })?;
11065 let archived_edge_props =
11066 old_base
11067 .edge_props_section()
11068 .map_err(|e| GraphError::Corrupt {
11069 detail: format!("v8 snapshot: edge_props section: {e:?}"),
11070 })?;
11071 let edge_props_raw =
11072 old_base
11073 .edge_props_raw_bytes()
11074 .map_err(|e| GraphError::Corrupt {
11075 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11076 })?;
11077 let prov_raw =
11078 old_base
11079 .provenance_raw_bytes()
11080 .map_err(|e| GraphError::Corrupt {
11081 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11082 })?;
11083 encode_v8(
11084 Some(archived_csr),
11085 Some(archived_cols),
11086 archived_strings,
11087 Some((archived_edge_props, edge_props_raw)),
11088 Some(prov_raw),
11089 &self.topo,
11090 &self.props,
11091 &self.ids,
11092 &self.syms,
11093 &meta,
11094 &mut buf,
11095 )?;
11096 }
11097 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11098 // Remap the freshly-written snapshot as the new base.
11099 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11100 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11101 core_storage::v8::MappedBase::map(&snap_path)
11102 } else {
11103 core_storage::v8::MappedBase::from_bytes(buf)
11104 }
11105 .map_err(|e| GraphError::Corrupt {
11106 detail: format!("v8 snapshot: remap new base: {e:?}"),
11107 })?;
11108 self.base = Some(Arc::new(new_base));
11109 // Clear the overlay and prop tombstones — all data is now in the new base.
11110 self.topo = Topology::new();
11111 self.props = core_storage::columns::ColumnStore::new();
11112 } else {
11113 // Legacy path (V5–V7 stores without a V8 base).
11114 //
11115 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11116 // clone and no encode_v8_from_state intermediate clones. The big
11117 // structures (self.topo, self.props) are borrowed, not cloned.
11118 // self.edge_props is moved (not cloned) because we immediately clear it
11119 // when we remap the new V8 snapshot as self.base (see below).
11120 //
11121 // Eliminates from peak RSS vs. the old SnapshotState path:
11122 // • self.topo.clone() (~topology HashMap footprint)
11123 // • self.props.clone() (~column-store footprint)
11124 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11125 let meta = V8Meta {
11126 labels: self.labels.clone(),
11127 wal_truncated: !opts.keep_wal,
11128 // Move edge_props out so the large overlay is freed when meta
11129 // drops at end of this block (self.edge_props is now empty; reads
11130 // after base assignment go through the mmap'd base section).
11131 edge_props: std::mem::take(&mut self.edge_props),
11132 rule_defs,
11133 provenance,
11134 rule_tripped,
11135 rule_fires,
11136 ivf_bytes,
11137 view_defs,
11138 hnsw: hnsw_state,
11139 last_change: self.last_change.clone(),
11140 };
11141 let mut buf = Vec::new();
11142 encode_v8(
11143 None,
11144 None,
11145 None,
11146 None,
11147 None,
11148 &self.topo,
11149 &self.props,
11150 &self.ids,
11151 &self.syms,
11152 &meta,
11153 &mut buf,
11154 )?;
11155 // meta (and the moved edge_props inside it) is no longer needed;
11156 // drop it before the write to keep the peak window narrow.
11157 drop(meta);
11158 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11159 // Remap the freshly-written V8 snapshot as self.base.
11160 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11161 // On SimFs (tests): pass buf to from_bytes.
11162 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11163 drop(buf);
11164 core_storage::v8::MappedBase::map(&snap_path)
11165 } else {
11166 core_storage::v8::MappedBase::from_bytes(buf)
11167 }
11168 .map_err(|e| GraphError::Corrupt {
11169 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11170 })?;
11171 self.base = Some(Arc::new(new_base));
11172 // Free the large heap-allocated decoded state — all data is now in the
11173 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
11174 // self.edge_props was already moved into meta and is effectively empty.
11175 self.topo = Topology::new();
11176 self.props = core_storage::columns::ColumnStore::new();
11177 }
11178
11179 if opts.archive_wal {
11180 // History-preserving snapshot (Task 4):
11181 // 1. Snapshot already written above (write_atomic → fsynced).
11182 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
11183 // Crash window B: crash here leaves archive present, WAL
11184 // absent. Reopen: snapshot loaded (full state), no WAL
11185 // replay. Archive is NOT replayed into live state — it is
11186 // pre-snapshot by construction. Safe.
11187 // 3. Optionally write genesis marker (first archive only, no
11188 // prior WAL truncation).
11189 // 4. Prune old archives (retention), update horizon floor.
11190 // Pruning invalidates the genesis chain; delete marker.
11191 // 5. Write new minimal baseline WAL (write_atomic).
11192 // Crash window C: crash here leaves new archive plus no live
11193 // WAL. Same as window B — handled above.
11194 //
11195 // Sample existing archives BEFORE the rename so we can detect
11196 // whether this is the first archive.
11197 let existing_archives = self.fs.list_archives()?;
11198 let is_first_archive = existing_archives.is_empty();
11199
11200 // Compute a globally-monotonic archive name: the name equals the
11201 // cumulative end-frame index of the archive in global commit space.
11202 //
11203 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11204 // commit_seq is seeded from max(last_change), which underestimates
11205 // the WAL depth when trailing commits (e.g. insert_edge) do not
11206 // update last_change. A session-2 archive could then receive a name
11207 // ≤ the session-1 archive, causing incorrect sort order or collision.
11208 //
11209 // Instead: read and decode the live WAL here (before the rename) to
11210 // get its exact frame count, then add it to the last known global
11211 // end-frame index (the name of the most recent existing archive, or
11212 // wal_horizon_floor if no archives exist). This is O(WAL size) but
11213 // snapshot is already serialising the full graph state, so the cost
11214 // is dominated.
11215 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11216 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11217 let archive_n = existing_archives
11218 .last()
11219 .copied()
11220 .unwrap_or(self.wal_horizon_floor)
11221 + live_frames_for_name.len() as u64;
11222 self.fs.archive_wal(archive_n)?;
11223
11224 // Genesis marker: written once when the first archive is taken
11225 // from a store that has never undergone a WAL-truncating snapshot.
11226 // When present, `open_at` may replay archive-resident commits from
11227 // empty state (the archive chain covers from global index 0).
11228 //
11229 // Two conditions must ALL hold:
11230 // 1. This is the first archive (existing_archives was empty).
11231 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11232 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11233 // before truncating the WAL, so if any prior truncating snapshot was taken
11234 // — even in a previous session — snapshot.bin is present and this condition
11235 // is false. This subsumes the cross-session truncation case without
11236 // requiring a separate wal.truncated sidecar file.
11237 // For legacy stores (snapshot.bin written by an older code version that
11238 // may have truncated the WAL), the same conservative refusal applies:
11239 // we cannot prove the chain is complete, so we refuse genesis (cost =
11240 // no as-of-through-archives; never silent wrong data).
11241 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11242 // so SimFs always passes this check.
11243 if is_first_archive && !had_prior_snapshot {
11244 self.fs.write_genesis_marker()?;
11245 self.archive_genesis_chain = true;
11246 }
11247
11248 // Retention pruning: keep newest `keep` archives; delete oldest.
11249 // Pruning is the ONLY deletion site for archives.
11250 //
11251 // Crash-safety ordering (C1 fix):
11252 // 1. Count frames in surplus archives (reads only — no mutation).
11253 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
11254 // rename (atomic). A crash after this point leaves orphaned
11255 // archives on disk, but the floor is correct. The opening
11256 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
11257 // the next open, so the store is always safe to reopen.
11258 // 3. Delete the genesis marker (floor > 0 already blocks open_at
11259 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
11260 // 4. Delete surplus archives. A crash between any two deletes
11261 // leaves the floor committed and orphaned archives cleaned at
11262 // next open — never a stale floor with a missing archive prefix.
11263 if let Some(keep) = self.wal_archive_retention {
11264 if keep > 0 {
11265 let archives = self.fs.list_archives()?;
11266 // archives is sorted ascending (oldest first)
11267 if archives.len() as u32 > keep {
11268 let surplus = archives.len() - keep as usize;
11269 // Step 1: count pruned frames (reads, no mutation).
11270 let mut pruned_frames = 0u64;
11271 for &n in &archives[..surplus] {
11272 let bytes = self.fs.read_archive(n)?;
11273 let (frames, _) = decode_all(&bytes);
11274 pruned_frames += frames.len() as u64;
11275 }
11276 // Step 2: advance and persist floor FIRST.
11277 self.wal_horizon_floor += pruned_frames;
11278 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11279 // Step 3: delete genesis marker (floor > 0 already
11280 // blocks open_at; this is belt-and-suspenders cleanup).
11281 if pruned_frames > 0 && self.archive_genesis_chain {
11282 self.fs.delete_genesis_marker()?;
11283 self.archive_genesis_chain = false;
11284 }
11285 // Step 4: delete surplus archives. Crash here →
11286 // orphaned archives; cleaned at next open.
11287 for &n in &archives[..surplus] {
11288 self.fs.delete_archive(n)?;
11289 }
11290 }
11291 }
11292 }
11293
11294 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11295 let mut baseline_wal: Vec<u8> = Vec::new();
11296 for (label, field) in self.fulltext.enabled_pairs() {
11297 let rec = WalRecord::EnableFulltext {
11298 label: label.clone(),
11299 field: field.clone(),
11300 };
11301 baseline_wal.extend_from_slice(&encode_record(&rec));
11302 }
11303 for (label, field) in self.prop_index.enabled_pairs() {
11304 let rec = WalRecord::EnableIndex {
11305 label: label.clone(),
11306 field: field.clone(),
11307 };
11308 baseline_wal.extend_from_slice(&encode_record(&rec));
11309 }
11310 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11311 } else if opts.keep_wal {
11312 // keep_wal=true: WAL is left untouched. The existing WAL already
11313 // contains the EnableFulltext records from the original enable calls;
11314 // replay is idempotent (guards in apply() skip already-live entries).
11315 // No baseline re-write is needed or safe here — the full WAL history
11316 // must remain intact for open_at to reach pre-snapshot commits.
11317 } else {
11318 // keep_wal=false (default): truncate by replacing the WAL with a
11319 // minimal baseline of one EnableFulltext record per active pair.
11320 //
11321 // Crash-ordering: write_atomic is atomic.
11322 // • Crash before snapshot write → WAL unchanged. Safe.
11323 // • Crash after snapshot write but before this WAL write → full
11324 // pre-snapshot WAL still present; open_with replays idempotently.
11325 // • Crash after both writes → normal post-snapshot state.
11326 //
11327 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11328 // for any archives taken AFTER this point (their WAL slices would
11329 // not start at genesis). Delete any existing genesis marker so that
11330 // open_at refuses archive-resident commits. Future sessions are
11331 // covered by had_prior_snapshot: snapshot.bin written here persists
11332 // across sessions and prevents a later archiving session from
11333 // incorrectly claiming a complete genesis chain.
11334 if self.archive_genesis_chain {
11335 self.fs.delete_genesis_marker()?;
11336 self.archive_genesis_chain = false;
11337 }
11338 let mut baseline_wal: Vec<u8> = Vec::new();
11339 for (label, field) in self.fulltext.enabled_pairs() {
11340 let rec = WalRecord::EnableFulltext {
11341 label: label.clone(),
11342 field: field.clone(),
11343 };
11344 baseline_wal.extend_from_slice(&encode_record(&rec));
11345 }
11346 for (label, field) in self.prop_index.enabled_pairs() {
11347 let rec = WalRecord::EnableIndex {
11348 label: label.clone(),
11349 field: field.clone(),
11350 };
11351 baseline_wal.extend_from_slice(&encode_record(&rec));
11352 }
11353 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11354 }
11355 // After snapshot the overlay may have changed (V8 merge path clears
11356 // self.topo and self.props). Refresh the MVCC fold so future readers
11357 // see the post-snapshot state rather than stale overlay data.
11358 self.fold_now();
11359 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11360 // markers this handle uses to detect other processes' work must be
11361 // re-taken from disk. Skipping this would make our own snapshot look
11362 // like a peer's on the next staleness check and force a needless
11363 // reload.
11364 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11365 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11366 Ok(())
11367 }
11368}
11369
11370/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11371///
11372/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11373/// callers can build a set of mutations without holding `&mut GraphDb` and
11374/// hand them off to the group-committing writer for durable, batched I/O.
11375pub enum BatchOp {
11376 InsertNode {
11377 label: String,
11378 key: String,
11379 props: Vec<(String, Value)>,
11380 },
11381 InsertEdge {
11382 edge_type: String,
11383 src_key: String,
11384 dst_key: String,
11385 },
11386 SetProp {
11387 key: String,
11388 field: String,
11389 value: Value,
11390 },
11391 RemoveProp {
11392 key: String,
11393 field: String,
11394 },
11395 DeleteEdge {
11396 edge_type: String,
11397 src_key: String,
11398 dst_key: String,
11399 },
11400 DeleteNode {
11401 key: String,
11402 },
11403 CreateRule(RuleDef),
11404 DeleteRule {
11405 name: String,
11406 },
11407 /// Rename a node's key. Validated: old must exist, new must not.
11408 RenameNode {
11409 old_key: String,
11410 new_key: String,
11411 },
11412 /// Insert an edge, auto-creating any missing endpoint as a plain node with
11413 /// `placeholder_label` and no props. Rules fire and last-change is updated
11414 /// for each created endpoint (normal InsertNode semantics in the batch frame).
11415 InsertEdgeUpsert {
11416 edge_type: String,
11417 src_key: String,
11418 dst_key: String,
11419 placeholder_label: String,
11420 },
11421}
11422
11423/// Three-way node visibility status used by `check_single_op_authz`.
11424enum NodeAuthzStatus {
11425 /// Node exists in the store and is in the role's read mask.
11426 Visible(String), // carries the node's label
11427 /// Node exists in the store but is NOT in the role's read mask.
11428 Hidden,
11429 /// Node does not exist in the store.
11430 Absent,
11431}
11432
11433/// Overlay of ops already accepted earlier in the same batch. Never written
11434/// back to the database — validation only.
11435#[derive(Default)]
11436struct Overlay {
11437 extra_keys: BTreeSet<String>,
11438 deleted_keys: BTreeSet<String>,
11439 extra_props: BTreeMap<(String, String), Value>,
11440 removed_props: BTreeSet<(String, String)>,
11441 extra_edges: BTreeSet<(String, String, String)>,
11442 deleted_edges: BTreeSet<(String, String, String)>,
11443 extra_rules: BTreeSet<String>,
11444 deleted_rules: BTreeSet<String>,
11445 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
11446 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
11447 /// sees only the rules already committed to the engine. Keyed by name so a
11448 /// later `DeleteRule` in the same batch drops the arc with the rule.
11449 extra_rule_arcs: BTreeMap<String, (String, String)>,
11450}
11451
11452/// Read-only view of live db state plus a batch overlay. Shared by single-op
11453/// public methods (empty overlay) and `commit_batch`.
11454struct MutPreview<'a, F: Fs> {
11455 db: &'a GraphDb<F>,
11456 overlay: Overlay,
11457}
11458
11459/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
11460/// `None` if `target` is unreachable.
11461///
11462/// Used for rule-chain cycle detection, where an arc is "a rule hops over
11463/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
11464/// reported path is stable for a given rule set, and iterative so a pathological
11465/// rule graph cannot overflow the stack.
11466fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
11467 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
11468 for (from, to) in arcs {
11469 adj.entry(from.as_str()).or_default().insert(to.as_str());
11470 }
11471 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
11472 let mut visited: BTreeSet<&str> = BTreeSet::new();
11473 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
11474 visited.insert(start);
11475 queue.push_back(start);
11476 while let Some(node) = queue.pop_front() {
11477 if node == target {
11478 let mut path = vec![node.to_string()];
11479 let mut cur = node;
11480 while let Some(&p) = parent.get(cur) {
11481 path.push(p.to_string());
11482 cur = p;
11483 }
11484 path.reverse();
11485 return Some(path);
11486 }
11487 for &next in adj.get(node).into_iter().flatten() {
11488 if visited.insert(next) {
11489 parent.insert(next, node);
11490 queue.push_back(next);
11491 }
11492 }
11493 }
11494 None
11495}
11496
11497impl<'a, F: Fs> MutPreview<'a, F> {
11498 fn new(db: &'a GraphDb<F>) -> Self {
11499 Self {
11500 db,
11501 overlay: Overlay::default(),
11502 }
11503 }
11504
11505 fn has_key(&self, key: &str) -> bool {
11506 if self.overlay.extra_keys.contains(key) {
11507 return true;
11508 }
11509 if self.overlay.deleted_keys.contains(key) {
11510 return false;
11511 }
11512 self.db.ids.get(key).is_some()
11513 }
11514
11515 fn has_prop(&self, key: &str, field: &str) -> bool {
11516 if !self.has_key(key) {
11517 return false;
11518 }
11519 let k = (key.to_string(), field.to_string());
11520 if self.overlay.removed_props.contains(&k) {
11521 return false;
11522 }
11523 if self.overlay.extra_props.contains_key(&k) {
11524 return true;
11525 }
11526 // Fresh identity (first insert in this batch, or delete+reinsert):
11527 // ignore props still sitting on the soon-to-be-tombstoned slot.
11528 if self.overlay.extra_keys.contains(key) {
11529 return false;
11530 }
11531 self.db.get_prop(key, field).is_some()
11532 }
11533
11534 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11535 let k = (
11536 edge_type.to_string(),
11537 src_key.to_string(),
11538 dst_key.to_string(),
11539 );
11540 if self.overlay.deleted_edges.contains(&k) {
11541 return false;
11542 }
11543 if self.overlay.extra_edges.contains(&k) {
11544 return true;
11545 }
11546 // A key created in this batch (including reinsert) has no db edges.
11547 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11548 return false;
11549 }
11550 if self.overlay.deleted_keys.contains(src_key)
11551 || self.overlay.deleted_keys.contains(dst_key)
11552 {
11553 return false;
11554 }
11555 let Some(src) = self.db.ids.get(src_key) else {
11556 return false;
11557 };
11558 let Some(dst) = self.db.ids.get(dst_key) else {
11559 return false;
11560 };
11561 let Some(sym) = self.db.syms.get(edge_type) else {
11562 return false;
11563 };
11564 self.db
11565 .topo_view()
11566 .neighbors(sym, Direction::Out, src)
11567 .binary_search(&dst)
11568 .is_ok()
11569 }
11570
11571 fn has_rule(&self, name: &str) -> bool {
11572 if self.overlay.extra_rules.contains(name) {
11573 return true;
11574 }
11575 if self.overlay.deleted_rules.contains(name) {
11576 return false;
11577 }
11578 self.db.engine.rules().any(|r| r.name == name)
11579 }
11580
11581 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11582 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11583 return false;
11584 }
11585 if self.overlay.deleted_keys.contains(src_key)
11586 || self.overlay.deleted_keys.contains(dst_key)
11587 {
11588 return false;
11589 }
11590 let Some(src) = self.db.ids.get(src_key) else {
11591 return false;
11592 };
11593 let Some(dst) = self.db.ids.get(dst_key) else {
11594 return false;
11595 };
11596 let Some(et) = self.db.syms.get(edge_type) else {
11597 return false;
11598 };
11599 // extra_rules is deliberately not consulted: a CreateRule earlier in
11600 // this batch has not fired, so it contributes no provenance. That is
11601 // the documented rule-window gap (see GraphDb::batch).
11602 if self.overlay.deleted_rules.is_empty() {
11603 return self.db.engine.is_owned(et, src, dst);
11604 }
11605 for (rule, triples) in self.db.engine.provenance() {
11606 if self.overlay.deleted_rules.contains(rule) {
11607 continue;
11608 }
11609 if triples.contains(&(et, src, dst)) {
11610 return true;
11611 }
11612 }
11613 false
11614 }
11615
11616 fn check_insert_node(&self, key: &str) -> Result<()> {
11617 if self.has_key(key) {
11618 Err(GraphError::DuplicateKey { key: key.into() })
11619 } else {
11620 Ok(())
11621 }
11622 }
11623
11624 fn check_live_key(&self, key: &str) -> Result<()> {
11625 if self.has_key(key) {
11626 Ok(())
11627 } else {
11628 Err(GraphError::KeyNotFound { key: key.into() })
11629 }
11630 }
11631
11632 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11633 for k in [src_key, dst_key] {
11634 if !self.has_key(k) {
11635 return Err(GraphError::KeyNotFound { key: k.into() });
11636 }
11637 }
11638 if self.is_rule_owned(edge_type, src_key, dst_key) {
11639 return Err(GraphError::RuleOwned {
11640 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
11641 });
11642 }
11643 // A user-written edge stays inside one namespace. Derived edges do not
11644 // come through here — the engine adds them directly — and the rule
11645 // scoping check is what keeps those pure.
11646 let src_ns = self.namespace_in_batch(src_key);
11647 let dst_ns = self.namespace_in_batch(dst_key);
11648 if src_ns != dst_ns {
11649 return Err(GraphError::CrossNamespace {
11650 src: src_key.to_string(),
11651 src_ns,
11652 dst: dst_key.to_string(),
11653 dst_ns,
11654 });
11655 }
11656 Ok(!self.has_edge(edge_type, src_key, dst_key))
11657 }
11658
11659 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
11660 self.check_live_key(key)?;
11661 // Removing `ns` is changing the namespace — to `default`, the namespace
11662 // an absent property names. It goes through this one choke-point and NOT
11663 // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
11664 // the immutability rule has to be stated here as well. Without it the
11665 // node silently lands in `default` on the next open: the cross-namespace
11666 // edge guard is defeated and a default-bound role reads a tenant's node.
11667 if field == NS_PROP {
11668 let from = self.namespace_in_batch(key);
11669 if from != NS_DEFAULT {
11670 return Err(GraphError::NamespaceImmutable {
11671 key: key.to_string(),
11672 from,
11673 to: NS_DEFAULT.to_string(),
11674 });
11675 }
11676 // Already in `default`: the removal changes no namespace. It is the
11677 // no-op `set_prop` to the current namespace is, not an error.
11678 return Ok(false);
11679 }
11680 Ok(self.has_prop(key, field))
11681 }
11682
11683 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11684 for k in [src_key, dst_key] {
11685 if !self.has_key(k) {
11686 return Err(GraphError::KeyNotFound { key: k.into() });
11687 }
11688 }
11689 // Provenance-owned OR a live rule would derive this pair. User-first
11690 // edges that a later rule matches are not in `owned`, but deleting
11691 // them would leave a hole `rebuild_rule` immediately fills.
11692 if self.is_rule_owned(edge_type, src_key, dst_key) {
11693 return Err(GraphError::RuleOwned {
11694 detail: format!(
11695 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11696 delete or change the owning rule"
11697 ),
11698 });
11699 }
11700 if self.would_derive(edge_type, src_key, dst_key) {
11701 return Err(GraphError::RuleOwned {
11702 detail: format!(
11703 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11704 delete or change the owning rule, or a live rule would re-derive it"
11705 ),
11706 });
11707 }
11708 Ok(self.has_edge(edge_type, src_key, dst_key))
11709 }
11710
11711 /// True if any live rule (minus overlay-deleted names) would derive
11712 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
11713 /// CreateRule names in `extra_rules` are ignored — same documented
11714 /// same-batch rule-window as [`Self::is_rule_owned`].
11715 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11716 if src_key == dst_key {
11717 return false;
11718 }
11719 let Some(src_label) = self.label_of(src_key) else {
11720 return false;
11721 };
11722 let Some(dst_label) = self.label_of(dst_key) else {
11723 return false;
11724 };
11725 for rule in self.db.engine.rules() {
11726 if self.overlay.deleted_rules.contains(&rule.name) {
11727 continue;
11728 }
11729 if rule.edge_type != edge_type {
11730 continue;
11731 }
11732 if rule.src_label != src_label || rule.dst_label != dst_label {
11733 continue;
11734 }
11735 let src_props = |f: &str| self.prop_value(src_key, f);
11736 let dst_props = |f: &str| self.prop_value(dst_key, f);
11737 let src_view = NodeView {
11738 key: src_key,
11739 props: &src_props,
11740 };
11741 let dst_view = NodeView {
11742 key: dst_key,
11743 props: &dst_props,
11744 };
11745 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
11746 return true;
11747 }
11748 }
11749 false
11750 }
11751
11752 fn label_of(&self, key: &str) -> Option<String> {
11753 if self.overlay.deleted_keys.contains(key) {
11754 return None;
11755 }
11756 // Fresh identities created in this batch have no stored label in the
11757 // overlay; they cannot be provenance-owned yet either.
11758 let id = self.db.ids.get(key)?;
11759 let sym = self.db.labels.get(id as usize).copied()?;
11760 if sym == u32::MAX {
11761 return None;
11762 }
11763 self.db.syms.resolve(sym).map(str::to_string)
11764 }
11765
11766 /// The namespace `key` is in as this batch sees it — including a node
11767 /// inserted earlier in the same batch, which the store does not have yet.
11768 fn namespace_in_batch(&self, key: &str) -> String {
11769 namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
11770 }
11771
11772 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
11773 if !self.has_key(key) {
11774 return None;
11775 }
11776 let k = (key.to_string(), field.to_string());
11777 if self.overlay.removed_props.contains(&k) {
11778 return None;
11779 }
11780 if let Some(v) = self.overlay.extra_props.get(&k) {
11781 return Some(v.clone());
11782 }
11783 if self.overlay.extra_keys.contains(key) {
11784 return None;
11785 }
11786 self.db.get_prop(key, field)
11787 }
11788
11789 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
11790 def.validate()
11791 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
11792 if self.has_rule(&def.name) {
11793 return Err(GraphError::RuleInvalid {
11794 detail: format!("rule {:?} already exists", def.name),
11795 });
11796 }
11797 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
11798 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
11799 // `edge_type`". A cycle in that graph is a rule set that would re-fire
11800 // itself forever; the engine's depth cap would silently truncate it
11801 // instead, leaving an arbitrary partial result. Reject it here, the one
11802 // place that sees the whole rule set.
11803 //
11804 // Rules accepted earlier in the same batch count too: the overlay
11805 // carries their arcs, so a cycle cannot be assembled one op at a time.
11806 if let Some(via) = def.via_edge.as_deref() {
11807 if via == def.edge_type {
11808 return Err(GraphError::RuleInvalid {
11809 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
11810 });
11811 }
11812 let mut arcs: Vec<(String, String)> = self
11813 .db
11814 .engine
11815 .rules()
11816 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
11817 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
11818 .collect();
11819 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
11820 arcs.push((via.to_string(), def.edge_type.clone()));
11821 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
11822 return Err(GraphError::RuleInvalid {
11823 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
11824 });
11825 }
11826 }
11827 Ok(())
11828 }
11829
11830 fn check_delete_rule(&self, name: &str) -> Result<()> {
11831 if self.has_rule(name) {
11832 Ok(())
11833 } else {
11834 Err(GraphError::RuleNotFound { name: name.into() })
11835 }
11836 }
11837
11838 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
11839 self.overlay.deleted_keys.remove(key);
11840 self.overlay.extra_keys.insert(key.to_string());
11841 self.overlay.extra_props.retain(|(k, _), _| k != key);
11842 self.overlay.removed_props.retain(|(k, _)| k != key);
11843 for (field, value) in props {
11844 self.overlay
11845 .extra_props
11846 .insert((key.to_string(), field.clone()), value.clone());
11847 }
11848 }
11849
11850 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11851 let k = (
11852 edge_type.to_string(),
11853 src_key.to_string(),
11854 dst_key.to_string(),
11855 );
11856 self.overlay.deleted_edges.remove(&k);
11857 self.overlay.extra_edges.insert(k);
11858 }
11859
11860 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
11861 let k = (key.to_string(), field.to_string());
11862 self.overlay.removed_props.remove(&k);
11863 self.overlay.extra_props.insert(k, value.clone());
11864 }
11865
11866 fn note_remove_prop(&mut self, key: &str, field: &str) {
11867 let k = (key.to_string(), field.to_string());
11868 self.overlay.extra_props.remove(&k);
11869 self.overlay.removed_props.insert(k);
11870 }
11871
11872 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11873 let k = (
11874 edge_type.to_string(),
11875 src_key.to_string(),
11876 dst_key.to_string(),
11877 );
11878 self.overlay.extra_edges.remove(&k);
11879 self.overlay.deleted_edges.insert(k);
11880 }
11881
11882 fn note_delete_node(&mut self, key: &str) {
11883 self.overlay.extra_keys.remove(key);
11884 self.overlay.deleted_keys.insert(key.to_string());
11885 self.overlay.extra_props.retain(|(k, _), _| k != key);
11886 self.overlay.removed_props.retain(|(k, _)| k != key);
11887 self.overlay
11888 .extra_edges
11889 .retain(|(_, s, d)| s != key && d != key);
11890 self.overlay
11891 .deleted_edges
11892 .retain(|(_, s, d)| s != key && d != key);
11893 }
11894
11895 fn note_create_rule(&mut self, def: &RuleDef) {
11896 self.overlay.deleted_rules.remove(&def.name);
11897 self.overlay.extra_rules.insert(def.name.clone());
11898 // Rules accepted earlier in this batch are not in the engine yet, so
11899 // the cycle check would not see their arcs. Keep the arc, not just the
11900 // name, so a batch cannot smuggle in a cycle one op at a time.
11901 if let Some(via) = def.via_edge.clone() {
11902 self.overlay
11903 .extra_rule_arcs
11904 .insert(def.name.clone(), (via, def.edge_type.clone()));
11905 }
11906 }
11907
11908 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
11909 if !self.has_key(old) {
11910 return Err(GraphError::KeyNotFound { key: old.into() });
11911 }
11912 if self.has_key(new) {
11913 return Err(GraphError::DuplicateKey { key: new.into() });
11914 }
11915 Ok(())
11916 }
11917
11918 fn note_rename_node(&mut self, old: &str, new: &str) {
11919 // Mark old as deleted so subsequent batch ops cannot reference it.
11920 self.overlay.extra_keys.remove(old);
11921 self.overlay.deleted_keys.insert(old.to_string());
11922 // Mark new as extra so subsequent batch ops can reference it.
11923 self.overlay.deleted_keys.remove(new);
11924 self.overlay.extra_keys.insert(new.to_string());
11925 // Migrate any overlay props from old key to new key.
11926 let new_str = new.to_string();
11927 let transferred: Vec<((String, String), Value)> = self
11928 .overlay
11929 .extra_props
11930 .iter()
11931 .filter(|((k, _), _)| k.as_str() == old)
11932 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
11933 .collect();
11934 self.overlay
11935 .extra_props
11936 .retain(|(k, _), _| k.as_str() != old);
11937 for (k, v) in transferred {
11938 self.overlay.extra_props.insert(k, v);
11939 }
11940 // Migrate removed_props.
11941 let transferred_removed: Vec<(String, String)> = self
11942 .overlay
11943 .removed_props
11944 .iter()
11945 .filter(|(k, _)| k.as_str() == old)
11946 .map(|(_, f)| (new_str.clone(), f.clone()))
11947 .collect();
11948 self.overlay
11949 .removed_props
11950 .retain(|(k, _)| k.as_str() != old);
11951 for k in transferred_removed {
11952 self.overlay.removed_props.insert(k);
11953 }
11954 }
11955
11956 fn note_delete_rule(&mut self, name: &str) {
11957 self.overlay.extra_rules.remove(name);
11958 // Drop its chain arc too: a rule created and then deleted in the same
11959 // batch must not make a later, legal rule look like a cycle.
11960 self.overlay.extra_rule_arcs.remove(name);
11961 self.overlay.deleted_rules.insert(name.to_string());
11962 // Treat the deleted rule's current provenance as gone so a later
11963 // delete_edge of those triples is a no-op (matches sequential).
11964 if let Some(triples) = self.db.engine.provenance().get(name) {
11965 for &(et, s, d) in triples {
11966 let Some(etype) = self.db.syms.resolve(et) else {
11967 continue;
11968 };
11969 let Some(src) = self.db.ids.key_of(s) else {
11970 continue;
11971 };
11972 let Some(dst) = self.db.ids.key_of(d) else {
11973 continue;
11974 };
11975 let k = (etype.to_string(), src.to_string(), dst.to_string());
11976 self.overlay.extra_edges.remove(&k);
11977 self.overlay.deleted_edges.insert(k);
11978 }
11979 }
11980 }
11981}
11982
11983/// Collects mutations and commits them as one WAL `Batch` frame.
11984///
11985/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11986/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11987/// See [`GraphDb::batch`] for validation and atomicity rules.
11988pub struct BatchBuilder<'a, F: Fs> {
11989 db: &'a mut GraphDb<F>,
11990 ops: Vec<BatchOp>,
11991}
11992
11993impl<'a, F: Fs> BatchBuilder<'a, F> {
11994 pub fn insert_node(
11995 &mut self,
11996 label: &str,
11997 key: &str,
11998 props: Vec<(String, Value)>,
11999 ) -> &mut Self {
12000 self.ops.push(BatchOp::InsertNode {
12001 label: label.into(),
12002 key: key.into(),
12003 props,
12004 });
12005 self
12006 }
12007
12008 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12009 self.ops.push(BatchOp::InsertEdge {
12010 edge_type: edge_type.into(),
12011 src_key: src_key.into(),
12012 dst_key: dst_key.into(),
12013 });
12014 self
12015 }
12016
12017 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12018 self.ops.push(BatchOp::SetProp {
12019 key: key.into(),
12020 field: field.into(),
12021 value,
12022 });
12023 self
12024 }
12025
12026 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12027 self.ops.push(BatchOp::RemoveProp {
12028 key: key.into(),
12029 field: field.into(),
12030 });
12031 self
12032 }
12033
12034 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12035 self.ops.push(BatchOp::DeleteEdge {
12036 edge_type: edge_type.into(),
12037 src_key: src_key.into(),
12038 dst_key: dst_key.into(),
12039 });
12040 self
12041 }
12042
12043 pub fn delete_node(&mut self, key: &str) -> &mut Self {
12044 self.ops.push(BatchOp::DeleteNode { key: key.into() });
12045 self
12046 }
12047
12048 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12049 self.ops.push(BatchOp::CreateRule(def));
12050 self
12051 }
12052
12053 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12054 self.ops.push(BatchOp::DeleteRule { name: name.into() });
12055 self
12056 }
12057
12058 /// Queue a node-rename in this batch.
12059 ///
12060 /// Validation (old exists, new not taken) runs at commit time.
12061 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12062 self.ops.push(BatchOp::RenameNode {
12063 old_key: old_key.into(),
12064 new_key: new_key.into(),
12065 });
12066 self
12067 }
12068
12069 /// Queue an edge insert with endpoint auto-creation.
12070 ///
12071 /// Any missing endpoint is created as a plain node `{key, label:
12072 /// placeholder_label, no props}` inside this batch frame. Rules fire and
12073 /// last-change is updated for each auto-created node.
12074 pub fn insert_edge_upsert(
12075 &mut self,
12076 edge_type: &str,
12077 src_key: &str,
12078 dst_key: &str,
12079 placeholder_label: &str,
12080 ) -> &mut Self {
12081 self.ops.push(BatchOp::InsertEdgeUpsert {
12082 edge_type: edge_type.into(),
12083 src_key: src_key.into(),
12084 dst_key: dst_key.into(),
12085 placeholder_label: placeholder_label.into(),
12086 });
12087 self
12088 }
12089
12090 /// Validate every queued op, then log one `Batch` frame and apply.
12091 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12092 /// A second `commit()` after a successful one is an empty-batch no-op
12093 /// (queued ops were taken).
12094 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12095 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12096 ///
12097 /// **Rule-window limitation:** batch validation cannot see edges that a
12098 /// rule created earlier in the *same* batch will derive at apply time, so
12099 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12100 /// where sequential calls would return `Err(RuleOwned)`. State integrity
12101 /// is unaffected (idempotent apply, provenance intact). Create rules in
12102 /// their own batch, or sequentially, when later ops may touch derived
12103 /// edges.
12104 /// Validate every queued op and commit atomically.
12105 ///
12106 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12107 /// WAL records actually written (duplicate edges are silent no-ops and are
12108 /// NOT counted). Both are 0 when the batch is empty or all-noop.
12109 pub fn commit(&mut self) -> Result<(usize, usize)> {
12110 let ops = std::mem::take(&mut self.ops);
12111 self.db.commit_batch(ops)
12112 }
12113
12114 /// Same as [`commit`](Self::commit) but tail the inner events with
12115 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12116 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12117 let ops = std::mem::take(&mut self.ops);
12118 self.db
12119 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12120 }
12121}
12122
12123pub struct NodeRef<'a, F: Fs> {
12124 db: &'a GraphDb<F>,
12125 id: u32,
12126}
12127
12128impl<'a, F: Fs> NodeRef<'a, F> {
12129 pub fn key(&self) -> &str {
12130 self.db.ids.key_of(self.id).expect("dense ids")
12131 }
12132
12133 pub fn label(&self) -> &str {
12134 let sym = self
12135 .db
12136 .labels
12137 .get(self.id as usize)
12138 .copied()
12139 .filter(|&s| s != u32::MAX)
12140 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12141 self.db.syms.resolve(sym).expect("interned label symbol")
12142 }
12143
12144 pub fn prop(&self, field: &str) -> Option<Value> {
12145 self.db
12146 .props_view()
12147 .get(self.id, field)
12148 .map(|vr| vr.into_value())
12149 }
12150
12151 /// All stored fields for this node, sorted by field name.
12152 ///
12153 /// Reads from the full base+overlay view so that props stored only in the
12154 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12155 pub fn props(&self) -> BTreeMap<String, Value> {
12156 let mut out = BTreeMap::new();
12157 let pv = self.db.props_view();
12158 for field in pv.field_names() {
12159 if let Some(vr) = pv.get(self.id, &field) {
12160 out.insert(field, vr.into_value());
12161 }
12162 }
12163 out
12164 }
12165
12166 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12167 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12168 let view = self.db.view();
12169 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12170 names
12171 .iter()
12172 .filter_map(|name| view.syms.get(name))
12173 .collect()
12174 });
12175 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12176 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12177 for (nid, d) in nb.nodes {
12178 let key = view.key_of(nid);
12179 let label = view
12180 .label_of(nid)
12181 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12182 rs.push_row(vec![
12183 Some(Value::Str(key.to_string())),
12184 Some(Value::Str(label.to_string())),
12185 Some(Value::Int(d as i64)),
12186 ]);
12187 }
12188 rs
12189 }
12190
12191 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12192 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12193 let view = self.db.view();
12194 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12195 for e in expand(&view, self.id, None, Dir::Both) {
12196 // Skip edges with unknown etypes (only possible from corrupt large
12197 // TOPOLOGY section; function returns BTreeMap not Result).
12198 let Some(etype) = view.syms.resolve(e.etype) else {
12199 continue;
12200 };
12201 let etype = etype.to_string();
12202 let nbr = if e.src == self.id { e.dst } else { e.src };
12203 groups
12204 .entry(etype)
12205 .or_default()
12206 .insert(view.key_of(nbr).to_string());
12207 }
12208 groups
12209 .into_iter()
12210 .map(|(k, v)| (k, v.into_iter().collect()))
12211 .collect()
12212 }
12213}
12214
12215#[cfg(test)]
12216mod tests {
12217 use super::*;
12218 use core_rules::Predicate;
12219
12220 fn tmp_dir(name: &str) -> std::path::PathBuf {
12221 let d =
12222 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12223 let _ = std::fs::remove_dir_all(&d);
12224 d
12225 }
12226
12227 fn fk_rule() -> RuleDef {
12228 RuleDef {
12229 name: "works_at".into(),
12230 src_label: "Person".into(),
12231 dst_label: "Org".into(),
12232 predicate: Predicate::KeyMatch {
12233 field: "org_id".into(),
12234 },
12235 edge_type: "WORKS_AT".into(),
12236 weight_prop: None,
12237 max_edges: None,
12238 approximate: false,
12239 via_label: None,
12240 via_edge: None,
12241 via_dir: None,
12242 namespace: None,
12243 }
12244 }
12245
12246 /// Regression guard for the no-views delta-copy fast path.
12247 ///
12248 /// When no views are defined, `pending_deltas_since().to_vec()` must never
12249 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12250 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12251 /// a count of 0 after the entire sequence proves the guard fires correctly.
12252 #[test]
12253 fn no_delta_copy_when_no_views() {
12254 DELTA_COPY_COUNT.with(|c| c.set(0));
12255 let dir = tmp_dir("no-delta-copy");
12256 {
12257 let mut db = GraphDb::open(&dir).unwrap();
12258 // Insert 50 Org + 50 Person nodes with FK links.
12259 for i in 0..50u32 {
12260 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12261 }
12262 for i in 0..50u32 {
12263 db.insert_node(
12264 "Person",
12265 &format!("p{i}"),
12266 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12267 )
12268 .unwrap();
12269 }
12270 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12271 db.create_rule(fk_rule()).unwrap();
12272
12273 // Counter must stay 0 — no views, no copies.
12274 let copies = DELTA_COPY_COUNT.with(|c| c.get());
12275 assert_eq!(
12276 copies, 0,
12277 "pending_deltas_since().to_vec() called despite no views"
12278 );
12279
12280 // Derived edges must still be correct (the guard skips only the
12281 // empty delta propagation loop, not the rule application itself).
12282 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12283 assert_eq!(
12284 nbrs,
12285 vec!["o0"],
12286 "rule must derive edges even with no views"
12287 );
12288 }
12289 let _ = std::fs::remove_dir_all(&dir);
12290 }
12291
12292 /// Gating regression: subscribe AFTER a backfill must see no stale events.
12293 /// subscribe BEFORE a backfill must see every edge-fire event.
12294 #[test]
12295 fn subscribe_after_backfill_no_stale_events() {
12296 let dir = tmp_dir("sub-after-backfill");
12297 {
12298 let mut db = GraphDb::open(&dir).unwrap();
12299 for i in 0..10u32 {
12300 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12301 db.insert_node(
12302 "Person",
12303 &format!("p{i}"),
12304 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12305 )
12306 .unwrap();
12307 }
12308 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12309 db.create_rule(fk_rule()).unwrap();
12310
12311 // Subscribe AFTER the backfill — queue must be empty (no stale events).
12312 let sub = db.subscribe_all_rules().unwrap();
12313 // No events should have queued for the prior backfill.
12314 assert!(
12315 sub.try_recv().is_none(),
12316 "subscribe after backfill must see no stale events"
12317 );
12318
12319 // Inserting a new node now should fire an event (emit_deltas is now true).
12320 db.insert_node("Org", "o_new", vec![]).unwrap();
12321 db.insert_node(
12322 "Person",
12323 "p_new",
12324 vec![("org_id".into(), Value::Str("o_new".into()))],
12325 )
12326 .unwrap();
12327 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12328 assert!(
12329 ev.is_some(),
12330 "edge-fire event must arrive after subscribe (emit_deltas=true)"
12331 );
12332 }
12333 let _ = std::fs::remove_dir_all(&dir);
12334 }
12335
12336 /// Gating regression: subscribe BEFORE a backfill → events flow.
12337 #[test]
12338 fn subscribe_before_backfill_events_flow() {
12339 let dir = tmp_dir("sub-before-backfill");
12340 {
12341 let mut db = GraphDb::open(&dir).unwrap();
12342 // Subscribe FIRST — emit_deltas becomes true.
12343 let sub = db.subscribe_all_rules().unwrap();
12344
12345 for i in 0..5u32 {
12346 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12347 db.insert_node(
12348 "Person",
12349 &format!("p{i}"),
12350 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12351 )
12352 .unwrap();
12353 }
12354 // Backfill fires with emit_deltas=true → events queued.
12355 db.create_rule(fk_rule()).unwrap();
12356
12357 // Should receive at least one edge-fired event from the backfill.
12358 let mut received = 0usize;
12359 while sub.try_recv().is_some() {
12360 received += 1;
12361 }
12362 assert!(
12363 received > 0,
12364 "subscribe before backfill must receive edge-fire events (got 0)"
12365 );
12366 }
12367 let _ = std::fs::remove_dir_all(&dir);
12368 }
12369
12370 /// Companion: when a view IS defined, the delta path fires and view values update.
12371 #[test]
12372 fn delta_copy_fires_when_view_exists() {
12373 use core_rules::ViewSource;
12374 DELTA_COPY_COUNT.with(|c| c.set(0));
12375 let dir = tmp_dir("delta-copy-with-view");
12376 {
12377 let mut db = GraphDb::open(&dir).unwrap();
12378 db.insert_node("Org", "o1", vec![]).unwrap();
12379 db.insert_node(
12380 "Person",
12381 "p1",
12382 vec![("org_id".into(), Value::Str("o1".into()))],
12383 )
12384 .unwrap();
12385 // Declare a Degree view so is_empty() returns false.
12386 db.create_view(ViewDef {
12387 name: "degree_out".into(),
12388 label: "Person".into(),
12389 view_prop: "degree_out".into(),
12390 source: ViewSource::Degree {
12391 edge_type: "WORKS_AT".into(),
12392 direction: Direction::Out,
12393 },
12394 })
12395 .unwrap();
12396 db.create_rule(fk_rule()).unwrap();
12397
12398 // At least one delta copy should have happened (CreateRule backfill).
12399 let copies = DELTA_COPY_COUNT.with(|c| c.get());
12400 assert!(
12401 copies > 0,
12402 "expected delta copy to fire when a view is defined"
12403 );
12404
12405 // View value should be computed: p1 has one WORKS_AT out-edge.
12406 let info = db.node_info("p1").unwrap();
12407 let degree = info.props.get("degree_out");
12408 assert!(
12409 degree.is_some(),
12410 "view prop should be written to node props"
12411 );
12412 }
12413 let _ = std::fs::remove_dir_all(&dir);
12414 }
12415
12416 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
12417 /// derived-edge-driven view values reflect the as-of state rather than just
12418 /// the initial backfill written at `CreateView` time.
12419 ///
12420 /// Base WAL frames (indices 0..=5 before history markers):
12421 /// 0: insert Org "o1"
12422 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
12423 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
12424 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
12425 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
12426 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
12427 ///
12428 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
12429 /// no-op), so the total commit count is higher than the base frame count.
12430 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
12431 ///
12432 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
12433 /// initial backfill value (0) instead of reflecting the replayed derived edges.
12434 #[test]
12435 fn open_at_derived_edge_view_values_correct() {
12436 use core_rules::ViewSource;
12437 let dir = tmp_dir("open-at-view-rebuild");
12438 {
12439 let mut db = GraphDb::open(&dir).unwrap();
12440 // frame 0
12441 db.insert_node("Org", "o1", vec![]).unwrap();
12442 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
12443 db.create_view(ViewDef {
12444 name: "employee_count".into(),
12445 label: "Org".into(),
12446 view_prop: "emp".into(),
12447 source: ViewSource::Degree {
12448 edge_type: "WORKS_AT".into(),
12449 direction: Direction::In,
12450 },
12451 })
12452 .unwrap();
12453 // frame 2: create rule — no Persons yet; backfill is a no-op
12454 db.create_rule(fk_rule()).unwrap();
12455 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
12456 db.insert_node(
12457 "Person",
12458 "p1",
12459 vec![("org_id".into(), Value::Str("o1".into()))],
12460 )
12461 .unwrap();
12462 // frame 4: p2 — degree = 2
12463 db.insert_node(
12464 "Person",
12465 "p2",
12466 vec![("org_id".into(), Value::Str("o1".into()))],
12467 )
12468 .unwrap();
12469 // frame 5: p3 — degree = 3
12470 db.insert_node(
12471 "Person",
12472 "p3",
12473 vec![("org_id".into(), Value::Str("o1".into()))],
12474 )
12475 .unwrap();
12476 // Sanity: normal open sees degree = 3.
12477 assert_eq!(
12478 db.get_view_prop("o1", "emp"),
12479 Some(Value::Int(3)),
12480 "normal db must show degree 3 after 3 derived edges"
12481 );
12482 } // WAL flushed
12483
12484 // Re-open normally to get the authoritative reference value.
12485 let normal_db = GraphDb::open(&dir).unwrap();
12486 let normal_emp = normal_db.get_view_prop("o1", "emp");
12487 assert_eq!(
12488 normal_emp,
12489 Some(Value::Int(3)),
12490 "re-opened normal db must show degree 3"
12491 );
12492
12493 // Latest as-of (last WAL commit): must match the normal open.
12494 // History-marker frames are appended after each rule-fire, so the total
12495 // commit count is computed dynamically rather than hardcoded.
12496 let total = crate::wal_commit_count_at(&dir).unwrap();
12497 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
12498 assert_eq!(
12499 aof_latest.get_view_prop("o1", "emp"),
12500 normal_emp,
12501 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
12502 );
12503
12504 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
12505 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
12506 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
12507 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
12508 assert_eq!(
12509 aof_mid.get_view_prop("o1", "emp"),
12510 Some(Value::Int(1)),
12511 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
12512 );
12513
12514 let _ = std::fs::remove_dir_all(&dir);
12515 }
12516
12517 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
12518 /// as-of instances never commit, so distribute_events never runs and any
12519 /// subscription would wait forever.
12520 #[test]
12521 fn subscribe_on_as_of_returns_read_only_error() {
12522 let dir = tmp_dir("sub-as-of-read-only");
12523 {
12524 let mut db = GraphDb::open(&dir).unwrap();
12525 db.insert_node("Org", "o1", vec![]).unwrap();
12526 db.create_rule(fk_rule()).unwrap();
12527 }
12528 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
12529
12530 assert!(
12531 matches!(
12532 aof.subscribe_all_rules(),
12533 Err(core_storage::GraphError::ReadOnly)
12534 ),
12535 "subscribe_all_rules on as-of must return ReadOnly"
12536 );
12537 assert!(
12538 matches!(
12539 aof.subscribe_writes(),
12540 Err(core_storage::GraphError::ReadOnly)
12541 ),
12542 "subscribe_writes on as-of must return ReadOnly"
12543 );
12544 assert!(
12545 matches!(
12546 aof.subscribe_rule("works_at"),
12547 Err(core_storage::GraphError::ReadOnly)
12548 ),
12549 "subscribe_rule on as-of must return ReadOnly"
12550 );
12551 let _ = std::fs::remove_dir_all(&dir);
12552 }
12553
12554 /// Regression: a failed dense WAL rewrite must not leave speculative
12555 /// interns in `syms`. If it does, the next successful mutation logs an
12556 /// `Intern` record with an inflated id; replay (which never saw the
12557 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
12558 #[test]
12559 fn dense_rewrite_error_rolls_back_speculative_interns() {
12560 let dir = tmp_dir("dense-rewrite-rollback");
12561 {
12562 let mut db = GraphDb::open(&dir).unwrap();
12563 db.insert_node("Person", "a", vec![]).unwrap();
12564
12565 // Bypass MutPreview validation to hit the rewrite's own error path
12566 // (same shape as an id-exhaustion failure mid-rewrite). The
12567 // InsertEdge arm interns the edge type before it resolves keys.
12568 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
12569 edge_type: "ORPHAN_TYPE".into(),
12570 src_key: "missing".into(),
12571 dst_key: "a".into(),
12572 }]);
12573 assert!(err.is_err(), "rewrite of a missing src key must fail");
12574 assert_eq!(
12575 db.syms.get("ORPHAN_TYPE"),
12576 None,
12577 "failed rewrite must roll back speculative interns"
12578 );
12579
12580 // A later successful mutation must produce a replayable WAL.
12581 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
12582 }
12583 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
12584 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
12585 let _ = std::fs::remove_dir_all(&dir);
12586 }
12587}