core_api/db.rs
1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{PropPredicate, 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, Expr, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem,
10 RetVal, WriteStatement,
11};
12use core_query::{eval_cmp, eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, ef_max, evaluate, BuildProgress, EngineEdgeDelta, GraphMut, NodeView,
15 Predicate, 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;
37
38/// The reserved edge property holding a pair's insert count (§5.13).
39///
40/// Absent means 1 — the count is written only from the second insert of a
41/// triple onward, and only on a store that called
42/// [`GraphDb::enable_multiplicity`]. The engine owns the name: Cypher `SET` on
43/// it is refused, as the other reserved names are.
44pub const EDGE_COUNT_PROP: &str = "count";
45use serde::{Deserialize, Serialize};
46use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
47use std::sync::Arc;
48
49/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
50/// Zero-cost when the env var is absent (the var check is O(1) after first call).
51macro_rules! trace_open {
52 ($phase:literal, $t:expr) => {
53 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
54 eprintln!(
55 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
56 $phase,
57 $t.elapsed()
58 );
59 }
60 };
61}
62
63/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
64/// Zero-cost when the env var is absent (the var check is O(1) after first call).
65macro_rules! trace_migrate {
66 ($phase:literal, $t:expr) => {
67 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
68 eprintln!(
69 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
70 $phase,
71 $t.elapsed()
72 );
73 }
74 };
75}
76
77// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
78// executes (i.e., at least one view is defined). Used to verify the fast-path
79// guard skips the allocation when `view_store.is_empty()`.
80#[cfg(test)]
81thread_local! {
82 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
83}
84
85// Per-thread count of query-subscription `execute` calls in `distribute_events`.
86//
87// Incremented each time a query subscription actually runs its plan (i.e.,
88// the label-skip fast-path did not fire). Because `distribute_events` is
89// called synchronously on the writer thread, this thread-local correctly
90// isolates each test thread's count even when integration tests run in
91// parallel. Read via [`query_sub_exec_count`].
92thread_local! {
93 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
94}
95
96/// Return the number of query-subscription re-executions logged on this
97/// thread since the process started (or since last reset via
98/// [`reset_query_sub_exec_count`]).
99///
100/// Primarily for integration tests that verify the label-skip fast-path.
101#[doc(hidden)]
102pub fn query_sub_exec_count() -> usize {
103 QUERY_SUB_EXECS_TL.with(|c| c.get())
104}
105
106/// Reset the per-thread query-subscription execution counter to zero.
107#[doc(hidden)]
108pub fn reset_query_sub_exec_count() {
109 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
110}
111
112// Exact-versus-approximate warnings emitted on this thread. Thread-local for
113// the same reason [`QUERY_SUB_EXECS_TL`] is: integration tests run in parallel
114// and each gets its own thread, so a neighbour's masked search cannot be
115// mistaken for this test's.
116thread_local! {
117 static AMBIGUOUS_EXACTNESS_WARNS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
118 static AMBIGUOUS_EXACTNESS_LAST: std::cell::RefCell<Option<String>> =
119 const { std::cell::RefCell::new(None) };
120}
121
122/// How many times a masked, non-exact vector search has explained itself on
123/// this thread since the last [`ambiguous_exactness_warns_reset`].
124///
125/// The line itself is the product; this counter exists so a test can assert it
126/// is printed **once per index** rather than once per call.
127///
128/// **Single-threaded assertions only.** The suppression set this counts is a
129/// `Mutex<HashSet<_>>` on the `GraphDb` — shared by every thread — while the
130/// counter is thread-local. Under a concurrent caller (`serve`, which is the
131/// deployment the warning exists for) the thread that prints the line is not
132/// necessarily the thread that asked, so a zero here does not mean the line was
133/// not printed and a one does not mean it was printed once. It answers
134/// "once per index" only in a test that owns the store.
135#[doc(hidden)]
136pub fn ambiguous_exactness_warns() -> u64 {
137 AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.get())
138}
139
140/// The most recent exactness warning printed on this thread, verbatim.
141///
142/// The advice a caller reads has to be advice that caller can act on, which the
143/// counter alone cannot witness — see
144/// `the_hybrid_path_advises_a_call_the_hybrid_caller_can_make`. Carries the
145/// same single-threaded caveat as [`ambiguous_exactness_warns`].
146#[doc(hidden)]
147pub fn ambiguous_exactness_last_warning() -> Option<String> {
148 AMBIGUOUS_EXACTNESS_LAST.with(|c| c.borrow().clone())
149}
150
151/// Reset this thread's exactness-warning counter and recorded line.
152#[doc(hidden)]
153pub fn ambiguous_exactness_warns_reset() {
154 AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(0));
155 AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = None);
156}
157
158/// Which signature reached the approximate masked vector leg.
159///
160/// One code path, two entry points, and the advice cannot be the same: a
161/// warning that names an argument the caller's function does not take sends
162/// them looking for a parameter that is not there. `search_hybrid` and
163/// `search_hybrid_scoped` take `(text_field, query_text, vector_field,
164/// query_vec, label, k[, mask])` — no `exact`, no `where`.
165///
166/// The warning is not suppressed on the hybrid path. The approximation is the
167/// same one, and a caller who read `mask=` as a promise of exhaustiveness is
168/// the reader it was written for whichever door they came in by; only the
169/// remedy differs, so only the remedy changes.
170#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
171enum ExactnessCaller {
172 /// `find_similar` / `find_similar_vector_*` — `exact` and `where` are its
173 /// own parameters.
174 Vector,
175 /// `search_hybrid` / `search_hybrid_scoped` — neither argument exists, and
176 /// the leg is one half of a fusion.
177 Hybrid,
178}
179
180impl ExactnessCaller {
181 fn subject(self) -> &'static str {
182 match self {
183 Self::Vector => "a masked vector search",
184 Self::Hybrid => "the vector leg of a masked hybrid search",
185 }
186 }
187
188 fn advice(self) -> &'static str {
189 match self {
190 Self::Vector => "pass exact=True or a where= predicate.",
191 // Names the call that does take the argument, because this one
192 // does not: the caller's own next step, not a parameter hunt.
193 Self::Hybrid => {
194 "run the vector leg on its own with find_similar(field, vector, mask=…, \
195 exact=True) and fuse it with search() yourself — search_hybrid itself \
196 takes no exactness argument."
197 }
198 }
199 }
200}
201
202/// Internal state for a single `subscribe_query` subscription.
203///
204/// On every commit, `distribute_events` re-executes `ops` against the current
205/// graph state, diffs the result against `prev_rows`, and pushes
206/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
207///
208/// **Full re-run per commit; use LIMIT to bound execution cost.**
209/// (Differential evaluation is roadmap / Phase 5.)
210pub(crate) struct QuerySubEntry {
211 /// Compiled plan for the subscribed Cypher query.
212 ops: Vec<PlanOp>,
213 /// Column names from the first execution (fixed for the subscription lifetime).
214 columns: Vec<String>,
215 /// Serialized (JSON) row key → row data, representing the result set at
216 /// the end of the last commit. Used to diff against the new result.
217 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
218 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
219 inner: std::sync::Weak<SubInner>,
220 /// Interned label sym captured at subscribe time from the plan's leading scan
221 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
222 ///
223 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
224 /// with a concrete label), and this subscription must re-execute on every
225 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
226 /// queries are never skipped because edges can alter join results regardless
227 /// of which node labels were written.
228 scan_label: Option<u32>,
229}
230
231/// A post-commit mutation notification.
232///
233/// Emitted from `log_then_apply` after the WAL append, fsync, and
234/// in-memory `apply` all succeed. Never emitted for rejected operations
235/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
236/// deletes/removes). Event payloads carry user keys and rule names, never
237/// internal ids.
238///
239/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
240/// `apply` only. Emission lives exclusively in `log_then_apply`, so
241/// recovery is silent even if a sink were installed (it cannot be: the
242/// sink is in-memory and set after open).
243///
244/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
245/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
246/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
247/// or all-noop batch writes no WAL and emits nothing (including no summary).
248///
249/// **Derived edges:** rule-created or retracted edges are not individually
250/// evented — they are recoverable from the triggering mutation plus the live
251/// rule set. Only the triggering record is emitted.
252///
253/// **Wire form:** externally tagged snake_case JSON
254/// (`{"node_inserted":{"label":"A","key":"k"}}`).
255#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
256#[serde(rename_all = "snake_case")]
257pub enum MutationEvent {
258 NodeInserted {
259 label: String,
260 key: String,
261 },
262 PropSet {
263 key: String,
264 field: String,
265 },
266 PropRemoved {
267 key: String,
268 field: String,
269 },
270 EdgeInserted {
271 edge_type: String,
272 src: String,
273 dst: String,
274 },
275 EdgeDeleted {
276 edge_type: String,
277 src: String,
278 dst: String,
279 },
280 NodeDeleted {
281 key: String,
282 },
283 RuleCreated {
284 name: String,
285 },
286 RuleDeleted {
287 name: String,
288 },
289 RuleRebuilt {
290 name: String,
291 },
292 BatchApplied {
293 ops: usize,
294 },
295 Ingested {
296 label: String,
297 inserted: usize,
298 },
299}
300
301/// Whether `rec` is the frame [`GraphDb::create_rule`] logs: a `CreateRule`,
302/// alone or behind the `Intern` record for its edge type.
303///
304/// `create_rule` goes through the dense rewrite like every other write, so its
305/// frame is `Batch([Intern { edge_type }, CreateRule])` rather than a bare
306/// `CreateRule`. Anything that asks "was this commit a rule creation" must
307/// accept both shapes, or it silently stops recognising the one the
308/// standalone call writes. A batch carrying anything else is a user batch and
309/// is not this frame.
310fn is_create_rule_frame(rec: &WalRecord) -> bool {
311 match rec {
312 WalRecord::CreateRule { .. } => true,
313 WalRecord::Batch(inner) => {
314 inner
315 .iter()
316 .any(|r| matches!(r, WalRecord::CreateRule { .. }))
317 && inner
318 .iter()
319 .all(|r| matches!(r, WalRecord::CreateRule { .. } | WalRecord::Intern { .. }))
320 }
321 _ => false,
322 }
323}
324
325fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
326 match rec {
327 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
328 label: label.clone(),
329 key: key.clone(),
330 }),
331 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
332 label: intern.resolve(*label)?.to_string(),
333 key: key.clone(),
334 }),
335 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
336 key: key.clone(),
337 field: field.clone(),
338 }),
339 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
340 key: ids.key_of(*id)?.to_string(),
341 field: intern.resolve(*field)?.to_string(),
342 }),
343 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
344 key: key.clone(),
345 field: field.clone(),
346 }),
347 WalRecord::InsertEdge {
348 edge_type,
349 src_key,
350 dst_key,
351 } => Some(MutationEvent::EdgeInserted {
352 edge_type: edge_type.clone(),
353 src: src_key.clone(),
354 dst: dst_key.clone(),
355 }),
356 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
357 edge_type: intern.resolve(*etype)?.to_string(),
358 src: ids.key_of(*src)?.to_string(),
359 dst: ids.key_of(*dst)?.to_string(),
360 }),
361 WalRecord::DeleteEdge {
362 edge_type,
363 src_key,
364 dst_key,
365 } => Some(MutationEvent::EdgeDeleted {
366 edge_type: edge_type.clone(),
367 src: src_key.clone(),
368 dst: dst_key.clone(),
369 }),
370 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
371 WalRecord::CreateRule { def_bytes } => {
372 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
373 Some(MutationEvent::RuleCreated { name: def.name })
374 }
375 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
376 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
377 WalRecord::Batch(_)
378 | WalRecord::CreateView { .. }
379 | WalRecord::DeleteView { .. }
380 | WalRecord::EnableFulltext { .. }
381 | WalRecord::DisableFulltext { .. }
382 | WalRecord::EnableIndex { .. }
383 | WalRecord::DisableIndex { .. }
384 | WalRecord::Intern { .. }
385 // History markers are no-ops for mutation events — they carry no new
386 // state and rules re-derive deterministically on replay.
387 | WalRecord::DerivedEdgeAdded { .. }
388 | WalRecord::DerivedEdgeRetracted { .. }
389 // A count changes neither the node nor the edge population: the pair it
390 // counts was already there, which is why it is written at all.
391 | WalRecord::SetEdgeCount { .. }
392 // RenameNode carries no node/edge count change; no special event.
393 | WalRecord::RenameNode { .. } => None,
394 }
395}
396
397/// Whether `rec` only declares or withdraws something about the store — a
398/// full-text pair, an equality index, the multiplicity opt-in — and writes no
399/// node, edge or property. These are the records a truncating snapshot
400/// re-emits into its baseline WAL.
401fn is_declaration(rec: &WalRecord) -> bool {
402 matches!(
403 rec,
404 WalRecord::EnableFulltext { .. }
405 | WalRecord::DisableFulltext { .. }
406 | WalRecord::EnableIndex { .. }
407 | WalRecord::DisableIndex { .. }
408 ) || *rec == core_storage::wal::MULTIPLICITY_ENABLED
409}
410
411/// Database-wide counters plus per-rule budget/fire stats.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
413pub struct Stats {
414 pub nodes_live: usize,
415 pub nodes_tombstoned: usize,
416 pub edges: u64,
417 pub rules: Vec<RuleStats>,
418 /// How many writes hit the rule-chaining depth cap with work still pending,
419 /// since this handle was opened. Non-zero means some derived edges beyond
420 /// the cap are stale and no single later write will repair them: split the
421 /// rule chain or shorten it. Never persisted, so it resets on reopen.
422 #[serde(default)]
423 pub chain_truncations: u64,
424 /// The oldest commit index history still reaches (the WAL horizon floor).
425 /// `0` means nothing has been pruned and history is complete; a non-zero
426 /// value means events before that commit were pruned and are gone.
427 #[serde(default)]
428 pub history_floor: u64,
429 /// Live node counts per namespace, in name order. Always carries
430 /// `default` — a store is at least its default namespace — so a
431 /// single-tenant store reads `[{"name":"default", …}]` and a reader can
432 /// tell "no namespaces in use" from one entry.
433 #[serde(default)]
434 pub namespaces: Vec<NamespaceStats>,
435}
436
437/// Live node count for one namespace; one entry of [`Stats::namespaces`].
438#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
439pub struct NamespaceStats {
440 pub name: String,
441 pub nodes_live: usize,
442}
443
444/// One rule's provenance size, trip latch, and fire counter.
445///
446/// `tripped` is a one-way latch: once set, the engine adds no new edges for
447/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
448/// set then fits). `fires` counts `on_node_changed` evaluations plus
449/// backfill/rebuild participant ticks (rebuild counts even when it is a
450/// provenance no-op).
451#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
452pub struct RuleStats {
453 pub name: String,
454 pub edges: u64,
455 pub tripped: bool,
456 pub fires: u64,
457 /// Whether this rule uses the approximate IVF-Flat candidate path.
458 pub approximate: bool,
459 /// `Some` while this rule's vector index is still being built.
460 ///
461 /// The rule derives **no** edges until it is `None`: the backfill is one
462 /// commit that runs after the index is whole, so a caller never sees a
463 /// partial edge set. Absent from the JSON when the rule is not building,
464 /// which is every rule created over a corpus at or below
465 /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub building: Option<BuildProgress>,
468}
469
470/// One entry in the slow-query ring buffer.
471#[derive(Debug, Clone, Serialize)]
472pub struct SlowQueryEntry {
473 /// Execution time in whole milliseconds.
474 pub ms: u64,
475 /// The Cypher query string that was slow.
476 pub query: String,
477 /// The commit sequence number at the time the query ran.
478 pub at_commit: u64,
479}
480
481/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
482#[derive(Debug, Clone, Serialize)]
483pub struct SlowQuerySnapshot {
484 /// Current threshold in milliseconds (0 = disabled).
485 pub threshold_ms: u64,
486 /// Total number of slow queries ever recorded (not capped by ring size).
487 pub count: u64,
488 /// Most-recent slow queries (up to 16), oldest first.
489 pub last: Vec<SlowQueryEntry>,
490}
491
492/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
493/// write to it without a mutable borrow.
494struct SlowQueryLog {
495 entries: std::collections::VecDeque<SlowQueryEntry>,
496 total: u64,
497}
498
499/// Maximum number of entries kept in the slow-query ring buffer.
500const SLOW_QUERY_RING_CAP: usize = 16;
501
502/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
503/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct PredicateSummary {
506 pub kind: String,
507 pub fields: Vec<String>,
508 pub min: Option<f64>,
509 pub tolerance: Option<f64>,
510 pub km: Option<f64>,
511 pub parts: Option<Vec<PredicateSummary>>,
512 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
513 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
514 #[serde(default)]
515 pub approximate: bool,
516}
517
518impl From<&Predicate> for PredicateSummary {
519 fn from(p: &Predicate) -> Self {
520 match p {
521 Predicate::KeyMatch { field } => PredicateSummary {
522 kind: "key_match".into(),
523 fields: vec![field.clone()],
524 min: None,
525 tolerance: None,
526 km: None,
527 parts: None,
528 approximate: false,
529 },
530 Predicate::FieldEqual { field } => PredicateSummary {
531 kind: "field_equal".into(),
532 fields: vec![field.clone()],
533 min: None,
534 tolerance: None,
535 km: None,
536 parts: None,
537 approximate: false,
538 },
539 Predicate::Overlap { field, min } => PredicateSummary {
540 kind: "overlap".into(),
541 fields: vec![field.clone()],
542 min: Some(*min),
543 tolerance: None,
544 km: None,
545 parts: None,
546 approximate: false,
547 },
548 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
549 kind: "numeric_within".into(),
550 fields: vec![field.clone()],
551 min: None,
552 tolerance: Some(*tolerance),
553 km: None,
554 parts: None,
555 approximate: false,
556 },
557 Predicate::GeoRadius { field, km } => PredicateSummary {
558 kind: "geo_radius".into(),
559 fields: vec![field.clone()],
560 min: None,
561 tolerance: None,
562 km: Some(*km),
563 parts: None,
564 approximate: false,
565 },
566 Predicate::VectorSimilar { field, min } => PredicateSummary {
567 kind: "vector_similar".into(),
568 fields: vec![field.clone()],
569 min: Some(*min),
570 tolerance: None,
571 km: None,
572 parts: None,
573 approximate: false,
574 },
575 Predicate::All(inner) => {
576 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
577 let mut fields = Vec::new();
578 for part in &parts {
579 for f in &part.fields {
580 if !fields.contains(f) {
581 fields.push(f.clone());
582 }
583 }
584 }
585 PredicateSummary {
586 kind: "all".into(),
587 fields,
588 min: None,
589 tolerance: None,
590 km: None,
591 parts: Some(parts),
592 approximate: false,
593 }
594 }
595 Predicate::Any(inner) => {
596 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
597 let mut fields = Vec::new();
598 for part in &parts {
599 for f in &part.fields {
600 if !fields.contains(f) {
601 fields.push(f.clone());
602 }
603 }
604 }
605 PredicateSummary {
606 kind: "any".into(),
607 fields,
608 min: None,
609 tolerance: None,
610 km: None,
611 parts: Some(parts),
612 approximate: false,
613 }
614 }
615 }
616 }
617}
618
619/// Snapshot of a live node's key, label, and columnar properties.
620///
621/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
622/// regardless of insert order or the columnar store's `HashMap` iteration.
623///
624/// Deliberately does not derive `Serialize`: `Value`'s serde form is
625/// internally tagged. Wire JSON is built by `value_to_json` in the server.
626#[derive(Debug, Clone, PartialEq)]
627pub struct NodeInfo {
628 pub key: String,
629 pub label: String,
630 pub props: BTreeMap<String, Value>,
631}
632
633/// Counts returned by [`GraphDb::delete_node`].
634#[derive(Debug, Clone, PartialEq, Eq, Default)]
635pub struct DeleteReport {
636 /// Number of manual (user-inserted) edges removed.
637 pub manual_edges: u64,
638 /// Number of derived (rule-owned) edges retracted.
639 pub derived_edges: u64,
640}
641
642/// One directed edge incident on a node, with provenance membership.
643///
644/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
645/// Plan-8 `by_node` provenance index.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
647pub struct EdgeInfo {
648 pub edge_type: String,
649 pub src_key: String,
650 pub dst_key: String,
651 pub derived: bool,
652}
653
654/// One directed edge incident on a node at a point in WAL history, with the
655/// rule that derived it when it is rule-owned.
656///
657/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
658/// and by [`GraphDb::what_if_set_prop`].
659#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
660pub struct EdgeAt {
661 pub edge_type: String,
662 pub src_key: String,
663 pub dst_key: String,
664 /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
665 /// live provenance entry).
666 pub derived: bool,
667 /// The rule that derived the edge. `None` for a manual edge.
668 pub rule: Option<String>,
669}
670
671/// The derived edges a hypothetical property change would retract and derive.
672///
673/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
674/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
675#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
676pub struct WhatIf {
677 /// Derived edges that exist now and would be retracted.
678 pub lost: Vec<EdgeAt>,
679 /// Derived edges that do not exist now and would be derived.
680 pub gained: Vec<EdgeAt>,
681}
682
683/// An edge with mask-aware endpoint visibility.
684///
685/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
686/// mode — hidden endpoints carry `*_restricted: true`.
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct MaskedEdge {
689 pub edge_type: String,
690 pub src_key: String,
691 /// `true` when `src_key` is in the DB but hidden from the mask.
692 pub src_restricted: bool,
693 pub dst_key: String,
694 /// `true` when `dst_key` is in the DB but hidden from the mask.
695 pub dst_restricted: bool,
696 pub derived: bool,
697}
698
699/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
700///
701/// `None` from that method means the key does not exist (→ 404).
702/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
703#[derive(Debug, PartialEq)]
704pub enum MaskedNodeResult {
705 Visible(NodeInfo),
706 /// Node exists in the DB but is hidden from this mask.
707 Restricted,
708}
709
710/// One rule-owned edge between two nodes, with the rule name, edge type,
711/// direction (src_key → dst_key), and weight if the rule stores one.
712#[derive(Debug, Clone, PartialEq, Serialize)]
713pub struct Explanation {
714 pub rule: String,
715 pub edge_type: String,
716 pub src_key: String,
717 pub dst_key: String,
718 pub weight: Option<f64>,
719 pub predicate: PredicateSummary,
720 /// For a via-hop rule, the edge type the rule hops over to reach its
721 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
722 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
723 /// was written by another rule in the same commit.
724 #[serde(default)]
725 pub via_edge: Option<String>,
726}
727
728/// Report returned by [`GraphDb::backup_to`].
729#[derive(Debug, Clone)]
730pub struct BackupReport {
731 /// Filenames copied into the destination directory (sorted ascending).
732 pub files: Vec<String>,
733 /// Total bytes written across all copied files.
734 pub bytes: u64,
735 /// `true` when the destination opened cleanly and passed post-copy checks.
736 ///
737 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
738 /// matched **and** the destination opened without error.
739 ///
740 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
741 /// CRC-check; `verified` is `true` when the destination opened and
742 /// replayed the WAL without error (record-level checksums in the WAL
743 /// provide the integrity signal, not section CRCs).
744 pub verified: bool,
745}
746
747/// One directed edge in export form, with optional rule attribution for derived edges.
748///
749/// Returned by [`GraphDb::all_edges_for_export`].
750///
751/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
752/// order. Callers that need a stable edge ordering already sort by
753/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
754#[derive(Debug, Clone, PartialEq, PartialOrd)]
755pub struct ExportEdge {
756 pub edge_type: String,
757 pub src: String,
758 pub dst: String,
759 pub derived: bool,
760 /// Rule name that created this edge, if derived. `None` for manual edges.
761 pub rule: Option<String>,
762 /// The creating rule's declared `weight_prop`, read off this edge, when
763 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
764 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
765 pub weight: Option<f64>,
766}
767
768/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
769///
770/// Deliberately per *type* and not per edge: everything here is a summary a
771/// caller can print in one line, and none of it costs a record per edge.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct EdgeTypeCensus {
774 pub edge_type: String,
775 /// Directed edges of this type. Counted the way
776 /// [`GraphDb::edge_count`] counts: each edge once, from its source.
777 pub edges: u64,
778 /// Every label seen on a source of this type, sorted.
779 pub src_labels: Vec<String>,
780 /// Every label seen on a destination of this type, sorted.
781 pub dst_labels: Vec<String>,
782 /// The rules that declare this `edge_type`, sorted. Empty for a type
783 /// written by hand.
784 pub rules: Vec<String>,
785 /// `(src key, dst key)` of the first edge of this type in the store's own
786 /// id order — a real pair to quote in an example.
787 pub sample: Option<(String, String)>,
788}
789
790/// Construct the standard write-query result set (columns: created, properties_set, deleted).
791fn write_result_set() -> ResultSet {
792 ResultSet::new(vec![
793 "created".into(),
794 "properties_set".into(),
795 "deleted".into(),
796 ])
797}
798
799fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
800 match op {
801 Operand::Lit(v) => Ok(v.clone()),
802 Operand::Param(name) => params
803 .get(name)
804 .cloned()
805 .ok_or_else(|| GraphError::QueryError {
806 detail: format!("missing parameter `{name}`"),
807 }),
808 _ => Err(GraphError::QueryError {
809 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
810 }),
811 }
812}
813
814fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
815 match op {
816 Operand::Prop { var, .. } | Operand::Var(var) => {
817 if !out.contains(var) {
818 out.push(var.clone());
819 }
820 }
821 Operand::FuncCall { args, .. } => {
822 for arg in args {
823 operand_node_vars(arg, out);
824 }
825 }
826 Operand::BinArith { left, right, .. } => {
827 operand_node_vars(left, out);
828 operand_node_vars(right, out);
829 }
830 Operand::Case { branches, default } => {
831 // Branch conditions reference vars already bound (and mask-filtered)
832 // by the MATCH phase, so collecting from the value operands + ELSE
833 // is sufficient for RETURN-projection var discovery.
834 for (_, value) in branches {
835 operand_node_vars(value, out);
836 }
837 if let Some(d) = default {
838 operand_node_vars(d, out);
839 }
840 }
841 Operand::Index { base, index } => {
842 operand_node_vars(base, out);
843 operand_node_vars(index, out);
844 }
845 Operand::Lit(_) | Operand::Param(_) => {}
846 }
847}
848
849fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
850 let mut out = Vec::new();
851 for item in items {
852 match &item.value {
853 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
854 if !out.contains(v) {
855 out.push(v.clone());
856 }
857 }
858 RetVal::FuncCall { args, .. } => {
859 for arg in args {
860 operand_node_vars(arg, &mut out);
861 }
862 }
863 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
864 RetVal::Agg { .. } => {}
865 }
866 }
867 out
868}
869
870fn add_var(out: &mut Vec<String>, v: &str) {
871 if !out.iter().any(|x| x == v) {
872 out.push(v.to_string());
873 }
874}
875
876fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
877 let mut out = Vec::new();
878 for p in pats {
879 if let Some(v) = &p.start.var {
880 add_var(&mut out, v);
881 }
882 for (_, dest) in &p.chain {
883 if let Some(v) = &dest.var {
884 add_var(&mut out, v);
885 }
886 }
887 }
888 out
889}
890
891fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
892 let mut out = Vec::new();
893 for p in pats {
894 for (rel, _) in &p.chain {
895 if rel.hops.is_none() {
896 if let Some(v) = &rel.var {
897 add_var(&mut out, v);
898 }
899 }
900 }
901 }
902 out
903}
904
905fn rel_type_alias(var: &str) -> String {
906 format!("__rt_{var}")
907}
908
909fn ret_column_name(item: &RetItem) -> String {
910 if let Some(alias) = &item.alias {
911 return alias.clone();
912 }
913 // The same naming rule the planner and the executor use, so a
914 // write-statement RETURN names its columns exactly as a read query does.
915 // An aggregate is not legal in a write-statement RETURN; it keeps the
916 // placeholder it always had.
917 ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
918}
919
920fn eval_set_return_operand<F: Fs>(
921 db: &GraphDb<F>,
922 match_rs: &ResultSet,
923 row: usize,
924 rel_vars: &[String],
925 op: &Operand,
926 params: &BTreeMap<String, Value>,
927) -> Result<Option<Value>> {
928 match op {
929 Operand::Lit(v) => Ok(Some(v.clone())),
930 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
931 detail: format!("missing parameter `{name}`"),
932 }).map(Some),
933 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
934 detail: format!(
935 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
936 ),
937 }),
938 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
939 Operand::Prop { var, field } => {
940 if rel_vars.iter().any(|r| r == var) {
941 return Ok(None);
942 }
943 let Some(Value::Str(key)) = match_rs.get(row, var) else {
944 return Ok(None);
945 };
946 if let Some(v) = db.get_prop(key, field) {
947 return Ok(Some(v));
948 }
949 // Same stored-wins identity fallback as the read path:
950 // n.key / n.id / n.label, not only get_prop.
951 Ok(match field.as_str() {
952 "key" | "id" => Some(Value::Str(key.clone())),
953 "label" => db
954 .node_ref(key)
955 .map(|n| Value::Str(n.label().to_owned())),
956 _ => None,
957 })
958 }
959 Operand::FuncCall { name, args } => {
960 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
961 }
962 Operand::BinArith { op, left, right } => {
963 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
964 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
965 eval_set_return_arith(op, lv, rv)
966 }
967 Operand::Case { branches, default } => {
968 for (cond, value) in branches {
969 if eval_set_return_expr(db, match_rs, row, rel_vars, cond, params, 0)? {
970 return eval_set_return_operand(db, match_rs, row, rel_vars, value, params);
971 }
972 }
973 match default {
974 Some(d) => eval_set_return_operand(db, match_rs, row, rel_vars, d, params),
975 None => Ok(None),
976 }
977 }
978 Operand::Index { base, index } => {
979 let base_val = eval_set_return_operand(db, match_rs, row, rel_vars, base, params)?;
980 let idx_val = eval_set_return_operand(db, match_rs, row, rel_vars, index, params)?;
981 Ok(core_query::value_ops::index_list(base_val, idx_val))
982 }
983 }
984}
985
986fn eval_set_return_expr<F: Fs>(
987 db: &GraphDb<F>,
988 match_rs: &ResultSet,
989 row: usize,
990 rel_vars: &[String],
991 expr: &Expr,
992 params: &BTreeMap<String, Value>,
993 depth: u32,
994) -> Result<bool> {
995 if depth > 256 {
996 return Err(GraphError::QueryError {
997 detail: "expression nesting too deep".into(),
998 });
999 }
1000 match expr {
1001 Expr::And(lhs, rhs) => {
1002 let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1003 let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1004 Ok(l && r)
1005 }
1006 Expr::Or(lhs, rhs) => {
1007 let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1008 let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1009 Ok(l || r)
1010 }
1011 Expr::Not(inner) => Ok(!eval_set_return_expr(
1012 db,
1013 match_rs,
1014 row,
1015 rel_vars,
1016 inner,
1017 params,
1018 depth + 1,
1019 )?),
1020 Expr::Cmp { lhs, op, rhs } => {
1021 let l = eval_set_return_operand(db, match_rs, row, rel_vars, lhs, params)?;
1022 let r = eval_set_return_operand(db, match_rs, row, rel_vars, rhs, params)?;
1023 match (l, r) {
1024 (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
1025 _ => Ok(false),
1026 }
1027 }
1028 Expr::Truthy(op) => {
1029 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1030 Ok(match val {
1031 None => false,
1032 Some(Value::Bool(b)) => b,
1033 Some(Value::Int(n)) => n != 0,
1034 Some(Value::Float(f)) => f != 0.0,
1035 Some(Value::Str(s)) => !s.is_empty(),
1036 Some(Value::List(v)) => !v.is_empty(),
1037 Some(Value::Map(m)) => !m.is_empty(),
1038 })
1039 }
1040 Expr::IsNull(op) => {
1041 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1042 Ok(val.is_none())
1043 }
1044 Expr::IsNotNull(op) => {
1045 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1046 Ok(val.is_some())
1047 }
1048 Expr::In { expr, list } => {
1049 let Some(needle) = eval_set_return_operand(db, match_rs, row, rel_vars, expr, params)?
1050 else {
1051 return Ok(false);
1052 };
1053 for item_op in list {
1054 match eval_set_return_operand(db, match_rs, row, rel_vars, item_op, params)? {
1055 None => {}
1056 Some(Value::List(items)) => {
1057 for item in items {
1058 if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) {
1059 return Ok(true);
1060 }
1061 }
1062 }
1063 Some(item) if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) => {
1064 return Ok(true);
1065 }
1066 Some(_) => {}
1067 }
1068 }
1069 Ok(false)
1070 }
1071 }
1072}
1073
1074fn eval_set_return_arith(
1075 op: &ArithOp,
1076 lv: Option<Value>,
1077 rv: Option<Value>,
1078) -> Result<Option<Value>> {
1079 match (lv, rv) {
1080 (None, _) | (_, None) => Ok(None),
1081 (Some(Value::Int(a)), Some(Value::Int(b))) => {
1082 let result = match op {
1083 ArithOp::Sub => a.saturating_sub(b),
1084 ArithOp::Mul => a.saturating_mul(b),
1085 ArithOp::Add => a.saturating_add(b),
1086 ArithOp::Div => {
1087 if b == 0 {
1088 return Err(GraphError::QueryError {
1089 detail: "division by zero".into(),
1090 });
1091 }
1092 a.checked_div(b).unwrap_or(i64::MAX)
1093 }
1094 };
1095 Ok(Some(Value::Int(result)))
1096 }
1097 (Some(lv), Some(rv)) => {
1098 let a = match &lv {
1099 Value::Float(f) => *f,
1100 Value::Int(i) => *i as f64,
1101 _ => {
1102 return Err(GraphError::QueryError {
1103 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
1104 })
1105 }
1106 };
1107 let b = match &rv {
1108 Value::Float(f) => *f,
1109 Value::Int(i) => *i as f64,
1110 _ => {
1111 return Err(GraphError::QueryError {
1112 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
1113 })
1114 }
1115 };
1116 let result = match op {
1117 ArithOp::Sub => a - b,
1118 ArithOp::Mul => a * b,
1119 ArithOp::Add => a + b,
1120 ArithOp::Div => {
1121 if b == 0.0 {
1122 return Err(GraphError::QueryError {
1123 detail: "division by zero".into(),
1124 });
1125 }
1126 a / b
1127 }
1128 };
1129 Ok(Some(Value::Float(result)))
1130 }
1131 }
1132}
1133
1134fn eval_set_return_func<F: Fs>(
1135 db: &GraphDb<F>,
1136 match_rs: &ResultSet,
1137 row: usize,
1138 rel_vars: &[String],
1139 name: &str,
1140 args: &[Operand],
1141 params: &BTreeMap<String, Value>,
1142) -> Result<Option<Value>> {
1143 let norm = name.to_ascii_lowercase();
1144 if norm == "type" {
1145 if args.len() != 1 {
1146 return Err(GraphError::QueryError {
1147 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
1148 });
1149 }
1150 let Operand::Var(rel) = &args[0] else {
1151 return Err(GraphError::QueryError {
1152 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
1153 });
1154 };
1155 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
1156 }
1157 if norm == "key" || norm == "id" {
1158 let fname = if norm == "id" { "id" } else { "key" };
1159 if args.len() != 1 {
1160 return Err(GraphError::QueryError {
1161 detail: format!("{fname}() requires exactly 1 argument, got {}", args.len()),
1162 });
1163 }
1164 let Operand::Var(var) = &args[0] else {
1165 return Err(GraphError::QueryError {
1166 detail: format!("{fname}() argument must be a node variable (e.g. {fname}(n))"),
1167 });
1168 };
1169 if rel_vars.iter().any(|r| r == var) {
1170 return Err(GraphError::QueryError {
1171 detail: format!("{fname}() argument `{var}` is a relationship, not a node"),
1172 });
1173 }
1174 // MATCH rows bind node variables to their key string, so the column
1175 // value *is* the key. `id()` aliases `key()`.
1176 return Ok(match_rs.get(row, var).cloned());
1177 }
1178 let mut vals = Vec::with_capacity(args.len());
1179 for arg in args {
1180 vals.push(eval_set_return_operand(
1181 db, match_rs, row, rel_vars, arg, params,
1182 )?);
1183 }
1184 match norm.as_str() {
1185 "tolower" => {
1186 if vals.len() != 1 {
1187 return Err(GraphError::QueryError {
1188 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
1189 });
1190 }
1191 Ok(vals[0].clone().map(|val| match val {
1192 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1193 other => other,
1194 }))
1195 }
1196 "toupper" => {
1197 if vals.len() != 1 {
1198 return Err(GraphError::QueryError {
1199 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
1200 });
1201 }
1202 Ok(vals[0].clone().map(|val| match val {
1203 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1204 other => other,
1205 }))
1206 }
1207 "size" => match vals.first().cloned().flatten() {
1208 None => Ok(None),
1209 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1210 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1211 Some(_) => Ok(None),
1212 },
1213 "coalesce" => Ok(vals.into_iter().flatten().next()),
1214 "abs" => match vals.first().cloned().flatten() {
1215 None => Ok(None),
1216 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
1217 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1218 Some(_) => Ok(None),
1219 },
1220 "round" => match vals.first().cloned().flatten() {
1221 None => Ok(None),
1222 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1223 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
1224 Some(_) => Ok(None),
1225 },
1226 "decay" => {
1227 if vals.len() != 3 {
1228 return Err(GraphError::QueryError {
1229 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
1230 });
1231 }
1232 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
1233 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
1234 (Some(b), Some(a), Some(h)) => {
1235 let numeric = |v: Value| -> Result<f64> {
1236 match v {
1237 Value::Int(n) => Ok(n as f64),
1238 Value::Float(f) => Ok(f),
1239 other => Err(GraphError::QueryError {
1240 detail: format!(
1241 "decay() requires numeric arguments, got {other:?}"
1242 ),
1243 }),
1244 }
1245 };
1246 let b = numeric(b)?;
1247 let a = numeric(a)?;
1248 let h = numeric(h)?;
1249 if h <= 0.0 {
1250 return Err(GraphError::QueryError {
1251 detail: "decay() requires halflife > 0".into(),
1252 });
1253 }
1254 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1255 }
1256 }
1257 }
1258 _ => Err(GraphError::QueryError {
1259 detail: format!(
1260 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key, id"
1261 ),
1262 }),
1263 }
1264}
1265
1266fn eval_set_return_item<F: Fs>(
1267 db: &GraphDb<F>,
1268 match_rs: &ResultSet,
1269 row: usize,
1270 rel_vars: &[String],
1271 item: &RetItem,
1272 params: &BTreeMap<String, Value>,
1273) -> Result<Option<Value>> {
1274 match &item.value {
1275 RetVal::Var(v) => eval_set_return_operand(
1276 db,
1277 match_rs,
1278 row,
1279 rel_vars,
1280 &Operand::Var(v.clone()),
1281 params,
1282 ),
1283 RetVal::Prop { var, field } => eval_set_return_operand(
1284 db,
1285 match_rs,
1286 row,
1287 rel_vars,
1288 &Operand::Prop {
1289 var: var.clone(),
1290 field: field.clone(),
1291 },
1292 params,
1293 ),
1294 RetVal::FuncCall { name, args } => {
1295 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1296 }
1297 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1298 RetVal::Agg { .. } => Err(GraphError::QueryError {
1299 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1300 }),
1301 }
1302}
1303
1304/// Project user RETURN from original MATCH rows after SET. No rematch.
1305fn project_set_return_rows<F: Fs>(
1306 db: &GraphDb<F>,
1307 rel_vars: &[String],
1308 match_rs: &ResultSet,
1309 returns: &[RetItem],
1310 params: &BTreeMap<String, Value>,
1311) -> Result<ResultSet> {
1312 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1313 let mut out = ResultSet::new(columns);
1314 for row in 0..match_rs.len() {
1315 let mut cells = Vec::with_capacity(returns.len());
1316 for item in returns {
1317 cells.push(eval_set_return_item(
1318 db, match_rs, row, rel_vars, item, params,
1319 )?);
1320 }
1321 out.push_row(cells);
1322 }
1323 Ok(out)
1324}
1325
1326/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1327/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1328/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1329/// Returns `None` for non-list values or lists with non-numeric elements.
1330/// Extra candidates pulled from an approximate index before re-scoring, over and
1331/// above the `k` asked for.
1332///
1333/// The index orders candidates by `f32` distances, which agree with the exact
1334/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1335/// candidates inside a band that narrow — it cannot move a hit past one that is
1336/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1337/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1338/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1339/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1340/// then the members are interchangeable anyway. `min` is applied to the exact
1341/// score, never to the index's, so a hit sitting on the threshold is decided
1342/// exactly.
1343const VECTOR_RESCORE_MARGIN: usize = 16;
1344
1345/// Cosine similarity between an already-unit query and node `id`'s `field`
1346/// vector, read from the **`f64`** properties. `None` when the node has no
1347/// numeric-list vector there, or its norm is zero.
1348///
1349/// The single definition of the score this API reports. Both the brute-force
1350/// scan and the re-scoring step that follows an index lookup go through it, so
1351/// the two paths cannot disagree — which is the property
1352/// `index_and_brute_force_agree_on_scores` pins.
1353fn exact_vector_similarity(
1354 view: &GraphView<'_>,
1355 id: u32,
1356 field: &str,
1357 q_unit: &[f64],
1358) -> Option<f64> {
1359 let v = view.prop(id, field)?;
1360 let xs = value_as_float_list(&v.into_value())?;
1361 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1362 if v_norm == 0.0 {
1363 return None;
1364 }
1365 Some(
1366 q_unit
1367 .iter()
1368 .zip(xs.iter())
1369 .map(|(a, b)| a * (b / v_norm))
1370 .sum(),
1371 )
1372}
1373
1374fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1375 match v {
1376 Value::List(items) => items
1377 .iter()
1378 .map(|item| match item {
1379 Value::Float(f) => Some(*f),
1380 Value::Int(i) => Some(*i as f64),
1381 _ => None,
1382 })
1383 .collect(),
1384 _ => None,
1385 }
1386}
1387
1388fn make_graph_mut<'a>(
1389 ids: &'a IdMap,
1390 syms: &'a mut Interner,
1391 labels: &'a [u32],
1392 props: core_storage::v8::seam::ColumnsView<'a>,
1393 topo: &'a mut Topology,
1394 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1395 edge_props: &'a mut EdgeProps,
1396) -> GraphMut<'a> {
1397 GraphMut {
1398 ids,
1399 syms,
1400 labels,
1401 props,
1402 topo,
1403 base_topo: base_csr(base),
1404 edge_props,
1405 }
1406}
1407
1408/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1409///
1410/// A store opened from a snapshot keeps its edges in the mapping and its
1411/// overlay empty, so a rule that reads the graph's shape has to see both.
1412fn base_csr(
1413 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1414) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1415 base.as_ref().map(|b| {
1416 b.topology()
1417 .expect("base topology section bounds validated at open")
1418 })
1419}
1420
1421/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1422///
1423/// Takes explicit field references rather than `&self` so the caller can hold
1424/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1425fn build_props_view<'a>(
1426 props: &'a ColumnStore,
1427 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1428) -> core_storage::v8::seam::ColumnsView<'a> {
1429 match base {
1430 None => core_storage::v8::seam::ColumnsView::owned(props),
1431 Some(b) => {
1432 let archived = b
1433 .columns()
1434 .expect("base columns section bounds validated at open");
1435 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1436 .with_shared_strings(base_string_table(b))
1437 }
1438 }
1439}
1440
1441/// The base columns section paired with the string table that resolves its
1442/// string ids — what `ViewStore` needs to read a neighbour's string property
1443/// out of a V9 snapshot.
1444fn base_columns(
1445 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1446) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1447 base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1448 cols: b
1449 .columns()
1450 .expect("base columns section bounds validated at open"),
1451 strings: base_string_table(b),
1452 })
1453}
1454
1455/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1456///
1457/// Every `ColumnsView` built over a base must carry it: without it a V9
1458/// snapshot's string columns, whose own tables are empty, read back as absent.
1459fn base_string_table(
1460 base: &core_storage::v8::MappedBase,
1461) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1462 base.string_table()
1463 .transpose()
1464 .expect("base strings section bounds validated at open")
1465}
1466
1467fn build_topo_view<'a>(
1468 overlay: &'a Topology,
1469 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1470) -> core_storage::v8::seam::TopologyView<'a> {
1471 match base {
1472 None => core_storage::v8::seam::TopologyView::owned(overlay),
1473 Some(b) => {
1474 let archived_csr = b
1475 .topology()
1476 .expect("base topology section bounds validated at open");
1477 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1478 }
1479 }
1480}
1481
1482/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1483///
1484/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1485/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1486/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1487/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1488/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1489#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1490pub enum FsyncPolicy {
1491 /// Every WAL commit calls `fs.sync` (today's behavior).
1492 #[default]
1493 Strict,
1494 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1495 /// this policy is set on the database.
1496 Batched,
1497 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1498 Relaxed,
1499}
1500
1501/// A precondition for a compare-and-set batch write.
1502///
1503/// All preconditions in a [`GraphDb::write_batch_cas`] or
1504/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1505/// any operation in the batch is applied. If any precondition fails, the
1506/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1507/// is written.
1508///
1509/// # Touch definition
1510///
1511/// A node's last-change commit (`last_changed`) is updated when any of the
1512/// following state-changing WAL records touch it:
1513///
1514/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1515/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1516/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1517/// endpoints (an edge change touches both sides).
1518/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1519/// for deleted keys so the pre-deletion entry is never observed.
1520///
1521/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1522/// state no-ops. The underlying mutation that triggered rule firing already
1523/// updated the relevant nodes' last-change entries. Rule-management records
1524/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1525/// do not touch any node's last-change.
1526#[derive(Debug, Clone, PartialEq, Eq)]
1527pub enum Precondition {
1528 /// The node's last-change commit must equal `expected`.
1529 ///
1530 /// Fails with [`GraphError::CasConflict`] when:
1531 /// - The node does not exist (`last_changed` returns `None`), or
1532 /// - The recorded commit seq does not match `expected`.
1533 NodeUnchangedSince { key: String, expected: u64 },
1534 /// The node must not exist (not inserted, or already deleted).
1535 ///
1536 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1537 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1538 NodeAbsent { key: String },
1539}
1540
1541pub struct GraphDb<F: Fs> {
1542 fs: F,
1543 ids: Arc<IdMap>,
1544 syms: Arc<Interner>,
1545 topo: Arc<Topology>,
1546 props: Arc<ColumnStore>,
1547 labels: Arc<Vec<u32>>, // node id -> label symbol
1548 /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1549 /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1550 ///
1551 /// A private table rather than the shared [`Interner`]: interning
1552 /// `"default"` at open would add a symbol to the store's symbol table and
1553 /// change the bytes of the next snapshot of a store that has no namespaces
1554 /// at all.
1555 ns_names: Vec<String>,
1556 /// Namespace index per dense node id, into [`Self::ns_names`];
1557 /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1558 ///
1559 /// Derived: built by one pass over the `ns` column at open (which reads
1560 /// nothing when the column does not exist) and maintained at every node
1561 /// insert. Never written to a snapshot or the WAL, because the property it
1562 /// mirrors already is. A namespace cannot change, so no other record shape
1563 /// can move a node between namespaces.
1564 node_ns: Vec<u32>,
1565 edge_props: Arc<EdgeProps>,
1566 engine: RuleEngine,
1567 view_store: ViewStore,
1568 /// Incremental inverted index for full-text-lite search.
1569 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1570 fulltext: Arc<FulltextIndex>,
1571 /// Opt-in equality index over scalar node properties.
1572 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1573 /// open end (mirrors `fulltext`).
1574 prop_index: PropertyIndex,
1575 /// Whether this store records insert-count multiplicity (§5.13).
1576 ///
1577 /// Declared like `prop_index`'s enabled pairs — a WAL record replayed at
1578 /// open, re-emitted into the baseline by a truncating snapshot — but it
1579 /// gates a *format* step rather than an index: `WalRecord::SetEdgeCount`
1580 /// (discriminant 23) is written only when this is `true`, so a store that
1581 /// never opts in stays readable by a binary that predates the record.
1582 multiplicity: bool,
1583 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1584 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1585 fsync: FsyncPolicy,
1586 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1587 /// call increments this once; all events emitted from that call share the same
1588 /// `commit_seq` value.
1589 commit_seq: u64,
1590 /// Commit → wall-clock map, loaded from the `commit_times.bin` sidecar at
1591 /// open and appended to by `log_then_apply_with` — the one place a commit
1592 /// is born. Replay does **not** stamp: `apply_frames` re-applies commits
1593 /// that already happened, and `SystemTime::now()` there would record replay
1594 /// time as commit time. Empty on a store written before v0.6.11, which
1595 /// makes every date query answer `NoRecordedTime` rather than guess.
1596 commit_times: core_storage::commit_times::CommitTimes,
1597 /// When set, subsequent commits are recorded at this instant instead of the
1598 /// system clock.
1599 ///
1600 /// Sticky on purpose. A backfill replays history that happened over months,
1601 /// and a day's worth of rows genuinely share one instant — a one-shot flag
1602 /// would mean setting it before every row of a bulk load, and forgetting one
1603 /// would stamp that row "now" in the middle of 2026-06. Sticky makes the
1604 /// failure visible instead: forget to move it and every commit carries the
1605 /// same timestamp, which a date query answers oddly and an inspection shows
1606 /// at once.
1607 commit_time_override: Option<i64>,
1608 /// `true` only while the open path is replaying, where `load_from_disk`
1609 /// calls `fulltext.rebuild_all` unconditionally afterwards.
1610 ///
1611 /// Replaying an `EnableFulltext` record backfills its pair with a full
1612 /// `0..ids.len()` scan, and every snapshot re-emits one such record per
1613 /// enabled pair — so on a snapshotted store the open does that scan once per
1614 /// pair and then `rebuild_all` clears every posting and does it all again.
1615 /// The backfill is pure waste *when a rebuild follows*, which is true of the
1616 /// open path and **false** of `refresh()`: refresh applies peer frames and
1617 /// then only folds, so its backfill is the only thing that indexes them.
1618 fulltext_rebuild_follows: bool,
1619 /// `true` when this handle's full-text index was read from the snapshot
1620 /// it opened and nothing replayed over it since has spoiled it, so the
1621 /// open did not build it.
1622 fulltext_loaded: bool,
1623 /// Set by the insert arms of `apply` when they index a node that is
1624 /// already indexed under that field. `add_tokens` appends, so that node's
1625 /// postings are now doubled: the WAL being replayed holds writes the
1626 /// snapshot already has — a `keep_wal` snapshot, or a crash between a
1627 /// snapshot and its WAL replacement. The open rebuilds when it sees this.
1628 fulltext_reindexed_a_node: bool,
1629 /// Whether the snapshot this handle opened replaced its WAL when it was
1630 /// written. One that kept it (`keep_wal`) has the whole WAL replayed over
1631 /// it at open, and its index is built after that replay, not read.
1632 base_wal_truncated: bool,
1633 /// How often that happened during this handle's open. Tests read it.
1634 fulltext_reindexed_at_open: u64,
1635 /// `true` only while `load_from_disk` replays the WAL. A node indexed
1636 /// twice there is a sign the index must be rebuilt, and is left for the
1637 /// rebuild; anywhere else it is corrected on the spot.
1638 replaying_at_open: bool,
1639 /// `true` when `snapshot.intent` may hold something that is no longer
1640 /// true: this handle found one that did not match, or a snapshot of its
1641 /// own wrote one and did not get to empty it. Settled, under the write
1642 /// lock, before the next append.
1643 intent_unsettled: bool,
1644 /// Set by [`GraphDb::stop_next_snapshot`]; taken by the next snapshot.
1645 snapshot_stop: Option<SnapshotStop>,
1646 /// `true` when `commit_times.bin` was present but would not decode.
1647 ///
1648 /// Mirrors `roles: None`: a damaged map must not read as "this store
1649 /// records no times", because that is also what an honest pre-v0.6.11 store
1650 /// says. Date queries answer `Corrupt` instead, and nothing is appended to
1651 /// a file already known to be damaged.
1652 commit_times_poisoned: bool,
1653 /// RBAC role definitions loaded from `roles.json` at open.
1654 ///
1655 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1656 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1657 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1658 roles: Option<Vec<RoleDef>>,
1659 /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1660 /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1661 ///
1662 /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1663 /// taken from this handle. Replaced (not cleared) whenever the role
1664 /// definitions change or the store is reloaded, which `commit_seq` does not
1665 /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1666 role_masks: Arc<crate::mask::RoleMaskCache>,
1667 /// Which loaded store this handle is, for memos that outlive it.
1668 ///
1669 /// `role_masks` needs no such thing — the handle owns it and replaces it —
1670 /// but a [`Scope`](crate::mask::Scope) is the caller's, so its resolved key
1671 /// leg is stamped with this alongside `commit_seq`. Minted fresh here and
1672 /// again in [`reset_for_reload`](GraphDb::reset_for_reload), at exactly the
1673 /// two points a fresh `RoleMaskCache` is installed; see
1674 /// [`StoreStamp`](crate::mask::StoreStamp) for the invariant.
1675 store_id: crate::mask::StoreId,
1676 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1677 /// distribute_events call.
1678 subscriptions: Vec<SubEntry>,
1679 /// Live query subscriptions. Re-executed on every commit when non-empty.
1680 /// Dead `Weak` entries are pruned inside `distribute_events`.
1681 query_subscriptions: Vec<QuerySubEntry>,
1682 /// Queue capacity for new subscriptions created by this db. Default is
1683 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1684 /// to test Lagged behaviour with small queues.
1685 sub_capacity: usize,
1686 /// True for as-of instances opened via [`GraphDb::open_at`].
1687 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1688 /// when this flag is set.
1689 read_only: bool,
1690 /// Total WAL commit count at the time [`open_at`] was called.
1691 /// 0 for normal (non-as-of) instances.
1692 total_wal_commits: u64,
1693 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1694 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1695 /// reads go through a merged `TopologyView`. `self.props` is always
1696 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1697 base: Option<Arc<core_storage::v8::MappedBase>>,
1698 // ── MVCC epoch reader state ───────────────────────────────────────────────
1699 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1700 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1701 /// `None` only between struct creation and the first fold.
1702 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1703 /// Per-commit deltas accumulated since the last fold.
1704 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1705 /// How many commits have occurred since the last fold.
1706 commits_since_fold: usize,
1707 /// When true, `log_then_apply_with` buffers event notifications instead of
1708 /// firing them immediately. Used by the group-commit drain thread to defer
1709 /// events until after the group fsync (R2: durability before notification).
1710 /// Cleared to false once the drain thread flushes or discards the buffer.
1711 defer_events: bool,
1712 /// Buffered events accumulated while `defer_events` is true.
1713 deferred_events: Vec<DeferredEvent>,
1714 /// Set to true by the group-commit drain thread when a group fsync fails
1715 /// after WAL truncation. All subsequent mutation attempts return an IO
1716 /// error until the database is reopened.
1717 degraded: bool,
1718 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1719 /// HNSW, and IVF sections from the mmap base into the engine's retained
1720 /// fields. `false` on all opens until first use; always `true` for non-V8
1721 /// opens (base is None, fast-path sets flag immediately).
1722 v8_sections_loaded: std::sync::atomic::AtomicBool,
1723 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1724 v8_sections_mutex: std::sync::Mutex<()>,
1725 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1726 /// the node was last modified by commit `seq`.
1727 ///
1728 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1729 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1730 /// nodes return `None` from `last_changed` until they are next mutated.
1731 ///
1732 /// See [`Precondition`] for the full touch definition.
1733 last_change: HashMap<u32, u64>,
1734 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1735 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1736 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1737 wal_archive_retention: Option<u32>,
1738 /// Global frame index of the first commit that is still reachable through
1739 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1740 /// Default 0 = all history reachable.
1741 wal_horizon_floor: u64,
1742 /// True when the surviving archive chain forms a continuous WAL history
1743 /// starting from the store's first commit (the genesis chain).
1744 ///
1745 /// `open_at` may replay archive-resident commits from empty state only when
1746 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1747 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1748 /// already exist (breaks the chain for subsequent archives), or
1749 /// - any archive is pruned (floor advances past zero).
1750 ///
1751 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1752 archive_genesis_chain: bool,
1753 /// True when this handle can *prove* the live WAL has never been truncated:
1754 /// there was no `snapshot.bin` when it opened the store, and it has taken no
1755 /// truncating snapshot since.
1756 ///
1757 /// The archive path's genesis check asks "did a snapshot exist before this
1758 /// one?" as a proxy for "was the WAL ever truncated". The proxy is sound
1759 /// across sessions — this binary cannot tell a history-preserving snapshot
1760 /// from a truncating one once the handle that took it is gone — but inside
1761 /// one session it is not, and `enable_multiplicity` made that visible: its
1762 /// forced `keep_wal` snapshot left the WAL entirely intact and yet
1763 /// permanently disqualified the store from ever receiving a genesis marker
1764 /// (defect #23). This flag is what the proxy defers to when the answer is
1765 /// actually known.
1766 snapshot_preserved_history: bool,
1767 /// Transient write-authz context set by `write_batch_authz` /
1768 /// `query_write_authz` for the duration of ONE mutation call.
1769 /// Always `None` at rest. Never serialized, never WAL-replayed.
1770 pending_write_authz: Option<WriteAuthz>,
1771 /// Slow-query threshold in milliseconds. 0 = disabled.
1772 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1773 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1774 /// — env vars are process-global and race parallel test threads).
1775 slow_query_threshold_ms: u64,
1776 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1777 /// can record entries without requiring `&mut self`).
1778 slow_queries: std::sync::Mutex<SlowQueryLog>,
1779 /// `(field, label, caller)` triples whose exact-versus-approximate
1780 /// ambiguity this handle has already explained once. See
1781 /// [`note_ambiguous_exactness`](GraphDb::note_ambiguous_exactness).
1782 /// The caller shape is part of the key because the two shapes give
1783 /// different advice — silencing one with the other would leave a caller
1784 /// reading advice meant for a signature it does not have.
1785 /// Advice bookkeeping, not graph state: a reload keeps it, as the
1786 /// slow-query log does.
1787 warned_ambiguous_exactness: std::sync::Mutex<HashSet<(String, String, ExactnessCaller)>>,
1788 /// Instant at which the database was opened (used by `/metrics` uptime).
1789 started_at: std::time::Instant,
1790 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1791 /// Byte offset of the WAL prefix already applied to in-memory state.
1792 ///
1793 /// Advanced by exactly the encoded length of every frame this handle
1794 /// appends, and by the decoded byte count of every tail
1795 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1796 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1797 /// drain thread truncates a failed group. Compared against the WAL's
1798 /// on-disk length to decide staleness.
1799 wal_consumed: u64,
1800 /// The **global 0-based frame index the next appended WAL frame will
1801 /// occupy** — `wal_horizon_floor` plus every frame currently reachable
1802 /// through archives and the live WAL.
1803 ///
1804 /// This is the space every history surface addresses: `edges_at`,
1805 /// `was_linked`, both history readouts and `open_at` all index the sequence
1806 /// [`all_frames`](GraphDb::all_frames) returns, and
1807 /// [`wal_total_commits`](GraphDb::wal_total_commits) counts it.
1808 ///
1809 /// It exists because **a commit is not a frame**. `commit_seq` counts
1810 /// commits; a commit whose rules fire appends a *second* frame — the
1811 /// derived-edge history marker — that no counter of commits ever sees. The
1812 /// two diverge by one frame per rule-firing commit, cumulatively, so
1813 /// deriving a frame index from `commit_seq` under-reports by more and more
1814 /// as history grows and resolves every date to an earlier graph. Silently:
1815 /// an older graph is a plausible answer, not an error.
1816 ///
1817 /// Maintained in lockstep with [`wal_consumed`](GraphDb::wal_consumed) —
1818 /// the same appends advance both, one in frames and one in bytes — so the
1819 /// two are seeded and rewound at exactly the same places. Keep it that way.
1820 wal_frames_written: u64,
1821 /// Identity of the snapshot this handle's base state came from, as
1822 /// `(len, mtime_nanos)`. A different value means another process replaced
1823 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1824 snapshot_ident: Option<(u64, u64)>,
1825 /// The options this handle was opened with. Replayed verbatim when
1826 /// `refresh` has to rebuild from disk.
1827 open_opts: OpenOptions,
1828 /// True when this handle holds the cross-process write lock for its whole
1829 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1830 /// no-op on such a handle, and never releases the lock.
1831 holds_lifetime_lock: bool,
1832 /// True between a failed lock acquisition and the end of the write scope
1833 /// that failed. Makes every WAL-appending mutation in that scope return
1834 /// [`GraphError::Busy`] instead of writing.
1835 lock_denied: bool,
1836 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1837 /// pinned to one commit, so it is never stale and never refreshes — later
1838 /// commits by any process are deliberately invisible to it.
1839 pinned: bool,
1840 /// Correct edge count for the V8 base, computed at open by summing the
1841 /// out-row lengths of every etype in the base CSR.
1842 ///
1843 /// The snapshot header's `edge_count` field is arithmetic carried forward
1844 /// from the previous snapshot, and is wrong on stores snapshotted while two
1845 /// rules owned one edge (ledger row 107). This field replaces it in
1846 /// `edge_count()` and `stats()` so the reading path is correct even before
1847 /// the store takes a new snapshot.
1848 ///
1849 /// `None` on stores with no V8 base (WAL-only or V5/V7 snapshot opens).
1850 base_edge_count_from_rows: Option<u64>,
1851}
1852
1853/// One group of deferred event notifications, held until the group fsync
1854/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1855struct DeferredEvent {
1856 rec: core_storage::WalRecord,
1857 engine_deltas: Vec<EngineEdgeDelta>,
1858 seq: u64,
1859 ingest: Option<(String, usize)>,
1860}
1861
1862/// Options for [`GraphDb::open_with_options`].
1863#[derive(Clone, Copy, Debug)]
1864pub struct OpenOptions {
1865 /// Rewrite an old-format snapshot to the current VERSION after a
1866 /// successful load (default `true`). The old snapshot is kept as
1867 /// `snapshot.bin.bak` until the next clean open at the current version,
1868 /// at which point the `.bak` is deleted.
1869 ///
1870 /// Set to `false` to open a store without touching any on-disk files
1871 /// (useful for read-only inspection of a store at an older format).
1872 pub auto_migrate: bool,
1873
1874 /// Write the valid WAL prefix back over a torn tail on open (default
1875 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1876 ///
1877 /// Set to `false` for an unattended reader. The valid prefix is still
1878 /// decoded and replayed in memory, but nothing is written: a reader that
1879 /// opens while another process is mid-append would otherwise discard a
1880 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1881 /// every prompt, passes `false` for exactly this reason.
1882 pub repair_wal: bool,
1883
1884 /// Open without ever writing to the store (default `false`).
1885 ///
1886 /// A read-only handle:
1887 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1888 /// `snapshot()`;
1889 /// - performs no disk write at open — no WAL repair write-back and no
1890 /// auto-migration rewrite, whatever the other two flags say;
1891 /// - never takes the cross-process write lock, so it opens immediately even
1892 /// while another process is writing, and never makes a writer wait.
1893 ///
1894 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1895 /// normally, so a read-only handle can follow another process's commits.
1896 pub read_only: bool,
1897}
1898
1899impl Default for OpenOptions {
1900 fn default() -> Self {
1901 Self {
1902 auto_migrate: true,
1903 repair_wal: true,
1904 read_only: false,
1905 }
1906 }
1907}
1908
1909/// How long a writer polls for the cross-process write lock before giving up
1910/// with [`GraphError::Busy`].
1911///
1912/// Long enough to ride out another process's commit (a batch apply plus one
1913/// fsync), short enough that a stuck peer surfaces as an error rather than a
1914/// hang.
1915pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1916
1917/// Refusal when a `MERGE` create cannot choose a namespace.
1918///
1919/// A role bound to two or more namespaces cannot have its create arm land in
1920/// `default`, and the statement did not name `ns`. The role must name one.
1921pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1922 "role-bound token: MERGE create requires the role to name one namespace";
1923
1924/// Interval between poll attempts while waiting for the cross-process lock.
1925pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1926
1927/// Why `load_from_disk` is running, which decides whether it may repair.
1928#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1929enum LoadOrigin {
1930 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1931 /// the signature of a crash and truncating it is correct, and archives
1932 /// orphaned by an interrupted prune can be swept.
1933 Open,
1934 /// A reload driven by [`GraphDb::refresh`], because another process
1935 /// replaced the snapshot. Nothing here is crash recovery — the store is
1936 /// live and someone else is writing it — so this origin writes nothing.
1937 Reload,
1938}
1939
1940/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1941///
1942/// `None` at the call site = full authority (today's zero-cost behavior).
1943/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1944/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1945/// record is built. A denial returns an error with no WAL frame written.
1946///
1947/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1948/// hidden-node existence to callers.
1949#[derive(Clone, Debug)]
1950pub struct WriteAuthz {
1951 pub role: String,
1952 pub scope: WriteScope,
1953 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1954 /// Always `Omit`-mode — never `Stub`.
1955 pub mask: crate::mask::NodeMask,
1956}
1957
1958/// The error every role surface gives when `roles.json` did not parse at open.
1959///
1960/// One text, so `mask_for_role` and [`GraphDb::roles_checked`] cannot drift
1961/// apart on the same cause.
1962fn roles_poisoned() -> GraphError {
1963 GraphError::Corrupt {
1964 detail: "roles.json was corrupt at open; fix the file and re-open to restore role access"
1965 .into(),
1966 }
1967}
1968
1969/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1970///
1971/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1972/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1973/// syncs the directory entry. This is the only correct path for writing the
1974/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1975/// the directory sync.
1976pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1977 use core_storage::fs::{FileId, Fs as _};
1978 RealFs::new(dir)
1979 .map_err(core_storage::GraphError::Io)?
1980 .write_atomic(FileId::SnapshotBak, bytes)
1981 .map_err(core_storage::GraphError::Io)
1982}
1983
1984/// Return the on-disk snapshot format version without decoding the full snapshot.
1985///
1986/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1987/// snapshot file exists (WAL-only store). Returns an error if the header is
1988/// malformed.
1989pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1990 use std::io::Read as _;
1991 let path = dir.join("snapshot.bin");
1992 let mut header = [0u8; 6];
1993 let n = match std::fs::File::open(&path) {
1994 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1995 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1996 Err(e) => return Err(core_storage::GraphError::Io(e)),
1997 };
1998 core_storage::snapshot::peek_version(&header[..n])
1999}
2000
2001/// Where [`GraphDb::stop_next_snapshot`] makes the next snapshot fail, leaving
2002/// the files as a crash at that point would. For tests of what an interrupted
2003/// snapshot leaves behind; not part of the stable surface.
2004#[doc(hidden)]
2005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2006pub enum SnapshotStop {
2007 /// The intent is written; `snapshot.bin` is still the old one.
2008 BeforeSnapshotWrite,
2009 /// `snapshot.bin` is the new one; nothing else has moved.
2010 AfterSnapshotWrite,
2011 /// As above, and the history floor has been advanced; the WAL is still
2012 /// the one the snapshot was made from.
2013 BeforeWalReplacement,
2014 /// The WAL is replaced; the intent has not been emptied.
2015 AfterWalReplacement,
2016}
2017
2018/// Options for [`GraphDb::snapshot_with`].
2019#[derive(Debug, Clone, Default)]
2020pub struct SnapshotOptions {
2021 /// When `true`, the WAL is preserved after the snapshot write.
2022 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
2023 /// When `false` (the default), the WAL is truncated to a minimal
2024 /// baseline so cold-start replay stays fast.
2025 pub keep_wal: bool,
2026 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
2027 /// before a fresh WAL baseline is written (history-preserving snapshot).
2028 ///
2029 /// This is the feature opt-in: `false` (the default) leaves the existing
2030 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
2031 /// precedence over `keep_wal` when both are set.
2032 ///
2033 /// Archives can be scanned by [`GraphDb::node_history`],
2034 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
2035 /// [`GraphDb::open_at`], extending the reachable history horizon across
2036 /// snapshot boundaries.
2037 pub archive_wal: bool,
2038}
2039
2040/// Derive the scan-label sym for the commit-skip fast-path.
2041///
2042/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
2043/// or `IndexIntersect`) with a concrete label string, then interns it.
2044///
2045/// Returns `None` in all cases where skipping is unsafe:
2046/// - Any `Expand` op is present (edge traversal; edges change results regardless
2047/// of node labels).
2048/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
2049/// - No recognizable leading scan op is found.
2050///
2051/// This is the conservative v0.4.3 boundary. The caller stores the result in
2052/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
2053fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
2054 // Any Expand → must always re-execute (edges can change join results).
2055 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
2056 return None;
2057 }
2058 for op in ops {
2059 match op {
2060 PlanOp::ScanLabel {
2061 label: Some(label), ..
2062 } => return Some(syms.intern(label)),
2063 PlanOp::IndexScan {
2064 label: Some(label), ..
2065 } => return Some(syms.intern(label)),
2066 PlanOp::IndexIntersect {
2067 label: Some(label), ..
2068 } => return Some(syms.intern(label)),
2069 _ => {}
2070 }
2071 }
2072 None
2073}
2074
2075/// How an as-of read is restricted — the argument to
2076/// [`GraphDb::query_at_scoped`].
2077///
2078/// Every variant is resolved against the graph **as it was at the requested
2079/// commit**, not against the current graph.
2080#[derive(Debug, Clone, Copy)]
2081pub enum AsOfScope<'a> {
2082 /// Everything the named role may see. The role *definition* is the current
2083 /// one — `roles.json` is a sidecar and has no past version — but its
2084 /// `keys` and `labels` are resolved against the as-of graph.
2085 Role(&'a str),
2086 /// An explicit node-key allow-list. Keys that did not exist at that commit
2087 /// resolve to nothing.
2088 Keys(&'a [String]),
2089 /// A role intersected with a client-supplied allow-list. The intersection
2090 /// is the never-widen rule: a client mask can only narrow a role.
2091 RoleAndKeys(&'a str, &'a [String]),
2092 /// Every live node in one namespace, as the graph was at that commit.
2093 ///
2094 /// A namespace cannot change — it is set at insert and immutable — so the
2095 /// answer is simply "the nodes that existed then and are in this
2096 /// namespace". A name no node uses resolves to nothing, never to
2097 /// everything.
2098 Namespace(&'a str),
2099}
2100
2101impl GraphDb<RealFs> {
2102 /// Open the database at `dir` with default options.
2103 ///
2104 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
2105 /// Old-format snapshots (V5, V6) are automatically migrated to the
2106 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
2107 pub fn open(dir: &std::path::Path) -> Result<Self> {
2108 Self::open_with_options(dir, OpenOptions::default())
2109 }
2110
2111 /// Open the database at `dir` with explicit options.
2112 ///
2113 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
2114 /// snapshot is an older format version, this function:
2115 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
2116 /// + fsynced) before any modification.
2117 /// 2. Rewrites `snapshot.bin` at the current format version via
2118 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
2119 ///
2120 /// If migration fails the error is returned and the original files are
2121 /// intact (the `.bak` was written before the new snapshot was attempted).
2122 ///
2123 /// A clean open that finds the snapshot already at the current version
2124 /// deletes any leftover `.bak` file.
2125 ///
2126 /// WAL-only stores (no snapshot) are never auto-migrated on open.
2127 ///
2128 /// `opts.repair_wal` controls the other write this function can make; see
2129 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
2130 /// no file on disk.
2131 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
2132 Self::open_dir(dir, opts, true)
2133 }
2134
2135 /// Open without taking the cross-process write lock for the handle's
2136 /// lifetime.
2137 ///
2138 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
2139 /// its handle open indefinitely, so it takes the lock per write instead of
2140 /// keeping every other process out of the store for as long as it runs.
2141 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
2142 Self::open_dir(dir, OpenOptions::default(), false)
2143 }
2144
2145 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2146 // Header-only peek — 6 bytes, no full decode.
2147 let snap_version = snapshot_version_at(dir)?;
2148
2149 // Full load: decode snapshot + replay WAL + rebuild indexes.
2150 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
2151
2152 // A read-only handle writes nothing at open, so it never migrates —
2153 // the old-format snapshot is loaded and left exactly as it is.
2154 if opts.auto_migrate && !opts.read_only {
2155 match snap_version {
2156 Some(ver) if ver < core_storage::snapshot::VERSION => {
2157 let _tm = std::time::Instant::now();
2158 // Copy the original snapshot to .bak at OS level — no in-memory
2159 // buffer required for a 2+ GiB file.
2160 //
2161 // Crash-safety: snapshot.bin remains intact (write_atomic inside
2162 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
2163 // A torn .bak on crash is acceptable because the original
2164 // snapshot.bin is the authoritative source until after the rename.
2165 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
2166 .map_err(core_storage::GraphError::Io)?;
2167 trace_migrate!("bak copy done", _tm);
2168 // Rewrite snapshot at current version; keep WAL intact.
2169 db.snapshot_with(SnapshotOptions {
2170 keep_wal: true,
2171 ..SnapshotOptions::default()
2172 })?;
2173 trace_migrate!("snapshot_with done", _tm);
2174 }
2175 Some(_) => {
2176 // Already current version: remove any leftover .bak.
2177 let bak = dir.join("snapshot.bin.bak");
2178 if bak.exists() {
2179 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
2180 }
2181 }
2182 None => {
2183 // WAL-only store — nothing to migrate on open.
2184 }
2185 }
2186 }
2187
2188 Ok(db)
2189 }
2190
2191 /// Open a read-only view of the database as it existed after `commit`.
2192 ///
2193 /// Commit indices are 0-based over the current WAL: commit 0 is the state
2194 /// after the first WAL frame, commit N-1 is the state after the N-th (most
2195 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
2196 ///
2197 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
2198 /// so as-of can only reach commits recorded in the current WAL (those
2199 /// written after the most recent snapshot, or all commits if no snapshot
2200 /// was ever taken). Commit 0 in `open_at` always refers to the first
2201 /// frame in the WAL that exists on disk, not the first ever write to the
2202 /// database. When the on-disk snapshot recorded that it truncated the
2203 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
2204 /// before frame replay, so the as-of view includes all pre-snapshot data.
2205 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
2206 /// are ignored and replay is WAL-only, as before.
2207 ///
2208 /// **Read-only.** Every mutation method and `snapshot()` on the returned
2209 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
2210 /// `stats()` work normally.
2211 ///
2212 /// # Errors
2213 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
2214 /// when the WAL is empty after a snapshot).
2215 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
2216 Self::open_at_with(RealFs::new(dir)?, commit)
2217 }
2218
2219 /// Run a **read-only** Cypher query against the graph as it existed at
2220 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
2221 /// of this store's directory at that commit and executes the read there.
2222 ///
2223 /// The current instance is unaffected. Write statements are rejected (the
2224 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
2225 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
2226 /// state. Prefer this over holding many historical instances open.
2227 ///
2228 /// # Errors
2229 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
2230 /// - A query error for a malformed or write query.
2231 pub fn query_at(
2232 &self,
2233 commit: u64,
2234 cypher: &str,
2235 params: &std::collections::BTreeMap<String, Value>,
2236 ) -> Result<ResultSet> {
2237 let temporal = self.open_at_for_read(commit, cypher)?;
2238 temporal.query(cypher, params)
2239 }
2240
2241 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
2242 ///
2243 /// The **graph** is as of `commit`; the **role definition** is as it is
2244 /// now, because `roles.json` is a sidecar and is never a WAL record — it
2245 /// has no past version to read. A role's `keys` and `labels` are resolved
2246 /// against the commit-`commit` graph, so a role that may see a label sees
2247 /// exactly the nodes that carried it then, and an explicit key that did
2248 /// not exist yet resolves to nothing.
2249 ///
2250 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
2251 /// only narrow what a role may see, never widen it.
2252 ///
2253 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
2254 /// them.
2255 ///
2256 /// # Errors
2257 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2258 /// range; the error carries that range.
2259 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2260 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2261 /// - A query error for a malformed or write query.
2262 pub fn query_at_scoped(
2263 &self,
2264 commit: u64,
2265 cypher: &str,
2266 params: &std::collections::BTreeMap<String, Value>,
2267 scope: AsOfScope<'_>,
2268 ) -> Result<ResultSet> {
2269 let temporal = self.open_at_for_read(commit, cypher)?;
2270 let mask = temporal.mask_at_scope(scope)?;
2271 temporal.query_masked(cypher, params, &mask)
2272 }
2273
2274 /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
2275 /// whatever `scope` resolves to.
2276 ///
2277 /// This is what a surface needs when a caller passes `namespace` beside a
2278 /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
2279 /// restriction, and the namespace is a second one that composes with it
2280 /// rather than replacing it. The intersection is the never-widen rule — a
2281 /// namespace can only narrow what the scope already allows — and both legs
2282 /// are resolved against the graph as it was at `commit`.
2283 ///
2284 /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
2285 pub fn query_at_scoped_in_namespace(
2286 &self,
2287 commit: u64,
2288 cypher: &str,
2289 params: &std::collections::BTreeMap<String, Value>,
2290 scope: AsOfScope<'_>,
2291 namespace: &str,
2292 ) -> Result<ResultSet> {
2293 let temporal = self.open_at_for_read(commit, cypher)?;
2294 let mask = temporal
2295 .mask_at_scope(scope)?
2296 .intersect(&temporal.mask_for_namespace(namespace));
2297 temporal.query_masked(cypher, params, &mask)
2298 }
2299
2300 /// Run a **read-only** Cypher query at `commit`, restricted by a
2301 /// [`Scope`](crate::mask::Scope).
2302 ///
2303 /// [`AsOfScope`] names *one* restriction — a role, a key list, a namespace,
2304 /// or a role-and-keys pair. A `Scope` is the general shape a handle carries,
2305 /// and nesting can give it several role or namespace legs at once, so it
2306 /// cannot be spelled as an `AsOfScope`. This is the entry point a scoped
2307 /// handle uses for time travel; `query_at_scoped` stays the way to ask for
2308 /// one named restriction.
2309 ///
2310 /// Both the graph and the scope's key and namespace legs are resolved
2311 /// against `commit`; a role's *definition* is the current one, because
2312 /// `roles.json` is a sidecar with no past version — the same split
2313 /// [`GraphDb::query_at_scoped`] documents.
2314 ///
2315 /// The scope resolves **cold** here: a temporal handle is its own store, so
2316 /// its ids could never be served to a live read, but filling the scope's
2317 /// one-entry key memo from a handle thrown away at the end of this call
2318 /// would evict the live entry for nothing. See
2319 /// [`Scope::resolve_uncached`](crate::mask::Scope::resolve_uncached).
2320 ///
2321 /// # Errors
2322 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2323 /// range; the error carries that range.
2324 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2325 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2326 /// - A query error for a malformed or write query.
2327 pub fn query_at_with_scope(
2328 &self,
2329 commit: u64,
2330 cypher: &str,
2331 params: &std::collections::BTreeMap<String, Value>,
2332 scope: &crate::mask::Scope,
2333 ) -> Result<ResultSet> {
2334 let temporal = self.open_at_for_read(commit, cypher)?;
2335 let mask = scope.resolve_uncached(&temporal)?;
2336 temporal.query_masked(cypher, params, &mask)
2337 }
2338
2339 /// Open the temporal view for a time-travel read and refuse write Cypher.
2340 ///
2341 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2342 /// resolve the commit and reject writes identically.
2343 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2344 let dir = self.fs.dir().to_path_buf();
2345 let temporal = Self::open_at(&dir, commit)?;
2346 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2347 detail: format!("lex: {e}"),
2348 })?) {
2349 return Err(GraphError::QueryError {
2350 detail: "query_at is read-only: write statements are not permitted in a \
2351 time-travel query"
2352 .into(),
2353 });
2354 }
2355 Ok(temporal)
2356 }
2357}
2358
2359impl<F: Fs> GraphDb<F> {
2360 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2361 pub fn open_with(fs: F) -> Result<Self> {
2362 Self::open_with_repair(fs, true)
2363 }
2364
2365 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2366 /// prefix without writing the truncation back. See
2367 /// [`OpenOptions::repair_wal`].
2368 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2369 Self::open_generic(
2370 fs,
2371 OpenOptions {
2372 repair_wal,
2373 ..OpenOptions::default()
2374 },
2375 true,
2376 )
2377 }
2378
2379 /// Shared open path.
2380 ///
2381 /// `hold_lock` requests the cross-process write lock for the whole handle
2382 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2383 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2384 /// `false` and takes the lock per write instead, so that a long-lived
2385 /// server does not keep every other process out of the store.
2386 ///
2387 /// A read-only open never takes the lock regardless of `hold_lock`.
2388 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2389 let mut db = Self::new_empty(fs, opts);
2390 db.read_only = opts.read_only;
2391 if hold_lock && !opts.read_only {
2392 if !db.poll_lock(WRITE_LOCK_WAIT)? {
2393 return Err(GraphError::Busy { holder: None });
2394 }
2395 db.holds_lifetime_lock = true;
2396 }
2397 db.load_from_disk(LoadOrigin::Open)?;
2398 Ok(db)
2399 }
2400
2401 /// A handle with no state loaded: every field at its empty value, the
2402 /// filesystem and options in place. Only [`load_from_disk`] makes it
2403 /// usable.
2404 fn new_empty(fs: F, opts: OpenOptions) -> Self {
2405 Self {
2406 fs,
2407 ids: Arc::new(IdMap::new()),
2408 syms: Arc::new(Interner::new()),
2409 topo: Arc::new(Topology::new()),
2410 props: Arc::new(ColumnStore::new()),
2411 labels: Arc::new(Vec::new()),
2412 ns_names: vec![NS_DEFAULT.to_string()],
2413 node_ns: Vec::new(),
2414 edge_props: Arc::new(EdgeProps::new()),
2415 engine: RuleEngine::new(),
2416 view_store: ViewStore::new(),
2417 fulltext: Arc::new(FulltextIndex::new()),
2418 prop_index: PropertyIndex::new(),
2419 multiplicity: false,
2420 event_sink: None,
2421 fsync: FsyncPolicy::Strict,
2422 commit_seq: 0,
2423 commit_times: core_storage::commit_times::CommitTimes::default(),
2424 commit_times_poisoned: false,
2425 fulltext_rebuild_follows: false,
2426 fulltext_loaded: false,
2427 fulltext_reindexed_a_node: false,
2428 base_wal_truncated: false,
2429 fulltext_reindexed_at_open: 0,
2430 replaying_at_open: false,
2431 intent_unsettled: false,
2432 snapshot_stop: None,
2433 commit_time_override: None,
2434 roles: Some(vec![]),
2435 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2436 store_id: crate::mask::StoreId::next(),
2437 subscriptions: Vec::new(),
2438 query_subscriptions: Vec::new(),
2439 sub_capacity: DEFAULT_SUB_CAPACITY,
2440 read_only: false,
2441 total_wal_commits: 0,
2442 base: None,
2443 fold_overlay: None,
2444 delta_tail: Vec::new(),
2445 commits_since_fold: 0,
2446 defer_events: false,
2447 deferred_events: Vec::new(),
2448 degraded: false,
2449 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2450 v8_sections_mutex: std::sync::Mutex::new(()),
2451 last_change: HashMap::new(),
2452 wal_archive_retention: None,
2453 wal_horizon_floor: 0,
2454 archive_genesis_chain: false,
2455 // Nothing is proven until `load_from_disk` has looked at the store.
2456 snapshot_preserved_history: false,
2457 pending_write_authz: None,
2458 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2459 .ok()
2460 .and_then(|v| v.parse().ok())
2461 .unwrap_or(100),
2462 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2463 entries: std::collections::VecDeque::new(),
2464 total: 0,
2465 }),
2466 warned_ambiguous_exactness: std::sync::Mutex::new(HashSet::new()),
2467 started_at: std::time::Instant::now(),
2468 wal_consumed: 0,
2469 wal_frames_written: 0,
2470 snapshot_ident: None,
2471 open_opts: opts,
2472 holds_lifetime_lock: false,
2473 lock_denied: false,
2474 pinned: false,
2475 base_edge_count_from_rows: None,
2476 }
2477 }
2478
2479 /// Return every field describing stored graph state to its empty value,
2480 /// leaving this handle's own identity alone.
2481 ///
2482 /// Preserved on purpose: the filesystem, open options, lock ownership, the
2483 /// event sink and subscriptions, fsync policy, degraded flag, and the
2484 /// slow-query configuration and log. A caller that registered a sink or a
2485 /// subscription keeps it across a reload.
2486 fn reset_for_reload(&mut self) {
2487 self.ids = Arc::new(IdMap::new());
2488 self.syms = Arc::new(Interner::new());
2489 self.topo = Arc::new(Topology::new());
2490 self.props = Arc::new(ColumnStore::new());
2491 self.labels = Arc::new(Vec::new());
2492 self.ns_names = vec![NS_DEFAULT.to_string()];
2493 self.node_ns = Vec::new();
2494 self.edge_props = Arc::new(EdgeProps::new());
2495 self.engine = RuleEngine::new();
2496 self.view_store = ViewStore::new();
2497 self.fulltext = Arc::new(FulltextIndex::new());
2498 self.prop_index = PropertyIndex::new();
2499 // Cleared like every other declaration: a reload replays the store's own
2500 // WAL, and the opt-in comes back from it or not at all.
2501 self.multiplicity = false;
2502 self.commit_seq = 0;
2503 self.commit_times = core_storage::commit_times::CommitTimes::default();
2504 self.commit_times_poisoned = false;
2505 self.fulltext_rebuild_follows = false;
2506 self.fulltext_loaded = false;
2507 self.fulltext_reindexed_a_node = false;
2508 self.base_wal_truncated = false;
2509 self.fulltext_reindexed_at_open = 0;
2510 self.replaying_at_open = false;
2511 self.commit_time_override = None;
2512 self.roles = Some(vec![]);
2513 // A fresh cache, not a cleared one: any reader snapshot still holding
2514 // the old `Arc` keeps it to itself, so nothing it memoised against the
2515 // pre-reload store can be read back through this handle.
2516 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2517 // The same move for memos this handle does not own. `commit_seq` is
2518 // zeroed just above and reseeded from `max(last_change)`, which a
2519 // delete-only commit leaves where it was — so a reload can land back on
2520 // a sequence a caller's `Scope` already cached a mask at. A new id is
2521 // what makes that entry stop matching.
2522 self.store_id = crate::mask::StoreId::next();
2523 self.total_wal_commits = 0;
2524 self.base = None;
2525 self.fold_overlay = None;
2526 self.delta_tail = Vec::new();
2527 self.commits_since_fold = 0;
2528 self.deferred_events = Vec::new();
2529 self.v8_sections_loaded
2530 .store(false, std::sync::atomic::Ordering::Release);
2531 self.last_change = HashMap::new();
2532 self.wal_horizon_floor = 0;
2533 self.archive_genesis_chain = false;
2534 // Re-derived by `load_from_disk` from the store it is about to read.
2535 self.snapshot_preserved_history = false;
2536 self.pending_write_authz = None;
2537 self.wal_consumed = 0;
2538 self.wal_frames_written = 0;
2539 self.snapshot_ident = None;
2540 self.base_edge_count_from_rows = None;
2541 }
2542
2543 /// Load the snapshot base and replay the WAL into an empty handle — the
2544 /// whole of what opening a store does after the struct exists.
2545 ///
2546 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2547 /// rebuild a handle in place, without ownership of `F`, when another
2548 /// process replaces the snapshot underneath it.
2549 ///
2550 /// `origin` decides whether the two repair writes this function can make
2551 /// are appropriate; see [`LoadOrigin`].
2552 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2553 // Both writes below are crash recovery, and only an open is entitled to
2554 // perform them. A read-only handle promises to touch nothing, and a
2555 // reload driven by `refresh` is looking at a store another process is
2556 // actively writing: what looks like a torn tail there is a peer
2557 // mid-append, and what looks like an orphaned archive may be one that
2558 // peer is about to reference.
2559 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2560 let repair_wal = self.open_opts.repair_wal && may_repair;
2561 let db = self;
2562 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2563 db.archive_genesis_chain = db.fs.has_genesis_marker();
2564 // Opening cleanup: remove orphaned archives — archives whose frames all
2565 // fall below the horizon floor. Orphans arise when a crash interrupted
2566 // the retention-prune sequence after the floor was written but before
2567 // all surplus archives were deleted. Safe to delete: floor already
2568 // accounts for their frames.
2569 if may_repair {
2570 db.cleanup_orphaned_archives()?;
2571 }
2572 let _t0 = std::time::Instant::now();
2573 // Peek 6 bytes to determine snapshot version without reading the full
2574 // file. For RealFs this is a true partial read (O(1)); for SimFs the
2575 // default impl reads all bytes and truncates (still correct).
2576 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2577 // V8, V9 and V10 share the mmap-able container; V9 only adds section 12
2578 // and V10 adds nothing but its version stamp. A version outside that set
2579 // falls through to the full-read path below, where `snapshot::decode`
2580 // either handles it (V5–V7) or refuses it by name — which is what stops
2581 // an older binary before it reaches the WAL.
2582 let is_v8 = snap_header.len() >= 6
2583 && &snap_header[0..4] == b"GDB1"
2584 && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
2585 snap_header[4],
2586 snap_header[5],
2587 ]));
2588 // The version this store is stamped with, or `None` when it has never
2589 // been snapshotted. Read from the same six bytes, with no second read.
2590 let snapshot_version = if snap_header.len() >= 6 && &snap_header[0..4] == b"GDB1" {
2591 Some(u16::from_le_bytes([snap_header[4], snap_header[5]]))
2592 } else {
2593 None
2594 };
2595 // No snapshot means no snapshot has ever truncated the WAL, so this
2596 // handle can prove the history is whole. Once a snapshot exists that
2597 // this handle did not take, it cannot: see `snapshot_preserved_history`.
2598 db.snapshot_preserved_history = snap_header.is_empty();
2599 if is_v8 {
2600 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2601 // No 2.4GB heap Vec is allocated on RealFs.
2602 let mapped = Arc::new(
2603 if let Some(snap_path) = db.fs.snapshot_path() {
2604 core_storage::v8::MappedBase::map(&snap_path)
2605 } else {
2606 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2607 core_storage::v8::MappedBase::from_bytes(snap_bytes)
2608 }
2609 .map_err(|e| GraphError::Corrupt {
2610 detail: format!("v8: mmap open: {e:?}"),
2611 })?,
2612 );
2613 db.restore_v8_base(Arc::clone(&mapped))?;
2614 trace_open!("restore_v8_base", _t0);
2615 db.base = Some(mapped);
2616 trace_open!("base assigned", _t0);
2617 // Only a snapshot from a writer that could leave a deleted node's
2618 // edges behind is searched for them.
2619 if !db.base.as_ref().is_some_and(|base| base.edges_swept()) {
2620 db.drop_edges_of_retired_nodes();
2621 }
2622 db.load_fulltext_from_base();
2623 trace_open!("full-text index read from the snapshot", _t0);
2624 } else if !snap_header.is_empty() {
2625 // Legacy V5-V7: full read required for decode.
2626 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2627 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2628 db.restore_snapshot_state(state)?;
2629 }
2630 }
2631 // else: snap_header is empty = no snapshot file, fresh store.
2632 //
2633 // Seed commit_seq from the highest seq persisted in last_change so that
2634 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2635 // already stored in the snapshot. Without this, a db with one snapshot
2636 // commit would save last_change["a"]=1, then on reopen the first WAL
2637 // frame would replay at seq=1 again — colliding and making WAL-tail
2638 // mutations indistinguishable from the snapshot baseline.
2639 //
2640 // Safety invariant (seq-recycling):
2641 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2642 // last_change because they belonged to a previous db lifetime — a new
2643 // db starts at commit_seq=0 with an empty last_change. Therefore no
2644 // CAS precondition can carry a recycled seq as its `expected` value
2645 // and accidentally match a live node's last_change entry.
2646 //
2647 // `expected:0` on a deleted-then-reinserted node:
2648 // After deletion, last_changed() returns None; callers that call
2649 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2650 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2651 // expected=0 correctly conflicts. The only way to observe actual=0 in
2652 // a CasConflict would be a caller that invented expected=0 without ever
2653 // calling last_changed() — unreachable via the documented API contract.
2654 if let Some(&max_seq) = db.last_change.values().max() {
2655 db.commit_seq = db.commit_seq.max(max_seq);
2656 }
2657 let bytes = db.fs.read(FileId::Wal)?;
2658 let (records, valid_len) = decode_all(&bytes);
2659 // The valid prefix is replayed either way; `repair_wal` only decides
2660 // whether the truncation is written back. A reader that races a live
2661 // appender must not persist a truncation the writer never asked for.
2662 if valid_len < bytes.len() && repair_wal {
2663 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2664 }
2665 // WAL-present path: build indexes eagerly BEFORE replay so that the
2666 // first replayed record does not trigger the lazy-init guard (which
2667 // would call reindex_all_load_state on an empty graph, defeating the
2668 // point of restoring IVF/HNSW blobs from the snapshot).
2669 if !records.is_empty() {
2670 db.ensure_v8_base_sections_loaded();
2671 trace_open!("lazy sections loaded (WAL path)", _t0);
2672 }
2673 // Scoped to this call: `refresh()` also replays frames and is *not*
2674 // followed by a rebuild, so its backfill must still run.
2675 //
2676 // And only when a rebuild does follow. An index read from the snapshot
2677 // is kept, so a pair declared after that snapshot is indexed by its
2678 // own backfill or not at all.
2679 // Every decoded frame occupies an index, replayed or not: markers
2680 // are state no-ops but they are not index no-ops.
2681 let decoded_frames = records.len() as u64;
2682 // A snapshot that kept its WAL says how much of it it already holds.
2683 // Those frames are counted, and not applied a second time.
2684 //
2685 // So does the intent of a snapshot that was to replace its WAL and
2686 // did not get that far: the same state, reached by a crash.
2687 let (interrupted, intent_left_over) = db.interrupted_snapshot(&bytes)?;
2688 // An intent that is not about this snapshot and this WAL is left over
2689 // from a snapshot that finished, or never wrote its snapshot. It has
2690 // to go before this handle appends: once new frames follow, a WAL can
2691 // come to begin with the bytes an old intent names. It goes at the
2692 // first append and not here, because only an appender holds the write
2693 // lock, and a handle without it may be looking at a peer's snapshot
2694 // half way through.
2695 db.intent_unsettled = intent_left_over;
2696 let held = db
2697 .base
2698 .as_ref()
2699 .and_then(|base| base.wal_mark())
2700 .or(interrupted)
2701 .filter(|mark| mark.matches(&bytes))
2702 .map(|mark| (mark.len as usize, decode_all(&bytes[..mark.len as usize])))
2703 // The mark was taken at the end of a whole WAL, so it ends on a
2704 // frame boundary; one that does not is not this WAL's.
2705 .filter(|(len, (_, consumed))| consumed == len)
2706 .map(|(_, (held, _))| held.len());
2707 let marked_wal_is_here = held.is_some();
2708 let held = held.unwrap_or(0);
2709 // A snapshot that kept its WAL has its index read on the strength of
2710 // its mark. If the WAL beside it is not the one marked — it lost its
2711 // tail, or was repaired shorter — the whole of it replays over the
2712 // snapshot, and what that produces is not what the index was read
2713 // for: a replayed insert can relabel a node and leave a posting under
2714 // a field its new label does not index. The index is set aside and
2715 // built after the replay, as it was before it could be read.
2716 if db.fulltext_loaded && !db.base_wal_truncated && !marked_wal_is_here {
2717 db.fulltext = Arc::new(core_storage::fulltext::FulltextIndex::new());
2718 db.fulltext_loaded = false;
2719 }
2720 db.fulltext_rebuild_follows = !db.fulltext_loaded;
2721 db.fulltext_reindexed_a_node = false;
2722 db.replaying_at_open = true;
2723 let replayed = db.apply_frames_over(records, held);
2724 db.fulltext_rebuild_follows = false;
2725 db.replaying_at_open = false;
2726 let replayed = replayed?;
2727 // ── The multiplicity declaration, recovered from the stamp ───────────
2728 //
2729 // The opt-in is re-emitted into every baseline WAL a snapshot writes, so
2730 // ordinarily the replay above has already found it. But
2731 // `snapshot_with(archive_wal)` renames the live WAL away and writes its
2732 // replacement afterwards, and between those two points the store holds
2733 // no live declaration at all. A crash there — or a single `Err` from any
2734 // call in between — used to opt the store back out on the next open
2735 // (defect #22): it would stop counting and write a **V9** snapshot while
2736 // the archives still carried discriminant 23, which is the exact state
2737 // the V10 stamp exists to prevent.
2738 //
2739 // The V10 stamp is what carries the conclusion. The archive clause is a
2740 // scope restriction, not a second proof — an earlier version of this
2741 // comment, and defect #22, claimed otherwise, and defect #33 corrects
2742 // it. Taking the two in order:
2743 //
2744 // **The stamp.** `snapshot_with` stamps the snapshot from
2745 // `self.multiplicity` *before* it touches the WAL, and nothing rewrites
2746 // a V10 snapshot at V9 while the store believes it is opted in. So a
2747 // V10 stamp says this store reached `enable_multiplicity` far enough to
2748 // write the snapshot — and, decisively, that every older binary already
2749 // refuses this store by name. Opting in here can cost such a reader
2750 // nothing it was not already being told.
2751 //
2752 // **What the archive clause does not prove.** It is *not* evidence that
2753 // the archive was taken while the store was opted in. A store can
2754 // archive at V9 and opt in afterwards, leaving a V10 snapshot standing
2755 // beside an archive whose WAL carries no declaration at all — see
2756 // `a_failed_opt_in_beside_an_archive_comes_back_opted_in`. The inference
2757 // held in the success case by coincidence, not by construction.
2758 //
2759 // **What it does buy: scope.** Without it the recovery would also fire
2760 // on a store that reached the V10 snapshot write and then failed with no
2761 // archive in sight. That store must stay opted out, and can: no WAL was
2762 // renamed away, nothing carries discriminant 23, and its next snapshot
2763 // rewrites at V9, which puts it back within reach of every older reader.
2764 // An archive is the marker for the one state that is not recoverable
2765 // that way — a WAL renamed away that may hold the only copy of the
2766 // declaration. `no_crash_leaves_discriminant_23_unguarded` pins that
2767 // line: it sweeps a workload with no archives at all and refuses a
2768 // V10-implies-enabled rule.
2769 //
2770 // **The invariant, whichever way the clause goes:** the recovery never
2771 // opts in a store whose snapshot is not V10. A V9 store has made no
2772 // promise to an older reader, so opting it in would start writing
2773 // discriminant 23 behind a stamp that does not guard it. Pinned by
2774 // `the_recovery_never_opts_in_a_store_whose_snapshot_is_not_v10` and
2775 // `the_recovery_does_not_opt_a_store_in_by_itself`.
2776 //
2777 // What this recovery cannot do is make the opt-in atomic; it is not,
2778 // and `enable_multiplicity` says so. See defects #32-#34.
2779 if !db.multiplicity
2780 && snapshot_version == Some(core_storage::snapshot::VERSION_10)
2781 && !db.fs.list_archives()?.is_empty()
2782 {
2783 db.multiplicity = true;
2784 }
2785 // The cursor sits at the end of the valid prefix, not the end of the
2786 // file: a torn or still-being-written tail is unconsumed by definition
2787 // and stays visible to `is_stale` until it decodes.
2788 db.wal_consumed = valid_len as u64;
2789 // The frame cursor counts the same sequence `all_frames` returns:
2790 // surviving archives first, then the live WAL, offset by the floor.
2791 // Counting the archives separately rather than calling
2792 // `wal_total_commits` keeps the live WAL from being decoded twice on
2793 // every open, and costs nothing on a store that has never archived.
2794 db.wal_frames_written = db.wal_horizon_floor + db.archive_frame_count()? + decoded_frames;
2795 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2796 trace_open!("wal replay done", _t0);
2797 // Rebuild view values after WAL replay only when there is no V8 base.
2798 // With a V8 base, view values are correct in the snapshot and are updated
2799 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2800 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2801 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2802 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2803 // base value).
2804 if db.base.is_none() {
2805 let topo_view = TopologyView::owned(&db.topo);
2806 db.view_store.rebuild_all(
2807 Arc::make_mut(&mut db.props),
2808 &topo_view,
2809 &db.ids,
2810 &db.syms,
2811 &db.labels,
2812 );
2813 }
2814 // The full-text index after WAL replay. One read from the snapshot has
2815 // had the replay applied to it record by record and is kept — unless
2816 // the replay indexed a node the snapshot already held, which doubles
2817 // that node's postings. Then, and wherever no index was read, it is
2818 // built from the declared pairs and the stored values.
2819 db.fulltext_loaded = db.fulltext_loaded && !db.fulltext_reindexed_a_node;
2820 if db.fulltext_loaded {
2821 #[cfg(debug_assertions)]
2822 {
2823 let mut rebuilt = (*db.fulltext).clone();
2824 rebuilt.rebuild_all(
2825 &db.ids,
2826 &db.labels,
2827 &db.syms,
2828 build_props_view(&db.props, &db.base),
2829 );
2830 debug_assert!(
2831 rebuilt == *db.fulltext,
2832 "the full-text index read from the snapshot is not the one a rebuild gives"
2833 );
2834 }
2835 } else {
2836 Arc::make_mut(&mut db.fulltext).rebuild_all(
2837 &db.ids,
2838 &db.labels,
2839 &db.syms,
2840 build_props_view(&db.props, &db.base),
2841 );
2842 }
2843 trace_open!("full-text index ready", _t0);
2844 db.prop_index.rebuild_all(
2845 &db.ids,
2846 &db.labels,
2847 &db.syms,
2848 build_props_view(&db.props, &db.base),
2849 );
2850 // Namespaces: one pass over the `ns` column, after the snapshot is
2851 // restored and the WAL replayed. Replay maintains `node_ns` record by
2852 // record as well; this pass is what makes a snapshot-only open right,
2853 // and it reads nothing on a store with no `ns` column.
2854 db.rebuild_node_ns();
2855 // A mid-build snapshot's HNSW blob carries `complete == false`.
2856 // Register it so `serve`'s ticker sees work without waiting for a write.
2857 db.register_outstanding_index_builds();
2858 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2859 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2860 db.roles = Self::load_roles_from_fs(&db.fs)?;
2861 // The time sidecar. Absent is the normal case for any store written
2862 // before v0.6.11 and is not an error; unreadable is recorded so date
2863 // queries can say "damaged" rather than "none recorded".
2864 db.load_commit_times_from_fs();
2865 // Capture the initial MVCC fold so reader() is ready immediately.
2866 db.fold_now();
2867 trace_open!("open_with complete", _t0);
2868 Ok(replayed)
2869 }
2870
2871 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2872 /// replay does — same `apply` calls, same per-frame delta drain, same
2873 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2874 /// edges appear identically whether a frame arrives at open, from a local
2875 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2876 ///
2877 /// Returns the number of frames applied.
2878 ///
2879 /// Deltas are drained and discarded per frame: replayed frames are already
2880 /// reflected on disk, so they are not news to a subscriber, and draining
2881 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2882 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2883 self.apply_frames_over(records, 0)
2884 }
2885
2886 /// [`Self::apply_frames`] over a base that already holds the first `held`
2887 /// records: the state a snapshot that kept its WAL was taken at. Those
2888 /// are numbered like any other frame and not applied, because applying a
2889 /// write to the state it produced is not always a no-op — an insert
2890 /// under a key since renamed or deleted makes a second node. A
2891 /// declaration among them is applied all the same: the pairs a store has
2892 /// declared are known from the WAL — and, when a snapshot carries a
2893 /// full-text index that is read, from that index too.
2894 fn apply_frames_over(&mut self, records: Vec<WalRecord>, held: usize) -> Result<usize> {
2895 if records.is_empty() {
2896 return Ok(0);
2897 }
2898 // Materialize any state retained in the mmap base before the first
2899 // frame lands, so a replayed record cannot trip the lazy-init guard and
2900 // rebuild indexes from an empty graph. Both calls are idempotent.
2901 //
2902 // Not before a declaration, though. Every truncating snapshot re-emits
2903 // one record for each declared pair, so a snapshotted store with any
2904 // declaration always has frames to replay; counting those as writes
2905 // made every open of such a store pay the scan a store with no
2906 // declaration defers to its first write.
2907 let mut retained_consumed = false;
2908 let applied = records.len();
2909 // A live commit turns on the engine's report of the edges its rules
2910 // add and retract, which is what keeps a view over a derived edge
2911 // type current. Replay has to do the same, or a store with a snapshot
2912 // — where nothing recomputes the views afterwards — reopens with the
2913 // counts the snapshot had.
2914 let emitted_before = self.engine.emit_deltas();
2915 for (i, rec) in records.into_iter().enumerate() {
2916 if !self.view_store.is_empty() && !self.engine.emit_deltas() {
2917 self.engine.set_emit_deltas(true);
2918 }
2919 // A frame the snapshot already holds is counted and not applied,
2920 // unless it is a declaration.
2921 let apply = i >= held || is_declaration(&rec);
2922 if apply && !retained_consumed && !is_declaration(&rec) {
2923 self.ensure_v8_base_sections_loaded();
2924 self.engine.consume_retained_state_eager(
2925 &self.ids,
2926 &self.syms,
2927 &self.labels,
2928 build_props_view(&self.props, &self.base),
2929 );
2930 retained_consumed = true;
2931 }
2932 if apply {
2933 self.apply(&rec)?;
2934 }
2935 let _ = self.engine.drain_deltas();
2936 // Track commit_seq during replay so last_change entries are
2937 // consistent with the seqs assigned by log_then_apply_with on
2938 // subsequent live commits. After N replayed frames, commit_seq=N;
2939 // live commits begin at N+1.
2940 self.commit_seq += 1;
2941 let replay_seq = self.commit_seq;
2942 self.update_last_change_from_rec(&rec, replay_seq);
2943 }
2944 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2945 // this assert catches the regression in debug builds immediately.
2946 debug_assert_eq!(
2947 self.engine.pending_delta_count(),
2948 0,
2949 "pending_deltas non-empty after replay — \
2950 per-frame drain must run inside the loop to keep memory O(1)"
2951 );
2952 // T2 note: the per-frame drain IS the suppression seam for replay.
2953 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2954 // replaying subscribers; the mechanism is already in place.
2955 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2956 self.engine.set_emit_deltas(emitted_before);
2957 Ok(applied)
2958 }
2959
2960 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2961 //
2962 // mushroomdb is many-readers / one-writer across processes. Writers take an
2963 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2964 // Every handle tracks how much of the WAL it has consumed, so it can pick
2965 // up another process's commits by decoding only the new tail rather than
2966 // reopening. See `docs/site/concurrency.md`.
2967
2968 /// Whether the store on disk has moved ahead of (or out from under) this
2969 /// handle's in-memory state.
2970 ///
2971 /// True when the WAL's length differs from this handle's cursor — another
2972 /// process committed, or is mid-append — or when the snapshot file's
2973 /// identity changed. Costs two metadata lookups and reads no file contents,
2974 /// so it is cheap enough for a read path to call.
2975 ///
2976 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2977 /// pinned to one commit and later commits are deliberately invisible to it.
2978 pub fn is_stale(&self) -> Result<bool> {
2979 if self.pinned {
2980 return Ok(false);
2981 }
2982 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2983 return Ok(true);
2984 }
2985 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2986 }
2987
2988 /// Bring this handle up to date with every commit other processes have made,
2989 /// and return how many frames were applied.
2990 ///
2991 /// The WAL tail is decoded from this handle's cursor and applied through the
2992 /// same path the open replay uses, so rules fire and derived edges appear
2993 /// exactly as they would on a fresh open. Interners, id maps and indexes
2994 /// stay valid for the same reason.
2995 ///
2996 /// A frame another process is still writing is left alone: a trailing
2997 /// partial frame is a wait, not a corruption, and the handle stays stale
2998 /// until that frame is complete. Nothing is written to disk, so a read-only
2999 /// handle can refresh freely.
3000 ///
3001 /// When the snapshot file's identity changed, or the WAL is shorter than
3002 /// this handle's cursor, the WAL no longer continues our state — another
3003 /// process snapshotted or archived. The handle is then rebuilt from disk
3004 /// with the options it was opened with, and the return value is the number
3005 /// of frames in the new WAL.
3006 ///
3007 /// Returns 0 for an as-of view, which never follows later commits.
3008 ///
3009 /// # Errors
3010 ///
3011 /// An error here leaves the handle **degraded**: it got partway through
3012 /// applying the tail, or partway through a reload, so its in-memory state
3013 /// no longer matches any point on disk. Further mutations are refused and
3014 /// the handle must be reopened. Nothing on disk was damaged — the store
3015 /// itself is fine, and a fresh open recovers it.
3016 pub fn refresh(&mut self) -> Result<u64> {
3017 if self.pinned {
3018 return Ok(0);
3019 }
3020 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
3021 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
3022 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
3023 // The WAL no longer continues our state: rebuild from disk. State
3024 // is cleared first, so a failed load leaves an empty handle — mark
3025 // it degraded rather than let a caller read an empty graph as if
3026 // it were the store's contents.
3027 self.reset_for_reload();
3028 return match self.load_from_disk(LoadOrigin::Reload) {
3029 Ok(frames) => Ok(frames as u64),
3030 Err(e) => {
3031 self.degraded = true;
3032 Err(e)
3033 }
3034 };
3035 }
3036 if wal_len == self.wal_consumed {
3037 return Ok(0);
3038 }
3039 let tail = self
3040 .fs
3041 .read_range(FileId::Wal, self.wal_consumed)
3042 .map_err(GraphError::Io)?;
3043 let (records, valid_len) = decode_all(&tail);
3044 let decoded_frames = records.len() as u64;
3045 let applied = match self.apply_frames(records) {
3046 Ok(n) => n,
3047 Err(e) => {
3048 // Some frames landed and some did not, and the cursor cannot
3049 // say how many. Advancing it would skip the rest; leaving it
3050 // would replay what already applied. Neither is recoverable in
3051 // place, so refuse further writes and require a reopen.
3052 self.degraded = true;
3053 return Err(e);
3054 }
3055 };
3056 // Advance by the bytes actually decoded, never by the file length: an
3057 // incomplete trailing frame stays unconsumed for the next refresh.
3058 self.wal_consumed += valid_len as u64;
3059 self.wal_frames_written += decoded_frames;
3060 // The peer that wrote those frames also stamped them. Absorbing the
3061 // frames without the stamps leaves this handle resolving dates from a
3062 // prefix of the store's history, and — while our own map is still
3063 // empty — one commit away from rewriting the peer's file out of
3064 // existence (`first` below decides on the map, and the map is what we
3065 // just brought up to date).
3066 self.load_commit_times_from_fs();
3067 if applied > 0 {
3068 // Peer commits must reach `reader()` snapshots taken from here on.
3069 // A full fold is what open does; refresh does not build per-commit
3070 // deltas, so there is nothing cheaper that stays correct.
3071 self.fold_now();
3072 }
3073 Ok(applied as u64)
3074 }
3075
3076 /// Byte offset of the WAL prefix this handle has applied.
3077 ///
3078 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
3079 #[doc(hidden)]
3080 pub fn wal_consumed(&self) -> u64 {
3081 self.wal_consumed
3082 }
3083
3084 /// Rewind the WAL cursor after the group-commit drain thread truncated a
3085 /// failed group off the tail, so the cursor still describes the file.
3086 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
3087 self.wal_consumed = len;
3088 }
3089
3090 /// One non-blocking attempt at the cross-process write lock.
3091 ///
3092 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
3093 /// in-process write guard. That ordering is what keeps a busy peer in
3094 /// another process from stalling this process's readers.
3095 ///
3096 /// A handle that owns the lock for its lifetime always succeeds.
3097 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
3098 if self.holds_lifetime_lock {
3099 return Ok(true);
3100 }
3101 self.fs.try_lock_exclusive().map_err(GraphError::Io)
3102 }
3103
3104 /// Poll for the cross-process write lock until `wait` elapses.
3105 ///
3106 /// One attempt is always made, so a zero wait is a single try. Returns
3107 /// `false` when the lock is still held elsewhere at the deadline; nothing
3108 /// has been written and retrying later is safe.
3109 ///
3110 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
3111 /// handle outright. [`SharedDb`](crate::SharedDb) polls
3112 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
3113 /// that it holds no in-process guard while it waits.
3114 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
3115 let deadline = std::time::Instant::now() + wait;
3116 loop {
3117 if self.try_cross_process_lock()? {
3118 return Ok(true);
3119 }
3120 let now = std::time::Instant::now();
3121 if now >= deadline {
3122 return Ok(false);
3123 }
3124 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
3125 }
3126 }
3127
3128 /// Open a cross-process write scope, given the outcome of an already-made
3129 /// lock attempt.
3130 ///
3131 /// The caller polls for the lock first — outside any in-process guard — and
3132 /// passes what it got. On success this refreshes, so the writes about to
3133 /// happen land on top of every other process's commits. On failure the
3134 /// handle refuses WAL-appending mutations and `snapshot()` with
3135 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
3136 /// closes the scope, so a caller holding a guard cannot write behind
3137 /// another process's back.
3138 ///
3139 /// A handle that already owns the lock for its lifetime skips the refresh:
3140 /// no other process can have written, so there is nothing to pick up.
3141 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
3142 self.lock_denied = !acquired;
3143 if !acquired || self.holds_lifetime_lock {
3144 return Ok(());
3145 }
3146 if let Err(e) = self.refresh() {
3147 // Do not hold a lock we cannot use: release it and let the caller
3148 // see the underlying failure.
3149 let _ = self.fs.unlock();
3150 self.lock_denied = true;
3151 return Err(e);
3152 }
3153 Ok(())
3154 }
3155
3156 /// Close a cross-process write scope opened by
3157 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
3158 /// clear the Busy latch. Safe to call when the lock was never taken.
3159 pub(crate) fn end_write_lock(&mut self) {
3160 self.lock_denied = false;
3161 if !self.holds_lifetime_lock {
3162 // Releasing a lock we do not hold is a no-op; a failure to release
3163 // is reported by the OS closing the descriptor at handle drop.
3164 let _ = self.fs.unlock();
3165 }
3166 }
3167
3168 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
3169 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
3170 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
3171 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
3172 /// Restore all persisted state from a decoded snapshot. Shared by
3173 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
3174 fn restore_snapshot_state(
3175 &mut self,
3176 state: core_storage::snapshot::SnapshotState,
3177 ) -> Result<()> {
3178 self.ids = Arc::new(state.ids);
3179 self.syms = Arc::new(state.syms);
3180 self.topo = Arc::new(state.topo);
3181 self.props = Arc::new(state.props);
3182 self.labels = Arc::new(state.labels);
3183 self.edge_props = Arc::new(state.edge_props);
3184 // Cross-section label integrity for V5/V7 snapshots: same invariants as
3185 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
3186 // out-of-range sym ids, or a sentinel label on a live node would otherwise
3187 // open successfully and panic later in `NodeRef::label()` or
3188 // `neighborhood_masked()`. Catching it here turns those into typed
3189 // `GraphError::Corrupt` at open time.
3190 {
3191 let ids_len = self.ids.len();
3192 if self.labels.len() != ids_len {
3193 return Err(GraphError::Corrupt {
3194 detail: format!(
3195 "snapshot: labels vec has {} entries but id table has {} total slots",
3196 self.labels.len(),
3197 ids_len,
3198 ),
3199 });
3200 }
3201 let syms_len = self.syms.len() as u32;
3202 for (i, &sym) in self.labels.iter().enumerate() {
3203 let is_tombstoned = self.ids.is_tombstoned(i as u32);
3204 if sym == u32::MAX {
3205 if !is_tombstoned {
3206 return Err(GraphError::Corrupt {
3207 detail: format!(
3208 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
3209 ),
3210 });
3211 }
3212 } else if sym >= syms_len {
3213 return Err(GraphError::Corrupt {
3214 detail: format!(
3215 "snapshot: label at id slot {i} references sym {sym} \
3216 which is out of interner range ({syms_len})"
3217 ),
3218 });
3219 }
3220 }
3221 }
3222 let defs: Vec<RuleDef> = state
3223 .rule_defs
3224 .iter()
3225 .map(|b| {
3226 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3227 detail: format!("snapshot rule_def deserialize: {e}"),
3228 })
3229 })
3230 .collect::<Result<Vec<_>>>()?;
3231 self.engine =
3232 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
3233 // Candidate indexes are rebuilt lazily on the first mutation (see
3234 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
3235 // snapshot are retained without deserializing so that:
3236 // - clean-open (empty WAL): indexes stay empty; blobs load on first
3237 // ANN query via ensure_hnsw_loaded, or on first mutation via the
3238 // lazy-init guard which calls reindex_all_load_state (the scan
3239 // skips the HNSW build for every side the blob supplies).
3240 // - WAL-present: open_with calls consume_retained_state_eager before
3241 // replay so HNSW/IVF are live before any record fires the hooks.
3242 let ivf_bytes = if state.ivf_state.is_empty() {
3243 Vec::new()
3244 } else {
3245 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
3246 };
3247 // Store blobs without eagerly deserializing them.
3248 // `self.ids` is the snapshot's id table at this point — WAL replay has
3249 // not run — so its length is the line an interrupted build is detected
3250 // against.
3251 let snapshot_ids = self.ids.len() as u32;
3252 self.engine
3253 .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
3254 // Restore view defs from snapshot (V5).
3255 // The ColumnStore already contains view values from the snapshot;
3256 // use restore_view (no collision check, no backfill) so the store
3257 // is aware of the definitions. rebuild_all runs after WAL replay.
3258 for def_bytes in &state.view_defs {
3259 let def: ViewDef =
3260 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3261 detail: format!("snapshot view_def deserialize: {e}"),
3262 })?;
3263 self.view_store
3264 .restore_view(def)
3265 .map_err(|e| GraphError::Corrupt {
3266 detail: format!("snapshot view restore: {e}"),
3267 })?;
3268 }
3269 Ok(())
3270 }
3271
3272 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
3273 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
3274 ///
3275 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
3276 /// deserialization and view rebuild have access to all column data.
3277 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
3278 self.ids = Arc::new(archived_to_idmap(mapped.ids().map_err(|e| {
3279 GraphError::Corrupt {
3280 detail: format!("v8: ids section: {e:?}"),
3281 }
3282 })?));
3283 self.syms = Arc::new(archived_to_interner(mapped.syms().map_err(|e| {
3284 GraphError::Corrupt {
3285 detail: format!("v8: syms section: {e:?}"),
3286 }
3287 })?));
3288
3289 // C1: self.props is left as an empty overlay. Column reads go through
3290 // props_view() (ColumnsView::with_base), which consults the archived base
3291 // section zero-copy. This avoids the O(columns) heap copy at every open.
3292
3293 // self.topo deliberately left as Topology::new() — overlay path.
3294
3295 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
3296 detail: format!("v8: meta section: {e:?}"),
3297 })?)
3298 .map_err(|e| GraphError::Corrupt {
3299 detail: format!("v8: meta decode: {e:?}"),
3300 })?;
3301 self.base_wal_truncated = meta.wal_truncated;
3302 self.labels = Arc::new(meta.labels);
3303 // Cross-section label integrity: labels must cover every id slot (live
3304 // and tombstoned), every non-sentinel sym must be within the interner's
3305 // bound, and no live (non-tombstoned) node may carry the u32::MAX
3306 // sentinel label. Without this check, a crafted snapshot where the META
3307 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
3308 // sym ids, or a sentinel label on a live node, would open successfully
3309 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
3310 // related read paths. Catching the inconsistency here converts those
3311 // panics into typed `GraphError::Corrupt` at open time.
3312 {
3313 let ids_len = self.ids.len();
3314 if self.labels.len() != ids_len {
3315 return Err(GraphError::Corrupt {
3316 detail: format!(
3317 "v8: labels section has {} entries but id table has {} total slots",
3318 self.labels.len(),
3319 ids_len,
3320 ),
3321 });
3322 }
3323 let syms_len = self.syms.len() as u32;
3324 for (i, &sym) in self.labels.iter().enumerate() {
3325 let is_tombstoned = self.ids.is_tombstoned(i as u32);
3326 if sym == u32::MAX {
3327 // Sentinel is only valid for tombstoned slots.
3328 if !is_tombstoned {
3329 return Err(GraphError::Corrupt {
3330 detail: format!(
3331 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
3332 ),
3333 });
3334 }
3335 } else if sym >= syms_len {
3336 return Err(GraphError::Corrupt {
3337 detail: format!(
3338 "v8: label at id slot {i} references sym {sym} \
3339 which is out of interner range ({syms_len})"
3340 ),
3341 });
3342 }
3343 }
3344 }
3345 // C3: self.edge_props stays as an empty overlay. Reads go through
3346 // edge_props_view() which consults the mmap'd base section zero-copy
3347 // via EdgePropsView::with_base. No heap decode at open time.
3348
3349 // Restore rule engine.
3350 let (rule_def_bytes, rule_tripped, rule_fires) =
3351 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
3352 GraphError::Corrupt {
3353 detail: format!("v8: rules_meta section: {e:?}"),
3354 }
3355 })?);
3356 let defs: Vec<RuleDef> = rule_def_bytes
3357 .iter()
3358 .map(|b| {
3359 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3360 detail: format!("v8: rule_def deserialize: {e}"),
3361 })
3362 })
3363 .collect::<Result<Vec<_>>>()?;
3364 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
3365 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
3366 // `ensure_v8_base_sections_loaded` reads them on first use from
3367 // `self.base` (set by the caller immediately after this returns).
3368 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
3369
3370 // Restore view definitions.
3371 let view_defs =
3372 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
3373 detail: format!("v8: views section: {e:?}"),
3374 })?);
3375 for def_bytes in &view_defs {
3376 let def: ViewDef =
3377 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3378 detail: format!("v8: view_def deserialize: {e}"),
3379 })?;
3380 self.view_store
3381 .restore_view(def)
3382 .map_err(|e| GraphError::Corrupt {
3383 detail: format!("v8: view restore: {e}"),
3384 })?;
3385 }
3386 // Load the last-change map from section 11 (small section; load eagerly).
3387 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
3388 // in that case and `decode_last_change_bytes` returns an empty map.
3389 let last_change_raw = mapped
3390 .last_change_bytes()
3391 .map_err(|e| GraphError::Corrupt {
3392 detail: format!("v8: last_change section: {e:?}"),
3393 })?;
3394 self.last_change = decode_last_change_bytes(last_change_raw);
3395
3396 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
3397 // the file. Pure bounds check — no bytes read, no page faults triggered.
3398 // Catches truncated snapshots at open time before the lazy deferred reads.
3399 mapped.validate_section_bounds().map_err(|e| match e {
3400 GraphError::Corrupt { detail } => GraphError::Corrupt {
3401 detail: format!("v8: section bounds: {detail}"),
3402 },
3403 other => other,
3404 })?;
3405
3406 // Count edges from the base CSR's actual out-rows rather than trusting
3407 // the snapshot header's `edge_count` field, which is wrong on stores
3408 // snapshotted while two rules owned one edge (ledger row 107). The
3409 // topology section is already mmap'd; this is a single pass over the
3410 // row-length fields with no heap allocation.
3411 self.base_edge_count_from_rows = mapped.topology().ok().map(|csr| {
3412 csr.etypes
3413 .iter()
3414 .map(|e| {
3415 e.out_adj
3416 .rows
3417 .iter()
3418 .map(|r| r.neighbors.len() as u64)
3419 .sum::<u64>()
3420 })
3421 .sum::<u64>()
3422 });
3423
3424 Ok(())
3425 }
3426
3427 /// Read provenance, HNSW, and IVF sections from the mmap base into the
3428 /// engine's retained fields on first call. Subsequent calls are a no-op
3429 /// (AtomicBool fast-path).
3430 ///
3431 /// Must be called before any code path that reads or mutates engine
3432 /// provenance, HNSW, or IVF state:
3433 /// - WAL replay (before `consume_retained_state_eager`)
3434 /// - First mutation (`log_then_apply_with`)
3435 /// - Read-only paths (`stats`, `explain`, `node_edges`)
3436 /// - Snapshot (`snapshot_with`)
3437 ///
3438 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
3439 fn ensure_v8_base_sections_loaded(&self) {
3440 use std::sync::atomic::Ordering;
3441 if self.v8_sections_loaded.load(Ordering::Acquire) {
3442 return;
3443 }
3444 let _guard = self
3445 .v8_sections_mutex
3446 .lock()
3447 .expect("v8 sections mutex poisoned");
3448 if self.v8_sections_loaded.load(Ordering::Acquire) {
3449 return; // another caller populated while we waited
3450 }
3451 let _t = std::time::Instant::now();
3452 if let Some(base) = &self.base {
3453 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
3454 // Bounds are already validated at open time (restore_v8_base →
3455 // validate_section_bounds) — unreachable post-validate_section_bounds;
3456 // unwrap_or_default is a safety belt against impossible errors.
3457 let prov_bytes = base
3458 .provenance_raw_bytes()
3459 .map(|b| b.to_vec())
3460 .unwrap_or_default();
3461 self.engine.store_provenance_bytes(prov_bytes);
3462 // HNSW: decode rkyv blobs into owned map.
3463 let hnsw_state = base
3464 .hnsw_section()
3465 .map(archived_hnsw_to_owned)
3466 .unwrap_or_default();
3467 // IVF: raw bincode bytes; deserialized on first mutation/query.
3468 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
3469 // Called before WAL replay on a WAL-present open (`open_with`) and
3470 // before any write on a clean one, so this is the snapshot's count.
3471 let snapshot_ids = self.ids.len() as u32;
3472 self.engine
3473 .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
3474 }
3475 self.v8_sections_loaded.store(true, Ordering::Release);
3476 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
3477 eprintln!(
3478 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
3479 _t.elapsed()
3480 );
3481 }
3482 }
3483
3484 /// Return a `TopologyView` that merges the mmap'd base (when present) with
3485 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
3486 /// the full merged topology without going through `self.view()`.
3487 fn topo_view(&self) -> TopologyView<'_> {
3488 match self.base {
3489 None => TopologyView::owned(&self.topo),
3490 Some(ref base) => {
3491 // SAFETY: base lives as long as self; section bounds validated at open.
3492 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
3493 let archived = base
3494 .topology()
3495 .expect("base topology section bounds validated at open");
3496 TopologyView::with_base(&self.topo, archived)
3497 }
3498 }
3499 }
3500
3501 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
3502 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
3503 /// overlay first, then fall through to the archived base section zero-copy.
3504 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
3505 match self.base {
3506 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
3507 Some(ref base) => {
3508 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
3509 let archived = base
3510 .columns()
3511 .expect("base columns section bounds validated at open");
3512 core_storage::v8::seam::ColumnsView::with_base_cached(
3513 &self.props,
3514 archived,
3515 base.mixed_cache(),
3516 )
3517 .with_shared_strings(base_string_table(base))
3518 }
3519 }
3520 }
3521
3522 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
3523 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
3524 ///
3525 /// Reads consult the overlay first (for post-snapshot mutations), then fall
3526 /// through to the archived base section zero-copy. Tombstones in the
3527 /// overlay mask deleted-from-base entries.
3528 fn edge_props_view(&self) -> EdgePropsView<'_> {
3529 match self.base {
3530 None => EdgePropsView::owned(&self.edge_props),
3531 Some(ref base) => {
3532 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
3533 let archived = base
3534 .edge_props_section()
3535 .expect("base edge_props section bounds validated at open");
3536 EdgePropsView::with_base(&self.edge_props, archived)
3537 }
3538 }
3539 }
3540
3541 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
3542 // An as-of view never writes and is pinned to one commit: it takes no
3543 // cross-process lock and does not follow later commits.
3544 let mut db = Self::new_empty(
3545 fs,
3546 OpenOptions {
3547 repair_wal: false,
3548 auto_migrate: false,
3549 read_only: true,
3550 },
3551 );
3552 db.pinned = true; // read_only is set after replay, but pinning is immediate
3553 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
3554 db.archive_genesis_chain = db.fs.has_genesis_marker();
3555 // Same orphaned-archive cleanup as open_with: floor was written first
3556 // during pruning, so a crash may have left stale archives below floor.
3557 db.cleanup_orphaned_archives()?;
3558 // Collect archive frames (oldest-first) and live WAL frames.
3559 // Archives represent pre-snapshot history; the snapshot captures the
3560 // cumulative state at the time of archiving. Crash-window guarantee:
3561 // A: crash before rename → WAL intact, no archive. Reopen: normal.
3562 // B: crash after rename, before new WAL → archive present, WAL
3563 // absent. Reopen: snapshot loaded (full state), no WAL replay.
3564 // C: crash after new baseline WAL written → normal post-archive.
3565 let archive_ns = db.fs.list_archives()?;
3566 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
3567 for n in &archive_ns {
3568 let arc_bytes = db.fs.read_archive(*n)?;
3569 let (arc_frames, _) = decode_all(&arc_bytes);
3570 archive_frames_all.extend(arc_frames);
3571 }
3572 let total_archive_frames = archive_frames_all.len() as u64;
3573
3574 let live_bytes = db.fs.read(FileId::Wal)?;
3575 let (live_records, _valid_len) = decode_all(&live_bytes);
3576 let total_surviving = total_archive_frames + live_records.len() as u64;
3577 // Global total including any pruned history below the horizon floor.
3578 let total = db.wal_horizon_floor + total_surviving;
3579
3580 // Horizon and range check.
3581 if commit < db.wal_horizon_floor {
3582 return Err(GraphError::CommitOutOfRange {
3583 commit,
3584 total,
3585 floor: db.wal_horizon_floor,
3586 });
3587 }
3588 if commit >= total {
3589 return Err(GraphError::CommitOutOfRange {
3590 commit,
3591 total,
3592 floor: db.wal_horizon_floor,
3593 });
3594 }
3595
3596 // Local index into surviving frames (0 = first frame of oldest archive).
3597 let local = commit - db.wal_horizon_floor;
3598
3599 if local < total_archive_frames {
3600 // Target commit is in an archive. Correct replay from empty state
3601 // is only possible when the archive chain is an uninterrupted
3602 // genesis chain (first archive taken from a fresh store, no prior
3603 // WAL truncation) and no archives have been pruned (floor == 0).
3604 //
3605 // If either condition is violated the prefix needed to reconstruct
3606 // the requested state is gone; refuse rather than return wrong data.
3607 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
3608 return Err(GraphError::CommitOutOfRange {
3609 commit,
3610 total,
3611 floor: db.wal_horizon_floor,
3612 });
3613 }
3614 // Replay all archive frames up to and including the target commit
3615 // from an empty database state. Archives must be replayed in order
3616 // so that dense-id intern tables are built up correctly.
3617 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
3618 db.apply(&rec)?;
3619 let _ = db.engine.drain_deltas();
3620 }
3621 } else {
3622 // Target commit is in the live WAL: load snapshot as base, then
3623 // replay the needed live WAL prefix.
3624 //
3625 // Base state: a truncating snapshot (wal_truncated=true) compacts
3626 // all pre-truncation / pre-archive commits. Dense-id records in
3627 // the live WAL reference ids/interns that the snapshot provides.
3628 //
3629 // A `keep_wal` snapshot records `wal_truncated=false`: it is the
3630 // state at the head, not at the start of the WAL, so it is no base
3631 // for a prefix. That is right when the live WAL reaches the store's
3632 // first commit and can be replayed from nothing. It is wrong when
3633 // the WAL starts above that — the floor has moved, or archives sit
3634 // in front of it — because the snapshot that *was* its base has
3635 // been overwritten. Replaying the prefix over nothing then answers
3636 // with a graph missing everything older than the WAL. The base is
3637 // gone, so the read is refused (ledger row 82).
3638 let base_is_gone = db.wal_horizon_floor > 0 || total_archive_frames > 0;
3639 // A snapshot interrupted before its WAL was replaced is, like a
3640 // `keep_wal` one, the state at the END of the live WAL, whatever
3641 // it says about truncation: no base for a prefix of that WAL.
3642 let live_wal = db.fs.read(FileId::Wal)?;
3643 let snapshot_is_wal_end = db.interrupted_snapshot(&live_wal)?.0.is_some();
3644 // Peek 6 bytes (same pattern as open_with).
3645 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3646 let is_v8 = snap_header.len() >= 6
3647 && &snap_header[0..4] == b"GDB1"
3648 && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
3649 snap_header[4],
3650 snap_header[5],
3651 ]));
3652 if is_v8 {
3653 let state = if let Some(snap_path) = db.fs.snapshot_path() {
3654 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3655 GraphError::Corrupt {
3656 detail: format!("v8: open_at mmap: {e:?}"),
3657 }
3658 })?;
3659 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3660 } else {
3661 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3662 core_storage::snapshot::decode(&snap_bytes)?
3663 };
3664 if let Some(state) = state {
3665 if state.wal_truncated && !snapshot_is_wal_end {
3666 db.restore_snapshot_state(state)?;
3667 db.drop_edges_of_retired_nodes();
3668 } else if base_is_gone {
3669 return Err(GraphError::CommitOutOfRange {
3670 commit,
3671 total,
3672 floor: db.wal_horizon_floor,
3673 });
3674 }
3675 }
3676 } else if !snap_header.is_empty() {
3677 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3678 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3679 if state.wal_truncated && !snapshot_is_wal_end {
3680 db.restore_snapshot_state(state)?;
3681 db.drop_edges_of_retired_nodes();
3682 } else if base_is_gone {
3683 return Err(GraphError::CommitOutOfRange {
3684 commit,
3685 total,
3686 floor: db.wal_horizon_floor,
3687 });
3688 }
3689 }
3690 }
3691 // else: snap_header empty = no snapshot file.
3692 let live_local = local - total_archive_frames;
3693 for rec in live_records.into_iter().take((live_local + 1) as usize) {
3694 db.apply(&rec)?;
3695 let _ = db.engine.drain_deltas();
3696 }
3697 }
3698 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3699 // post-loop assert in open_with.
3700 debug_assert_eq!(
3701 db.engine.pending_delta_count(),
3702 0,
3703 "pending_deltas non-empty after open_at replay — \
3704 per-frame drain must run inside the loop to keep memory O(1)"
3705 );
3706 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3707 // Rebuild view values after WAL replay so derived-edge-driven views
3708 // reflect the as-of state. open_at always uses the legacy path (no V8
3709 // base), so topo_view is always owned.
3710 {
3711 let topo_view = TopologyView::owned(&db.topo);
3712 db.view_store.rebuild_all(
3713 Arc::make_mut(&mut db.props),
3714 &topo_view,
3715 &db.ids,
3716 &db.syms,
3717 &db.labels,
3718 );
3719 }
3720 // Rebuild full-text index for as-of view (mirrors open_with pattern).
3721 Arc::make_mut(&mut db.fulltext).rebuild_all(
3722 &db.ids,
3723 &db.labels,
3724 &db.syms,
3725 build_props_view(&db.props, &db.base),
3726 );
3727 db.prop_index.rebuild_all(
3728 &db.ids,
3729 &db.labels,
3730 &db.syms,
3731 build_props_view(&db.props, &db.base),
3732 );
3733 // Namespaces on the temporal handle, built by the same pass the live
3734 // open uses, so an as-of mask narrows by the namespaces of that commit.
3735 db.rebuild_node_ns();
3736 // Load roles sidecar (current roles, not point-in-time).
3737 db.roles = Self::load_roles_from_fs(&db.fs)?;
3738 db.read_only = true;
3739 db.total_wal_commits = total;
3740 // Capture initial fold so reader() is immediately usable.
3741 db.fold_now();
3742 Ok(db)
3743 }
3744
3745 /// Whether this instance is a read-only as-of view.
3746 pub fn is_read_only(&self) -> bool {
3747 self.read_only
3748 }
3749
3750 // ── MVCC epoch reader ─────────────────────────────────────────────────────
3751
3752 /// Clone the current overlay state into a new `FrozenOverlay` and reset
3753 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3754 /// the end of `open_with` / `open_at_with` to prime the reader.
3755 fn fold_now(&mut self) {
3756 // Eight `Arc::clone`s — refcount bumps, O(1). This used to deep-copy the
3757 // whole overlay: `IdMap` alone is a `HashMap<String, u32>` plus a
3758 // `Vec<String>`, so every node key was copied twice, on every open,
3759 // after every snapshot, every FOLD_EVERY_K commits on the write path,
3760 // and — with no commit threshold — on every `refresh()` that applied a
3761 // peer commit. A refreshing reader now pays nothing for a fold.
3762 //
3763 // `props` and `topo` were already cheap for a different reason: on a
3764 // snapshotted store `restore_v8_base` leaves them as empty overlays over
3765 // the zero-copy mmap. `ids`, `syms` and `fulltext` were not, and that
3766 // inconsistency was the defect.
3767 let frozen = crate::reader::FrozenOverlay {
3768 ids: std::sync::Arc::clone(&self.ids),
3769 syms: std::sync::Arc::clone(&self.syms),
3770 topo: std::sync::Arc::clone(&self.topo),
3771 props: std::sync::Arc::clone(&self.props),
3772 labels: std::sync::Arc::clone(&self.labels),
3773 edge_props: std::sync::Arc::clone(&self.edge_props),
3774 roles: self.roles.clone().map(std::sync::Arc::new),
3775 fulltext: std::sync::Arc::clone(&self.fulltext),
3776 };
3777 self.fold_overlay = Some(Arc::new(frozen));
3778 self.delta_tail.clear();
3779 self.commits_since_fold = 0;
3780 // The fold holds every view value as it stands; what replay or a
3781 // refresh touched on the way here is not owed to any delta.
3782 self.view_store.clear_touched();
3783 }
3784
3785 /// The view properties the commit of `rec` left on the nodes it touched,
3786 /// for the reader's delta tail — or `None` when the commit should be
3787 /// folded instead of described.
3788 ///
3789 /// Folded: a commit that creates or deletes a view, which writes its
3790 /// property on every node of a label, and one that moves far more view
3791 /// values than it has records — a write to a hub moves a view on every
3792 /// neighbour. A delta listing those would be held, and replayed by every
3793 /// reader, until the next fold.
3794 ///
3795 /// Not folded: a commit whose view values are in proportion to its
3796 /// records, however many. A bulk load gives each node it inserts a
3797 /// starting value, and the reader replays each of those inserts anyway. A
3798 /// fold is eight `Arc` clones to take, but it shares the store's maps
3799 /// with the reader, so the commit after it copies them in full; a load
3800 /// that folded on every batch paid that on every batch.
3801 fn view_values_of_commit(
3802 &mut self,
3803 rec: &WalRecord,
3804 ) -> Option<Vec<(u32, String, Option<Value>)>> {
3805 /// View values a commit may carry beyond those its own records
3806 /// account for.
3807 const MAX_BEYOND_RECORDS: usize = 1024;
3808
3809 fn declares_a_view(rec: &WalRecord) -> bool {
3810 match rec {
3811 WalRecord::CreateView { .. } | WalRecord::DeleteView { .. } => true,
3812 WalRecord::Batch(inner) => inner.iter().any(declares_a_view),
3813 _ => false,
3814 }
3815 }
3816
3817 fn leaf_records(rec: &WalRecord) -> usize {
3818 match rec {
3819 WalRecord::Batch(inner) => inner.iter().map(leaf_records).sum(),
3820 _ => 1,
3821 }
3822 }
3823
3824 let mut touched = self.view_store.take_touched();
3825 if declares_a_view(rec) {
3826 return None;
3827 }
3828 if touched.is_empty() {
3829 return Some(Vec::new());
3830 }
3831 touched.sort_unstable();
3832 touched.dedup();
3833 let views = self.view_store.views().count();
3834 let budget = MAX_BEYOND_RECORDS + leaf_records(rec).saturating_mul(views);
3835 let cols = build_props_view(&self.props, &self.base);
3836 let mut values = Vec::new();
3837 for &node in &touched {
3838 // A node deleted later in the same commit: the reader's replay of
3839 // the delete clears its properties, the view's among them.
3840 if self.ids.key_of(node).is_none() {
3841 continue;
3842 }
3843 let label = self.labels.get(node as usize).copied();
3844 for def in self.view_store.views() {
3845 if label.is_none() || self.syms.get(&def.label) != label {
3846 continue;
3847 }
3848 if values.len() == budget {
3849 return None;
3850 }
3851 let value = cols.get(node, &def.view_prop).map(|v| v.into_value());
3852 values.push((node, def.view_prop.clone(), value));
3853 }
3854 }
3855 Some(values)
3856 }
3857
3858 /// Capture a lock-free reader snapshot of the current db state.
3859 ///
3860 /// The read lock is held only for the duration of this call (to clone a
3861 /// handful of `Arc` handles). Subsequent query operations run without any
3862 /// lock.
3863 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3864 crate::reader::ReaderSnapshot::new(
3865 self.fold_overlay
3866 .clone()
3867 .expect("fold_overlay is always Some after open_with; call reader() after open"),
3868 self.base.clone(),
3869 self.delta_tail.clone(),
3870 // The snapshot's effective state is exactly this handle's state at
3871 // this commit, so it shares the memo and its version key.
3872 self.commit_seq,
3873 Arc::clone(&self.role_masks),
3874 )
3875 }
3876
3877 /// Append a delta the reader cannot apply, so that a corrupt overlay is
3878 /// reachable from a test.
3879 ///
3880 /// Compiled only under `test-hooks`, which the server's dev-dependency on
3881 /// this crate turns on. One call permanently corrupts every
3882 /// [`ReaderSnapshot`](crate::reader::ReaderSnapshot) taken from the handle,
3883 /// so it must not be in the published surface: `#[doc(hidden)]` hides it
3884 /// from rustdoc and from nothing else. The feature gate — not
3885 /// `#[cfg(test)]` — because its only callers are in `crates/server/tests`,
3886 /// a different crate, exactly as `core_rules`'s index counters are.
3887 ///
3888 /// [`ReaderSnapshot::effective`](crate::reader::ReaderSnapshot) folds the
3889 /// delta tail into a clone of the frozen overlay and answers
3890 /// [`GraphError::Corrupt`] when a record will not apply. Nothing a caller
3891 /// can do produces that state — `apply_one`'s failures are disagreements
3892 /// between the tail and the fold it is applied to, which the write path
3893 /// cannot create — so the `Corrupt` arm of every scoped reader method was
3894 /// reachable only by inspection until this hook existed. An `Intern` record
3895 /// claiming an id the frozen interner will not hand back is the smallest
3896 /// such disagreement.
3897 ///
3898 /// Only the tail is touched. This handle's own state is untouched and
3899 /// `commit_seq` does not move, so a role mask already memoised at this
3900 /// version stays memoised — which is exactly the state in which the HTTP
3901 /// role branches reach a scoped read with a corrupt overlay under them.
3902 #[cfg(any(test, feature = "test-hooks"))]
3903 #[doc(hidden)]
3904 pub fn push_unapplyable_delta_for_test(&mut self) {
3905 self.delta_tail.push(Arc::new(crate::reader::CommitDelta {
3906 records: vec![WalRecord::Intern {
3907 id: u32::MAX,
3908 text: "delta-tail-corruption".into(),
3909 }],
3910 derived_inserts: Vec::new(),
3911 derived_deletes: Vec::new(),
3912 view_values: Vec::new(),
3913 }));
3914 }
3915
3916 /// Inject a triple into a second rule's provenance, bypassing `GraphMut::add_edge`'s
3917 /// base check, and add the edge to the overlay topology so the next snapshot will
3918 /// double-count it in its header. Together these reproduce the damaged state that
3919 /// row 103's fix prevents: two rules each holding the same triple, and an
3920 /// `edge_count` that is one too high per duplicated edge. Call `snapshot()` after
3921 /// this to persist the damage for a row-107 repair test.
3922 ///
3923 /// Panics if either key is not live in this store.
3924 #[cfg(any(test, feature = "test-hooks"))]
3925 #[doc(hidden)]
3926 pub fn inject_double_ownership_for_test(
3927 &mut self,
3928 rule_name: &str,
3929 edge_type: &str,
3930 src_key: &str,
3931 dst_key: &str,
3932 ) {
3933 // Load the V8 base sections first: without this the engine's retained
3934 // provenance bytes are not yet populated, so the mutation call below would
3935 // find nothing to decode and the injection would be silently discarded.
3936 self.ensure_v8_base_sections_loaded();
3937 // Materialize provenance into live fields so the injection is visible to
3938 // subsequent writes and to the snapshot encoder.
3939 self.engine.ensure_provenance_loaded_mut();
3940 let etype = Arc::make_mut(&mut self.syms).intern(edge_type);
3941 let src = self.ids.get(src_key).unwrap_or_else(|| {
3942 panic!("inject_double_ownership_for_test: src key {src_key:?} not found")
3943 });
3944 let dst = self.ids.get(dst_key).unwrap_or_else(|| {
3945 panic!("inject_double_ownership_for_test: dst key {dst_key:?} not found")
3946 });
3947 self.engine
3948 .inject_double_ownership_for_test(rule_name, etype, src, dst);
3949 // Add the edge to the overlay topology so topology_merge_to_csr counts the
3950 // base copy (from the prior snapshot) plus this overlay copy — making the
3951 // next snapshot's header one too high per duplicated edge.
3952 Arc::make_mut(&mut self.topo).add_edge(etype, src, dst);
3953 }
3954
3955 /// The raw `edge_count` field from the current V8 snapshot header, without
3956 /// the `corrected_edge_count` row-walk fix applied. On a store snapshotted
3957 /// before the encoder fix (ledger row 107), this returns the inflated value.
3958 /// Used by tests to assert that new snapshots are written with the correct
3959 /// header value (proving the encoder fix is needed independently of the
3960 /// db.rs count fix).
3961 #[doc(hidden)]
3962 pub fn snapshot_header_edge_count_for_test(&self) -> Option<u64> {
3963 self.base
3964 .as_ref()
3965 .and_then(|b| b.topology().ok())
3966 .map(|t| u64::from(t.edge_count))
3967 }
3968
3969 /// Total number of WAL commits at the time [`open_at`] was called.
3970 /// Returns 0 for normal (non-as-of) instances.
3971 pub fn total_wal_commits(&self) -> u64 {
3972 self.total_wal_commits
3973 }
3974
3975 /// Apply a record to in-memory state. Used by both live writes and replay,
3976 /// so replay is definitionally identical to the original execution.
3977 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3978 // Before the record mutates anything: a store restored from a snapshot
3979 // defers building its candidate indexes until the first write, and that
3980 // build is a full node scan. Left where it used to fire — inside the
3981 // engine hook, after `props.set` and the label assignment — the scan
3982 // read the half-applied record and took the in-flight node's vector for
3983 // one the snapshot should have carried, which read as an interrupted
3984 // vector-index build and cost a full `RebuildRule` on the first
3985 // embedded write after every reopen. Hoisted here the scan sees exactly
3986 // the persisted state; the record's own hook then files its vector
3987 // through the ordinary insert path a line later.
3988 //
3989 // A declaration writes no node and no edge, and its arm reads no rule
3990 // index, so it does not start the scan.
3991 if !is_declaration(rec) {
3992 self.populate_indexes_before_write();
3993 }
3994 match rec {
3995 WalRecord::InsertNode { label, key, props } => {
3996 let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
3997 let sym = Arc::make_mut(&mut self.syms).intern(label);
3998 if self.labels.len() <= id as usize {
3999 // gap slots are sentinels, never valid label symbols
4000 Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
4001 }
4002 Arc::make_mut(&mut self.labels)[id as usize] = sym;
4003 let mut ns_name = NS_DEFAULT.to_string();
4004 for (field, value) in props {
4005 if field == NS_PROP {
4006 ns_name = namespace_of_value(Some(value)).to_string();
4007 }
4008 Arc::make_mut(&mut self.props).set(id, field, value.clone());
4009 }
4010 self.set_node_ns(id, &ns_name);
4011 // Initialize view values for the new node before the engine runs so
4012 // delta-based increments start from a known zero baseline.
4013 self.view_store.init_node_views(
4014 id,
4015 Arc::make_mut(&mut self.props),
4016 &self.syms,
4017 &self.labels,
4018 );
4019 // Fire rules for the newly inserted node.
4020 let cursor = self.engine.pending_delta_count();
4021 let mut eng = std::mem::take(&mut self.engine);
4022 {
4023 let mut gm = make_graph_mut(
4024 &self.ids,
4025 Arc::make_mut(&mut self.syms),
4026 &self.labels,
4027 build_props_view(&self.props, &self.base),
4028 Arc::make_mut(&mut self.topo),
4029 &self.base,
4030 Arc::make_mut(&mut self.edge_props),
4031 );
4032 eng.on_node_changed(id, None, &mut gm);
4033 }
4034 self.engine = eng;
4035 // Process derived-edge deltas for view maintenance.
4036 // Fast path: skip the O(delta_count) allocation when no views exist.
4037 if !self.view_store.is_empty() {
4038 #[cfg(test)]
4039 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4040 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4041 for d in &new_deltas {
4042 self.view_store.on_edge_changed(
4043 d.etype_sym,
4044 d.src_id,
4045 d.dst_id,
4046 d.fired,
4047 Arc::make_mut(&mut self.props),
4048 &build_topo_view(&self.topo, &self.base),
4049 &self.ids,
4050 &self.syms,
4051 &self.labels,
4052 base_columns(&self.base),
4053 );
4054 }
4055 }
4056 // Full-text index maintenance: index enabled fields for this label.
4057 if self.fulltext.has_label(label) {
4058 for (field, value) in props {
4059 if self.fulltext.is_enabled(label, field) {
4060 self.unindex_before_reindexing(id, field);
4061 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4062 }
4063 }
4064 }
4065 // Property (equality) index maintenance.
4066 if self.prop_index.has_label(label) {
4067 for (field, value) in props {
4068 self.prop_index.set(label, field, id, value);
4069 }
4070 }
4071 }
4072 WalRecord::InsertEdge {
4073 edge_type,
4074 src_key,
4075 dst_key,
4076 } => {
4077 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
4078 detail: format!("wal replay references unknown key {src_key}"),
4079 })?;
4080 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
4081 detail: format!("wal replay references unknown key {dst_key}"),
4082 })?;
4083 let etype = Arc::make_mut(&mut self.syms).intern(edge_type);
4084 // Skip if the edge is already visible in the merged base+overlay
4085 // view. This keeps WAL replay idempotent when the WAL contains
4086 // pre-snapshot records that are already encoded in a V8 base
4087 // (keep_wal=true opens and crash-before-truncation scenarios).
4088 if self.base.is_some()
4089 && self
4090 .topo_view()
4091 .neighbors(etype, Direction::Out, src)
4092 .contains(&dst)
4093 {
4094 return Ok(());
4095 }
4096 Arc::make_mut(&mut self.topo).add_edge(etype, src, dst);
4097 // View maintenance for manual edge insert.
4098 self.view_store.on_edge_changed(
4099 etype,
4100 src,
4101 dst,
4102 true,
4103 Arc::make_mut(&mut self.props),
4104 &build_topo_view(&self.topo, &self.base),
4105 &self.ids,
4106 &self.syms,
4107 &self.labels,
4108 base_columns(&self.base),
4109 );
4110 // Rule engine: via-hop rules must update when user edges change.
4111 let cursor = self.engine.pending_delta_count();
4112 let mut eng = std::mem::take(&mut self.engine);
4113 {
4114 let mut gm = make_graph_mut(
4115 &self.ids,
4116 Arc::make_mut(&mut self.syms),
4117 &self.labels,
4118 build_props_view(&self.props, &self.base),
4119 Arc::make_mut(&mut self.topo),
4120 &self.base,
4121 Arc::make_mut(&mut self.edge_props),
4122 );
4123 eng.on_edge_changed(edge_type, src, dst, &mut gm);
4124 }
4125 self.engine = eng;
4126 if !self.view_store.is_empty() {
4127 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4128 for d in &new_deltas {
4129 self.view_store.on_edge_changed(
4130 d.etype_sym,
4131 d.src_id,
4132 d.dst_id,
4133 d.fired,
4134 Arc::make_mut(&mut self.props),
4135 &build_topo_view(&self.topo, &self.base),
4136 &self.ids,
4137 &self.syms,
4138 &self.labels,
4139 base_columns(&self.base),
4140 );
4141 }
4142 }
4143 }
4144 WalRecord::SetProp { key, field, value } => {
4145 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
4146 detail: format!("wal replay references unknown key {key}"),
4147 })?;
4148 let old_value = build_props_view(&self.props, &self.base)
4149 .get(id, field)
4150 .map(|vr| vr.into_value());
4151 Arc::make_mut(&mut self.props).set(id, field, value.clone());
4152 // Fire rules for the changed field.
4153 let cursor = self.engine.pending_delta_count();
4154 let mut eng = std::mem::take(&mut self.engine);
4155 {
4156 let mut gm = make_graph_mut(
4157 &self.ids,
4158 Arc::make_mut(&mut self.syms),
4159 &self.labels,
4160 build_props_view(&self.props, &self.base),
4161 Arc::make_mut(&mut self.topo),
4162 &self.base,
4163 Arc::make_mut(&mut self.edge_props),
4164 );
4165 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
4166 }
4167 self.engine = eng;
4168 // Derived-edge deltas → view updates.
4169 if !self.view_store.is_empty() {
4170 #[cfg(test)]
4171 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4172 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4173 for d in &new_deltas {
4174 self.view_store.on_edge_changed(
4175 d.etype_sym,
4176 d.src_id,
4177 d.dst_id,
4178 d.fired,
4179 Arc::make_mut(&mut self.props),
4180 &build_topo_view(&self.topo, &self.base),
4181 &self.ids,
4182 &self.syms,
4183 &self.labels,
4184 base_columns(&self.base),
4185 );
4186 }
4187 }
4188 // Neighbor-aggregate views that read `field` must also update.
4189 self.view_store.on_prop_changed(
4190 id,
4191 field,
4192 Arc::make_mut(&mut self.props),
4193 &build_topo_view(&self.topo, &self.base),
4194 &self.ids,
4195 &self.syms,
4196 &self.labels,
4197 base_columns(&self.base),
4198 );
4199 // Full-text index maintenance: update tokens for this field if indexed.
4200 if self.fulltext.field_indexed(field) {
4201 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4202 if sym == u32::MAX {
4203 None
4204 } else {
4205 self.syms.resolve(sym)
4206 }
4207 });
4208 if let Some(label) = label_opt {
4209 if self.fulltext.is_enabled(label, field) {
4210 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4211 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4212 }
4213 }
4214 }
4215 // Property (equality) index maintenance: re-key this node's value.
4216 if self.prop_index.field_indexed(field) {
4217 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4218 if sym == u32::MAX {
4219 None
4220 } else {
4221 self.syms.resolve(sym)
4222 }
4223 });
4224 if let Some(label) = label_opt {
4225 self.prop_index.set(label, field, id, value);
4226 }
4227 }
4228 }
4229 WalRecord::Intern { id, text } => {
4230 if let Some(existing) = self.syms.get(text) {
4231 if existing != *id {
4232 return Err(GraphError::Corrupt {
4233 detail: format!(
4234 "wal intern mismatch for {text:?}: have {existing}, record {id}"
4235 ),
4236 });
4237 }
4238 } else {
4239 let got = Arc::make_mut(&mut self.syms).intern(text);
4240 if got != *id {
4241 return Err(GraphError::Corrupt {
4242 detail: format!(
4243 "wal intern assigned {got} for {text:?}, record wanted {id}"
4244 ),
4245 });
4246 }
4247 }
4248 }
4249 WalRecord::InsertNodeId { label, key, props } => {
4250 let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
4251 if self.labels.len() <= id as usize {
4252 Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
4253 }
4254 Arc::make_mut(&mut self.labels)[id as usize] = *label;
4255 let label_str = self
4256 .syms
4257 .resolve(*label)
4258 .ok_or_else(|| GraphError::Corrupt {
4259 detail: format!("wal InsertNodeId unknown label intern {label}"),
4260 })?
4261 .to_string();
4262 let mut ns_name = NS_DEFAULT.to_string();
4263 for (field_sym, value) in props {
4264 let field =
4265 self.syms
4266 .resolve(*field_sym)
4267 .ok_or_else(|| GraphError::Corrupt {
4268 detail: format!(
4269 "wal InsertNodeId unknown field intern {field_sym}"
4270 ),
4271 })?;
4272 if field == NS_PROP {
4273 ns_name = namespace_of_value(Some(value)).to_string();
4274 }
4275 Arc::make_mut(&mut self.props).set(id, field, value.clone());
4276 }
4277 self.set_node_ns(id, &ns_name);
4278 self.view_store.init_node_views(
4279 id,
4280 Arc::make_mut(&mut self.props),
4281 &self.syms,
4282 &self.labels,
4283 );
4284 let cursor = self.engine.pending_delta_count();
4285 let mut eng = std::mem::take(&mut self.engine);
4286 {
4287 let mut gm = make_graph_mut(
4288 &self.ids,
4289 Arc::make_mut(&mut self.syms),
4290 &self.labels,
4291 build_props_view(&self.props, &self.base),
4292 Arc::make_mut(&mut self.topo),
4293 &self.base,
4294 Arc::make_mut(&mut self.edge_props),
4295 );
4296 eng.on_node_changed(id, None, &mut gm);
4297 }
4298 self.engine = eng;
4299 if !self.view_store.is_empty() {
4300 #[cfg(test)]
4301 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4302 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4303 for d in &new_deltas {
4304 self.view_store.on_edge_changed(
4305 d.etype_sym,
4306 d.src_id,
4307 d.dst_id,
4308 d.fired,
4309 Arc::make_mut(&mut self.props),
4310 &build_topo_view(&self.topo, &self.base),
4311 &self.ids,
4312 &self.syms,
4313 &self.labels,
4314 base_columns(&self.base),
4315 );
4316 }
4317 }
4318 if self.fulltext.has_label(&label_str) {
4319 for (field_sym, value) in props {
4320 let Some(field) = self.syms.resolve(*field_sym).map(str::to_string) else {
4321 continue;
4322 };
4323 let field = field.as_str();
4324 if self.fulltext.is_enabled(&label_str, field) {
4325 self.unindex_before_reindexing(id, field);
4326 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4327 }
4328 }
4329 }
4330 if self.prop_index.has_label(&label_str) {
4331 for (field_sym, value) in props {
4332 let Some(field) = self.syms.resolve(*field_sym) else {
4333 continue;
4334 };
4335 self.prop_index.set(&label_str, field, id, value);
4336 }
4337 }
4338 }
4339 WalRecord::InsertEdgeId { etype, src, dst } => {
4340 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
4341 // already be tombstoned. Skip rather than attaching edges to
4342 // dead ids (DeleteNode keys the live re-insert, not the old id).
4343 if self.ids.is_tombstoned(*src)
4344 || self.ids.is_tombstoned(*dst)
4345 || self.ids.key_of(*src).is_none()
4346 || self.ids.key_of(*dst).is_none()
4347 {
4348 return Ok(());
4349 }
4350 // Skip if already visible in the merged view (same idempotency
4351 // guard as InsertEdge above: prevents double-counting when
4352 // pre-snapshot WAL records are replayed over a V8 base).
4353 if self.base.is_some()
4354 && self
4355 .topo_view()
4356 .neighbors(*etype, Direction::Out, *src)
4357 .contains(dst)
4358 {
4359 return Ok(());
4360 }
4361 Arc::make_mut(&mut self.topo).add_edge(*etype, *src, *dst);
4362 self.view_store.on_edge_changed(
4363 *etype,
4364 *src,
4365 *dst,
4366 true,
4367 Arc::make_mut(&mut self.props),
4368 &build_topo_view(&self.topo, &self.base),
4369 &self.ids,
4370 &self.syms,
4371 &self.labels,
4372 base_columns(&self.base),
4373 );
4374 // Rule engine: via-hop rules fire when user via-edges are inserted.
4375 // Resolve etype back to string so on_edge_changed can match rules by name.
4376 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
4377 let cursor = self.engine.pending_delta_count();
4378 let mut eng = std::mem::take(&mut self.engine);
4379 {
4380 let mut gm = make_graph_mut(
4381 &self.ids,
4382 Arc::make_mut(&mut self.syms),
4383 &self.labels,
4384 build_props_view(&self.props, &self.base),
4385 Arc::make_mut(&mut self.topo),
4386 &self.base,
4387 Arc::make_mut(&mut self.edge_props),
4388 );
4389 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
4390 }
4391 self.engine = eng;
4392 if !self.view_store.is_empty() {
4393 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4394 for d in &new_deltas {
4395 self.view_store.on_edge_changed(
4396 d.etype_sym,
4397 d.src_id,
4398 d.dst_id,
4399 d.fired,
4400 Arc::make_mut(&mut self.props),
4401 &build_topo_view(&self.topo, &self.base),
4402 &self.ids,
4403 &self.syms,
4404 &self.labels,
4405 base_columns(&self.base),
4406 );
4407 }
4408 }
4409 }
4410 }
4411 WalRecord::SetPropId { id, field, value } => {
4412 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
4413 return Ok(());
4414 }
4415 let field_str = self
4416 .syms
4417 .resolve(*field)
4418 .ok_or_else(|| GraphError::Corrupt {
4419 detail: format!("wal SetPropId unknown field intern {field}"),
4420 })?
4421 .to_string();
4422 let old_value = build_props_view(&self.props, &self.base)
4423 .get(*id, &field_str)
4424 .map(|vr| vr.into_value());
4425 Arc::make_mut(&mut self.props).set(*id, &field_str, value.clone());
4426 let cursor = self.engine.pending_delta_count();
4427 let mut eng = std::mem::take(&mut self.engine);
4428 {
4429 let mut gm = make_graph_mut(
4430 &self.ids,
4431 Arc::make_mut(&mut self.syms),
4432 &self.labels,
4433 build_props_view(&self.props, &self.base),
4434 Arc::make_mut(&mut self.topo),
4435 &self.base,
4436 Arc::make_mut(&mut self.edge_props),
4437 );
4438 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
4439 }
4440 self.engine = eng;
4441 if !self.view_store.is_empty() {
4442 #[cfg(test)]
4443 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4444 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4445 for d in &new_deltas {
4446 self.view_store.on_edge_changed(
4447 d.etype_sym,
4448 d.src_id,
4449 d.dst_id,
4450 d.fired,
4451 Arc::make_mut(&mut self.props),
4452 &build_topo_view(&self.topo, &self.base),
4453 &self.ids,
4454 &self.syms,
4455 &self.labels,
4456 base_columns(&self.base),
4457 );
4458 }
4459 }
4460 self.view_store.on_prop_changed(
4461 *id,
4462 &field_str,
4463 Arc::make_mut(&mut self.props),
4464 &build_topo_view(&self.topo, &self.base),
4465 &self.ids,
4466 &self.syms,
4467 &self.labels,
4468 base_columns(&self.base),
4469 );
4470 if self.fulltext.field_indexed(&field_str) {
4471 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4472 if sym == u32::MAX {
4473 None
4474 } else {
4475 self.syms.resolve(sym)
4476 }
4477 });
4478 if let Some(label) = label_opt {
4479 if self.fulltext.is_enabled(label, &field_str) {
4480 Arc::make_mut(&mut self.fulltext).remove_node_field(*id, &field_str);
4481 Arc::make_mut(&mut self.fulltext).add_tokens(*id, &field_str, value);
4482 }
4483 }
4484 }
4485 if self.prop_index.field_indexed(&field_str) {
4486 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4487 if sym == u32::MAX {
4488 None
4489 } else {
4490 self.syms.resolve(sym)
4491 }
4492 });
4493 if let Some(label) = label_opt {
4494 self.prop_index.set(label, &field_str, *id, value);
4495 }
4496 }
4497 }
4498 WalRecord::CreateRule { def_bytes } => {
4499 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4500 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4501 })?;
4502 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
4503 // so the engine already has it; silently skip to avoid a spurious
4504 // RuleInvalid error in the crash window between snapshot write and WAL
4505 // truncation.
4506 if self.engine.rules().any(|r| r.name == def.name) {
4507 return Ok(());
4508 }
4509 let cursor = self.engine.pending_delta_count();
4510 let mut eng = std::mem::take(&mut self.engine);
4511 let result = {
4512 let mut gm = make_graph_mut(
4513 &self.ids,
4514 Arc::make_mut(&mut self.syms),
4515 &self.labels,
4516 build_props_view(&self.props, &self.base),
4517 Arc::make_mut(&mut self.topo),
4518 &self.base,
4519 Arc::make_mut(&mut self.edge_props),
4520 );
4521 eng.create_rule(def, &mut gm)
4522 };
4523 self.engine = eng;
4524 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
4525 // Derived-edge fires from backfill → view updates.
4526 // Fast path: skip O(edge_count) allocation when no views exist.
4527 if !self.view_store.is_empty() {
4528 #[cfg(test)]
4529 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4530 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4531 for d in &new_deltas {
4532 self.view_store.on_edge_changed(
4533 d.etype_sym,
4534 d.src_id,
4535 d.dst_id,
4536 d.fired,
4537 Arc::make_mut(&mut self.props),
4538 &build_topo_view(&self.topo, &self.base),
4539 &self.ids,
4540 &self.syms,
4541 &self.labels,
4542 base_columns(&self.base),
4543 );
4544 }
4545 }
4546 }
4547 WalRecord::DeleteRule { name } => {
4548 // Replay-over-snapshot idempotency: the snapshot already captured the
4549 // post-delete state so the rule is absent; silently skip to avoid a
4550 // spurious RuleNotFound error in the crash window between snapshot write
4551 // and WAL truncation.
4552 if !self.engine.rules().any(|r| r.name == *name) {
4553 return Ok(());
4554 }
4555 let cursor = self.engine.pending_delta_count();
4556 let mut eng = std::mem::take(&mut self.engine);
4557 let result = {
4558 let mut gm = make_graph_mut(
4559 &self.ids,
4560 Arc::make_mut(&mut self.syms),
4561 &self.labels,
4562 build_props_view(&self.props, &self.base),
4563 Arc::make_mut(&mut self.topo),
4564 &self.base,
4565 Arc::make_mut(&mut self.edge_props),
4566 );
4567 eng.delete_rule(name, &mut gm)
4568 };
4569 self.engine = eng;
4570 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4571 // Derived-edge retractions → view updates.
4572 if !self.view_store.is_empty() {
4573 #[cfg(test)]
4574 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4575 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4576 for d in &new_deltas {
4577 self.view_store.on_edge_changed(
4578 d.etype_sym,
4579 d.src_id,
4580 d.dst_id,
4581 d.fired,
4582 Arc::make_mut(&mut self.props),
4583 &build_topo_view(&self.topo, &self.base),
4584 &self.ids,
4585 &self.syms,
4586 &self.labels,
4587 base_columns(&self.base),
4588 );
4589 }
4590 }
4591 }
4592 WalRecord::RemoveProp { key, field } => {
4593 // Recovery-safe: unknown key or already-absent field is a
4594 // clean no-op. Crash-window replay over a snapshot that
4595 // already applied this record must not Err.
4596 let Some(id) = self.ids.get(key) else {
4597 return Ok(());
4598 };
4599 // Read old value through the seam for rule retraction.
4600 let old = build_props_view(&self.props, &self.base)
4601 .get(id, field)
4602 .map(|vr| vr.into_value());
4603 Arc::make_mut(&mut self.props).remove(id, field);
4604 // If the base still supplies the value after the overlay removal,
4605 // record a tombstone so ColumnsView::get does not resurrect it.
4606 // This covers both the base-only case AND the both-resident case:
4607 // base-only (in_overlay=false): old prop was only in base, remove
4608 // is a no-op on overlay, base still visible → tombstone needed.
4609 // both-resident (in_overlay=true): overlay had v2, base has v1;
4610 // removing overlay uncovers v1 → tombstone needed.
4611 // Idempotent on double-replay: second pass sees the tombstone →
4612 // get() returns None → condition is false → no duplicate tombstone.
4613 if build_props_view(&self.props, &self.base)
4614 .get(id, field)
4615 .is_some()
4616 {
4617 Arc::make_mut(&mut self.props).record_prop_tombstone(id, field);
4618 }
4619 let cursor = self.engine.pending_delta_count();
4620 let mut eng = std::mem::take(&mut self.engine);
4621 {
4622 let mut gm = make_graph_mut(
4623 &self.ids,
4624 Arc::make_mut(&mut self.syms),
4625 &self.labels,
4626 build_props_view(&self.props, &self.base),
4627 Arc::make_mut(&mut self.topo),
4628 &self.base,
4629 Arc::make_mut(&mut self.edge_props),
4630 );
4631 eng.on_node_changed(id, Some((field, old)), &mut gm);
4632 }
4633 self.engine = eng;
4634 // Derived-edge deltas → view updates.
4635 if !self.view_store.is_empty() {
4636 #[cfg(test)]
4637 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4638 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4639 for d in &new_deltas {
4640 self.view_store.on_edge_changed(
4641 d.etype_sym,
4642 d.src_id,
4643 d.dst_id,
4644 d.fired,
4645 Arc::make_mut(&mut self.props),
4646 &build_topo_view(&self.topo, &self.base),
4647 &self.ids,
4648 &self.syms,
4649 &self.labels,
4650 base_columns(&self.base),
4651 );
4652 }
4653 }
4654 // Neighbor-aggregate views that read `field` must also update.
4655 self.view_store.on_prop_changed(
4656 id,
4657 field,
4658 Arc::make_mut(&mut self.props),
4659 &build_topo_view(&self.topo, &self.base),
4660 &self.ids,
4661 &self.syms,
4662 &self.labels,
4663 base_columns(&self.base),
4664 );
4665 // Full-text index maintenance: remove tokens for this field.
4666 if self.fulltext.field_indexed(field) {
4667 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4668 }
4669 // Property (equality) index maintenance: drop this node's entry.
4670 if self.prop_index.field_indexed(field) {
4671 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
4672 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
4673 }) {
4674 self.prop_index.remove_node(label, field, id);
4675 }
4676 }
4677 }
4678 WalRecord::DeleteEdge {
4679 edge_type,
4680 src_key,
4681 dst_key,
4682 } => {
4683 // Recovery-safe: unknown keys, unknown etype, or already-
4684 // absent edge is a clean no-op (remove_edge returns false).
4685 let Some(src) = self.ids.get(src_key) else {
4686 return Ok(());
4687 };
4688 let Some(dst) = self.ids.get(dst_key) else {
4689 return Ok(());
4690 };
4691 let Some(etype) = self.syms.get(edge_type) else {
4692 return Ok(());
4693 };
4694 // I3: phantom-tombstone guard. When a V8 base is present, a
4695 // DeleteEdge WAL record for an edge that was already absorbed into
4696 // the new base (i.e. neither in overlay nor in base) must be skipped.
4697 // Without this guard, remove_edge records a tombstone for an edge
4698 // that no longer exists, incorrectly understating edge_count.
4699 if self.base.is_some()
4700 && !self
4701 .topo_view()
4702 .neighbors(etype, core_storage::topology::Direction::Out, src)
4703 .contains(&dst)
4704 {
4705 return Ok(());
4706 }
4707 Arc::make_mut(&mut self.topo).remove_edge(etype, src, dst);
4708 Arc::make_mut(&mut self.edge_props).remove_edge(etype, src, dst);
4709 // View maintenance for manual edge delete (topo already updated above).
4710 self.view_store.on_edge_changed(
4711 etype,
4712 src,
4713 dst,
4714 false,
4715 Arc::make_mut(&mut self.props),
4716 &build_topo_view(&self.topo, &self.base),
4717 &self.ids,
4718 &self.syms,
4719 &self.labels,
4720 base_columns(&self.base),
4721 );
4722 // Rule engine: via-hop rules must retract when user via-edges are deleted.
4723 let cursor = self.engine.pending_delta_count();
4724 let mut eng = std::mem::take(&mut self.engine);
4725 {
4726 let mut gm = make_graph_mut(
4727 &self.ids,
4728 Arc::make_mut(&mut self.syms),
4729 &self.labels,
4730 build_props_view(&self.props, &self.base),
4731 Arc::make_mut(&mut self.topo),
4732 &self.base,
4733 Arc::make_mut(&mut self.edge_props),
4734 );
4735 eng.on_edge_changed(edge_type, src, dst, &mut gm);
4736 }
4737 self.engine = eng;
4738 if !self.view_store.is_empty() {
4739 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4740 for d in &new_deltas {
4741 self.view_store.on_edge_changed(
4742 d.etype_sym,
4743 d.src_id,
4744 d.dst_id,
4745 d.fired,
4746 Arc::make_mut(&mut self.props),
4747 &build_topo_view(&self.topo, &self.base),
4748 &self.ids,
4749 &self.syms,
4750 &self.labels,
4751 base_columns(&self.base),
4752 );
4753 }
4754 }
4755 }
4756 WalRecord::DeleteNode { key } => {
4757 // Recovery-safe: already-tombstoned / unknown key is a clean
4758 // no-op. Crash-window replay over a snapshot that already
4759 // applied this record cannot recover the retired id from the
4760 // key (`IdMap::get` is None), so every subsequent step is
4761 // skipped. Each step is independently idempotent if invoked
4762 // twice on a still-live id: retraction is a no-op on empty
4763 // provenance, `remove_edge` returns false, `remove_all` is a
4764 // no-op, `ids.delete` returns None, label sentinel is sticky.
4765 let Some(n) = self.ids.get(key) else {
4766 return Ok(());
4767 };
4768
4769 // (1) Retract derived edges + de-index while props/labels live.
4770 let cursor = self.engine.pending_delta_count();
4771 let mut eng = std::mem::take(&mut self.engine);
4772 {
4773 let mut gm = make_graph_mut(
4774 &self.ids,
4775 Arc::make_mut(&mut self.syms),
4776 &self.labels,
4777 build_props_view(&self.props, &self.base),
4778 Arc::make_mut(&mut self.topo),
4779 &self.base,
4780 Arc::make_mut(&mut self.edge_props),
4781 );
4782 eng.on_node_removed(n, &mut gm);
4783 }
4784 self.engine = eng;
4785 // Derived-edge retractions → view updates for neighbors.
4786 if !self.view_store.is_empty() {
4787 #[cfg(test)]
4788 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4789 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4790 for d in &new_deltas {
4791 self.view_store.on_edge_changed(
4792 d.etype_sym,
4793 d.src_id,
4794 d.dst_id,
4795 d.fired,
4796 Arc::make_mut(&mut self.props),
4797 &build_topo_view(&self.topo, &self.base),
4798 &self.ids,
4799 &self.syms,
4800 &self.labels,
4801 base_columns(&self.base),
4802 );
4803 }
4804 }
4805
4806 // (2) Sweep ALL remaining edges incident to n, both directions,
4807 // every etype. This cascade is intentionally mask-independent:
4808 // topology integrity requires removing every edge touching the
4809 // deleted node regardless of the caller's visibility scope.
4810 // (The mask limits which nodes a role's read phase can return;
4811 // the WAL delete always executes with full storage authority.)
4812 // Collect then remove so neighbor slices stay valid during
4813 // iteration. Remove from topo first, then call view maintenance
4814 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
4815 //
4816 // Through the view of the snapshot and what was written since,
4817 // not `self.topo` alone: that is only what was written since,
4818 // and an edge the snapshot holds was left behind, pointing at
4819 // a node that was gone (ledger row 95).
4820 let mut doomed = Vec::new();
4821 {
4822 let tv = self.topo_view();
4823 let etypes: Vec<u32> = tv.etypes().collect();
4824 for et in &etypes {
4825 for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
4826 doomed.push((*et, n, dst));
4827 }
4828 for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
4829 // A self-loop is already listed from its Out side.
4830 if src != n {
4831 doomed.push((*et, src, n));
4832 }
4833 }
4834 }
4835 }
4836 for (et, s, d) in doomed {
4837 Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
4838 Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
4839 // View maintenance: n's own view values will be cleared by
4840 // remove_all below; only update surviving neighbors.
4841 self.view_store.on_edge_changed_except(
4842 et,
4843 s,
4844 d,
4845 false,
4846 Arc::make_mut(&mut self.props),
4847 &build_topo_view(&self.topo, &self.base),
4848 &self.ids,
4849 &self.syms,
4850 &self.labels,
4851 base_columns(&self.base),
4852 Some(n),
4853 );
4854 }
4855
4856 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
4857 Arc::make_mut(&mut self.props).remove_all(n);
4858 // Full-text index maintenance: remove all tokens for this node.
4859 Arc::make_mut(&mut self.fulltext).remove_node(n);
4860 // Property (equality) index maintenance: drop all entries for n.
4861 self.prop_index.remove_node_all(n);
4862
4863 // (4) Retire the dense id and stamp the label sentinel.
4864 Arc::make_mut(&mut self.ids).delete(key);
4865 if let Some(slot) = Arc::make_mut(&mut self.labels).get_mut(n as usize) {
4866 *slot = u32::MAX;
4867 }
4868 }
4869 WalRecord::Batch(inner) => {
4870 // Apply each inner record in order through the same apply path.
4871 // Inner records are validated free of nested Batch by encode_record.
4872 for rec in inner {
4873 self.apply(rec)?;
4874 }
4875 }
4876 WalRecord::RebuildRule { name } => {
4877 // Replay-over-snapshot idempotency: the snapshot may already
4878 // reflect a later delete_rule, so the rule is absent; skip.
4879 if !self.engine.rules().any(|r| r.name == *name) {
4880 return Ok(());
4881 }
4882 let cursor = self.engine.pending_delta_count();
4883 let mut eng = std::mem::take(&mut self.engine);
4884 let result = {
4885 let mut gm = make_graph_mut(
4886 &self.ids,
4887 Arc::make_mut(&mut self.syms),
4888 &self.labels,
4889 build_props_view(&self.props, &self.base),
4890 Arc::make_mut(&mut self.topo),
4891 &self.base,
4892 Arc::make_mut(&mut self.edge_props),
4893 );
4894 eng.rebuild(name, &mut gm)
4895 };
4896 self.engine = eng;
4897 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4898 // Derived-edge delta changes → view updates.
4899 if !self.view_store.is_empty() {
4900 #[cfg(test)]
4901 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4902 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4903 for d in &new_deltas {
4904 self.view_store.on_edge_changed(
4905 d.etype_sym,
4906 d.src_id,
4907 d.dst_id,
4908 d.fired,
4909 Arc::make_mut(&mut self.props),
4910 &build_topo_view(&self.topo, &self.base),
4911 &self.ids,
4912 &self.syms,
4913 &self.labels,
4914 base_columns(&self.base),
4915 );
4916 }
4917 }
4918 }
4919 WalRecord::CreateView { def_bytes } => {
4920 let def: ViewDef =
4921 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4922 detail: format!("CreateView def_bytes deserialize failed: {e}"),
4923 })?;
4924 // Replay-over-snapshot idempotency: view already present → skip.
4925 if self.view_store.has_view(&def.name) {
4926 return Ok(());
4927 }
4928 self.view_store
4929 .create_view_over(
4930 def,
4931 Arc::make_mut(&mut self.props),
4932 &build_topo_view(&self.topo, &self.base),
4933 &self.ids,
4934 &self.syms,
4935 &self.labels,
4936 base_columns(&self.base),
4937 )
4938 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4939 }
4940 WalRecord::DeleteView { name } => {
4941 // Replay-over-snapshot idempotency: view already absent → skip.
4942 if !self.view_store.has_view(name) {
4943 return Ok(());
4944 }
4945 let doomed_prop = self
4946 .view_store
4947 .views()
4948 .find(|v| v.name == *name)
4949 .map(|v| (v.label.clone(), v.view_prop.clone()));
4950 self.view_store
4951 .delete_view(
4952 name,
4953 Arc::make_mut(&mut self.props),
4954 &self.ids,
4955 &self.labels,
4956 &self.syms,
4957 )
4958 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4959 // `delete_view` clears the view's property from what was
4960 // written since the snapshot. A value the snapshot holds is
4961 // hidden by a tombstone, or it stays readable on every node
4962 // of the label after the view that owned it is gone.
4963 if let (Some((label, prop)), true) = (doomed_prop, self.base.is_some()) {
4964 if let Some(label_sym) = self.syms.get(&label) {
4965 for id in 0..self.ids.len() as u32 {
4966 if self.labels.get(id as usize).copied() == Some(label_sym)
4967 && build_props_view(&self.props, &self.base)
4968 .get(id, &prop)
4969 .is_some()
4970 {
4971 Arc::make_mut(&mut self.props).record_prop_tombstone(id, &prop);
4972 }
4973 }
4974 }
4975 }
4976 }
4977 WalRecord::EnableFulltext { label, field } => {
4978 // Replay-over-snapshot idempotency: already enabled → skip.
4979 if self.fulltext.is_enabled(label, field) {
4980 return Ok(());
4981 }
4982 Arc::make_mut(&mut self.fulltext).enable(label, field);
4983 if self.fulltext_rebuild_follows {
4984 // The open path rebuilds the whole index after replay, which
4985 // clears every posting this scan would write. Doing it twice
4986 // costs a full tokenise-and-stem pass over the corpus per
4987 // enabled pair: measured at 456 ms against 3.8 ms for the
4988 // same 30,000-entity store with no pair enabled.
4989 return Ok(());
4990 }
4991 // Backfill: index all live nodes of this label that have the field.
4992 let n = self.ids.len() as u32;
4993 for id in 0..n {
4994 let Some(&sym) = self.labels.get(id as usize) else {
4995 continue;
4996 };
4997 if sym == u32::MAX {
4998 continue; // tombstoned
4999 }
5000 let Some(lbl) = self.syms.resolve(sym) else {
5001 continue;
5002 };
5003 if lbl != label {
5004 continue;
5005 }
5006 if let Some(value) = build_props_view(&self.props, &self.base)
5007 .get(id, field)
5008 .map(|vr| vr.into_value())
5009 {
5010 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, &value);
5011 }
5012 }
5013 }
5014 WalRecord::DisableFulltext { label, field } => {
5015 // Replay-over-snapshot idempotency: already disabled → skip.
5016 if !self.fulltext.is_enabled(label, field) {
5017 return Ok(());
5018 }
5019 // If another label still indexes this field, the postings column
5020 // is kept — but it must not contain node_ids from the now-disabled
5021 // label. Remove them before calling disable() so the field_indexed
5022 // guard inside disable() sees the correct post-removal state.
5023 if self.fulltext.field_indexed_by_other(label, field) {
5024 if let Some(label_sym) = self.syms.get(label) {
5025 for (node_id, &lsym) in self.labels.iter().enumerate() {
5026 if lsym == label_sym {
5027 Arc::make_mut(&mut self.fulltext)
5028 .remove_node_field(node_id as u32, field);
5029 }
5030 }
5031 }
5032 }
5033 Arc::make_mut(&mut self.fulltext).disable(label, field);
5034 }
5035 WalRecord::EnableIndex { label, field } => {
5036 // Replay-over-snapshot idempotency: already enabled → skip.
5037 if self.prop_index.is_enabled(label, field) {
5038 return Ok(());
5039 }
5040 self.prop_index.enable(label, field);
5041 // Backfill: index all live nodes of this label that have the field.
5042 let n = self.ids.len() as u32;
5043 for id in 0..n {
5044 let Some(&sym) = self.labels.get(id as usize) else {
5045 continue;
5046 };
5047 if sym == u32::MAX {
5048 continue; // tombstoned
5049 }
5050 let Some(lbl) = self.syms.resolve(sym) else {
5051 continue;
5052 };
5053 if lbl != label {
5054 continue;
5055 }
5056 if let Some(value) = build_props_view(&self.props, &self.base)
5057 .get(id, field)
5058 .map(|vr| vr.into_value())
5059 {
5060 self.prop_index.set(label, field, id, &value);
5061 }
5062 }
5063 }
5064 WalRecord::DisableIndex { label, field } => {
5065 self.prop_index.disable(label, field);
5066 }
5067 // ── insert-count multiplicity (§5.13) ────────────────────────────
5068 //
5069 // Two shapes, told apart by `count`: the opt-in declaration, and an
5070 // absolute count for one triple. Absolute is what makes this
5071 // idempotent over a snapshot base — a pre-snapshot frame replayed
5072 // over a base that already folded it in lands on the same number
5073 // rather than adding to it, which is the failure a delta (or a count
5074 // derived from `InsertEdgeId` records) would have.
5075 WalRecord::SetEdgeCount {
5076 etype,
5077 src,
5078 dst,
5079 count,
5080 } => {
5081 if rec.is_multiplicity_decl() {
5082 self.multiplicity = true;
5083 } else {
5084 Arc::make_mut(&mut self.edge_props).set(
5085 *etype,
5086 *src,
5087 *dst,
5088 EDGE_COUNT_PROP,
5089 Value::Int(*count as i64),
5090 );
5091 }
5092 }
5093 // History markers carry no replay state — rules re-derive edges
5094 // deterministically on open/replay. Skip unconditionally.
5095 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
5096 // ── rename_node ──────────────────────────────────────────────────
5097 WalRecord::RenameNode { old_key, new_key } => {
5098 // Recovery-safe: if old_key is already gone (key was renamed
5099 // by a snapshot or a prior replay frame), skip cleanly.
5100 if self.ids.get(old_key).is_none() {
5101 return Ok(());
5102 }
5103 // The rename only updates the key-table; the dense id, all
5104 // topo edges, props, labels, and rule state are id-indexed and
5105 // require no change.
5106 Arc::make_mut(&mut self.ids)
5107 .rename(old_key, new_key)
5108 .map_err(|e| GraphError::Corrupt {
5109 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
5110 })?;
5111 }
5112 }
5113 Ok(())
5114 }
5115
5116 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
5117 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
5118 /// idempotent when the string is already bound. Always emit: after
5119 /// `snapshot()` the WAL is truncated and live intern is not on disk.
5120 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
5121 let id = if let Some(id) = self.syms.get(s) {
5122 id
5123 } else {
5124 Arc::make_mut(&mut self.syms).intern(s)
5125 };
5126 (
5127 id,
5128 WalRecord::Intern {
5129 id,
5130 text: s.to_string(),
5131 },
5132 )
5133 }
5134
5135 /// Rewrite user-facing records into dense-id records. On `Err`, no live
5136 /// state is left mutated: speculative interns made while building the
5137 /// output are rolled back, so a later successful mutation cannot log an
5138 /// `Intern` record whose id replay would never reproduce.
5139 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
5140 self.rewrite_wal_dense_planned(recs.into_iter().map(PlannedRec::Rec).collect())
5141 }
5142
5143 /// [`rewrite_wal_dense`](Self::rewrite_wal_dense) for a frame that still
5144 /// carries [`PlannedRec::DuplicateCount`] entries — the shape a batch
5145 /// produces, where a duplicate's count can only be named once this pass has
5146 /// assigned the frame's own ids.
5147 fn rewrite_wal_dense_planned(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5148 let syms_checkpoint = self.syms.len();
5149 let result = self.rewrite_wal_dense_inner(recs);
5150 if result.is_err() {
5151 Arc::make_mut(&mut self.syms).truncate(syms_checkpoint);
5152 }
5153 result
5154 }
5155
5156 fn rewrite_wal_dense_inner(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5157 let mut out = Vec::with_capacity(recs.len());
5158 // Node ids allocated by later apply(InsertNodeId) in this same batch.
5159 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5160 // Namespace of each node inserted earlier in this same frame, so a SET
5161 // on a node this frame created is measured against the namespace it was
5162 // created in rather than against the store, where it does not exist yet.
5163 let mut pending_ns: std::collections::HashMap<String, String> =
5164 std::collections::HashMap::new();
5165 let mut interned = std::collections::HashSet::<u32>::new();
5166 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
5167 detail: "id space exhausted".into(),
5168 })?;
5169 // Insert counts this frame has already raised. `edge_insert_count`
5170 // reads committed state, which cannot see a count queued earlier in
5171 // this same frame, so N duplicates of one pair would otherwise all
5172 // compute `committed + 1` and the last would win.
5173 let mut pending_counts: HashMap<(u32, u32, u32), u64> = HashMap::new();
5174 // Keys this frame has taken off the node the store holds them on: the
5175 // old key of a rename, and a deleted node's. Until the frame inserts
5176 // under such a key again it names nothing, and after that it names the
5177 // new node — never the stored one, which is what the store alone
5178 // would still answer while the frame is being rewritten.
5179 let mut freed: std::collections::HashSet<String> = std::collections::HashSet::new();
5180 let lookup = |ids: &IdMap,
5181 pending: &std::collections::HashMap<String, u32>,
5182 freed: &std::collections::HashSet<String>,
5183 key: &str|
5184 -> Option<u32> {
5185 match pending.get(key) {
5186 Some(&id) => Some(id),
5187 None if freed.contains(key) => None,
5188 None => ids.get(key),
5189 }
5190 };
5191 for rec in recs {
5192 // A duplicate insert's count, resolved here and nowhere else.
5193 //
5194 // This is the only pass that knows the frame's own ids: a node
5195 // created earlier in the same frame has no dense id until the
5196 // `InsertNodeId` above allocates one, and an edge type first used in
5197 // this frame is not in `syms` until `intern_wal` puts it there.
5198 // Resolving the count in the batch's validate pass instead — where
5199 // it used to live — meant that a duplicate whose endpoints or type
5200 // were created in the same frame silently produced no count at all,
5201 // which is exactly the shape a mirror rebuild writes (defect #24).
5202 let rec = match rec {
5203 PlannedRec::Rec(rec) => rec,
5204 PlannedRec::DuplicateCount {
5205 edge_type,
5206 src_key,
5207 dst_key,
5208 } => {
5209 let (etype, intern) = self.intern_wal(&edge_type);
5210 if interned.insert(etype) {
5211 out.push(intern);
5212 }
5213 let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5214 GraphError::Corrupt {
5215 detail: format!("dense WAL rewrite missing src {src_key}"),
5216 }
5217 })?;
5218 let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5219 GraphError::Corrupt {
5220 detail: format!("dense WAL rewrite missing dst {dst_key}"),
5221 }
5222 })?;
5223 let count = pending_counts
5224 .get(&(etype, src, dst))
5225 .copied()
5226 .unwrap_or_else(|| self.edge_insert_count(etype, src, dst))
5227 .saturating_add(1);
5228 pending_counts.insert((etype, src, dst), count);
5229 out.push(WalRecord::SetEdgeCount {
5230 etype,
5231 src,
5232 dst,
5233 count,
5234 });
5235 continue;
5236 }
5237 };
5238 match rec {
5239 WalRecord::InsertNode { label, key, props } => {
5240 // Namespace validation and normalisation, on the one seam
5241 // every user-visible node insert passes through: insert_node,
5242 // a batch, ingest, Cypher CREATE and MERGE all arrive here
5243 // before the WAL append, and replay never does.
5244 let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
5245 pending_ns.insert(key.clone(), ns_name);
5246 let (label_id, intern) = self.intern_wal(&label);
5247 if interned.insert(label_id) {
5248 out.push(intern);
5249 }
5250 let mut props_id = Vec::with_capacity(props.len());
5251 for (field, value) in props {
5252 let (field_id, intern) = self.intern_wal(&field);
5253 if interned.insert(field_id) {
5254 out.push(intern);
5255 }
5256 props_id.push((field_id, value));
5257 }
5258 if lookup(&self.ids, &pending, &freed, &key).is_none() {
5259 pending.insert(key.clone(), next);
5260 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
5261 detail: "id space exhausted".into(),
5262 })?;
5263 }
5264 out.push(WalRecord::InsertNodeId {
5265 label: label_id,
5266 key,
5267 props: props_id,
5268 });
5269 }
5270 WalRecord::SetProp { key, field, value } => {
5271 // A namespace is set at insert and fixed after: the write is
5272 // refused when it would move the node, and dropped when it
5273 // names the namespace the node is already in. Checked here
5274 // so set_prop, a batch, Cypher SET/MERGE and every upsert
5275 // that merges props get the same answer.
5276 if field == NS_PROP {
5277 let Value::Str(ref to) = value else {
5278 return Err(GraphError::RuleInvalid {
5279 detail: format!(
5280 "node {key}: {NS_PROP} must be a string naming a namespace, \
5281 got {value:?}"
5282 ),
5283 });
5284 };
5285 let from = pending_ns
5286 .get(&key)
5287 .cloned()
5288 .or_else(|| self.namespace_of(&key))
5289 .unwrap_or_else(|| NS_DEFAULT.to_string());
5290 let to = to.clone();
5291 if to != from {
5292 return Err(GraphError::NamespaceImmutable {
5293 key: key.clone(),
5294 from,
5295 to,
5296 });
5297 }
5298 continue;
5299 }
5300 let id = lookup(&self.ids, &pending, &freed, &key).ok_or_else(|| {
5301 GraphError::Corrupt {
5302 detail: format!("dense WAL rewrite missing key {key}"),
5303 }
5304 })?;
5305 let (field_id, intern) = self.intern_wal(&field);
5306 if interned.insert(field_id) {
5307 out.push(intern);
5308 }
5309 out.push(WalRecord::SetPropId {
5310 id,
5311 field: field_id,
5312 value,
5313 });
5314 }
5315 WalRecord::InsertEdge {
5316 edge_type,
5317 src_key,
5318 dst_key,
5319 } => {
5320 let (etype, intern) = self.intern_wal(&edge_type);
5321 if interned.insert(etype) {
5322 out.push(intern);
5323 }
5324 let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5325 GraphError::Corrupt {
5326 detail: format!("dense WAL rewrite missing src {src_key}"),
5327 }
5328 })?;
5329 let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5330 GraphError::Corrupt {
5331 detail: format!("dense WAL rewrite missing dst {dst_key}"),
5332 }
5333 })?;
5334 out.push(WalRecord::InsertEdgeId { etype, src, dst });
5335 }
5336 WalRecord::RenameNode {
5337 ref old_key,
5338 ref new_key,
5339 } => {
5340 // Track the rename in `pending` so subsequent InsertEdge /
5341 // SetProp records in this batch can resolve the new key.
5342 let id = lookup(&self.ids, &pending, &freed, old_key).ok_or_else(|| {
5343 GraphError::Corrupt {
5344 detail: format!(
5345 "dense WAL rewrite: RenameNode old key {old_key} not found"
5346 ),
5347 }
5348 })?;
5349 pending.remove(old_key.as_str());
5350 freed.insert(old_key.clone());
5351 pending.insert(new_key.clone(), id);
5352 out.push(rec);
5353 }
5354 // # Symbol-order invariant (load-bearing)
5355 //
5356 // Write-time and replay-time symbol assignment must agree: every
5357 // symbol in a `Batch` frame has to receive the same dense id when
5358 // the frame's records are replayed in order as it received when
5359 // the frame was written.
5360 //
5361 // A rule's backfill interns its `edge_type` lazily
5362 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
5363 // site), and that backfill runs from `apply` — during the
5364 // `CreateRule` record itself, and again from any later
5365 // `InsertNodeId` in the same frame that makes the rule fire. At
5366 // write time the whole batch is rewritten before any of it is
5367 // applied, so a later `InsertEdge` in the same batch would win the
5368 // lower id for its edge type; on replay the rule's lazy intern
5369 // gets there first and steals it, and the `Intern` record fails at
5370 // the `wal intern assigned …` check in `apply`.
5371 //
5372 // Pre-interning the rule's `edge_type` here, and emitting its
5373 // `Intern` record ahead of the `CreateRule` record, makes both
5374 // orders identical. `weight_prop` needs no pre-intern:
5375 // `EdgeProps::set` keys props by `String`, never through the
5376 // interner. `via_edge` needs none either: via-hop rules resolve it
5377 // with `syms.get` and skip when it is absent.
5378 //
5379 // `RebuildRule` and `DeleteRule` need no such handling here:
5380 // `RebuildRule` has no `BatchOp` variant, so it never appears
5381 // inside a `Batch` today — it is only ever issued as its own
5382 // standalone commit (`rebuild_rule`, or the auto-rebuild path
5383 // that logs it as a second commit after the triggering op).
5384 // `DeleteRule` does have a `BatchOp` variant and can appear
5385 // inside a `Batch`, but it carries only a rule `name` — no
5386 // `edge_type` or other symbol that needs pre-interning — so
5387 // only `CreateRule` needs this arm.
5388 WalRecord::CreateRule { ref def_bytes } => {
5389 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
5390 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
5391 })?;
5392 let (etype, intern) = self.intern_wal(&def.edge_type);
5393 if interned.insert(etype) {
5394 out.push(intern);
5395 }
5396 out.push(rec);
5397 }
5398 WalRecord::DeleteNode { ref key } => {
5399 pending.remove(key.as_str());
5400 freed.insert(key.clone());
5401 out.push(rec);
5402 }
5403 other => out.push(other),
5404 }
5405 }
5406 Ok(out)
5407 }
5408
5409 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
5410 let recs = self.rewrite_wal_dense(recs)?;
5411 match recs.len() {
5412 0 => Ok(()),
5413 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
5414 _ => self.log_then_apply(WalRecord::Batch(recs)),
5415 }
5416 }
5417
5418 /// Durable write, then notify the event sink. Replay (`apply` during
5419 /// `open`) never enters this function, so it is the replay-silent seam.
5420 /// Record that the commit occupying `frame_index` happened now, and append
5421 /// those 16 bytes to the sidecar.
5422 ///
5423 /// `frame_index` is the **global 0-based WAL frame index** of the commit's
5424 /// own record — the space every history surface addresses — taken from
5425 /// [`wal_frames_written`](GraphDb::wal_frames_written) before the append
5426 /// that puts the record there.
5427 ///
5428 /// **It is deliberately not derived from `commit_seq`.** A commit is not a
5429 /// frame: one whose rules fire appends a second frame for the derived-edge
5430 /// history marker, so `commit_seq - 1` falls one frame further behind per
5431 /// rule-firing commit and every date resolves to an ever-earlier graph.
5432 /// That was the shipped behaviour through v0.6.11 and it failed silently,
5433 /// because an older graph is a plausible answer rather than an error.
5434 ///
5435 /// Called from exactly one place — `log_then_apply_with`, immediately after
5436 /// `commit_seq` is incremented. Every write path in the engine funnels
5437 /// through that function, and replay deliberately does not: `apply_frames`
5438 /// re-applies commits that already happened, so stamping there would record
5439 /// replay time as commit time.
5440 ///
5441 /// **This is the engine's only wall-clock read.** Everything else uses
5442 /// `Instant`, which is monotonic and not a date.
5443 ///
5444 /// Failure is swallowed on purpose. The sidecar is not part of the WAL or
5445 /// the snapshot, so a failed append must not fail a commit that is already
5446 /// durable — it costs a date, not data. The map is marked poisoned so the
5447 /// gap is reported rather than resolved across.
5448 fn stamp_commit_time(&mut self, frame_index: u64) {
5449 if self.commit_times_poisoned {
5450 return;
5451 }
5452 let unix_ms = match self.commit_time_override {
5453 Some(ms) => ms,
5454 None => {
5455 let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
5456 else {
5457 // A clock before 1970. Refuse to invent a timestamp.
5458 self.commit_times_poisoned = true;
5459 return;
5460 };
5461 now.as_millis() as i64
5462 }
5463 };
5464 let first = self.commit_times.is_empty();
5465 self.commit_times.push(frame_index, unix_ms);
5466
5467 let wrote = if first {
5468 self.fs.write_atomic(
5469 FileId::CommitTimes,
5470 &core_storage::commit_times::encode(&self.commit_times),
5471 )
5472 } else {
5473 self.fs.append(
5474 FileId::CommitTimes,
5475 &core_storage::commit_times::encode_entry(frame_index, unix_ms),
5476 )
5477 };
5478 if wrote.is_err() {
5479 self.commit_times_poisoned = true;
5480 }
5481 }
5482
5483 /// Read the time sidecar from disk into this handle.
5484 ///
5485 /// Absent is the normal case for any store written before v0.6.11 and is
5486 /// not an error; unreadable is recorded so date queries can say "damaged"
5487 /// rather than "none recorded".
5488 ///
5489 /// Called at open **and** by `refresh` when a peer's frames are absorbed.
5490 /// Both, because the map is a file another process appends to: a handle
5491 /// that raises its frame cursor to include a peer's commits while holding a
5492 /// stale map would answer dates from a prefix of the truth — and, if its
5493 /// own map were still empty, would rewrite the whole file with one entry
5494 /// and destroy the peer's.
5495 fn load_commit_times_from_fs(&mut self) {
5496 match self.fs.read(FileId::CommitTimes) {
5497 Ok(bytes) if bytes.is_empty() => {}
5498 Ok(bytes) => {
5499 // A map from an older format version is discarded, not
5500 // reported as damage and not reinterpreted. Its entries were
5501 // written correctly against a different meaning of the number
5502 // — see `COMMIT_TIMES_VERSION` — and reading them in this
5503 // build's space would resolve dates onto unrelated commits.
5504 // Leaving the map empty makes the store answer
5505 // `NoRecordedTime`, which is the truth: it records no times
5506 // this build can use, and the next commit starts a usable map.
5507 if core_storage::commit_times::superseded_version(&bytes).is_some() {
5508 return;
5509 }
5510 match core_storage::commit_times::decode(&bytes) {
5511 Ok(t) => self.commit_times = t,
5512 Err(_) => self.commit_times_poisoned = true,
5513 }
5514 }
5515 Err(_) => {}
5516 }
5517 }
5518
5519 /// Rewrite the sidecar from memory. Used after truncation, which is the one
5520 /// operation that cannot be expressed as an append.
5521 fn rewrite_commit_times(&mut self) {
5522 if self.commit_times_poisoned {
5523 return;
5524 }
5525 if self
5526 .fs
5527 .write_atomic(
5528 FileId::CommitTimes,
5529 &core_storage::commit_times::encode(&self.commit_times),
5530 )
5531 .is_err()
5532 {
5533 self.commit_times_poisoned = true;
5534 }
5535 }
5536
5537 /// The greatest commit whose recorded time is at or before `unix_ms`.
5538 ///
5539 /// Errors name what they can answer instead of guessing a commit:
5540 /// `Corrupt` when the sidecar would not decode, `NoRecordedTime` when the
5541 /// store records none, `TimeBeforeFloor` when the instant predates the
5542 /// oldest entry, and `CommitOutOfRange` when the answer falls below the WAL
5543 /// horizon and so cannot be replayed.
5544 ///
5545 /// The answer is a **0-based frame index**, ready to hand to `edges_at` or
5546 /// `was_linked` without adjustment.
5547 pub fn resolve_instant(&self, unix_ms: i64) -> Result<u64> {
5548 if self.commit_times_poisoned {
5549 return Err(GraphError::Corrupt {
5550 detail: "commit_times.bin will not decode; date queries are \
5551 unavailable on this store"
5552 .into(),
5553 });
5554 }
5555 let at = self
5556 .commit_times
5557 .resolve_instant(unix_ms, self.wal_horizon_floor)?;
5558 // The map outlives the history it describes. A truncating snapshot folds
5559 // the WAL and discards it, so entries can name commits the engine can no
5560 // longer replay — the floor check above catches pruning, and this catches
5561 // discarding. Returning an index the caller's next call will reject is a
5562 // two-step error where one will do, and `resolve_date` is public: it
5563 // either hands back a usable index or refuses.
5564 let total = self.wal_total_commits()?;
5565 if at >= total {
5566 return Err(GraphError::CommitOutOfRange {
5567 commit: at,
5568 total,
5569 floor: self.wal_horizon_floor,
5570 });
5571 }
5572 Ok(at)
5573 }
5574
5575 /// Record subsequent commits as having happened at `unix_ms`, or pass
5576 /// `None` to go back to the system clock.
5577 ///
5578 /// For **backfilled history**: a mirror importing rows that already carry
5579 /// their own timestamps, or a replay of events that happened months ago.
5580 /// Without this every such commit is stamped "now", so a store holding a
5581 /// year of imported history answers every date question with
5582 /// `TimeBeforeFloor` — the data is there and no date reaches it.
5583 ///
5584 /// Sticky until changed or cleared, because a day of backfilled rows
5585 /// genuinely shares one instant.
5586 ///
5587 /// **Import in chronological order.** A supplied instant earlier than
5588 /// anything already recorded is refused with
5589 /// [`GraphError::CommitTimeNotMonotonic`], because resolution walks commit
5590 /// order: a later commit carrying an earlier time would silently widen every
5591 /// answer after it. Equal is allowed — that is what a shared day means. The
5592 /// live clock is never held to this, so an NTP step backwards still commits.
5593 ///
5594 /// Deliberately **not** exposed over HTTP or MCP: asserting when a commit
5595 /// happened rewrites the store's apparent history, which is not something a
5596 /// role token models. It is an embedding-caller's operation.
5597 pub fn record_commits_at(&mut self, unix_ms: Option<i64>) -> Result<()> {
5598 if self.read_only {
5599 return Err(GraphError::ReadOnly);
5600 }
5601 if let Some(ms) = unix_ms {
5602 if self.commit_times_poisoned {
5603 return Err(GraphError::Corrupt {
5604 detail: "commit_times.bin will not decode; this store cannot \
5605 record an asserted commit time"
5606 .into(),
5607 });
5608 }
5609 if let Some(newest) = self.commit_times.max_ms() {
5610 if ms < newest {
5611 return Err(GraphError::CommitTimeNotMonotonic {
5612 supplied_ms: ms,
5613 newest_ms: newest,
5614 });
5615 }
5616 }
5617 }
5618 self.commit_time_override = unix_ms;
5619 Ok(())
5620 }
5621
5622 /// The instant subsequent commits are being recorded at, when one is set.
5623 pub fn commit_time_override(&self) -> Option<i64> {
5624 self.commit_time_override
5625 }
5626
5627 /// [`Self::edges_at`] addressed by an instant rather than a commit index.
5628 ///
5629 /// Resolves through [`Self::resolve_instant`] — the last commit at or
5630 /// before the instant — then answers exactly as the commit-indexed call
5631 /// does. A store that records no times refuses by name; it never guesses.
5632 pub fn edges_at_instant(&self, key: &str, unix_ms: i64) -> Result<Vec<EdgeAt>> {
5633 let commit = self.resolve_instant(unix_ms)?;
5634 self.edges_at(key, commit)
5635 }
5636
5637 /// [`Self::was_linked`] addressed by an instant rather than a commit index.
5638 pub fn was_linked_at_instant(
5639 &self,
5640 a: &str,
5641 b: &str,
5642 edge_type: &str,
5643 unix_ms: i64,
5644 ) -> Result<bool> {
5645 let commit = self.resolve_instant(unix_ms)?;
5646 self.was_linked(a, b, edge_type, commit)
5647 }
5648
5649 /// Parse an RFC 3339 instant (or a bare `YYYY-MM-DD`) and resolve it.
5650 ///
5651 /// The one place every caller-facing surface converts a date string, so
5652 /// HTTP, MCP, Python and the CLI cannot drift in what they accept.
5653 pub fn resolve_date(&self, s: &str) -> Result<u64> {
5654 // The **end** of what the string denotes. A bare date is a day, so it
5655 // resolves to the last commit at or before that day's end — resolving to
5656 // the midnight that starts it would exclude everything that happened on
5657 // the date the caller asked about.
5658 let ms = core_storage::commit_times::parse_rfc3339_end_ms(s).ok_or_else(|| {
5659 GraphError::QueryError {
5660 detail: format!(
5661 "could not parse {s:?} as a date; expected RFC 3339 \
5662 (2026-06-19, or 2026-06-19T12:00:00Z)"
5663 ),
5664 }
5665 })?;
5666 self.resolve_instant(ms)
5667 }
5668
5669 /// The recorded wall-clock time of `commit`, when the sidecar holds one.
5670 ///
5671 /// `commit` is a **0-based frame index** — the space `edges_at`,
5672 /// `was_linked` and the history events use, not the 1-based `commit_seq`.
5673 pub fn commit_time_ms(&self, commit: u64) -> Option<i64> {
5674 if self.commit_times_poisoned {
5675 return None;
5676 }
5677 self.commit_times.time_of(commit)
5678 }
5679
5680 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
5681 self.log_then_apply_with(rec, None, self.fsync)
5682 }
5683
5684 /// Whether this frame must fsync under `policy`.
5685 ///
5686 /// Batched contract: user-visible batches (>1 mutation) fsync; single
5687 /// mutations do not. The dense rewrite wraps a single mutation in a
5688 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
5689 /// from the count — removing that filter would make every single-op write
5690 /// fsync under Batched (or, if the threshold were raised instead, skip a
5691 /// needed fsync for real two-op batches).
5692 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
5693 match policy {
5694 FsyncPolicy::Relaxed => false,
5695 FsyncPolicy::Strict => true,
5696 FsyncPolicy::Batched => match rec {
5697 // Intern + one mutation is the single-op rewrite, not a user batch.
5698 WalRecord::Batch(inner) => {
5699 inner
5700 .iter()
5701 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
5702 .count()
5703 > 1
5704 }
5705 _ => false,
5706 },
5707 }
5708 }
5709
5710 /// # Apply-infallibility invariant (load-bearing)
5711 ///
5712 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
5713 /// for a `Batch` frame after a successful WAL write, the WAL would contain
5714 /// the full frame while in-memory state would reflect only the ops before
5715 /// the failure. On reopen, WAL replay would then apply the entire batch —
5716 /// diverging permanently from what the pre-crash process had in memory.
5717 ///
5718 /// For `Batch` frames this situation cannot arise because:
5719 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
5720 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
5721 /// use, with no concurrent mutation between validation exit and apply entry.
5722 /// - Every `apply` arm for a validated op is either infallible by construction
5723 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
5724 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
5725 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
5726 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
5727 ///
5728 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
5729 /// `Err` for a `Batch` frame, making any future regression immediately visible
5730 /// in tests rather than silently diverging crash-recovery behaviour.
5731 fn log_then_apply_with(
5732 &mut self,
5733 rec: WalRecord,
5734 ingest: Option<(String, usize)>,
5735 policy: FsyncPolicy,
5736 ) -> Result<()> {
5737 // Read-only guard: as-of instances must never write the WAL.
5738 if self.read_only {
5739 return Err(GraphError::ReadOnly);
5740 }
5741 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
5742 // partway; in-memory state is ahead of (or out of step with) the
5743 // on-disk WAL, so further mutations would deepen the divergence.
5744 // Reopen the database to recover. Checked before the lock guard: this
5745 // is the more serious condition and the more useful error.
5746 if self.degraded {
5747 return Err(GraphError::Io(std::io::Error::other(
5748 "database degraded after group-commit fsync failure; reopen required",
5749 )));
5750 }
5751 // Cross-process guard: this write scope asked for the store's write
5752 // lock and did not get it. Writing anyway would append frames on top of
5753 // a WAL another process is extending, so refuse instead.
5754 if self.lock_denied {
5755 return Err(GraphError::Busy { holder: None });
5756 }
5757 // Ensure retained provenance bytes are decoded into the live mutable
5758 // fields before any mutation touches self.engine.provenance. This is a
5759 // no-op if provenance was never stored (fresh store) or has already been
5760 // consumed (subsequent mutations). WAL replay calls apply() directly
5761 // and is covered by consume_retained_state_eager before replay.
5762 self.ensure_v8_base_sections_loaded();
5763 self.engine.ensure_provenance_loaded_mut();
5764 // Invariant (I-1): no stale deltas may enter from a previous apply.
5765 // If any engine method ever accumulates deltas before erroring, they would
5766 // contaminate the *next* commit's event stream. This assert fires in debug
5767 // builds, making any future regression visible at the earliest point.
5768 debug_assert_eq!(
5769 self.engine.pending_delta_count(),
5770 0,
5771 "stale engine deltas at log_then_apply_with entry — \
5772 a previous apply arm may have accumulated deltas before erroring; \
5773 the caller must drain_deltas() on any error path before returning"
5774 );
5775 if self.intent_unsettled {
5776 self.settle_snapshot_intent()?;
5777 }
5778 let frame = encode_record(&rec);
5779 self.fs.append(FileId::Wal, &frame)?;
5780 // The cursor advances by exactly the bytes appended: these frames are
5781 // ours and already applied, so a later refresh must not replay them.
5782 self.wal_consumed += frame.len() as u64;
5783 self.wal_frames_written += 1;
5784 if Self::wal_needs_sync(policy, &rec) {
5785 self.fs.sync(FileId::Wal)?;
5786 }
5787 // Marker writing always needs the engine deltas, but the engine only
5788 // accumulates them when emit_deltas is true (normally gated on subscribers
5789 // or views being present). Enable emission for this apply if it is
5790 // currently off, then restore the original state unconditionally via an
5791 // RAII guard — this prevents a panic in apply() from leaking the flag.
5792 // The same guard resets the engine's transient chaining state. A panic
5793 // unwinding out of a rule hook would otherwise leave `chain_depth`
5794 // non-zero, which makes every later `begin_chain` decide chaining is
5795 // already running and silently switch it off for good.
5796 struct RestoreEmitDeltas(*mut RuleEngine, bool);
5797 impl Drop for RestoreEmitDeltas {
5798 fn drop(&mut self) {
5799 // SAFETY: pointer into self (GraphDb); guard is dropped within
5800 // this frame before log_then_apply_with returns.
5801 unsafe {
5802 (*self.0).set_emit_deltas(self.1);
5803 (*self.0).reset_chain_state();
5804 }
5805 }
5806 }
5807 let original_emit = self.engine.emit_deltas();
5808 if !original_emit {
5809 self.engine.set_emit_deltas(true);
5810 }
5811 // SAFETY: raw pointer into self; guard dropped within this frame.
5812 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
5813
5814 let apply_result = self.apply(&rec);
5815 // For Batch frames, post-validation apply must be infallible (see above).
5816 // A debug_assert here catches any future change that makes apply fallible
5817 // before the caller notices via silent WAL/memory divergence.
5818 if matches!(&rec, WalRecord::Batch(_)) {
5819 debug_assert!(
5820 apply_result.is_ok(),
5821 "Batch apply returned Err after successful WAL write — \
5822 the validate-then-apply invariant has been violated; \
5823 see log_then_apply_with invariant doc"
5824 );
5825 }
5826 if apply_result.is_err() {
5827 // Discard any partial deltas accumulated by the failed apply.
5828 // They must not ride the next commit's event stream (I-1).
5829 // _emit_guard restores emit_deltas on drop automatically.
5830 let _ = self.engine.drain_deltas();
5831 let _ = self.engine.take_rebuild_needed();
5832 apply_result?;
5833 }
5834 self.commit_seq += 1;
5835 let seq = self.commit_seq;
5836 // Update per-node last-change map for the committed record.
5837 // Must happen after commit_seq is incremented so the seq is correct.
5838 self.update_last_change_from_rec(&rec, seq);
5839 // Drain engine deltas and distribute to subscribers before the existing
5840 // MutationEvent sink fires — both happen post-fsync, post-apply.
5841 // _emit_guard restores emit_deltas after this line when it drops.
5842 let engine_deltas = self.engine.drain_deltas();
5843
5844 // Append history-marker WAL records for any derived-edge changes so
5845 // that `edge_history` and `was_linked` can surface rule-attributed
5846 // events. Markers are STATE NO-OPS during replay; they are written
5847 // without an additional fsync (the triggering commit's sync already
5848 // happened; the next commit's sync covers these lazily).
5849 if !engine_deltas.is_empty() {
5850 let markers: Vec<WalRecord> = engine_deltas
5851 .iter()
5852 .map(|d| {
5853 if d.fired {
5854 WalRecord::DerivedEdgeAdded {
5855 rule: d.rule.clone(),
5856 edge_type: d.edge_type.clone(),
5857 src_key: d.src_key.clone(),
5858 dst_key: d.dst_key.clone(),
5859 }
5860 } else {
5861 WalRecord::DerivedEdgeRetracted {
5862 rule: d.rule.clone(),
5863 edge_type: d.edge_type.clone(),
5864 src_key: d.src_key.clone(),
5865 dst_key: d.dst_key.clone(),
5866 }
5867 }
5868 })
5869 .collect();
5870 let marker_frame = if markers.len() == 1 {
5871 markers.into_iter().next().unwrap()
5872 } else {
5873 WalRecord::Batch(markers)
5874 };
5875 // Ignore append errors: markers are best-effort history
5876 // annotations. Losing them does not affect state correctness.
5877 // The cursor only advances when the bytes actually landed.
5878 let marker_bytes = encode_record(&marker_frame);
5879 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
5880 self.wal_consumed += marker_bytes.len() as u64;
5881 // A marker is a state no-op during replay but it is not an
5882 // index no-op: it occupies a frame that every history surface
5883 // counts. Missing this increment is the whole of defect 1.
5884 self.wal_frames_written += 1;
5885 }
5886 }
5887
5888 // Stamp the commit against the **last** frame it wrote.
5889 //
5890 // `edges_at` and `was_linked` reconstruct derived edges by reading the
5891 // history markers out of the WAL, not by re-running rules over a
5892 // prefix. A commit's complete state — its record *and* the edges its
5893 // rules derived — is therefore only reached at its marker frame, so
5894 // that is the frame a date naming this commit must resolve to. Stamping
5895 // the record's own frame would answer every date with the graph as it
5896 // was one derivation short.
5897 //
5898 // This runs after the marker append for that reason, and it is still
5899 // the single stamping site: one commit, one entry.
5900 self.stamp_commit_time(self.wal_frames_written - 1);
5901
5902 // Record MVCC CommitDelta for the epoch reader. The WAL record is
5903 // stored as-is (including any nested Batch / Intern records); the
5904 // ReaderSnapshot's apply_one function handles all variants.
5905 {
5906 let derived_inserts = engine_deltas
5907 .iter()
5908 .filter(|d| d.fired)
5909 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5910 .collect();
5911 let derived_deletes = engine_deltas
5912 .iter()
5913 .filter(|d| !d.fired)
5914 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5915 .collect();
5916 // `None`: the commit moved more view values than a delta should
5917 // carry, so the fold below takes them instead.
5918 let view_values = self.view_values_of_commit(&rec);
5919 let fold = view_values.is_none();
5920 let delta = Arc::new(crate::reader::CommitDelta {
5921 records: vec![rec.clone()],
5922 derived_inserts,
5923 derived_deletes,
5924 view_values: view_values.unwrap_or_default(),
5925 });
5926 self.delta_tail.push(delta);
5927 self.commits_since_fold += 1;
5928 if fold || self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
5929 self.fold_now();
5930 }
5931 }
5932
5933 if self.defer_events {
5934 // Group-commit drain thread: hold events until after the group
5935 // fsync so subscribers only observe durable data (R2).
5936 self.deferred_events.push(DeferredEvent {
5937 rec: rec.clone(),
5938 engine_deltas,
5939 seq,
5940 ingest,
5941 });
5942 } else {
5943 self.distribute_events(&rec, &engine_deltas, seq);
5944 self.emit_committed(&rec, ingest);
5945 }
5946 // Drift is only known after apply, so auto-rebuild cannot join the
5947 // triggering op's WAL frame. Issue RebuildRule as a second commit.
5948 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
5949 // retrigger loop is impossible if the fit succeeded, but we still
5950 // drain the flag so a leftover cannot re-enter.
5951 // One slice of any outstanding vector-index build rides here too, so a
5952 // store that is being written to finishes its build without anyone
5953 // calling `pump_index_build`. A rule that becomes whole joins the same
5954 // RebuildRule loop below.
5955 let mut rebuilds = self.engine.take_rebuild_needed();
5956 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
5957 // Not after `CreateRule`: that record's own apply already did the
5958 // rule's first slice, and pumping again here would make one
5959 // `create_rule` call do two slices' work under one lock.
5960 // Nothing pending is the overwhelmingly common case and must cost
5961 // a map lookup, not an engine swap: a store being written to has
5962 // long since populated its indexes, so the `pump_index_build`
5963 // entry point owns the not-yet-populated case on its own.
5964 if !is_create_rule_frame(&rec) && !self.engine.builds_in_progress().is_empty() {
5965 rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
5966 }
5967 let mut failed = Vec::new();
5968 for name in rebuilds {
5969 if self.engine.rules().any(|r| r.name == name) {
5970 // User op is already durable. A failed second commit must
5971 // not surface as the caller's error.
5972 if let Err(e) =
5973 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
5974 {
5975 eprintln!(
5976 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
5977 );
5978 failed.push(name);
5979 }
5980 }
5981 }
5982 for name in failed {
5983 self.engine.queue_rebuild_needed(name);
5984 }
5985 }
5986 Ok(())
5987 }
5988
5989 /// Install a post-commit hook. Replaces any previous sink.
5990 ///
5991 /// The sink runs inside `log_then_apply` after a successful
5992 /// durable commit, while the caller still holds `&mut self`. When this
5993 /// database is behind a [`crate::SharedDb`], that means the **write
5994 /// guard is held**. The sink must never call `read` / `write` (or any
5995 /// other method) on the same `SharedDb` — the `RwLock` is not
5996 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
5997 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
5998 /// Intended examples: `std::sync::mpsc::SyncSender`,
5999 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
6000 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
6001 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
6002 self.event_sink = Some(sink);
6003 }
6004
6005 /// Whether a post-commit event sink is currently installed.
6006 pub fn has_event_sink(&self) -> bool {
6007 self.event_sink.is_some()
6008 }
6009
6010 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
6011 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
6012 self.fsync = p;
6013 }
6014
6015 /// Return the current WAL fsync cadence.
6016 pub fn fsync_policy(&self) -> FsyncPolicy {
6017 self.fsync
6018 }
6019
6020 // ── Group-commit event deferral ───────────────────────────────────────────
6021
6022 /// Enable or disable deferred event mode.
6023 ///
6024 /// When `true`, event notifications (subscription `DbEvent`s and legacy
6025 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
6026 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
6027 /// or [`discard_deferred_events`] if the fsync failed and the group must
6028 /// be treated as lost.
6029 pub fn set_deferred_events_mode(&mut self, defer: bool) {
6030 self.defer_events = defer;
6031 }
6032
6033 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
6034 /// was set to true. Clears the buffer.
6035 ///
6036 /// Called by the drain thread AFTER a successful group fsync, so
6037 /// subscribers observe only data that is durably on disk.
6038 pub fn flush_deferred_events(&mut self) {
6039 let events = std::mem::take(&mut self.deferred_events);
6040 for de in events {
6041 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
6042 self.emit_committed(&de.rec, de.ingest);
6043 }
6044 }
6045
6046 /// Discard all buffered events without firing them.
6047 ///
6048 /// Called by the drain thread when a group fsync fails: the WAL has been
6049 /// truncated back to the pre-group offset, so the committed-but-unsynced
6050 /// ops must not be observable to subscribers.
6051 pub fn discard_deferred_events(&mut self) {
6052 self.deferred_events.clear();
6053 }
6054
6055 // ── Degraded state ────────────────────────────────────────────────────────
6056
6057 /// Mark this database as degraded.
6058 ///
6059 /// Called by the group-commit drain thread after a group fsync failure and
6060 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
6061 /// further mutations would deepen the divergence. All subsequent calls to
6062 /// [`log_then_apply_with`] return `Err` until the database is reopened.
6063 pub fn set_degraded(&mut self) {
6064 self.degraded = true;
6065 }
6066
6067 fn emit(&self, ev: MutationEvent) {
6068 if let Some(sink) = &self.event_sink {
6069 sink(ev);
6070 }
6071 }
6072
6073 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
6074 match rec {
6075 WalRecord::Batch(inner) => {
6076 for r in inner {
6077 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
6078 self.emit(ev);
6079 }
6080 }
6081 match ingest {
6082 Some((label, inserted)) => {
6083 self.emit(MutationEvent::Ingested { label, inserted })
6084 }
6085 None => {
6086 let ops = inner
6087 .iter()
6088 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
6089 .count();
6090 if ops > 1 {
6091 self.emit(MutationEvent::BatchApplied { ops });
6092 }
6093 }
6094 }
6095 }
6096 other => {
6097 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
6098 self.emit(ev);
6099 }
6100 }
6101 }
6102 }
6103
6104 // -----------------------------------------------------------------------
6105 // Subscription API
6106 // -----------------------------------------------------------------------
6107
6108 /// Distribute post-commit events to all live subscribers.
6109 ///
6110 /// Build a row-key → row-data map from a [`ResultSet`].
6111 ///
6112 /// Each row is serialized to JSON to form its key; a debug fallback is used
6113 /// if serialization fails. Used by both the initial-seed path in
6114 /// [`Self::subscribe_query`] and the per-commit diff path in
6115 /// [`Self::distribute_events`] to keep the two in sync.
6116 fn result_to_row_map(
6117 result: &core_query::ResultSet,
6118 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
6119 (0..result.len())
6120 .map(|i| {
6121 let row = result.row(i).to_vec();
6122 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
6123 (key, row)
6124 })
6125 .collect()
6126 }
6127
6128 /// Collect the set of label syms touched by a WAL record.
6129 ///
6130 /// Returns `Some(set)` when every record in this commit can be attributed to
6131 /// a known label sym. Returns `None` when the commit must not be skipped:
6132 /// edge records, unresolvable key→label lookups, or any record type not in
6133 /// the explicit handled set.
6134 ///
6135 /// Handled record types and their actions:
6136 /// - `InsertNode` → look up label in interner (fails → None)
6137 /// - `InsertNodeId` → label sym is carried directly
6138 /// - `SetProp` → resolve key→id→label (fails → None)
6139 /// - `DeleteNode` → resolve key→id→label (fails → None)
6140 /// - `Batch` → recurse into every inner record
6141 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
6142 /// - everything else → None (conservative)
6143 fn commit_touched_labels(
6144 rec: &WalRecord,
6145 syms: &Interner,
6146 ids: &IdMap,
6147 labels: &[u32],
6148 ) -> Option<BTreeSet<u32>> {
6149 let mut out = BTreeSet::new();
6150 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
6151 Some(out)
6152 } else {
6153 None
6154 }
6155 }
6156
6157 fn collect_touched_labels(
6158 rec: &WalRecord,
6159 syms: &Interner,
6160 ids: &IdMap,
6161 labels: &[u32],
6162 out: &mut BTreeSet<u32>,
6163 ) -> bool {
6164 match rec {
6165 // String-key insert: the dense rewrite converts this to
6166 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
6167 // records written before the dense path was added.
6168 WalRecord::InsertNode { label, .. } => {
6169 if let Some(sym) = syms.get(label) {
6170 out.insert(sym);
6171 true
6172 } else {
6173 false
6174 }
6175 }
6176 // Dense-id insert (produced by rewrite_wal_dense for every
6177 // insert_node call in the current codebase).
6178 WalRecord::InsertNodeId { label, .. } => {
6179 out.insert(*label);
6180 true
6181 }
6182 // String-key prop set: dense path converts to [Intern, SetPropId].
6183 WalRecord::SetProp { key, .. } => {
6184 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6185 out.insert(sym);
6186 true
6187 } else {
6188 false
6189 }
6190 }
6191 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
6192 WalRecord::SetPropId { id, .. } => {
6193 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
6194 out.insert(sym);
6195 true
6196 } else {
6197 false
6198 }
6199 }
6200 WalRecord::DeleteNode { key } => {
6201 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6202 out.insert(sym);
6203 true
6204 } else {
6205 false
6206 }
6207 }
6208 WalRecord::Batch(inner) => inner
6209 .iter()
6210 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
6211 // Intern is a pure metadata record — it does not touch any node's
6212 // label and is safe to skip for the label-skip predicate.
6213 WalRecord::Intern { .. } => true,
6214 // Edge records: always re-execute (edges can change join results).
6215 WalRecord::InsertEdge { .. }
6216 | WalRecord::DeleteEdge { .. }
6217 | WalRecord::InsertEdgeId { .. } => false,
6218 _ => false,
6219 }
6220 }
6221
6222 /// Resolve a node key to its label sym via the dense id table.
6223 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
6224 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
6225 let id = ids.get(key)?;
6226 let sym = labels.get(id as usize).copied()?;
6227 (sym != u32::MAX).then_some(sym)
6228 }
6229
6230 /// Distribute post-commit events to all live subscribers.
6231 ///
6232 /// Called from `log_then_apply_with` after apply + fsync, before the
6233 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
6234 ///
6235 /// Query subscriptions (subscribe_query) re-execute their plan on every
6236 /// call and diff the result against the previous run. Zero overhead when
6237 /// no query subscriptions are active.
6238 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
6239 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
6240 return;
6241 }
6242
6243 if !self.subscriptions.is_empty() {
6244 // Build write events from the WAL record.
6245 let write_events: Vec<DbEvent> =
6246 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
6247
6248 // Build edge events from engine deltas. Weight is looked up from
6249 // edge_props at distribution time (after apply), so it's always fresh.
6250 let edge_events: Vec<DbEvent> = engine_deltas
6251 .iter()
6252 .map(|d| {
6253 if d.fired {
6254 // The score lives under the rule's declared weight_prop,
6255 // which is not always the literal "weight".
6256 let prop = self
6257 .engine
6258 .rules()
6259 .find(|r| r.name == d.rule)
6260 .and_then(|r| r.weight_prop.as_deref());
6261 let weight = prop.and_then(|p| {
6262 self.edge_props
6263 .get(d.etype_sym, d.src_id, d.dst_id, p)
6264 .and_then(|v| {
6265 if let core_storage::Value::Float(f) = v {
6266 Some(*f)
6267 } else {
6268 None
6269 }
6270 })
6271 });
6272 DbEvent::EdgeFired {
6273 rule: d.rule.clone(),
6274 src_key: d.src_key.clone(),
6275 dst_key: d.dst_key.clone(),
6276 edge_type: d.edge_type.clone(),
6277 weight,
6278 commit_seq: seq,
6279 }
6280 } else {
6281 DbEvent::EdgeRetracted {
6282 rule: d.rule.clone(),
6283 src_key: d.src_key.clone(),
6284 dst_key: d.dst_key.clone(),
6285 edge_type: d.edge_type.clone(),
6286 commit_seq: seq,
6287 }
6288 }
6289 })
6290 .collect();
6291
6292 // Prune dead entries; push matching events to live ones.
6293 self.subscriptions.retain(|entry| {
6294 let Some(inner) = entry.inner.upgrade() else {
6295 return false;
6296 };
6297 for ev in &write_events {
6298 if event_matches(ev, &entry.filter) {
6299 inner.push(ev.clone());
6300 }
6301 }
6302 for ev in &edge_events {
6303 if event_matches(ev, &entry.filter) {
6304 inner.push(ev.clone());
6305 }
6306 }
6307 true
6308 });
6309
6310 // Turn off delta accumulation if all subscribers dropped and no views remain.
6311 if self.subscriptions.is_empty() && self.view_store.is_empty() {
6312 self.engine.set_emit_deltas(false);
6313 }
6314 }
6315
6316 // Query subscriptions: full re-run per commit, then diff rows.
6317 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
6318 // Differential evaluation is roadmap / Phase 5.
6319 if !self.query_subscriptions.is_empty() {
6320 // Take the list out so we can call self.view() without borrow conflict.
6321 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
6322 let empty_params = BTreeMap::new();
6323 query_subs.retain_mut(|entry| {
6324 let Some(inner) = entry.inner.upgrade() else {
6325 return false; // subscriber dropped — prune
6326 };
6327 // Label-skip: if the plan has a known scan label and this commit
6328 // can be proven to touch only different labels (and no rule-derived
6329 // edge deltas fired), the result set cannot have changed — skip.
6330 if let Some(scan_sym) = entry.scan_label {
6331 if engine_deltas.is_empty() {
6332 let touched =
6333 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
6334 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
6335 return true; // safe to skip — result set unchanged
6336 }
6337 }
6338 }
6339 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
6340 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
6341 Ok(r) => r,
6342 Err(e) => {
6343 // Keep the subscription alive; skip the diff for this commit.
6344 // Re-run errors are transient (e.g., planner change) and
6345 // self-heal when the next commit succeeds.
6346 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
6347 return true;
6348 }
6349 };
6350 // Build new row map: serialized-key → row data.
6351 let new_row_map = Self::result_to_row_map(&result);
6352 // Removed rows: in prev but not in new.
6353 for (key, row) in &entry.prev_row_map {
6354 if !new_row_map.contains_key(key) {
6355 inner.push(DbEvent::QueryRowRemoved {
6356 columns: entry.columns.clone(),
6357 row: row.clone(),
6358 });
6359 }
6360 }
6361 // Added rows: in new but not in prev.
6362 for (key, row) in &new_row_map {
6363 if !entry.prev_row_map.contains_key(key) {
6364 inner.push(DbEvent::QueryRowAdded {
6365 columns: entry.columns.clone(),
6366 row: row.clone(),
6367 });
6368 }
6369 }
6370 entry.prev_row_map = new_row_map;
6371 true
6372 });
6373 self.query_subscriptions = query_subs;
6374 }
6375 }
6376
6377 /// Returns `true` if any live subscriber or view definition requires delta
6378 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
6379 fn needs_emit_deltas(&self) -> bool {
6380 !self.view_store.is_empty()
6381 || self
6382 .subscriptions
6383 .iter()
6384 .any(|e| e.inner.upgrade().is_some())
6385 }
6386
6387 /// Convert a WAL record into `DbEvent` write events with the given seq.
6388 fn write_events_from_record(
6389 rec: &WalRecord,
6390 seq: u64,
6391 intern: &Interner,
6392 ids: &IdMap,
6393 ) -> Vec<DbEvent> {
6394 match rec {
6395 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
6396 label: label.clone(),
6397 key: key.clone(),
6398 commit_seq: seq,
6399 }],
6400 // *Id arms run after a successful apply, so resolution can only
6401 // fail on a programming error. Skip the event rather than emit a
6402 // fabricated "" that clients can't tell from a real empty value
6403 // (mirrors event_from_record returning None).
6404 WalRecord::InsertNodeId { label, key, .. } => intern
6405 .resolve(*label)
6406 .map(|label| DbEvent::NodeInserted {
6407 label: label.to_string(),
6408 key: key.clone(),
6409 commit_seq: seq,
6410 })
6411 .into_iter()
6412 .collect(),
6413 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
6414 key: key.clone(),
6415 field: field.clone(),
6416 commit_seq: seq,
6417 }],
6418 WalRecord::SetPropId { id, field, .. } => ids
6419 .key_of(*id)
6420 .zip(intern.resolve(*field))
6421 .map(|(key, field)| DbEvent::PropSet {
6422 key: key.to_string(),
6423 field: field.to_string(),
6424 commit_seq: seq,
6425 })
6426 .into_iter()
6427 .collect(),
6428 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
6429 key: key.clone(),
6430 field: field.clone(),
6431 commit_seq: seq,
6432 }],
6433 WalRecord::InsertEdge {
6434 edge_type,
6435 src_key,
6436 dst_key,
6437 } => vec![DbEvent::EdgeInserted {
6438 edge_type: edge_type.clone(),
6439 src: src_key.clone(),
6440 dst: dst_key.clone(),
6441 commit_seq: seq,
6442 }],
6443 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
6444 Some(DbEvent::EdgeInserted {
6445 edge_type: intern.resolve(*etype)?.to_string(),
6446 src: ids.key_of(*src)?.to_string(),
6447 dst: ids.key_of(*dst)?.to_string(),
6448 commit_seq: seq,
6449 })
6450 })()
6451 .into_iter()
6452 .collect(),
6453 WalRecord::DeleteEdge {
6454 edge_type,
6455 src_key,
6456 dst_key,
6457 } => vec![DbEvent::EdgeDeleted {
6458 edge_type: edge_type.clone(),
6459 src: src_key.clone(),
6460 dst: dst_key.clone(),
6461 commit_seq: seq,
6462 }],
6463 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
6464 key: key.clone(),
6465 commit_seq: seq,
6466 }],
6467 WalRecord::Batch(inner) => inner
6468 .iter()
6469 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
6470 .collect(),
6471 WalRecord::CreateRule { .. }
6472 | WalRecord::DeleteRule { .. }
6473 | WalRecord::RebuildRule { .. }
6474 | WalRecord::CreateView { .. }
6475 | WalRecord::DeleteView { .. }
6476 | WalRecord::EnableFulltext { .. }
6477 | WalRecord::DisableFulltext { .. }
6478 | WalRecord::EnableIndex { .. }
6479 | WalRecord::DisableIndex { .. }
6480 | WalRecord::Intern { .. }
6481 // History markers produce no DbEvent — the engine delta already
6482 // fired the EdgeFired/EdgeRetracted subscription events.
6483 | WalRecord::DerivedEdgeAdded { .. }
6484 | WalRecord::DerivedEdgeRetracted { .. }
6485 // A count is not an edge event: the pair it counts already fired one
6486 // when it was first inserted.
6487 | WalRecord::SetEdgeCount { .. }
6488 | WalRecord::RenameNode { .. } => vec![],
6489 }
6490 }
6491
6492 /// Subscribe to edge-fire and edge-retract events for one named rule.
6493 ///
6494 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
6495 /// currently registered. Dropping the returned [`Subscription`] handle
6496 /// unregisters the subscriber — no further events are queued, no
6497 /// resources leak.
6498 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
6499 if self.read_only {
6500 return Err(core_storage::GraphError::ReadOnly);
6501 }
6502 if !self.engine.rules().any(|r| r.name == rule_name) {
6503 return Err(core_storage::GraphError::RuleNotFound {
6504 name: rule_name.to_string(),
6505 });
6506 }
6507 let inner = SubInner::new(self.sub_capacity());
6508 self.subscriptions.push(SubEntry {
6509 filter: SubFilter::Rule(rule_name.to_string()),
6510 inner: std::sync::Arc::downgrade(&inner),
6511 });
6512 self.engine.set_emit_deltas(true);
6513 Ok(Subscription(inner))
6514 }
6515
6516 /// Subscribe to edge-fire and edge-retract events for **all** rules.
6517 ///
6518 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6519 /// as-of instances never commit, so `distribute_events` never runs and the
6520 /// subscription would never deliver events.
6521 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
6522 if self.read_only {
6523 return Err(core_storage::GraphError::ReadOnly);
6524 }
6525 let inner = SubInner::new(self.sub_capacity());
6526 self.subscriptions.push(SubEntry {
6527 filter: SubFilter::AllRules,
6528 inner: std::sync::Arc::downgrade(&inner),
6529 });
6530 self.engine.set_emit_deltas(true);
6531 Ok(Subscription(inner))
6532 }
6533
6534 /// Subscribe to write events: node insert/delete, prop set/remove.
6535 ///
6536 /// Does not include edge-fire / edge-retract (rule-derived edge events).
6537 ///
6538 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6539 /// as-of instances never commit, so `distribute_events` never runs and the
6540 /// subscription would never deliver events.
6541 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
6542 if self.read_only {
6543 return Err(core_storage::GraphError::ReadOnly);
6544 }
6545 let inner = SubInner::new(self.sub_capacity());
6546 self.subscriptions.push(SubEntry {
6547 filter: SubFilter::Writes,
6548 inner: std::sync::Arc::downgrade(&inner),
6549 });
6550 self.engine.set_emit_deltas(true);
6551 Ok(Subscription(inner))
6552 }
6553
6554 /// Subscribe to incremental Cypher query results.
6555 ///
6556 /// Parses and plans `cypher`; rejects the query if the plan is not in the
6557 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
6558 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
6559 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
6560 ///
6561 /// SKIP is not supported — it shifts the result window on every commit,
6562 /// causing spurious Added/Removed churn for rows whose data never changed.
6563 /// Multi-hop Expand chains are not supported; each additional MATCH clause
6564 /// widens scope beyond the documented single-scan / single-hop subset.
6565 ///
6566 /// After each successful commit, the plan is **fully re-executed** and the
6567 /// result is diffed against the previous run. Added rows produce
6568 /// [`DbEvent::QueryRowAdded`]; removed rows produce
6569 /// [`DbEvent::QueryRowRemoved`].
6570 ///
6571 /// **Full re-run per commit; use LIMIT to bound execution cost.**
6572 /// The existing 1 M intermediate-row cap applies. Differential evaluation
6573 /// is roadmap / Phase 5.
6574 ///
6575 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6576 /// as-of instances never commit, so `distribute_events` never runs and the
6577 /// subscription would never deliver events.
6578 ///
6579 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
6580 /// or if the plan shape is not in the allowlist.
6581 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
6582 if self.read_only {
6583 return Err(GraphError::ReadOnly);
6584 }
6585 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6586 detail: format!("lex: {e}"),
6587 })?;
6588 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6589 detail: format!("parse: {e}"),
6590 })?;
6591 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6592 detail: format!("plan: {e}"),
6593 })?;
6594 if !is_subscribable(&ops) {
6595 return Err(GraphError::QueryError {
6596 detail: "subscribe_query only supports allowlisted plan shapes: \
6597 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
6598 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
6599 Not supported: multi-hop Expand chains, SKIP (creates \
6600 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
6601 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
6602 Use LIMIT to bound re-execution cost."
6603 .to_string(),
6604 });
6605 }
6606 // Execute once to capture initial state (initial rows are not emitted as
6607 // events — the subscriber learns the baseline via the first query call).
6608 let empty_params = BTreeMap::new();
6609 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
6610 GraphError::QueryError {
6611 detail: format!("execute: {e}"),
6612 }
6613 })?;
6614 let columns = initial.columns().to_vec();
6615 let prev_row_map = Self::result_to_row_map(&initial);
6616 let inner = SubInner::new(self.sub_capacity());
6617 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
6618 // or unrecognized leading scan → None (always re-execute).
6619 let scan_label = extract_scan_label(&ops, Arc::make_mut(&mut self.syms));
6620 self.query_subscriptions.push(QuerySubEntry {
6621 ops,
6622 columns,
6623 prev_row_map,
6624 inner: std::sync::Arc::downgrade(&inner),
6625 scan_label,
6626 });
6627 Ok(Subscription(inner))
6628 }
6629
6630 /// Queue capacity used for new subscriptions.
6631 fn sub_capacity(&self) -> usize {
6632 self.sub_capacity
6633 }
6634
6635 /// Override per-subscriber queue capacity for subsequently created
6636 /// subscriptions on this db instance.
6637 ///
6638 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
6639 /// value in tests to exercise the [`DbEvent::Lagged`] path without
6640 /// generating tens of thousands of events.
6641 ///
6642 /// This is a test-support escape hatch. Calling it in production reduces
6643 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
6644 /// to discourage accidental production use.
6645 #[doc(hidden)]
6646 pub fn set_sub_capacity(&mut self, capacity: usize) {
6647 self.sub_capacity = capacity;
6648 }
6649
6650 // -----------------------------------------------------------------------
6651
6652 /// Start an atomic batch.
6653 ///
6654 /// The returned [`BatchBuilder`] borrows `self` mutably until
6655 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
6656 /// validation, no WAL I/O. `commit` validates every queued op against
6657 /// live state plus preceding ops in this batch (duplicate key inside
6658 /// the batch is `Err`; an edge between two nodes created earlier in
6659 /// the batch is valid; `delete_node` then insert of the same key is a
6660 /// fresh identity). Validation never mutates the database. Any failure
6661 /// leaves WAL bytes and in-memory state identical to before `commit`.
6662 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
6663 /// and each inner record is applied in order so rules fire per record.
6664 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
6665 ///
6666 /// **Rule-window limitation:** batch validation cannot see edges that a
6667 /// rule created earlier in the *same* batch will derive at apply time, so
6668 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
6669 /// where sequential calls would return `Err(RuleOwned)`. State integrity
6670 /// is unaffected (idempotent apply, provenance intact). Create rules in
6671 /// their own batch, or sequentially, when later ops may touch derived
6672 /// edges.
6673 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
6674 BatchBuilder {
6675 db: self,
6676 ops: Vec::new(),
6677 }
6678 }
6679
6680 /// Closure-style atomic write batch.
6681 ///
6682 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
6683 /// then committing. All ops queued inside `build` are validated in order and
6684 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
6685 /// once per inner record, in order, after commit — semantically identical to
6686 /// sequential single-op writes.
6687 ///
6688 /// **Error semantics — validate-then-apply.** `build` queues ops without
6689 /// touching the database. [`BatchBuilder::commit`] validates every op against
6690 /// live state plus earlier ops in this batch before writing anything. If op N
6691 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
6692 /// entire batch is rejected: no WAL bytes are written and no in-memory state
6693 /// changes. The database is identical to its state before `write_batch` was
6694 /// called.
6695 ///
6696 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
6697 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
6698 /// either fully applied or not at all. However, while applying a committed
6699 /// batch, concurrent readers may observe intermediate states as ops are applied
6700 /// sequentially in memory. There is no interactive transaction isolation in v1.
6701 /// This is documented as "crash-atomic write batches; no interactive
6702 /// transactions or read isolation."
6703 ///
6704 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
6705 /// writes zero WAL bytes and returns `(0, 0)`.
6706 ///
6707 /// # Example
6708 ///
6709 /// ```rust,ignore
6710 /// let (nodes, edges) = db.write_batch(|b| {
6711 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
6712 /// b.insert_node("Person", "bob", vec![]);
6713 /// b.insert_edge("KNOWS", "alice", "bob");
6714 /// b.set_prop("alice", "role", Value::Str("admin".into()));
6715 /// b.delete_node("old_key");
6716 /// })?;
6717 /// // One fsync; on crash replay: all five ops land or none do.
6718 /// ```
6719 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
6720 where
6721 C: FnOnce(&mut BatchBuilder<'_, F>),
6722 {
6723 let mut b = self.batch();
6724 build(&mut b);
6725 b.commit()
6726 }
6727
6728 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
6729 /// auto-declared KeyMatch rules (if any) first, then the accepted node
6730 /// inserts, so incremental fire sees the new rules. Per-row key problems
6731 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
6732 /// `Err` means nothing was applied.
6733 ///
6734 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
6735 /// distinct source labels sharing an FK field each get their own rule.
6736 pub fn ingest(
6737 &mut self,
6738 label: &str,
6739 rows: Vec<BTreeMap<String, Value>>,
6740 opts: &IngestOptions,
6741 ) -> Result<IngestReport> {
6742 self.ingest_with_edges(label, rows, opts, &[])
6743 }
6744
6745 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
6746 /// A failing edge rejects the whole request; nothing is applied.
6747 pub fn ingest_with_edges(
6748 &mut self,
6749 label: &str,
6750 rows: Vec<BTreeMap<String, Value>>,
6751 opts: &IngestOptions,
6752 edges: &[(String, String, String)],
6753 ) -> Result<IngestReport> {
6754 crate::ingest::run(self, label, rows, opts, edges)
6755 }
6756
6757 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
6758 ///
6759 /// JSON `null` fields are silently omitted (not stored, not a row error).
6760 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
6761 /// Parse failures and a top-level value that is not an array of objects
6762 /// return [`GraphError::IngestError`].
6763 pub fn ingest_json(
6764 &mut self,
6765 label: &str,
6766 json: &str,
6767 opts: &IngestOptions,
6768 ) -> Result<IngestReport> {
6769 crate::ingest::run_json(self, label, json, opts)
6770 }
6771
6772 fn commit_logged_batch(
6773 &mut self,
6774 ops: Vec<BatchOp>,
6775 ingest: Option<(String, usize)>,
6776 // Two-source rule: write_batch_authz threads authz here directly (never
6777 // touches pending_write_authz); query_write_authz sets the field instead
6778 // and passes None. Only one source is non-None per call.
6779 param_authz: Option<WriteAuthz>,
6780 ) -> Result<BatchOutcome> {
6781 // Read-only guard: catches empty-batch calls before the early-return
6782 // that skips log_then_apply_with, ensuring all mutation entry points fail.
6783 if self.read_only {
6784 return Err(GraphError::ReadOnly);
6785 }
6786 // Ensure provenance is decoded before MutPreview accesses it
6787 // (note_delete_rule / is_rule_owned may call engine.provenance()).
6788 self.engine.ensure_provenance_loaded_mut();
6789
6790 // ── Authz pre-check ──────────────────────────────────────────────────
6791 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
6792 // produces no WAL frame (all-or-nothing at the authz boundary extends
6793 // the existing validate-then-apply contract to role-scope checks).
6794 //
6795 // `batch_created` tracks key→label for nodes created by earlier ops in
6796 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
6797 // as visible without needing to call `self.ids.get` on not-yet-committed
6798 // keys (they won't be there yet).
6799 //
6800 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
6801 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
6802 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
6803 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
6804 if let Some(ref authz) = authz_opt {
6805 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
6806 for op in &ops {
6807 self.check_single_op_authz(authz, op, &batch_created)?;
6808 // Update batch_created after a passing authz check so that
6809 // subsequent ops in this batch see the nodes as "about to exist".
6810 match op {
6811 BatchOp::InsertNode { label, key, .. } => {
6812 // Only track genuinely new nodes (absent from the
6813 // snapshot at authz-check time). A pre-existing visible
6814 // key would be a DuplicateKey — not a real creation —
6815 // so MutPreview handles it. Letting it into batch_created
6816 // would allow a later SetProp to bypass update_labels
6817 // via the "batch-created → always updatable" ruling
6818 // (delete+recreate exploit, fix for I1 review round 2).
6819 //
6820 // Accepted edge: for a delete+recreate-with-different-
6821 // label batch, node_status resolves the pre-delete
6822 // (store) label for any subsequent update checks. This
6823 // grants no net-new capability — a role that can delete+
6824 // create can already place arbitrary props via
6825 // InsertNode's own props field.
6826 if self.ids.get(key.as_str()).is_none() {
6827 batch_created.insert(key.clone(), label.clone());
6828 }
6829 }
6830 BatchOp::InsertEdgeUpsert {
6831 placeholder_label,
6832 src_key,
6833 dst_key,
6834 ..
6835 } => {
6836 // Both endpoints will be created if not already in store.
6837 for ep_key in [src_key, dst_key] {
6838 if self.ids.get(ep_key.as_str()).is_none()
6839 && !batch_created.contains_key(ep_key.as_str())
6840 {
6841 batch_created.insert(ep_key.clone(), placeholder_label.clone());
6842 }
6843 }
6844 }
6845 _ => {}
6846 }
6847 }
6848 }
6849
6850 let mut outcome = BatchOutcome::default();
6851 let recs = {
6852 let mut preview = MutPreview::new(self);
6853 let mut recs = Vec::with_capacity(ops.len());
6854 // Which node row we are on, counted over the node-insert ops only.
6855 // A caller that queues its rows in order reads this straight back
6856 // as the index into its own list.
6857 let mut node_row = 0usize;
6858 // Every field name the store knows, which a `Replace` needs to work
6859 // out what it removes. Resolved on the first `Replace` in the frame
6860 // and reused, so N replaces read the field list once, not N times.
6861 let mut store_fields: Option<Vec<String>> = None;
6862 // Duplicate inserts this frame has to count, each paired with the
6863 // position in `recs` it belongs at. The count itself is named in the
6864 // dense rewrite and not here: a duplicate's endpoints and edge type
6865 // may all be created by earlier ops in this same frame, and nothing
6866 // in the frame has a dense id yet. See [`PlannedRec`].
6867 let mut deferred_counts: Vec<(usize, String, String, String)> = Vec::new();
6868 for op in ops {
6869 match op {
6870 BatchOp::InsertNode { label, key, props } => {
6871 node_row += 1;
6872 preview.check_insert_node(&key, &props)?;
6873 preview.note_insert_node(&label, &key, &props);
6874 recs.push(WalRecord::InsertNode { label, key, props });
6875 }
6876 BatchOp::InsertNodeOnConflict {
6877 label,
6878 key,
6879 props,
6880 on_conflict,
6881 } => {
6882 let row = node_row;
6883 node_row += 1;
6884 if !preview.has_key(&key) {
6885 // No conflict: an ordinary insert on any policy —
6886 // except that a supplied view-owned field is the
6887 // same mistake here as on a taken key, and gets the
6888 // same row error rather than a frame error. Without
6889 // this, one op answered one request two ways
6890 // depending on whether the store already had the
6891 // key (defect #19).
6892 if let Some(why) = preview.supplied_view_owned_prop(&key, &props) {
6893 outcome.row_errors.push((row, why));
6894 continue;
6895 }
6896 preview.note_insert_node(&label, &key, &props);
6897 recs.push(WalRecord::InsertNode { label, key, props });
6898 continue;
6899 }
6900 match on_conflict {
6901 OnConflict::Error => {
6902 return Err(GraphError::DuplicateKey { key });
6903 }
6904 OnConflict::Skip => outcome.skipped += 1,
6905 OnConflict::Replace => {
6906 if store_fields.is_none() {
6907 store_fields = Some(preview.db.props_view().field_names());
6908 }
6909 let fields = store_fields.as_deref().unwrap_or_default();
6910 match preview.plan_replace(&label, &key, &props, fields) {
6911 Ok((writes, kept_view_owned)) => {
6912 outcome.kept_view_owned += kept_view_owned;
6913 for (field, value) in writes {
6914 match value {
6915 Some(value) => {
6916 preview.note_set_prop(&key, &field, &value);
6917 recs.push(WalRecord::SetProp {
6918 key: key.clone(),
6919 field,
6920 value,
6921 });
6922 }
6923 None => {
6924 preview.note_remove_prop(&key, &field);
6925 recs.push(WalRecord::RemoveProp {
6926 key: key.clone(),
6927 field,
6928 });
6929 }
6930 }
6931 }
6932 outcome.replaced += 1;
6933 }
6934 Err(why) => outcome.row_errors.push((row, why)),
6935 }
6936 }
6937 }
6938 }
6939 BatchOp::InsertEdge {
6940 edge_type,
6941 src_key,
6942 dst_key,
6943 } => {
6944 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6945 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6946 recs.push(WalRecord::InsertEdge {
6947 edge_type,
6948 src_key,
6949 dst_key,
6950 });
6951 } else if preview.db.multiplicity {
6952 // A duplicate inside a batch counts the way a
6953 // duplicate through `insert_edge` does: `ingest` and
6954 // Cypher `CREATE` reach this choke-point and not
6955 // that one, and a count only one entry point keeps
6956 // would be worse than no count at all.
6957 //
6958 // This is the one gate on discriminant 23 from the
6959 // batch path: a store that never opted in queues
6960 // nothing here and so writes no such record.
6961 deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6962 }
6963 }
6964 BatchOp::SetProp { key, field, value } => {
6965 if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
6966 return Err(GraphError::ViewPropReadOnly {
6967 view_name: view_name.to_string(),
6968 });
6969 }
6970 preview.check_live_key(&key)?;
6971 preview.note_set_prop(&key, &field, &value);
6972 recs.push(WalRecord::SetProp { key, field, value });
6973 }
6974 BatchOp::RemoveProp { key, field } => {
6975 if preview.prepare_remove_prop(&key, &field)? {
6976 preview.note_remove_prop(&key, &field);
6977 recs.push(WalRecord::RemoveProp { key, field });
6978 }
6979 }
6980 BatchOp::DeleteEdge {
6981 edge_type,
6982 src_key,
6983 dst_key,
6984 } => {
6985 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
6986 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
6987 recs.push(WalRecord::DeleteEdge {
6988 edge_type,
6989 src_key,
6990 dst_key,
6991 });
6992 }
6993 }
6994 BatchOp::DeleteNode { key } => {
6995 preview.check_live_key(&key)?;
6996 preview.note_delete_node(&key);
6997 recs.push(WalRecord::DeleteNode { key });
6998 }
6999 BatchOp::CreateRule(def) => {
7000 preview.check_create_rule(&def)?;
7001 let def_bytes =
7002 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7003 detail: format!("serialize rule: {e}"),
7004 })?;
7005 preview.note_create_rule(&def);
7006 recs.push(WalRecord::CreateRule { def_bytes });
7007 }
7008 BatchOp::DeleteRule { name } => {
7009 preview.check_delete_rule(&name)?;
7010 preview.note_delete_rule(&name);
7011 recs.push(WalRecord::DeleteRule { name });
7012 }
7013 BatchOp::RenameNode { old_key, new_key } => {
7014 preview.check_rename_node(&old_key, &new_key)?;
7015 // A stored `id` that equals the key is the key, written
7016 // as a property by `upsert_entity`'s create path and by
7017 // Cypher `CREATE {id: …}`. It moves with the key, in
7018 // this frame; left behind, `WHERE n.id = '<new key>'`
7019 // finds nothing (ledger row 72). An `id` holding
7020 // anything else is the caller's and stays.
7021 let id_follows = preview.prop_value(&old_key, "id")
7022 == Some(Value::Str(old_key.clone()))
7023 && preview.db.view_store.view_for_prop("id").is_none();
7024 preview.note_rename_node(&old_key, &new_key);
7025 recs.push(WalRecord::RenameNode {
7026 old_key,
7027 new_key: new_key.clone(),
7028 });
7029 if id_follows {
7030 let value = Value::Str(new_key.clone());
7031 preview.note_set_prop(&new_key, "id", &value);
7032 recs.push(WalRecord::SetProp {
7033 key: new_key,
7034 field: "id".into(),
7035 value,
7036 });
7037 }
7038 }
7039 BatchOp::InsertEdgeUpsert {
7040 edge_type,
7041 src_key,
7042 dst_key,
7043 placeholder_label,
7044 } => {
7045 // Auto-create any missing endpoints as plain InsertNode ops.
7046 // Rules fire and last-change is updated for each created node.
7047 for key in [&src_key, &dst_key] {
7048 if !preview.has_key(key) {
7049 // A placeholder endpoint carries no props, so
7050 // the view-owned check has nothing to refuse.
7051 preview.check_insert_node(key, &[])?;
7052 preview.note_insert_node(&placeholder_label, key, &[]);
7053 recs.push(WalRecord::InsertNode {
7054 label: placeholder_label.clone(),
7055 key: key.clone(),
7056 props: vec![],
7057 });
7058 }
7059 }
7060 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
7061 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
7062 recs.push(WalRecord::InsertEdge {
7063 edge_type,
7064 src_key,
7065 dst_key,
7066 });
7067 } else if preview.db.multiplicity {
7068 // Same choke-point, same gate as `BatchOp::InsertEdge`
7069 // above: an upsert that finds the pair already there
7070 // is a duplicate insert and counts as one.
7071 deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
7072 }
7073 }
7074 }
7075 }
7076 // Splice the deferred counts back into the positions they were
7077 // raised at, so a count still sits exactly where the duplicate did
7078 // — before any later op in the frame that deletes the pair.
7079 let mut planned: Vec<PlannedRec> =
7080 Vec::with_capacity(recs.len() + deferred_counts.len());
7081 let mut deferred = deferred_counts.into_iter().peekable();
7082 for (i, rec) in recs.into_iter().enumerate() {
7083 while deferred.peek().is_some_and(|(at, ..)| *at == i) {
7084 let (_, edge_type, src_key, dst_key) = deferred.next().expect("just peeked");
7085 planned.push(PlannedRec::DuplicateCount {
7086 edge_type,
7087 src_key,
7088 dst_key,
7089 });
7090 }
7091 planned.push(PlannedRec::Rec(rec));
7092 }
7093 for (_, edge_type, src_key, dst_key) in deferred {
7094 planned.push(PlannedRec::DuplicateCount {
7095 edge_type,
7096 src_key,
7097 dst_key,
7098 });
7099 }
7100 planned
7101 };
7102 // A frame that is nothing but skips or refused rows writes no WAL, but
7103 // it still has counts to report, so the early returns carry `outcome`
7104 // rather than zeros.
7105 if recs.is_empty() {
7106 return Ok(outcome);
7107 }
7108 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
7109 // *Id form, so only the dense variants can appear in `recs` here.
7110 let recs = self.rewrite_wal_dense_planned(recs)?;
7111 // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
7112 // namespace the node is already in is a no-op and is dropped there. An
7113 // empty `Batch` frame would still take a commit sequence and a WAL
7114 // record, so a batch that turns out to be nothing writes nothing.
7115 if recs.is_empty() {
7116 return Ok(outcome);
7117 }
7118 outcome.nodes_inserted = recs
7119 .iter()
7120 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
7121 .count();
7122 outcome.edges_inserted = recs
7123 .iter()
7124 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
7125 .count();
7126 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
7127 // under Strict. Pass self.fsync directly so Strict stays Strict —
7128 // wal_needs_sync(Strict, _) always returns true regardless of op count.
7129 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
7130 // short-circuit on single-op batches and silently skip the fsync.
7131 // Batched fsyncs only for multi-op batches; Relaxed always skips.
7132 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
7133 Ok(outcome)
7134 }
7135
7136 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7137 self.commit_logged_batch(ops, None, None).map(inserted_pair)
7138 }
7139
7140 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
7141 /// and the group-commit drain thread, which do a single group fsync later.
7142 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7143 // Restore fsync policy even on panic via a raw-pointer drop guard.
7144 // A panic here would poison the RwLock anyway, but the correct policy
7145 // must be in place if the guard is ever unwrapped.
7146 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7147 impl Drop for RestoreFsync {
7148 fn drop(&mut self) {
7149 // SAFETY: the pointer is valid for the full duration of
7150 // commit_batch_nosync; the guard is dropped before the frame
7151 // returns, and GraphDb outlives this frame.
7152 unsafe {
7153 *self.0 = self.1;
7154 }
7155 }
7156 }
7157 let saved = self.fsync;
7158 // SAFETY: raw pointer into self; guard dropped within this frame.
7159 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7160 self.fsync = FsyncPolicy::Relaxed;
7161 self.commit_logged_batch(ops, None, None).map(inserted_pair)
7162 }
7163
7164 /// Commit multiple op-batches as a **group**: each submission gets its own
7165 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
7166 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
7167 ///
7168 /// # Durability semantics
7169 ///
7170 /// A crash before the group fsync may lose **all** submissions in the group.
7171 /// A crash after the group fsync preserves all of them. No submission is
7172 /// ever torn: each WAL frame is either fully applied on replay or dropped
7173 /// in its entirety (CRC-protected frame boundaries).
7174 ///
7175 /// Events and subscription notifications fire per-submission immediately
7176 /// after apply, which may be before the group fsync. From a subscriber's
7177 /// perspective this is equivalent to the `Relaxed` durability window.
7178 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
7179 /// fsync, so from their perspective durability is fully guaranteed.
7180 ///
7181 /// # MVCC interplay
7182 ///
7183 /// Each submission records its own `CommitDelta`; the fold-every-K counter
7184 /// increments per submission (not per group), preserving existing reader
7185 /// snapshot semantics.
7186 ///
7187 /// # Returns
7188 ///
7189 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
7190 /// in order. Failures are per-submission (validation errors); the group
7191 /// fsync error (if any) is returned as the second tuple element.
7192 pub fn commit_group(
7193 &mut self,
7194 groups: Vec<Vec<BatchOp>>,
7195 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
7196 let mut results = Vec::with_capacity(groups.len());
7197 for ops in groups {
7198 results.push(self.commit_batch_nosync(ops));
7199 }
7200 let any_ok = results.iter().any(|r| r.is_ok());
7201 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
7202 self.fs
7203 .sync(core_storage::fs::FileId::Wal)
7204 .map_err(GraphError::Io)
7205 .err()
7206 } else {
7207 None
7208 };
7209 (results, sync_err)
7210 }
7211
7212 /// Like [`commit_group`] but skips the group fsync entirely.
7213 ///
7214 /// Used by the drain thread to apply submissions under the write lock and
7215 /// then perform the single fsync OUTSIDE the lock (via
7216 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
7217 /// to concurrent readers.
7218 pub fn commit_group_nosync(
7219 &mut self,
7220 groups: Vec<Vec<BatchOp>>,
7221 ) -> Vec<Result<(usize, usize)>> {
7222 let mut results = Vec::with_capacity(groups.len());
7223 for ops in groups {
7224 results.push(self.commit_batch_nosync(ops));
7225 }
7226 results
7227 }
7228
7229 pub fn insert_node(
7230 &mut self,
7231 label: &str,
7232 key: &str,
7233 props: Vec<(String, Value)>,
7234 ) -> Result<()> {
7235 if self.read_only {
7236 return Err(GraphError::ReadOnly);
7237 }
7238 MutPreview::new(self).check_insert_node(key, &props)?;
7239 self.log_dense(vec![WalRecord::InsertNode {
7240 label: label.into(),
7241 key: key.into(),
7242 props,
7243 }])
7244 }
7245
7246 /// Insert a user edge. `Ok(true)` when the pair was new, `Ok(false)` when it
7247 /// was already there — the question is "was this pair new", and a duplicate
7248 /// does not make it so.
7249 ///
7250 /// On a store that called [`enable_multiplicity`](Self::enable_multiplicity)
7251 /// a duplicate is no longer a total no-op: it raises the pair's insert count
7252 /// (§5.13). Adjacency is still a set, so [`degree`](Self::degree) is
7253 /// unchanged and the return value is still `Ok(false)`; the count is visible
7254 /// only through [`degree_multiplicity`](Self::degree_multiplicity) and the
7255 /// reserved [`EDGE_COUNT_PROP`]. On every other store a duplicate writes
7256 /// nothing at all, as it always has.
7257 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7258 if self.read_only {
7259 return Err(GraphError::ReadOnly);
7260 }
7261 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
7262 // The pair exists. The only thing left to record is that it was
7263 // asked for again, and only where the store asked to be told.
7264 if let Some(rec) = self.edge_count_record(edge_type, src_key, dst_key) {
7265 self.log_then_apply(rec)?;
7266 }
7267 return Ok(false);
7268 }
7269 self.log_dense(vec![WalRecord::InsertEdge {
7270 edge_type: edge_type.into(),
7271 src_key: src_key.into(),
7272 dst_key: dst_key.into(),
7273 }])?;
7274 Ok(true)
7275 }
7276
7277 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
7278 if self.read_only {
7279 return Err(GraphError::ReadOnly);
7280 }
7281 if let Some(view_name) = self.view_store.view_for_prop(field) {
7282 return Err(GraphError::ViewPropReadOnly {
7283 view_name: view_name.to_string(),
7284 });
7285 }
7286 MutPreview::new(self).check_live_key(key)?;
7287 self.log_dense(vec![WalRecord::SetProp {
7288 key: key.into(),
7289 field: field.into(),
7290 value,
7291 }])
7292 }
7293
7294 /// Set several properties on one live node in a single WAL commit.
7295 ///
7296 /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
7297 /// names, live key, the `ns` immutability rule and its type — is evaluated
7298 /// for the whole list before any record is logged. The first refusal
7299 /// returns and the node is unchanged. An empty list writes nothing.
7300 pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
7301 if self.read_only {
7302 return Err(GraphError::ReadOnly);
7303 }
7304 MutPreview::new(self).check_live_key(key)?;
7305 for (field, _) in &props {
7306 if let Some(view_name) = self.view_store.view_for_prop(field) {
7307 return Err(GraphError::ViewPropReadOnly {
7308 view_name: view_name.to_string(),
7309 });
7310 }
7311 }
7312 if props.is_empty() {
7313 return Ok(());
7314 }
7315 self.write_batch(|b| {
7316 for (field, value) in props {
7317 b.set_prop(key, &field, value);
7318 }
7319 })
7320 .map(|_| ())
7321 }
7322
7323 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
7324 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
7325 /// A field a view owns is `Err(ViewPropReadOnly)` — stated once, in
7326 /// [`MutPreview::prepare_remove_prop`], so that the batch ops reaching that
7327 /// same choke-point cannot miss it.
7328 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
7329 if self.read_only {
7330 return Err(GraphError::ReadOnly);
7331 }
7332 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
7333 return Ok(false);
7334 }
7335 self.log_then_apply(WalRecord::RemoveProp {
7336 key: key.into(),
7337 field: field.into(),
7338 })?;
7339 Ok(true)
7340 }
7341
7342 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
7343 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
7344 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
7345 /// (the rule would just put the edge back; delete or change the rule).
7346 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7347 if self.read_only {
7348 return Err(GraphError::ReadOnly);
7349 }
7350 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
7351 return Ok(false);
7352 }
7353 self.log_then_apply(WalRecord::DeleteEdge {
7354 edge_type: edge_type.into(),
7355 src_key: src_key.into(),
7356 dst_key: dst_key.into(),
7357 })?;
7358 Ok(true)
7359 }
7360
7361 /// Delete a live node. Unknown or already-tombstoned keys are
7362 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
7363 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
7364 /// (crash window) is a clean no-op.
7365 ///
7366 /// Returns a [`DeleteReport`] with counts of manual and derived edges
7367 /// removed (computed from live state before the deletion is applied).
7368 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
7369 if self.read_only {
7370 return Err(GraphError::ReadOnly);
7371 }
7372 // Provenance must be loaded before we query provenance_touching.
7373 self.engine.ensure_provenance_loaded_mut();
7374 let id = self
7375 .ids
7376 .get(key)
7377 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7378
7379 // Count edges before the delete is applied so we can report counts.
7380 let derived_set: BTreeSet<(u32, u32, u32)> = self
7381 .engine
7382 .provenance_touching(id)
7383 .map(|(_, etype, src, dst)| (etype, src, dst))
7384 .collect();
7385 let derived_edges = derived_set.len() as u64;
7386
7387 let mut total_topo = 0u64;
7388 let tv = self.topo_view();
7389 for et in tv.etypes() {
7390 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
7391 + tv.neighbors(et, Direction::In, id).len() as u64;
7392 }
7393 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
7394 // triples in both the topo scan (Out and In from id) and in provenance_touching.
7395 // The subtraction remains correct because both counts include both directions.
7396 let manual_edges = total_topo.saturating_sub(derived_edges);
7397
7398 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
7399 Ok(DeleteReport {
7400 manual_edges,
7401 derived_edges,
7402 })
7403 }
7404
7405 /// Rename a live node's key. The dense id (and therefore all edges,
7406 /// props, history, and last-change tracking) is unaffected.
7407 ///
7408 /// A stored `id` property that equals `old` is rewritten to `new` in the
7409 /// same commit, so `n.id` and `key(n)` keep agreeing; an `id` holding any
7410 /// other value, and a node that stores none, are left as they are.
7411 ///
7412 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
7413 /// Returns `Err(DuplicateKey)` if `new` is already live.
7414 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
7415 if self.read_only {
7416 return Err(GraphError::ReadOnly);
7417 }
7418 // The `id` rewrite lives in the batch arm, which HTTP reaches without
7419 // this function. A node with no such `id` keeps the bare frame.
7420 if self.get_prop(old, "id") == Some(Value::Str(old.to_string())) {
7421 return self
7422 .commit_batch(vec![BatchOp::RenameNode {
7423 old_key: old.into(),
7424 new_key: new.into(),
7425 }])
7426 .map(|_| ());
7427 }
7428 MutPreview::new(self).check_rename_node(old, new)?;
7429 self.log_then_apply(WalRecord::RenameNode {
7430 old_key: old.into(),
7431 new_key: new.into(),
7432 })
7433 }
7434
7435 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
7436 /// `None` if the rule does not exist or is not approximate.
7437 ///
7438 /// The drift counter increments on IVF insert/remove after the last fit.
7439 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
7440 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
7441 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
7442 // SideIvfExport = (centroids, node→cluster, drift)
7443 self.engine
7444 .export_ivf_state()
7445 .remove(rule)
7446 .map(|(_src, dst)| dst.2)
7447 }
7448
7449 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
7450 /// Validation and duplicate-name check run before logging so invalid rules
7451 /// never enter the WAL.
7452 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
7453 if self.read_only {
7454 return Err(GraphError::ReadOnly);
7455 }
7456 MutPreview::new(self).check_create_rule(&def)?;
7457 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7458 detail: format!("serialize rule: {e}"),
7459 })?;
7460 self.log_dense(vec![WalRecord::CreateRule { def_bytes }])
7461 }
7462
7463 /// Override this handle's HNSW build-slice size, or `None` to restore
7464 /// [`core_rules::HNSW_BUILD_BATCH`].
7465 ///
7466 /// Exposed for tests that need a small slice without a large corpus; not
7467 /// part of the stable surface.
7468 #[doc(hidden)]
7469 pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
7470 self.engine.set_hnsw_build_batch(batch);
7471 }
7472
7473 /// Rules whose vector index is still being built, in name order.
7474 ///
7475 /// The same list [`GraphDb::stats`] reports per rule in `building`.
7476 /// After a clean open this includes a build a snapshot cut short, so
7477 /// `serve`'s ticker can pump it without a write.
7478 pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
7479 self.engine.builds_in_progress()
7480 }
7481
7482 /// Advance any vector index still building and backfill each rule that
7483 /// finishes. Returns what is still outstanding.
7484 ///
7485 /// A map lookup when nothing is pending, so it is cheap to call on a timer.
7486 /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
7487 /// inserts per pending rule per call, so a caller can drive a large build
7488 /// to completion without ever holding the lock for more than a slice.
7489 ///
7490 /// A rule that finishes here is backfilled through the same
7491 /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
7492 /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
7493 /// and appear all at once.
7494 ///
7495 /// Every ordinary write pumps one slice on its own (see the post-commit
7496 /// hook in `log_then_apply_with`), so this is for quiescent stores and for
7497 /// operators who want the build finished before traffic arrives.
7498 pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
7499 Ok(self.pump_index_build_reporting()?.1)
7500 }
7501
7502 /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
7503 /// call finished, so a progress display can say so.
7504 ///
7505 /// A build can be registered and completed inside a single call — that is
7506 /// what a mid-build snapshot looks like on reopen, where the index scan
7507 /// finishes the graph and only the backfill is outstanding — and the
7508 /// outstanding list alone cannot show that anything happened.
7509 pub fn pump_index_build_reporting(
7510 &mut self,
7511 ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
7512 // A read-only handle cannot issue the `RebuildRule` a finished build
7513 // needs, so it would advance the index and then silently fail to
7514 // produce the edges. Refusing is the honest answer.
7515 if self.read_only {
7516 return Err(GraphError::ReadOnly);
7517 }
7518 let finished = self.pump_one_slice();
7519 for done in &finished {
7520 // The index is whole but the rule still owns no edges. A failed
7521 // second commit must leave the rule re-pumpable rather than
7522 // silently edge-less, so the error is surfaced here — unlike the
7523 // post-commit hook, this call is not riding someone else's commit.
7524 self.log_then_apply(WalRecord::RebuildRule {
7525 name: done.rule.clone(),
7526 })?;
7527 }
7528 Ok((finished, self.engine.builds_in_progress()))
7529 }
7530
7531 /// Run the deferred candidate-index build, if it is still owed, against the
7532 /// graph as it stands *now* — before the caller applies anything.
7533 ///
7534 /// A no-op bool test once the indexes are populated, which is after the
7535 /// first write of the handle's life, and for a store with no rules at all.
7536 fn populate_indexes_before_write(&mut self) {
7537 if !self.engine.needs_index_population() {
7538 return;
7539 }
7540 // The retained snapshot blobs arrive with the V8 base sections; without
7541 // them the scan would rebuild every graph the snapshot already holds.
7542 self.ensure_v8_base_sections_loaded();
7543 if !self.engine.needs_index_population() {
7544 return;
7545 }
7546 let mut eng = std::mem::take(&mut self.engine);
7547 {
7548 let gm = make_graph_mut(
7549 &self.ids,
7550 Arc::make_mut(&mut self.syms),
7551 &self.labels,
7552 build_props_view(&self.props, &self.base),
7553 Arc::make_mut(&mut self.topo),
7554 &self.base,
7555 Arc::make_mut(&mut self.edge_props),
7556 );
7557 eng.populate_indexes(&gm);
7558 }
7559 self.engine = eng;
7560 }
7561
7562 /// One slice of build work for every pending rule. Returns the rules whose
7563 /// index just became whole, which the caller must `RebuildRule`.
7564 ///
7565 /// Goes through the engine even with nothing pending when the indexes have
7566 /// not been populated yet: that call adopts the persisted graphs and, for
7567 /// an incomplete blob already registered at open, leaves the remainder to
7568 /// this slice rather than inserting it inline.
7569 fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
7570 // The retained snapshot blobs — and the id count an interrupted build
7571 // is recognised against — arrive with the V8 base sections, which a
7572 // clean open reads lazily. Without this a freshly opened handle pumps
7573 // against empty retained state and concludes there is nothing to do,
7574 // which is precisely the store `build-index` exists for.
7575 self.ensure_v8_base_sections_loaded();
7576 let mut eng = std::mem::take(&mut self.engine);
7577 let finished = {
7578 let mut gm = make_graph_mut(
7579 &self.ids,
7580 Arc::make_mut(&mut self.syms),
7581 &self.labels,
7582 build_props_view(&self.props, &self.base),
7583 Arc::make_mut(&mut self.topo),
7584 &self.base,
7585 Arc::make_mut(&mut self.edge_props),
7586 );
7587 eng.pump_index_build(&mut gm)
7588 };
7589 self.engine = eng;
7590 finished
7591 }
7592
7593 /// Register a sliced build a snapshot cut short, from blobs with
7594 /// `complete == false`.
7595 ///
7596 /// Peeks the V8 mmap for incomplete entries without copying complete
7597 /// graphs. V5–V7 already hold the blobs in the engine from restore.
7598 fn register_outstanding_index_builds(&mut self) {
7599 if self.engine.indexes_populated() {
7600 return;
7601 }
7602 let extra = self.collect_incomplete_hnsw_blobs();
7603 let mut eng = std::mem::take(&mut self.engine);
7604 {
7605 let gm = make_graph_mut(
7606 &self.ids,
7607 Arc::make_mut(&mut self.syms),
7608 &self.labels,
7609 build_props_view(&self.props, &self.base),
7610 Arc::make_mut(&mut self.topo),
7611 &self.base,
7612 Arc::make_mut(&mut self.edge_props),
7613 );
7614 eng.register_incomplete_hnsw_builds(&extra, &gm);
7615 }
7616 self.engine = eng;
7617 }
7618
7619 /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
7620 /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
7621 /// engine's retained map instead).
7622 fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
7623 let Some(base) = &self.base else {
7624 return BTreeMap::new();
7625 };
7626 let Ok(archived) = base.hnsw_section() else {
7627 return BTreeMap::new();
7628 };
7629 archived
7630 .rules
7631 .iter()
7632 .filter_map(|e| {
7633 let src = e.src_blob.as_slice();
7634 let dst = e.dst_blob.as_slice();
7635 if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
7636 || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
7637 {
7638 Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
7639 } else {
7640 None
7641 }
7642 })
7643 .collect()
7644 }
7645
7646 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
7647 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
7648 if self.read_only {
7649 return Err(GraphError::ReadOnly);
7650 }
7651 MutPreview::new(self).check_delete_rule(name)?;
7652 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
7653 }
7654
7655 /// Return a snapshot of all registered rules.
7656 pub fn rules(&self) -> Vec<RuleDef> {
7657 self.engine.rules().cloned().collect()
7658 }
7659
7660 // -----------------------------------------------------------------------
7661 // Rule suggestion API
7662 // -----------------------------------------------------------------------
7663
7664 /// Profile the database and suggest linking rules with previewed edge counts.
7665 ///
7666 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
7667 /// sampling. Suggestions are sorted by estimated edge count (descending).
7668 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
7669 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
7670 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
7671 }
7672
7673 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
7674 /// reproducibility. Same seed + same data = identical output.
7675 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
7676 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
7677 .suggestions
7678 }
7679
7680 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
7681 ///
7682 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
7683 /// and a `truncated` flag indicating whether the global budget fired before all
7684 /// candidates were evaluated.
7685 pub fn suggest_rules_with_config(
7686 &self,
7687 config: &core_rules::suggest::SuggestConfig,
7688 seed: u64,
7689 ) -> core_rules::SuggestReport {
7690 use std::collections::BTreeMap;
7691
7692 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
7693 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
7694 for id in 0..self.ids.len() as u32 {
7695 let Some(key) = self.ids.key_of(id) else {
7696 continue;
7697 };
7698 let Some(&sym) = self.labels.get(id as usize) else {
7699 continue;
7700 };
7701 if sym == u32::MAX {
7702 continue; // tombstoned
7703 }
7704 let Some(label) = self.syms.resolve(sym) else {
7705 continue;
7706 };
7707 label_nodes
7708 .entry(label.to_string())
7709 .or_default()
7710 .push((id, key.to_string()));
7711 }
7712
7713 let existing = self.rules();
7714 let pv = build_props_view(&self.props, &self.base);
7715 let all_fields: Vec<String> = pv.field_names();
7716
7717 core_rules::suggest::suggest_rules(
7718 &label_nodes,
7719 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
7720 &all_fields,
7721 &existing,
7722 config,
7723 seed,
7724 )
7725 }
7726
7727 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
7728 /// plus later mutations replay identically (rebuild is a pure function
7729 /// of state).
7730 ///
7731 /// Only exit from the tripped latch: if the full desired set fits the
7732 /// budget, it is applied completely and `tripped` clears; if it still
7733 /// exceeds the budget, provenance is left untouched and `tripped` stays
7734 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
7735 /// Unknown rule → `RuleNotFound`, nothing logged.
7736 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
7737 if self.read_only {
7738 return Err(GraphError::ReadOnly);
7739 }
7740 if !self.engine.rules().any(|r| r.name == name) {
7741 return Err(GraphError::RuleNotFound { name: name.into() });
7742 }
7743 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
7744 }
7745
7746 // -----------------------------------------------------------------------
7747 // Materialized view API
7748 // -----------------------------------------------------------------------
7749
7750 /// Register a new materialized property view, backfill its values for all
7751 /// existing nodes, and WAL-log the definition.
7752 ///
7753 /// # Errors
7754 /// - `ReadOnly`: called on an as-of instance.
7755 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
7756 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
7757 if self.read_only {
7758 return Err(GraphError::ReadOnly);
7759 }
7760 // Pre-validate before WAL write.
7761 def.validate()
7762 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
7763 if self.view_store.has_view(&def.name) {
7764 return Err(GraphError::RuleInvalid {
7765 detail: format!("view {:?} already exists", def.name),
7766 });
7767 }
7768 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
7769 return Err(GraphError::RuleInvalid {
7770 detail: format!(
7771 "view_prop {:?} is already used by view {:?}",
7772 def.view_prop, existing
7773 ),
7774 });
7775 }
7776 // A view cannot take the name of a property nodes already carry —
7777 // in the snapshot or written since. Refused here, before the WAL
7778 // write: `apply` refuses some of it too, but by then the record is
7779 // logged, and the next open replays it and fails.
7780 //
7781 // "Carry" is a node that reads a value under the name. The column
7782 // alone does not count: a snapshot keeps the column of a view that
7783 // has since been deleted, with every value in it hidden, and that
7784 // name is free.
7785 let taken = {
7786 let pv = build_props_view(&self.props, &self.base);
7787 pv.field_names().contains(&def.view_prop)
7788 && (0..self.ids.len() as u32)
7789 .any(|id| !self.ids.is_tombstoned(id) && pv.get(id, &def.view_prop).is_some())
7790 };
7791 if taken {
7792 return Err(GraphError::RuleInvalid {
7793 detail: format!(
7794 "view_prop {:?} conflicts with an existing node property",
7795 def.view_prop
7796 ),
7797 });
7798 }
7799 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7800 detail: format!("serialize view: {e}"),
7801 })?;
7802 // Enable delta accumulation before the view is registered so subsequent
7803 // incremental edge events reach view maintenance from this point onward.
7804 // (The backfill inside create_view reads topo directly; it does not rely
7805 // on pending deltas.)
7806 self.engine.set_emit_deltas(true);
7807 self.log_then_apply(WalRecord::CreateView { def_bytes })
7808 }
7809
7810 /// Remove a named view and delete its values from every node.
7811 ///
7812 /// # Errors
7813 /// - `ReadOnly`: called on an as-of instance.
7814 /// - `RuleNotFound`: view does not exist.
7815 pub fn delete_view(&mut self, name: &str) -> Result<()> {
7816 if self.read_only {
7817 return Err(GraphError::ReadOnly);
7818 }
7819 if !self.view_store.has_view(name) {
7820 return Err(GraphError::RuleNotFound { name: name.into() });
7821 }
7822 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
7823 // After deletion, disable accumulation if no listeners remain.
7824 if !self.needs_emit_deltas() {
7825 self.engine.set_emit_deltas(false);
7826 }
7827 result
7828 }
7829
7830 /// Snapshot of all registered view definitions.
7831 pub fn views(&self) -> Vec<ViewDef> {
7832 self.view_store.views().cloned().collect()
7833 }
7834
7835 // -----------------------------------------------------------------------
7836 // Full-text-lite API
7837 // -----------------------------------------------------------------------
7838
7839 /// Enable full-text indexing for all nodes of `label` on property `field`.
7840 ///
7841 /// After this call, every subsequent write to `(label, field)` is reflected
7842 /// in the index incrementally. Existing nodes are backfilled immediately.
7843 /// The declaration is persisted as a WAL record. The index is carried by
7844 /// the next snapshot and read from it at open; until then it is rebuilt
7845 /// at open from the stored values.
7846 ///
7847 /// # Errors
7848 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7849 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
7850 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7851 if self.read_only {
7852 return Err(GraphError::ReadOnly);
7853 }
7854 if self.fulltext.is_enabled(label, field) {
7855 return Err(GraphError::RuleInvalid {
7856 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
7857 });
7858 }
7859 self.log_then_apply(WalRecord::EnableFulltext {
7860 label: label.into(),
7861 field: field.into(),
7862 })
7863 }
7864
7865 /// Disable full-text indexing for `(label, field)` and drop its postings.
7866 ///
7867 /// # Errors
7868 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7869 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
7870 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7871 if self.read_only {
7872 return Err(GraphError::ReadOnly);
7873 }
7874 if !self.fulltext.is_enabled(label, field) {
7875 return Err(GraphError::RuleNotFound {
7876 name: format!("fulltext({label},{field})"),
7877 });
7878 }
7879 self.log_then_apply(WalRecord::DisableFulltext {
7880 label: label.into(),
7881 field: field.into(),
7882 })
7883 }
7884
7885 /// Whether this handle read its full-text index from the snapshot it
7886 /// opened, and kept it, where it would otherwise have built it from the
7887 /// stored values. False for a store with no snapshot or no declared pair,
7888 /// for a snapshot written before the index was carried, and for an open
7889 /// that had to fall back. The answers are the same either way; this is
7890 /// how a test or an operator tells which open they got.
7891 pub fn fulltext_loaded_from_snapshot(&self) -> bool {
7892 self.fulltext_loaded
7893 }
7894
7895 /// Called by the insert arms of `apply` before they index `field` on
7896 /// `id`. `add_tokens` appends, so a node already indexed under the field
7897 /// has to be cleared first or its old words stay searchable.
7898 ///
7899 /// Live, that is one insert naming a property twice: the last value is
7900 /// what is stored, and it is what is indexed. At open it means the WAL
7901 /// holds a write the loaded index already has; the index is rebuilt after
7902 /// the replay, and clearing one node at a time here would only add a walk
7903 /// of the vocabulary for every such insert, so it is counted and left.
7904 fn unindex_before_reindexing(&mut self, id: u32, field: &str) {
7905 if !self.fulltext.has_doc(id, field) {
7906 return;
7907 }
7908 if self.replaying_at_open {
7909 self.fulltext_reindexed_a_node = true;
7910 self.fulltext_reindexed_at_open += 1;
7911 } else {
7912 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
7913 }
7914 }
7915
7916 /// How many times the replay at open indexed a node that was already
7917 /// indexed under that field. Non-zero means the WAL held writes the
7918 /// loaded index already had, and the open rebuilt it. Exposed for tests;
7919 /// not part of the stable surface.
7920 #[doc(hidden)]
7921 pub fn fulltext_reindexed_during_open(&self) -> u64 {
7922 self.fulltext_reindexed_at_open
7923 }
7924
7925 /// Read the full-text index out of the snapshot this handle just mapped,
7926 /// if it carries one that reads back and was built over this id space.
7927 /// Anything else leaves the index empty for the rebuild that follows.
7928 fn load_fulltext_from_base(&mut self) {
7929 let Some(base) = self.base.as_ref() else {
7930 return;
7931 };
7932 // A snapshot that kept its WAL and carries no mark of how much of it
7933 // it holds is replayed over in full. Every insert in that WAL is
7934 // already in the blob, so reading it would end in a rebuild anyway —
7935 // after making each replayed insert tokenize first. With a mark, the
7936 // frames the snapshot holds are not applied again and the index is as
7937 // good as any other snapshot's.
7938 if !self.base_wal_truncated && base.wal_mark().is_none() {
7939 return;
7940 }
7941 let bytes = match base.fulltext_bytes() {
7942 Ok(bytes) if !bytes.is_empty() => bytes,
7943 _ => return,
7944 };
7945 match core_storage::fulltext::FulltextIndex::from_blob(bytes) {
7946 Some((index, node_count)) if node_count as usize == self.ids.len() => {
7947 self.fulltext = Arc::new(index);
7948 self.fulltext_loaded = true;
7949 }
7950 _ => eprintln!(
7951 "mushroomdb: the snapshot's full-text index could not be read; rebuilding it"
7952 ),
7953 }
7954 }
7955
7956 /// The full-text index as the bytes a snapshot carries, or nothing when
7957 /// no pair is declared.
7958 fn fulltext_blob(&self) -> Vec<u8> {
7959 if self.fulltext.enabled_pairs().next().is_none() {
7960 return Vec::new();
7961 }
7962 self.fulltext.to_blob(self.ids.len() as u32)
7963 }
7964
7965 /// Remove every edge the snapshot holds to or from a node that has been
7966 /// deleted.
7967 ///
7968 /// There should be none. Through 0.7.1 a node deleted after a snapshot
7969 /// kept the edges that snapshot held (ledger row 95), and the next
7970 /// snapshot wrote them into its own base, where they answered a
7971 /// neighbour read with `topology id N has no key`. This is what repairs
7972 /// such a store. It asks for the neighbours of every retired id under
7973 /// every edge type, which on a store with half a million deleted nodes is
7974 /// over a second, so it runs only over a snapshot that does not say its
7975 /// writer sweeps them (`MappedBase::edges_swept`). The removal is in
7976 /// memory until the next snapshot, which no longer carries them and says
7977 /// so.
7978 fn drop_edges_of_retired_nodes(&mut self) {
7979 let mut doomed = Vec::new();
7980 {
7981 let tv = self.topo_view();
7982 let etypes: Vec<u32> = tv.etypes().collect();
7983 for n in self.ids.tombstoned_ids() {
7984 for et in &etypes {
7985 for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
7986 doomed.push((*et, n, dst));
7987 }
7988 for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
7989 if src != n {
7990 doomed.push((*et, src, n));
7991 }
7992 }
7993 }
7994 }
7995 }
7996 for (et, s, d) in doomed {
7997 Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
7998 Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
7999 self.view_store.on_edge_changed(
8000 et,
8001 s,
8002 d,
8003 false,
8004 Arc::make_mut(&mut self.props),
8005 &build_topo_view(&self.topo, &self.base),
8006 &self.ids,
8007 &self.syms,
8008 &self.labels,
8009 base_columns(&self.base),
8010 );
8011 }
8012 }
8013
8014 /// What `snapshot.intent` says about the snapshot and the WAL on disk.
8015 ///
8016 /// The first value is the WAL that the snapshot already holds, when the
8017 /// intent names this snapshot and `wal` begins with exactly the WAL it
8018 /// names: a snapshot that was to replace its WAL and did not get that
8019 /// far. The second is `true` when the file holds anything else — an
8020 /// intent left over, which must not outlive this handle's next append.
8021 fn interrupted_snapshot(
8022 &self,
8023 wal: &[u8],
8024 ) -> Result<(Option<core_storage::v8::WalMark>, bool)> {
8025 let (standing, left_over) = self.standing_intent(wal)?;
8026 Ok((standing.map(|i| i.wal), left_over))
8027 }
8028
8029 /// The intent in `snapshot.intent` that is true of the snapshot and the
8030 /// WAL on disk, if one is, and whether the file holds anything that is
8031 /// not.
8032 ///
8033 /// A file that cannot be read is an error, not an absent intent: taking
8034 /// it for absent would replay the WAL over the very snapshot the intent
8035 /// is there to protect.
8036 fn standing_intent(
8037 &self,
8038 wal: &[u8],
8039 ) -> Result<(Option<core_storage::v8::SnapshotIntent>, bool)> {
8040 let bytes = self.fs.read(FileId::SnapshotIntent)?;
8041 if bytes.is_empty() {
8042 return Ok((None, false));
8043 }
8044 // The WAL is in hand, so it is asked first. Only a record whose mark
8045 // it matches is worth reading the whole snapshot for — which leaves a
8046 // left-over intent, the usual kind, costing nothing at each open.
8047 let candidates: Vec<_> = core_storage::v8::SnapshotIntent::decode_all(&bytes)
8048 .into_iter()
8049 .filter(|i| {
8050 i.wal.matches(wal)
8051 // The mark was taken at the end of a whole WAL.
8052 && decode_all(&wal[..i.wal.len as usize]).1 as u64 == i.wal.len
8053 })
8054 .collect();
8055 let standing = if candidates.is_empty() {
8056 None
8057 } else {
8058 let snapshot = self.fs.read(FileId::Snapshot)?;
8059 candidates.into_iter().find(|i| i.names(&snapshot))
8060 };
8061 // Anything in the file beyond the one standing intent is left over:
8062 // a record of a snapshot that finished or never landed.
8063 let left_over = bytes.len() != if standing.is_some() { 32 } else { 0 };
8064 Ok((standing, left_over))
8065 }
8066
8067 /// Empty `snapshot.intent` unless it is still true. Called with the write
8068 /// lock held, before an append.
8069 fn settle_snapshot_intent(&mut self) -> Result<()> {
8070 let wal = self.fs.read(FileId::Wal)?;
8071 let (standing, left_over) = self.standing_intent(&wal)?;
8072 if left_over {
8073 // Keep the one that is still true, drop the rest.
8074 let keep = standing.map(|i| i.encode()).unwrap_or_default();
8075 self.fs.write_atomic(FileId::SnapshotIntent, &keep)?;
8076 }
8077 self.intent_unsettled = false;
8078 Ok(())
8079 }
8080
8081 /// Make the next snapshot on this handle fail at `at`, leaving the files
8082 /// as a crash there would. For tests; not part of the stable surface.
8083 #[doc(hidden)]
8084 pub fn stop_next_snapshot(&mut self, at: SnapshotStop) {
8085 self.snapshot_stop = Some(at);
8086 }
8087
8088 /// Whether `(label, field)` is currently indexed for full-text search.
8089 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
8090 self.fulltext.is_enabled(label, field)
8091 }
8092
8093 /// Every `(label, field)` pair with a live full-text index, sorted.
8094 ///
8095 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
8096 /// declares which nodes are *indexed*, so callers that want to search
8097 /// everything indexed should query each distinct field once.
8098 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
8099 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
8100 v.sort();
8101 v
8102 }
8103
8104 /// Enable an equality index for all nodes of `label` on scalar property
8105 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
8106 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
8107 /// declaration persists via WAL and the postings rebuild on re-open.
8108 ///
8109 /// # Errors
8110 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8111 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
8112 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
8113 if self.read_only {
8114 return Err(GraphError::ReadOnly);
8115 }
8116 if self.prop_index.is_enabled(label, field) {
8117 return Err(GraphError::RuleInvalid {
8118 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
8119 });
8120 }
8121 self.log_then_apply(WalRecord::EnableIndex {
8122 label: label.into(),
8123 field: field.into(),
8124 })
8125 }
8126
8127 /// Disable the equality index for `(label, field)` and drop its postings.
8128 ///
8129 /// # Errors
8130 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8131 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
8132 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
8133 if self.read_only {
8134 return Err(GraphError::ReadOnly);
8135 }
8136 if !self.prop_index.is_enabled(label, field) {
8137 return Err(GraphError::RuleNotFound {
8138 name: format!("index({label},{field})"),
8139 });
8140 }
8141 self.log_then_apply(WalRecord::DisableIndex {
8142 label: label.into(),
8143 field: field.into(),
8144 })
8145 }
8146
8147 /// Whether `(label, field)` currently has an equality index.
8148 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
8149 self.prop_index.is_enabled(label, field)
8150 }
8151
8152 /// Every `(label, field)` pair with a live equality index, sorted.
8153 ///
8154 /// The enumerator [`is_index_enabled`](Self::is_index_enabled) never had:
8155 /// the MCP `schema` tool lists a store's indexes rather than probing them
8156 /// one guess at a time.
8157 pub fn index_pairs(&self) -> Vec<(String, String)> {
8158 self.prop_index.enabled_pairs().cloned().collect()
8159 }
8160
8161 /// The dense id of a live key, `None` for an unknown or deleted one.
8162 ///
8163 /// Ids are allocated in insertion order and never reused, so the lowest
8164 /// live id among a set of keys is the oldest node — which is how identity
8165 /// resolution picks a canonical node without a timestamp field
8166 /// (`memory::identity`). Crate-private: an id is an engine detail, not
8167 /// something a caller should hold.
8168 pub(crate) fn dense_id(&self, key: &str) -> Option<u32> {
8169 self.ids.get(key)
8170 }
8171
8172 /// Start recording insert-count multiplicity on this store (§5.13).
8173 ///
8174 /// Adjacency stays a set and nothing about an existing read changes: a
8175 /// duplicate [`insert_edge`](Self::insert_edge) still returns `Ok(false)`
8176 /// and still leaves [`degree`](Self::degree) alone. What it gains is that
8177 /// the duplicate is *counted*, as the reserved edge property
8178 /// [`EDGE_COUNT_PROP`], readable through
8179 /// [`degree_multiplicity`](Self::degree_multiplicity).
8180 ///
8181 /// # This is a one-way step, and that is why it is a call
8182 ///
8183 /// The count is durable, so it is written to the WAL — as discriminant 23,
8184 /// which no release before v0.6.10 knows. A reader meeting an unknown WAL
8185 /// discriminant cannot know what the record would have changed, so it
8186 /// cannot degrade the way an unreadable index blob can. **After this call
8187 /// the store can no longer be read by an older binary, and there is no call
8188 /// that undoes it.** Gating the record behind this method is what keeps
8189 /// that step a decision an operator makes when they want the feature,
8190 /// rather than one everybody takes by upgrading.
8191 ///
8192 /// # It fails loudly, and that costs a snapshot
8193 ///
8194 /// An older binary does not refuse discriminant 23 — it truncates the WAL
8195 /// at it and, with `repair_wal`, persists the truncation. So this call also
8196 /// writes a **V10 snapshot**, a version no earlier release knows, and it
8197 /// writes it *first*: the snapshot is read before the WAL, so an older
8198 /// binary stops at `snapshot: unsupported version 10` with the WAL
8199 /// untouched. Taking the snapshot before appending the record is what makes
8200 /// the guard unconditional — the store is never, at any interruption point,
8201 /// carrying the record without the stamp that announces it.
8202 ///
8203 /// The snapshot keeps the WAL (`keep_wal: true`): opting in is not a
8204 /// compaction, and history reachable by [`open_at`](Self::open_at) stays
8205 /// reachable. On a large store the call therefore costs one full snapshot
8206 /// write.
8207 ///
8208 /// # What it costs a store that archives
8209 ///
8210 /// Writing `snapshot.bin` is also how the archive path decides whether the
8211 /// store may have a *genesis chain* — whether `open_at` can replay
8212 /// archive-resident commits from empty state. The rule is conservative: a
8213 /// snapshot that was already on disk might have been a truncating one, and
8214 /// once the handle that took it is gone this binary cannot tell. A
8215 /// `keep_wal` snapshot taken by **this** handle is the case where it can, so
8216 /// opting in and then archiving **in the same session** keeps the chain.
8217 ///
8218 /// Opting in, closing the store, and archiving in a *later* session does
8219 /// not — but that is the answer any store with a prior snapshot gets, not
8220 /// something this call causes. A store that wants the chain should take its
8221 /// first archive in the session that opted in.
8222 ///
8223 /// Calling it on a store that has already opted in writes nothing and
8224 /// returns `Ok(())`: an operator should not have to ask first.
8225 ///
8226 /// # This call is not atomic, and an `Err` does not undo it
8227 ///
8228 /// There is no rollback here, and there never was one. An `Err` means this
8229 /// handle stopped believing the store is opted in — `self.multiplicity` is
8230 /// reset, so this handle reports `false` from then on — and nothing more. It
8231 /// says nothing about what reached disk. Two reachable failures leave the
8232 /// opt-in standing:
8233 ///
8234 /// * **The declaration landed and only its fsync failed.** `log_then_apply`
8235 /// appends, then syncs; a failed barrier leaves `MULTIPLICITY_ENABLED`
8236 /// already in `wal.bin`. The next open replays it and the store is opted
8237 /// in. No archive is involved — this one predates the recovery below.
8238 /// * **The declaration never landed, but the V10 snapshot did, on a store
8239 /// that already had an archive.** The open-time recovery in
8240 /// `load_from_disk` reads V10-beside-an-archive as an interrupted archive
8241 /// sequence and opts the store in.
8242 ///
8243 /// So a failed call may leave the opt-in on disk immediately (the first
8244 /// case) or conjure it at the next open (the second), and nothing puts the
8245 /// store back out. Treat `Err` as "the outcome is unknown", not as "nothing
8246 /// happened".
8247 ///
8248 /// **This is safe, and the ordering is the reason.** The V10 stamp is
8249 /// written *before* the declaration, so every one of these intermediate
8250 /// states is one an older binary refuses by name rather than truncates at.
8251 /// The failure direction costs a refusal, never a commit. That ordering is
8252 /// the property worth protecting, not the atomicity this call never had.
8253 ///
8254 /// **To know where the store stands, ask the store.** Reopen it and call
8255 /// [`is_multiplicity_enabled`](Self::is_multiplicity_enabled); that is the
8256 /// only answer that accounts for what reached disk.
8257 ///
8258 /// The one case that really does leave the store opted out is a failure with
8259 /// no archive present and no record written: a stray V10 snapshot remains,
8260 /// costing an older reader a refusal it did not strictly need, and *that*
8261 /// store's next snapshot rewrites at V9.
8262 ///
8263 /// # Errors
8264 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8265 /// - Anything [`snapshot_with`](Self::snapshot_with) can return, and
8266 /// anything the WAL append or its fsync can return. See the atomicity
8267 /// section above for what the store is left holding.
8268 pub fn enable_multiplicity(&mut self) -> Result<()> {
8269 if self.read_only {
8270 return Err(GraphError::ReadOnly);
8271 }
8272 if self.multiplicity {
8273 return Ok(());
8274 }
8275 // The snapshot goes first, and the order is the guard.
8276 //
8277 // An older reader refuses a V10 snapshot by name and stops; it does not
8278 // refuse discriminant 23, it truncates the WAL at it. So the store must
8279 // never hold the record without the snapshot that announces it — not
8280 // even for the width of one fsync. Writing the snapshot before the
8281 // record makes the only reachable intermediate state "V10 snapshot, no
8282 // record", which is merely conservative: this binary reads it as a
8283 // store that has not opted in, and an older one refuses it.
8284 //
8285 // `keep_wal: true` because opting in is not a compaction: an operator
8286 // asking for multiplicity has not asked to lose the history `open_at`
8287 // can reach.
8288 self.multiplicity = true;
8289 let forced = self
8290 .snapshot_with(SnapshotOptions {
8291 keep_wal: true,
8292 ..SnapshotOptions::default()
8293 })
8294 .and_then(|()| self.log_then_apply(core_storage::wal::MULTIPLICITY_ENABLED));
8295 if forced.is_err() {
8296 // This handle stops believing it is opted in. That is all this line
8297 // does — it is not a rollback, and cannot be one: the declaration
8298 // may already be in `wal.bin` (the append succeeded and only the
8299 // fsync failed), and even when it is not, the V10 snapshot beside an
8300 // existing archive is enough for the open-time recovery to opt the
8301 // store in. See the "not atomic" section on this method.
8302 //
8303 // It fails in the safe direction either way: the V10 stamp reached
8304 // disk before anything a v0.6.9 reader would truncate at, so the
8305 // worst an interruption costs that reader is a refusal by name.
8306 self.multiplicity = false;
8307 }
8308 forced
8309 }
8310
8311 /// Whether this store records insert-count multiplicity.
8312 ///
8313 /// `false` on every store that has not called
8314 /// [`enable_multiplicity`](Self::enable_multiplicity) — which is every
8315 /// store that did not ask for it, including one upgraded from an earlier
8316 /// release.
8317 pub fn is_multiplicity_enabled(&self) -> bool {
8318 self.multiplicity
8319 }
8320
8321 /// How many times `(etype, src, dst)` has been inserted: the reserved
8322 /// `count` edge property, or 1 when it is absent.
8323 ///
8324 /// Answers 1 for a pair on a store that never opted in, which is the truth
8325 /// available there — the pair was inserted at least once, and the store
8326 /// kept no record of any second insert.
8327 fn edge_insert_count(&self, etype: u32, src: u32, dst: u32) -> u64 {
8328 match self.edge_props_view().get(etype, src, dst, EDGE_COUNT_PROP) {
8329 Some(Value::Int(n)) if n > 0 => n as u64,
8330 _ => 1,
8331 }
8332 }
8333
8334 /// The `SetEdgeCount` record a duplicate insert of `(edge_type, src_key,
8335 /// dst_key)` should log, or `None` when nothing should be written.
8336 ///
8337 /// `None` when the store has not opted in, so **no discriminant-23 record
8338 /// is written at all** — the gate the whole feature rests on.
8339 ///
8340 /// The other two `None`s are unreachable from the one caller. This is the
8341 /// single-mutation path, where `prepare_insert_edge` has already refused a
8342 /// missing endpoint and an existing pair's edge type is necessarily
8343 /// interned. A batch is the case where a pair's endpoints and type can all
8344 /// be created by the same frame, and it does not come through here: it
8345 /// queues a [`PlannedRec::DuplicateCount`] and names the count in the dense
8346 /// rewrite, which is the only pass that knows the frame's own ids.
8347 fn edge_count_record(
8348 &self,
8349 edge_type: &str,
8350 src_key: &str,
8351 dst_key: &str,
8352 ) -> Option<WalRecord> {
8353 if !self.multiplicity {
8354 return None;
8355 }
8356 let etype = self.syms.get(edge_type)?;
8357 let src = self.ids.get(src_key)?;
8358 let dst = self.ids.get(dst_key)?;
8359 Some(WalRecord::SetEdgeCount {
8360 etype,
8361 src,
8362 dst,
8363 count: self.edge_insert_count(etype, src, dst).saturating_add(1),
8364 })
8365 }
8366
8367 /// Search a full-text-indexed field.
8368 ///
8369 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
8370 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
8371 ///
8372 /// **Query syntax:**
8373 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
8374 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
8375 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
8376 /// - `AND` keyword is accepted explicitly and is the default.
8377 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
8378 ///
8379 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
8380 /// Pin: this is the documented, tested, stable behavior for v1.
8381 ///
8382 /// **Memory / performance:** O(postings) lookup; no scan. The index is
8383 /// in-memory and proportional to total indexed text across all enabled fields.
8384 ///
8385 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
8386 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
8387 /// key ascending for deterministic tiebreaking.
8388 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8389 // Resolve node_ids to keys (excluding tombstones) then re-sort by
8390 // (score DESC, key ASC) to give a deterministic, key-lexicographic
8391 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
8392 // which diverges from key order when nodes were not inserted in key-lex order.
8393 let mut results: Vec<(String, f64)> = self
8394 .fulltext
8395 .search(field, query, 0)
8396 .into_iter()
8397 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8398 .collect();
8399 results.sort_by(|a, b| {
8400 b.1.partial_cmp(&a.1)
8401 .unwrap_or(std::cmp::Ordering::Equal)
8402 .then(a.0.cmp(&b.0))
8403 });
8404 results
8405 }
8406
8407 /// [`search`](Self::search), stopping at the `k` best hits.
8408 ///
8409 /// Same ranking and the same deterministic tiebreak, but the index drops
8410 /// everything past `k` before any key is resolved, so a caller that wants
8411 /// the top few out of a field that matched thousands does not pay to
8412 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
8413 /// [`search`](Self::search) behaves.
8414 ///
8415 /// The BM25 scoring itself is not bounded by `k` — every candidate is
8416 /// scored either way — so this trims the resolve and the sort, not the
8417 /// search.
8418 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
8419 // A tombstoned id resolves to nothing, so asking the index for exactly
8420 // `k` could return fewer. Over-fetching a little and truncating after
8421 // the filter keeps the count right without unbounding the call.
8422 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
8423 let mut results: Vec<(String, f64)> = self
8424 .fulltext
8425 .search(field, query, want)
8426 .into_iter()
8427 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8428 .collect();
8429 results.sort_by(|a, b| {
8430 b.1.partial_cmp(&a.1)
8431 .unwrap_or(std::cmp::Ordering::Equal)
8432 .then(a.0.cmp(&b.0))
8433 });
8434 if k > 0 {
8435 results.truncate(k);
8436 }
8437 results
8438 }
8439
8440 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
8441 ///
8442 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
8443 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
8444 /// them with RRF using a fixed constant of 60.
8445 ///
8446 /// ```text
8447 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
8448 /// ```
8449 ///
8450 /// Returns the top `k` nodes by fused score, ties broken by node key
8451 /// ascending (deterministic).
8452 ///
8453 /// # Vector leg fallback
8454 ///
8455 /// When `query_vec` is empty the vector leg is skipped entirely and
8456 /// results are ranked by the text list alone through the same RRF path
8457 /// (each text result scores `1/(60 + rank)` from that single list).
8458 ///
8459 /// When `label` is `None`, the vector leg **always** returns empty results.
8460 /// Internally `label` is mapped to `""`, which does not match any rule-created
8461 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
8462 /// the brute-force fallback finds no nodes with an empty label. The fused
8463 /// ranking is therefore text-only in this case.
8464 pub fn search_hybrid(
8465 &self,
8466 text_field: &str,
8467 query_text: &str,
8468 vector_field: &str,
8469 query_vec: &[f64],
8470 label: Option<&str>,
8471 k: usize,
8472 ) -> Vec<(String, f64)> {
8473 self.search_hybrid_inner(
8474 text_field,
8475 query_text,
8476 vector_field,
8477 query_vec,
8478 label,
8479 k,
8480 None,
8481 )
8482 }
8483
8484 /// [`search_hybrid`](Self::search_hybrid) with **each leg** filtered to the
8485 /// mask before the fusion.
8486 ///
8487 /// Filtering the fused list afterwards would quietly return fewer than `k`.
8488 /// Each leg over-fetches `4*k` candidates, so when the visible nodes rank
8489 /// below `4*k` hidden ones neither leg carries them into the fusion at all
8490 /// and the post-filter has nothing left to keep. Filtering first spends the
8491 /// `4*k` on **visible** hits, so a scoped call is as long as the corpus it
8492 /// can see allows.
8493 ///
8494 /// The ranks that enter RRF are therefore the ranks of the visible corpus,
8495 /// not the visible entries of the store-wide ranking. The constant stays 60
8496 /// and the tiebreak stays key-ascending.
8497 #[allow(clippy::too_many_arguments)]
8498 pub fn search_hybrid_scoped(
8499 &self,
8500 text_field: &str,
8501 query_text: &str,
8502 vector_field: &str,
8503 query_vec: &[f64],
8504 label: Option<&str>,
8505 k: usize,
8506 mask: &crate::mask::NodeMask,
8507 ) -> Vec<(String, f64)> {
8508 self.search_hybrid_inner(
8509 text_field,
8510 query_text,
8511 vector_field,
8512 query_vec,
8513 label,
8514 k,
8515 Some(mask),
8516 )
8517 }
8518
8519 /// The body shared by [`search_hybrid`](Self::search_hybrid) and
8520 /// [`search_hybrid_scoped`](Self::search_hybrid_scoped). `mask = None` is
8521 /// the unscoped contract unchanged: the filter below is then a no-op and
8522 /// the vector leg is the same unmasked call it has always been.
8523 #[allow(clippy::too_many_arguments)]
8524 fn search_hybrid_inner(
8525 &self,
8526 text_field: &str,
8527 query_text: &str,
8528 vector_field: &str,
8529 query_vec: &[f64],
8530 label: Option<&str>,
8531 k: usize,
8532 mask: Option<&crate::mask::NodeMask>,
8533 ) -> Vec<(String, f64)> {
8534 use std::collections::HashMap;
8535
8536 const RRF_K: f64 = 60.0;
8537 let pool = 4 * k;
8538
8539 // Accumulate per-node RRF scores.
8540 let mut scores: HashMap<String, f64> = HashMap::new();
8541
8542 // Text leg. The mask bites on the candidates, before `take(pool)`, so
8543 // the over-fetch is a budget of visible hits rather than one a hidden
8544 // prefix can exhaust.
8545 let text_hits = self.search(text_field, query_text);
8546 let visible_text = text_hits
8547 .into_iter()
8548 .filter(|(key, _count)| mask.is_none_or(|m| m.contains_node(self, key)));
8549 for (rank0, (key, _count)) in visible_text.take(pool).enumerate() {
8550 let rank = (rank0 + 1) as f64;
8551 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8552 }
8553
8554 // Vector leg (skipped when query_vec is empty). The masked variant
8555 // applies the mask before its own k-truncation, for the same reason.
8556 if !query_vec.is_empty() {
8557 // `ExactnessCaller::Hybrid`: the leg is the same one
8558 // `find_similar_vector_masked` runs, but the advice its warning
8559 // gives has to fit *this* signature, which has no `exact`.
8560 let vec_hits = self
8561 .find_similar_vector_as(
8562 vector_field,
8563 label,
8564 query_vec,
8565 pool,
8566 0.0,
8567 mask,
8568 None,
8569 false,
8570 ExactnessCaller::Hybrid,
8571 )
8572 .expect("find_similar_vector_as is infallible without where_");
8573 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
8574 let rank = (rank0 + 1) as f64;
8575 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8576 }
8577 }
8578
8579 // Sort: score DESC, then key ASC for deterministic tie-breaking.
8580 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
8581 ranked.sort_by(|a, b| {
8582 b.1.partial_cmp(&a.1)
8583 .unwrap_or(std::cmp::Ordering::Equal)
8584 .then(a.0.cmp(&b.0))
8585 });
8586 ranked.truncate(k);
8587 ranked
8588 }
8589
8590 /// For DST/testing: scratch BM25 search over live nodes without the index.
8591 /// Walks every live node, re-stems field tokens, computes corpus stats, and
8592 /// returns BM25-ranked results.
8593 ///
8594 /// The oracle: the ordered key list of `search(field, q)` must equal that of
8595 /// `scratch_search(field, q)` at every quiescent state.
8596 #[doc(hidden)]
8597 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8598 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
8599 use std::collections::BTreeMap;
8600
8601 let groups = parse_query(query);
8602 if groups.is_empty() {
8603 return vec![];
8604 }
8605
8606 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
8607 struct NodeData {
8608 key: String,
8609 /// stemmed_token → positions (sorted)
8610 tokens: BTreeMap<String, Vec<u32>>,
8611 dl: u32,
8612 }
8613
8614 let mut nodes: Vec<NodeData> = Vec::new();
8615 for id in 0..self.ids.len() as u32 {
8616 let Some(key) = self.ids.key_of(id) else {
8617 continue;
8618 };
8619 let Some(&sym) = self.labels.get(id as usize) else {
8620 continue;
8621 };
8622 if sym == u32::MAX {
8623 continue;
8624 }
8625 let label = match self.syms.resolve(sym) {
8626 Some(l) => l,
8627 None => continue,
8628 };
8629 if !self.fulltext.is_enabled(label, field) {
8630 continue;
8631 }
8632 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
8633 continue;
8634 };
8635 // Use value_tokens_stemmed_with_positions so list elements are
8636 // separated by POSITION_GAP — identical to the index path, which
8637 // prevents phrase queries from matching across element boundaries.
8638 let stemmed_with_pos = match &value {
8639 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
8640 _ => continue,
8641 };
8642 let dl = stemmed_with_pos.len() as u32;
8643 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
8644 for (tok, pos) in stemmed_with_pos {
8645 tok_map.entry(tok).or_default().push(pos);
8646 }
8647 nodes.push(NodeData {
8648 key: key.to_string(),
8649 tokens: tok_map,
8650 dl,
8651 });
8652 }
8653
8654 if nodes.is_empty() {
8655 return vec![];
8656 }
8657
8658 // --- BM25 corpus stats ---
8659 let n = nodes.len() as f64;
8660 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
8661 // df per stemmed token across all live indexed nodes.
8662 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
8663 for nd in &nodes {
8664 for tok in nd.tokens.keys() {
8665 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
8666 }
8667 }
8668
8669 const K1: f64 = 1.2;
8670 const B: f64 = 0.75;
8671
8672 // --- Pass 2: score each node against each OR-group ---
8673 let mut results: Vec<(String, f64)> = Vec::new();
8674 for nd in &nodes {
8675 let dl = nd.dl as f64;
8676 let mut total_score = 0.0f64;
8677
8678 'group: for group in &groups {
8679 let mut group_score = 0.0f64;
8680
8681 for term in group {
8682 if term.negated {
8683 // Negated: if doc has this stemmed token → group fails.
8684 let present = if term.prefix {
8685 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
8686 } else {
8687 nd.tokens.contains_key(term.token.as_str())
8688 };
8689 if present {
8690 continue 'group;
8691 }
8692 continue;
8693 }
8694 if term.prefix {
8695 // Prefix: sum BM25 for all matching stemmed tokens.
8696 let mut prefix_matched = false;
8697 for (tok, positions) in &nd.tokens {
8698 if tok.starts_with(term.token.as_str()) {
8699 let tf = positions.len() as f64;
8700 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
8701 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8702 let tf_norm =
8703 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8704 group_score += idf * tf_norm;
8705 prefix_matched = true;
8706 }
8707 }
8708 if !prefix_matched {
8709 continue 'group;
8710 }
8711 } else {
8712 // term.token is already stemmed by parse_query; use directly.
8713 match nd.tokens.get(term.token.as_str()) {
8714 None => continue 'group,
8715 Some(positions) => {
8716 let tf = positions.len() as f64;
8717 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
8718 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8719 let tf_norm =
8720 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8721 group_score += idf * tf_norm;
8722 }
8723 }
8724 }
8725 }
8726
8727 if group_score > 0.0 {
8728 total_score += group_score;
8729 }
8730 }
8731
8732 if total_score > 0.0 {
8733 results.push((nd.key.clone(), total_score));
8734 }
8735 }
8736
8737 results.sort_by(|a, b| {
8738 b.1.partial_cmp(&a.1)
8739 .unwrap_or(std::cmp::Ordering::Equal)
8740 .then(a.0.cmp(&b.0))
8741 });
8742 results
8743 }
8744
8745 /// Return the current view-maintained value of `view_prop` for node `key`.
8746 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
8747 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
8748 let id = self.ids.get(key)?;
8749 self.props_view()
8750 .get(id, view_prop)
8751 .map(|vr| vr.into_value())
8752 }
8753
8754 /// For testing / DST oracle: scratch recompute of a view value for one node.
8755 ///
8756 /// Returns `None` if the node does not exist, the view does not exist, or
8757 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
8758 #[doc(hidden)]
8759 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
8760 let node = self.ids.get(key)?;
8761 let def = self.view_store.views().find(|v| v.name == view_name)?;
8762 // Use TopologyView so that NeighborAgg sees base + overlay edges
8763 // without materialising a temporary Topology (I1).
8764 let topo_view = self.topo_view();
8765 core_rules::views::compute_view_value(
8766 def,
8767 node,
8768 self.props_view(),
8769 &topo_view,
8770 &self.ids,
8771 &self.syms,
8772 &self.labels,
8773 )
8774 }
8775
8776 // -----------------------------------------------------------------------
8777 // Graph algorithm API
8778 // -----------------------------------------------------------------------
8779
8780 /// Run PageRank over the unified topology (manual + derived edges).
8781 ///
8782 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
8783 /// ascending). Set `config.edge_type` to restrict to one edge type.
8784 /// `config.converged` is `true` only when the power iteration converged
8785 /// within `config.max_iters` and within any time budget.
8786 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
8787 let topo = build_topo_view(&self.topo, &self.base);
8788 let edge_props = self.edge_props_view();
8789 crate::algo::pagerank(
8790 &topo,
8791 &self.ids,
8792 &self.syms,
8793 &self.labels,
8794 &edge_props,
8795 config,
8796 )
8797 }
8798
8799 /// Weakly-connected components over the unified topology (treated as
8800 /// undirected regardless of how edges were inserted).
8801 ///
8802 /// Component IDs are the key of the smallest member in the component
8803 /// (deterministic). Result sorted by (component_id, key).
8804 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
8805 let topo = build_topo_view(&self.topo, &self.base);
8806 let edge_props = self.edge_props_view();
8807 crate::algo::wcc(
8808 &topo,
8809 &self.ids,
8810 &self.syms,
8811 &self.labels,
8812 &edge_props,
8813 config,
8814 )
8815 }
8816
8817 /// Degree centrality for every live node.
8818 ///
8819 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
8820 /// `AlgoDir::Both` = out + in (total directed degree).
8821 ///
8822 /// For one-shot ranking use this; for a live property updated on every
8823 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
8824 pub fn degree_centrality(
8825 &self,
8826 config: &crate::algo::DegreeConfig,
8827 ) -> crate::algo::DegreeReport {
8828 let topo = build_topo_view(&self.topo, &self.base);
8829 let edge_props = self.edge_props_view();
8830 crate::algo::degree_centrality(
8831 &topo,
8832 &self.ids,
8833 &self.syms,
8834 &self.labels,
8835 &edge_props,
8836 config,
8837 )
8838 }
8839
8840 /// Louvain community detection over the unified topology (undirected).
8841 ///
8842 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
8843 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
8844 /// result (communities sorted size-desc, then smallest member key asc).
8845 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
8846 let topo = build_topo_view(&self.topo, &self.base);
8847 let edge_props = self.edge_props_view();
8848 crate::algo::louvain(
8849 &topo,
8850 &self.ids,
8851 &self.syms,
8852 &self.labels,
8853 &edge_props,
8854 config,
8855 )
8856 }
8857
8858 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
8859 /// atomically via a single write-batch (one WAL frame, one fsync).
8860 ///
8861 /// # Errors
8862 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8863 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
8864 /// (collision check mirrors `create_view`).
8865 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
8866 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
8867 if self.read_only {
8868 return Err(GraphError::ReadOnly);
8869 }
8870 // Collision check: refuse if prop_name is view-managed.
8871 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
8872 return Err(GraphError::RuleInvalid {
8873 detail: format!(
8874 "prop {:?} is managed by view {:?} and cannot be written as scores",
8875 prop_name, view_name
8876 ),
8877 });
8878 }
8879 // Refuse if prop_name is a view name itself (confusing namespace collision).
8880 if self.view_store.has_view(prop_name) {
8881 return Err(GraphError::RuleInvalid {
8882 detail: format!(
8883 "prop_name {:?} collides with an existing view name",
8884 prop_name
8885 ),
8886 });
8887 }
8888 // Write all scores in a single crash-atomic batch.
8889 self.write_batch(|b| {
8890 for (key, score) in scores {
8891 b.set_prop(key, prop_name, Value::Float(*score));
8892 }
8893 })?;
8894 Ok(())
8895 }
8896
8897 /// Return the value of `field` for the node with key `key`, or `None` if
8898 /// the node or field is absent. Reads through the overlay-over-base
8899 /// `ColumnsView`, materialising base values on demand (zero heap cost for
8900 /// overlay hits; one clone per base hit).
8901 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
8902 let id = self.ids.get(key)?;
8903 self.props_view().get(id, field).map(|vr| vr.into_value())
8904 }
8905
8906 pub fn has_node(&self, key: &str) -> bool {
8907 self.ids.get(key).is_some()
8908 }
8909
8910 /// One human line for a node: the first of `text`, `summary` or `name` it
8911 /// carries, truncated. Used by the recall digest.
8912 pub fn node_summary_line(&self, key: &str) -> Option<String> {
8913 for field in ["text", "summary", "name"] {
8914 if let Some(Value::Str(s)) = self.get_prop(key, field) {
8915 let s = s.trim();
8916 if !s.is_empty() {
8917 return Some(s.chars().take(120).collect());
8918 }
8919 }
8920 }
8921 None
8922 }
8923
8924 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
8925 pub(crate) fn ids(&self) -> &IdMap {
8926 &self.ids
8927 }
8928
8929 // -----------------------------------------------------------------------
8930 // Namespaces
8931 // -----------------------------------------------------------------------
8932
8933 /// The index `name` already has in `ns_names`, if any.
8934 fn ns_index_of(&self, name: &str) -> Option<u32> {
8935 self.ns_names
8936 .iter()
8937 .position(|n| n == name)
8938 .map(|i| i as u32)
8939 }
8940
8941 /// The index for `name`, appending it to `ns_names` when it is new.
8942 ///
8943 /// The table holds one entry per distinct namespace in the store — a
8944 /// tenant count, not a node count — so the linear scan is cheaper than a
8945 /// map and keeps `namespaces()` allocation-free of a second index.
8946 fn ns_index_for(&mut self, name: &str) -> u32 {
8947 match self.ns_index_of(name) {
8948 Some(i) => i,
8949 None => {
8950 self.ns_names.push(name.to_string());
8951 (self.ns_names.len() - 1) as u32
8952 }
8953 }
8954 }
8955
8956 /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
8957 /// does not know (unreachable; the default is the narrowing answer).
8958 fn ns_name(&self, idx: u32) -> &str {
8959 self.ns_names
8960 .get(idx as usize)
8961 .map(String::as_str)
8962 .unwrap_or(NS_DEFAULT)
8963 }
8964
8965 /// The namespace index of dense node `id`, defaulting for an id with no
8966 /// entry (a node inserted before this handle rebuilt the array cannot
8967 /// exist: every insert path maintains it).
8968 fn node_ns_idx(&self, id: u32) -> u32 {
8969 self.node_ns
8970 .get(id as usize)
8971 .copied()
8972 .unwrap_or(NS_DEFAULT_IDX)
8973 }
8974
8975 /// File node `id` under namespace `name`, growing `node_ns` as `labels`
8976 /// grows. Called from `apply` for every node insert, live and replayed.
8977 fn set_node_ns(&mut self, id: u32, name: &str) {
8978 let idx = if name == NS_DEFAULT {
8979 NS_DEFAULT_IDX
8980 } else {
8981 self.ns_index_for(name)
8982 };
8983 if self.node_ns.len() <= id as usize {
8984 self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
8985 }
8986 self.node_ns[id as usize] = idx;
8987 }
8988
8989 /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
8990 /// open or a reload, after the snapshot is restored and the WAL replayed.
8991 ///
8992 /// A store with no `ns` column reads nothing: the column-name check fails
8993 /// and the vector is filled with one constant.
8994 fn rebuild_node_ns(&mut self) {
8995 let total = self.ids.len();
8996 self.ns_names.truncate(1);
8997 self.node_ns.clear();
8998 self.node_ns.resize(total, NS_DEFAULT_IDX);
8999 let has_ns_column = {
9000 let cv = self.props_view();
9001 cv.field_names().iter().any(|f| f == NS_PROP)
9002 };
9003 if !has_ns_column {
9004 return;
9005 }
9006 // Collected first so the props view is released before `ns_index_for`
9007 // takes `&mut self`.
9008 let named: Vec<(u32, String)> = {
9009 let cv = self.props_view();
9010 (0..total as u32)
9011 .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
9012 Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
9013 _ => None,
9014 })
9015 .collect()
9016 };
9017 for (id, name) in named {
9018 let idx = self.ns_index_for(&name);
9019 self.node_ns[id as usize] = idx;
9020 }
9021 }
9022
9023 /// Every namespace with at least one live node, in name order.
9024 ///
9025 /// `["default"]` on any store that has never named a namespace, including
9026 /// an empty one: a store is always at least its default namespace.
9027 pub fn namespaces(&self) -> Vec<String> {
9028 let mut out: BTreeSet<&str> = BTreeSet::new();
9029 out.insert(NS_DEFAULT);
9030 for (id, &idx) in self.node_ns.iter().enumerate() {
9031 if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
9032 continue;
9033 }
9034 out.insert(self.ns_name(idx));
9035 }
9036 out.into_iter().map(str::to_string).collect()
9037 }
9038
9039 /// The namespace of `key`, or `None` when the key names no live node.
9040 pub fn namespace_of(&self, key: &str) -> Option<String> {
9041 let id = self.ids.get(key)?;
9042 if !self.is_live_node(id) {
9043 return None;
9044 }
9045 Some(self.ns_name(self.node_ns_idx(id)).to_string())
9046 }
9047
9048 /// Every live node in `namespace`, as a visibility mask.
9049 ///
9050 /// Built off `node_ns` on whichever handle this is, so on a temporal handle
9051 /// it is the namespace's membership at that commit. A name no node uses
9052 /// gives an empty mask — a namespace scope never widens.
9053 pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
9054 let Some(idx) = self.ns_index_of(namespace) else {
9055 return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
9056 };
9057 let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
9058 .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
9059 .collect();
9060 crate::mask::NodeMask::from_ids(visible)
9061 }
9062
9063 /// Live-node test used by the namespace accessors: a deleted node keeps its
9064 /// dense id and its `node_ns` slot, and the label sentinel is what marks it
9065 /// gone — the same test `mask_for_role`'s label leg applies implicitly.
9066 fn is_live_node(&self, id: u32) -> bool {
9067 self.labels
9068 .get(id as usize)
9069 .is_some_and(|&sym| sym != u32::MAX)
9070 && self.ids.key_of(id).is_some()
9071 }
9072
9073 /// Per-namespace live node counts for [`Stats`], in name order.
9074 fn namespace_stats(&self) -> Vec<NamespaceStats> {
9075 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
9076 counts.insert(NS_DEFAULT, 0);
9077 for id in 0..self.ids.len() as u32 {
9078 if !self.is_live_node(id) {
9079 continue;
9080 }
9081 *counts
9082 .entry(self.ns_name(self.node_ns_idx(id)))
9083 .or_insert(0) += 1;
9084 }
9085 counts
9086 .into_iter()
9087 .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
9088 .map(|(name, nodes_live)| NamespaceStats {
9089 name: name.to_string(),
9090 nodes_live,
9091 })
9092 .collect()
9093 }
9094
9095 /// The namespace a create-class op would put its node in: the `ns` entry of
9096 /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
9097 fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
9098 Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
9099 }
9100
9101 /// The one `ns` entry in a node's props, or `None` when it carries none.
9102 ///
9103 /// A props list naming `ns` twice is refused. Without that refusal the
9104 /// write path and the authorisation path can read the same list
9105 /// differently — one taking the first entry, the other the last — and
9106 /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
9107 /// the role was checked against the other of. One entry is the only shape
9108 /// where "the node's namespace" is a single fact, so it is the only shape
9109 /// accepted, and every reader of it agrees by construction.
9110 fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
9111 let mut found: Option<&'a Value> = None;
9112 for (field, value) in props {
9113 if field != NS_PROP {
9114 continue;
9115 }
9116 if found.is_some() {
9117 return Err(GraphError::RuleInvalid {
9118 detail: format!(
9119 "node {key}: {NS_PROP} is given more than once; a node has exactly \
9120 one namespace"
9121 ),
9122 });
9123 }
9124 found = Some(value);
9125 }
9126 Ok(found)
9127 }
9128
9129 /// The definition of the role a write authorisation names.
9130 ///
9131 /// `None` when `roles.json` was corrupt at open or the role has since been
9132 /// removed — neither can reach a write, because the authorisation carries a
9133 /// mask `mask_for_role` already resolved for that name.
9134 fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
9135 self.roles.as_ref()?.iter().find(|r| r.name == role)
9136 }
9137
9138 /// Validate the `ns` entry of a node's props and drop an explicit default.
9139 ///
9140 /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
9141 /// a record that reached the WAL was already accepted here.
9142 fn normalise_insert_ns(
9143 key: &str,
9144 props: Vec<(String, Value)>,
9145 ) -> Result<(Vec<(String, Value)>, String)> {
9146 // One `ns` or none: this is where that is enforced, so every later
9147 // reader of the list — the authorisation gate, the two `apply` arms,
9148 // `node_ns` — is looking at a single entry and cannot disagree about
9149 // which one counts.
9150 Self::sole_ns_entry(key, &props)?;
9151 let mut name = NS_DEFAULT.to_string();
9152 let mut out = Vec::with_capacity(props.len());
9153 for (field, value) in props {
9154 if field != NS_PROP {
9155 out.push((field, value));
9156 continue;
9157 }
9158 let Value::Str(ref s) = value else {
9159 return Err(GraphError::RuleInvalid {
9160 detail: format!(
9161 "node {key}: {NS_PROP} must be a string naming a namespace, \
9162 got {value:?}"
9163 ),
9164 });
9165 };
9166 if !valid_namespace(s) {
9167 return Err(GraphError::RuleInvalid {
9168 detail: format!(
9169 "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
9170 characters of [A-Za-z0-9_.-]"
9171 ),
9172 });
9173 }
9174 name = s.clone();
9175 // An explicit default stores nothing, so a single-tenant store
9176 // never grows an `ns` column.
9177 if name != NS_DEFAULT {
9178 out.push((field, value));
9179 }
9180 }
9181 Ok((out, name))
9182 }
9183
9184 // -----------------------------------------------------------------------
9185 // RBAC role resolution
9186 // -----------------------------------------------------------------------
9187
9188 /// Parse `roles.json` bytes from `fs`.
9189 ///
9190 /// Return values:
9191 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
9192 /// and valid; in both cases `mask_for_role` uses the
9193 /// list normally (an absent file means no roles defined).
9194 /// `Ok(None)` — file present but corrupt or unrecognised version
9195 /// → poisoned state; `mask_for_role` returns `Err` for
9196 /// any role name until the file is fixed and the DB
9197 /// re-opened (or `apply_schema` is called to repair it).
9198 ///
9199 /// Note: `None` signals corruption, not absence — the opposite of what an
9200 /// optional "file missing" convention would suggest. The open path stores
9201 /// this result on `db.roles` directly.
9202 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
9203 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
9204 if bytes.is_empty() {
9205 // Empty bytes means either the file is absent or zero-byte — both
9206 // are treated identically as "no roles defined". A zero-byte
9207 // roles.json does NOT widen access: an absent file and a zero-byte
9208 // file both resolve to an empty role list (sees nothing by default).
9209 return Ok(Some(vec![]));
9210 }
9211 match serde_json::from_slice::<RolesFile>(&bytes) {
9212 Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
9213 // Corrupt or unrecognised version (>4): poison the roles state.
9214 // Never widen: a version this binary does not know may carry a
9215 // narrowing this binary would not apply.
9216 _ => Ok(None),
9217 }
9218 }
9219
9220 /// Resolve a role to a node-visibility mask against the current graph state.
9221 ///
9222 /// Returns `Err` when:
9223 /// - `roles.json` was present but corrupt at open (poisoned state), or
9224 /// - `role` does not match any defined role name.
9225 ///
9226 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
9227 /// all live nodes carrying any label in `labels` that also pass the role's
9228 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
9229 /// has one. Label resolution is live — new nodes of an allowed label are
9230 /// visible without re-applying the schema, and a property edited out of the
9231 /// predicate takes its node out of the mask on the next read. An empty
9232 /// union = empty mask = sees nothing.
9233 ///
9234 /// This is the one resolver every read path calls, live and as-of alike, so
9235 /// the predicate applies everywhere at once. On an as-of handle the role
9236 /// *definition* is the current one and the graph is the historical one: the
9237 /// predicate is evaluated against the property values at the commit being
9238 /// read.
9239 ///
9240 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
9241 /// between two writes resolves the role once. See
9242 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
9243 /// stale.
9244 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9245 self.role_masks
9246 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
9247 .map(|m| (*m).clone())
9248 }
9249
9250 /// The mask an [`AsOfScope`] names, resolved against this handle.
9251 ///
9252 /// Shared by [`GraphDb::query_at_scoped`] and
9253 /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
9254 /// however the namespace leg is added.
9255 fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
9256 // One resolver answers "what may this role see" — `mask_for_role` — and
9257 // it runs against this handle, so on a temporal one the answer is the
9258 // as-of one.
9259 Ok(match scope {
9260 AsOfScope::Role(role) => self.mask_for_role(role)?,
9261 AsOfScope::Keys(keys) => {
9262 crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
9263 }
9264 AsOfScope::RoleAndKeys(role, keys) => {
9265 self.mask_for_role(role)?
9266 .intersect(&crate::mask::NodeMask::from_keys(
9267 self,
9268 keys.iter().map(String::as_str),
9269 ))
9270 }
9271 AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
9272 })
9273 }
9274
9275 /// Resolve `role` against the current graph, ignoring the memo.
9276 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9277 let roles = self.roles.as_ref().ok_or_else(roles_poisoned)?;
9278 let def = roles
9279 .iter()
9280 .find(|r| r.name == role)
9281 .ok_or_else(|| GraphError::KeyNotFound {
9282 key: format!("role:{role}"),
9283 })?;
9284
9285 let mut visible = std::collections::HashSet::new();
9286
9287 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
9288 // An administrative grant, never narrowed by the predicate.
9289 for key in &def.keys {
9290 if let Some(id) = self.ids.get(key) {
9291 visible.insert(id);
9292 }
9293 }
9294
9295 // Label leg: live scan — iterate labels vec for matching symbol, and
9296 // when the role carries a predicate, test the property as well. The
9297 // property comes from the store's own merged view (overlay over the
9298 // mmap'd base), so an as-of handle reads the values of its own commit.
9299 let props = def.visible_where.as_ref().map(|_| self.props_view());
9300 for label_name in &def.labels {
9301 if let Some(sym) = self.syms.get(label_name) {
9302 for (i, &s) in self.labels.iter().enumerate() {
9303 if s != sym {
9304 continue;
9305 }
9306 let id = i as u32;
9307 match (&def.visible_where, &props) {
9308 (Some(pred), Some(view)) => {
9309 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
9310 if pred.holds(value.as_ref()) {
9311 visible.insert(id);
9312 }
9313 }
9314 _ => {
9315 visible.insert(id);
9316 }
9317 }
9318 }
9319 }
9320 }
9321
9322 // Namespace leg: an intersection over the whole union, the key leg
9323 // included. A namespace is a tenancy boundary, so a key naming a node in
9324 // another tenant's namespace is not an administrative grant — and
9325 // `apply_schema` has already refused that role, so this only has to be
9326 // right about the node that moved into existence afterwards.
9327 if def.namespaces.is_some() {
9328 visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
9329 }
9330
9331 Ok(crate::mask::NodeMask::from_ids(visible))
9332 }
9333
9334 /// Return the current list of role definitions.
9335 ///
9336 /// Returns an empty list when no roles are defined or when `roles.json`
9337 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
9338 /// the fail-loud error in that case, or call
9339 /// [`roles_checked`](Self::roles_checked), which is this readout with that
9340 /// error in it).
9341 pub fn roles(&self) -> Vec<RoleDef> {
9342 self.roles.as_deref().unwrap_or(&[]).to_vec()
9343 }
9344
9345 /// The role definitions, or the poison error when `roles.json` was corrupt
9346 /// at open.
9347 ///
9348 /// [`roles`](Self::roles) answers `[]` both for a store that defines no
9349 /// roles and for one whose sidecar did not parse, and a caller validating a
9350 /// role name at boot cannot tell those apart. The wrong reading of the pair
9351 /// is the dangerous one: a store with no roles at all is an unrestricted
9352 /// store, so a poisoned file would read as "nothing is restricted here".
9353 ///
9354 /// This is the same answer, for the same cause, that
9355 /// [`mask_for_role`](Self::mask_for_role) gives on the first read.
9356 pub fn roles_checked(&self) -> Result<Vec<RoleDef>> {
9357 match self.roles.as_deref() {
9358 Some(roles) => Ok(roles.to_vec()),
9359 None => Err(roles_poisoned()),
9360 }
9361 }
9362
9363 // ── Role-scoped write authz ───────────────────────────────────────────────
9364
9365 /// Execute `ops` with optional role-scoped write authorization.
9366 ///
9367 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
9368 /// (zero-cost bypass of all authz checks).
9369 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
9370 /// record is built. A denial returns an error with no WAL frame written
9371 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
9372 ///
9373 /// See the plan's "authz decision table" section for the full semantics.
9374 pub fn write_batch_authz(
9375 &mut self,
9376 authz: Option<&WriteAuthz>,
9377 ops: Vec<BatchOp>,
9378 ) -> Result<(usize, usize)> {
9379 // Thread authz as a direct parameter — never touches pending_write_authz.
9380 self.commit_logged_batch(ops, None, authz.cloned())
9381 .map(inserted_pair)
9382 }
9383
9384 /// Execute a Cypher write statement with role-scoped write authorization.
9385 ///
9386 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
9387 /// lifetime as execution, satisfying §5 lock discipline). The resolved
9388 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
9389 /// call so that all inner `batch.commit()` calls are authz-checked.
9390 ///
9391 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
9392 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
9393 /// timing-oracle item (hidden ≡ absent for unscoped roles).
9394 ///
9395 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
9396 /// "this endpoint is not permitted".
9397 pub fn query_write_authz(
9398 &mut self,
9399 role: &str,
9400 cypher: &str,
9401 params: &BTreeMap<String, Value>,
9402 ) -> Result<ResultSet> {
9403 // Resolve scope (fails fast if role has no write scope).
9404 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9405 let scope =
9406 {
9407 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9408 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9409 })?;
9410 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9411 GraphError::KeyNotFound {
9412 key: format!("role:{role}"),
9413 }
9414 })?;
9415 def.write
9416 .clone()
9417 .ok_or_else(|| GraphError::RoleWriteDenied {
9418 reason: "role-bound token: writes are not permitted".into(),
9419 })?
9420 };
9421 // Resolve mask inside the call (same guard, §5 coherence).
9422 let mask = self.mask_for_role(role)?;
9423 self.pending_write_authz = Some(WriteAuthz {
9424 role: role.into(),
9425 scope,
9426 mask,
9427 });
9428 // RAII guard: always clears pending_write_authz on scope exit, including
9429 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9430 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9431 impl Drop for ClearPendingAuthzOnDrop {
9432 fn drop(&mut self) {
9433 // SAFETY: pointer into the owning GraphDb; guard is dropped
9434 // within this function's frame before it returns.
9435 unsafe { *self.0 = None };
9436 }
9437 }
9438 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9439 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9440 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9441 detail: format!("lex: {e}"),
9442 })?;
9443 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9444 detail: format!("parse: {e}"),
9445 })?;
9446 self.exec_write_stmt(stmt, params)
9447 }
9448
9449 /// Execute `ops` with optional role-scoped write authorization, suppressing
9450 /// fsync (for use inside the group-commit drain thread, which performs one
9451 /// group fsync after releasing the write lock).
9452 ///
9453 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
9454 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
9455 /// contract established by [`commit_batch_nosync`].
9456 pub(crate) fn write_batch_authz_nosync(
9457 &mut self,
9458 authz: Option<&WriteAuthz>,
9459 ops: Vec<BatchOp>,
9460 ) -> Result<(usize, usize)> {
9461 let saved = self.fsync;
9462 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
9463 impl Drop for RestoreFsync {
9464 fn drop(&mut self) {
9465 // SAFETY: pointer into the owning GraphDb; guard is dropped
9466 // within the enclosing function's frame before it returns.
9467 unsafe { *self.0 = self.1 };
9468 }
9469 }
9470 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9471 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
9472 self.fsync = FsyncPolicy::Relaxed;
9473 self.commit_logged_batch(ops, None, authz.cloned())
9474 .map(inserted_pair)
9475 }
9476
9477 /// Execute a `/ingest` request with role-scoped write authorization.
9478 ///
9479 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
9480 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
9481 /// Sets `pending_write_authz` for the duration of the call so that the
9482 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
9483 /// and evaluates the decision table per-op before any WAL write.
9484 ///
9485 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
9486 /// denied by the decision table with the appropriate §4.3 scope reason;
9487 /// no special HTTP-layer check is needed.
9488 ///
9489 /// Roles with `write: None` return `RoleWriteDenied` with
9490 /// "writes are not permitted" (byte-identical to v1 blanket 403).
9491 pub fn ingest_with_edges_authz(
9492 &mut self,
9493 role: &str,
9494 label: &str,
9495 rows: Vec<std::collections::BTreeMap<String, Value>>,
9496 opts: &crate::ingest::IngestOptions,
9497 edges: &[(String, String, String)],
9498 ) -> Result<crate::ingest::IngestReport> {
9499 // Resolve scope (fails fast if role has no write scope).
9500 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9501 let scope =
9502 {
9503 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9504 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9505 })?;
9506 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9507 GraphError::KeyNotFound {
9508 key: format!("role:{role}"),
9509 }
9510 })?;
9511 def.write
9512 .clone()
9513 .ok_or_else(|| GraphError::RoleWriteDenied {
9514 reason: "role-bound token: writes are not permitted".into(),
9515 })?
9516 };
9517 let mask = self.mask_for_role(role)?;
9518 self.pending_write_authz = Some(WriteAuthz {
9519 role: role.into(),
9520 scope,
9521 mask,
9522 });
9523 // RAII guard: always clears pending_write_authz on scope exit, including
9524 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9525 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9526 impl Drop for ClearPendingAuthzOnDrop {
9527 fn drop(&mut self) {
9528 // SAFETY: pointer into the owning GraphDb; guard is dropped
9529 // within this function's frame before it returns.
9530 unsafe { *self.0 = None };
9531 }
9532 }
9533 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9534 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9535 self.ingest_with_edges(label, rows, opts, edges)
9536 }
9537
9538 /// Evaluate the write-authz decision table for one `BatchOp`.
9539 ///
9540 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
9541 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
9542 /// the remaining ops are not evaluated and no WAL frame is written.
9543 ///
9544 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
9545 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
9546 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
9547 /// batch creates counts as visible if its label passed the create-class gate").
9548 fn check_single_op_authz(
9549 &self,
9550 authz: &WriteAuthz,
9551 op: &BatchOp,
9552 batch_created: &BTreeMap<String, String>,
9553 ) -> Result<()> {
9554 // Helper: 3-way node status under the authz mask.
9555 //
9556 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
9557 // as Visible with their recorded label — their create gate already passed
9558 // and they are not yet in self.ids (not committed). This fixes the
9559 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
9560 // the SetProp must not see the node as Absent.
9561 let node_status = |key: &str| -> NodeAuthzStatus {
9562 if let Some(label) = batch_created.get(key) {
9563 return NodeAuthzStatus::Visible(label.clone());
9564 }
9565 match self.ids.get(key) {
9566 None => NodeAuthzStatus::Absent,
9567 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
9568 Some(id) => {
9569 let label = self
9570 .labels
9571 .get(id as usize)
9572 .and_then(|&sym| {
9573 if sym == u32::MAX {
9574 None
9575 } else {
9576 self.syms.resolve(sym).map(str::to_string)
9577 }
9578 })
9579 .unwrap_or_default();
9580 NodeAuthzStatus::Visible(label)
9581 }
9582 }
9583 };
9584
9585 // Helper: is an InsertEdgeUpsert endpoint visible?
9586 // A same-batch placeholder counts as visible if its label passed
9587 // the create-class gate (spec "upsert placeholder-counts-as-visible").
9588 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
9589 // In store and visible?
9590 if let Some(id) = self.ids.get(ep_key) {
9591 return authz.mask.contains_id(id);
9592 }
9593 // Created by an earlier op in this batch?
9594 if let Some(created_label) = batch_created.get(ep_key) {
9595 return authz.scope.create_labels.contains(created_label);
9596 }
9597 // Will be created by THIS InsertEdgeUpsert: placeholder_label
9598 // must pass the create-class gate.
9599 authz
9600 .scope
9601 .create_labels
9602 .contains(&placeholder_label.to_string())
9603 };
9604
9605 match op {
9606 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
9607 // These ops are never routed to role-scoped paths by the HTTP layer,
9608 // but we 403 them here to close any future bypass route.
9609 //
9610 // InsertNodeOnConflict joins them: it is reachable only from the
9611 // embedded Python binding, which has no role token, and `Replace`
9612 // is a create and an update at once. Rather than split the decision
9613 // table for an op no role-scoped path constructs, refuse it — a
9614 // role-scoped caller writes through the ops that are already in the
9615 // table.
9616 BatchOp::RenameNode { .. }
9617 | BatchOp::CreateRule(_)
9618 | BatchOp::DeleteRule { .. }
9619 | BatchOp::InsertNodeOnConflict { .. } => {
9620 return Err(GraphError::RoleWriteDenied {
9621 reason: "role-bound token: this endpoint is not permitted".into(),
9622 });
9623 }
9624
9625 // ── CREATE-class: InsertNode ─────────────────────────────────────
9626 //
9627 // Decision table row 1 (scope-before-lookup): check label in
9628 // create_labels BEFORE any key lookup. This is the structural
9629 // closure of the §6.2 timing-oracle item — the denial fires even
9630 // when the store is EMPTY (see test_create_scope_denied_empty_store).
9631 BatchOp::InsertNode { label, key, props } => {
9632 if !authz.scope.create_labels.contains(label) {
9633 return Err(GraphError::RoleWriteDenied {
9634 reason: format!(
9635 "role-bound token: label '{}' not in write scope (create_labels)",
9636 label
9637 ),
9638 });
9639 }
9640 // A role bound to namespaces may only create inside them. The
9641 // never-widen rule is about what a write makes visible to *any*
9642 // party, not only to the writer: a node this role could never
9643 // read back is a write into somebody else's tenancy. Also a
9644 // scope check, so it runs before the key lookup — it discloses
9645 // nothing about the store. Covers Cypher `CREATE` and the node
9646 // `MERGE` creates, both of which arrive as this op.
9647 // Resolved before the role lookup so a props list naming `ns`
9648 // twice is refused for every role, scoped or not: it is the same
9649 // malformed write the seam refuses, and leaving it to the seam
9650 // would mean the gate had already read one of the two.
9651 let target = Self::created_namespace(key, props)?;
9652 if let Some(def) = self.role_def_for(&authz.role) {
9653 if !def.sees_namespace(target) {
9654 return Err(GraphError::RoleWriteDenied {
9655 reason: format!(
9656 "role-bound token: namespace '{target}' not in the role's \
9657 namespaces"
9658 ),
9659 });
9660 }
9661 }
9662 // Row 2/3: key lookup.
9663 match self.ids.get(key.as_str()) {
9664 Some(id) if authz.mask.contains_id(id) => {
9665 // Visible: DuplicateKey — let MutPreview handle this.
9666 }
9667 Some(_) => {
9668 // Hidden: indistinguishable from absent to the role.
9669 return Err(GraphError::RoleWriteDenied {
9670 reason: "role-bound token: target node not visible".into(),
9671 });
9672 }
9673 None => {
9674 // Absent: proceed (create).
9675 }
9676 }
9677 }
9678
9679 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
9680 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
9681 if batch_created.contains_key(key.as_str()) {
9682 // Batch-created node: create gate already passed this batch.
9683 // Updating it in the same batch is always allowed, regardless
9684 // of update_labels (ruling §3.5: "writer just created it").
9685 } else {
9686 let label = match node_status(key) {
9687 NodeAuthzStatus::Visible(lbl) => lbl,
9688 _ => {
9689 return Err(GraphError::RoleWriteDenied {
9690 reason: "role-bound token: target node not visible".into(),
9691 });
9692 }
9693 };
9694 if !authz.scope.update_labels.contains(&label) {
9695 return Err(GraphError::RoleWriteDenied {
9696 reason: format!(
9697 "role-bound token: label '{}' not in write scope (update_labels)",
9698 label
9699 ),
9700 });
9701 }
9702 }
9703 }
9704
9705 // ── DELETE-class: DeleteNode ─────────────────────────────────────
9706 BatchOp::DeleteNode { key } => {
9707 let label = match node_status(key) {
9708 NodeAuthzStatus::Visible(lbl) => lbl,
9709 _ => {
9710 return Err(GraphError::RoleWriteDenied {
9711 reason: "role-bound token: target node not visible".into(),
9712 });
9713 }
9714 };
9715 if !authz.scope.delete_labels.contains(&label) {
9716 return Err(GraphError::RoleWriteDenied {
9717 reason: format!(
9718 "role-bound token: label '{}' not in write scope (delete_labels)",
9719 label
9720 ),
9721 });
9722 }
9723 }
9724
9725 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
9726 //
9727 // Derived-edge rejection runs BEFORE the delete_edge_types scope
9728 // check (spec §3.5: "existing derived-edge rejection precedes
9729 // delete_edge_types check").
9730 BatchOp::DeleteEdge {
9731 edge_type,
9732 src_key,
9733 dst_key,
9734 } => {
9735 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
9736 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
9737 self.ids.get(src_key.as_str()),
9738 self.ids.get(dst_key.as_str()),
9739 self.syms.get(edge_type.as_str()),
9740 ) {
9741 if self.engine.is_owned(et_sym, src_id, dst_id) {
9742 return Err(GraphError::RuleOwned {
9743 detail: format!(
9744 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9745 delete or change the owning rule"
9746 ),
9747 });
9748 }
9749 // Also check would_derive via MutPreview (empty overlay, pre-batch).
9750 let preview = MutPreview::new(self);
9751 if preview.would_derive(edge_type, src_key, dst_key) {
9752 return Err(GraphError::RuleOwned {
9753 detail: format!(
9754 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9755 delete or change the owning rule, or a live rule would \
9756 re-derive it"
9757 ),
9758 });
9759 }
9760 }
9761 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
9762 if !authz.scope.delete_edge_types.contains(edge_type) {
9763 return Err(GraphError::RoleWriteDenied {
9764 reason: format!(
9765 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
9766 edge_type
9767 ),
9768 });
9769 }
9770 // Both endpoints must be visible.
9771 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9772 match self.ids.get(ep_key) {
9773 None => {
9774 return Err(GraphError::RoleWriteDenied {
9775 reason: "role-bound token: edge endpoint not visible".into(),
9776 });
9777 }
9778 Some(id) if !authz.mask.contains_id(id) => {
9779 return Err(GraphError::RoleWriteDenied {
9780 reason: "role-bound token: edge endpoint not visible".into(),
9781 });
9782 }
9783 _ => {}
9784 }
9785 }
9786 }
9787
9788 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
9789 //
9790 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
9791 BatchOp::InsertEdge {
9792 edge_type,
9793 src_key,
9794 dst_key,
9795 } => {
9796 if !authz.scope.create_edge_types.contains(edge_type) {
9797 return Err(GraphError::RoleWriteDenied {
9798 reason: format!(
9799 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9800 edge_type
9801 ),
9802 });
9803 }
9804 // Both endpoints must be visible. A node created by an earlier
9805 // InsertNode in the same batch (tracked in batch_created) counts
9806 // as visible if its label passed the create-class gate.
9807 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9808 if batch_created.contains_key(ep_key) {
9809 // Created earlier this batch — already scope-checked.
9810 continue;
9811 }
9812 match self.ids.get(ep_key) {
9813 None => {
9814 return Err(GraphError::RoleWriteDenied {
9815 reason: "role-bound token: edge endpoint not visible".into(),
9816 });
9817 }
9818 Some(id) if !authz.mask.contains_id(id) => {
9819 return Err(GraphError::RoleWriteDenied {
9820 reason: "role-bound token: edge endpoint not visible".into(),
9821 });
9822 }
9823 _ => {}
9824 }
9825 }
9826 }
9827
9828 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
9829 //
9830 // Scope check first; then endpoint visibility using same-batch
9831 // placeholder awareness (spec: "a placeholder endpoint the SAME
9832 // batch creates counts as visible if its label passed the
9833 // create-class gate").
9834 BatchOp::InsertEdgeUpsert {
9835 edge_type,
9836 src_key,
9837 dst_key,
9838 placeholder_label,
9839 } => {
9840 if !authz.scope.create_edge_types.contains(edge_type) {
9841 return Err(GraphError::RoleWriteDenied {
9842 reason: format!(
9843 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9844 edge_type
9845 ),
9846 });
9847 }
9848 // Check placeholder label against create_labels (create-class gate).
9849 // This ensures the auto-created endpoints are scope-allowed.
9850 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9851 if !upsert_ep_visible(ep_key, placeholder_label) {
9852 return Err(GraphError::RoleWriteDenied {
9853 reason: "role-bound token: edge endpoint not visible".into(),
9854 });
9855 }
9856 }
9857 // A placeholder is created with no props, so it lands in the
9858 // default namespace. A role that cannot read `default` must not
9859 // create one there, for the same reason it may not create a node
9860 // there outright.
9861 //
9862 // The refusal is byte-identical to the hidden-endpoint one above,
9863 // and deliberately so: this arm fires only for an endpoint that
9864 // does **not** exist, and the one above only for an endpoint that
9865 // does. Two different strings would make the pair an existence
9866 // oracle — ask for an upsert and read off whether the key is
9867 // taken. Hidden ≡ absent is the rule everywhere else in this
9868 // table and it holds here too.
9869 if let Some(def) = self.role_def_for(&authz.role) {
9870 if !def.sees_namespace(NS_DEFAULT) {
9871 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9872 if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
9873 {
9874 return Err(GraphError::RoleWriteDenied {
9875 reason: "role-bound token: edge endpoint not visible".into(),
9876 });
9877 }
9878 }
9879 }
9880 }
9881 }
9882 }
9883 Ok(())
9884 }
9885
9886 /// Write `roles` to `roles.json` atomically and update the in-memory list.
9887 ///
9888 /// Called by `apply_schema` when roles change. Never called on unchanged
9889 /// re-apply — this preserves byte-identical idempotency.
9890 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
9891 let file = RolesFile::new_versioned(roles.clone());
9892 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
9893 detail: format!("roles serialization: {e}"),
9894 })?;
9895 self.fs
9896 .write_atomic(FileId::Roles, &bytes)
9897 .map_err(GraphError::Io)?;
9898 self.roles = Some(roles);
9899 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
9900 // and a memoised mask would still match its version. Install a fresh
9901 // cache instead of clearing the shared one: a reader snapshot frozen
9902 // against the old definitions keeps the old `Arc` to itself and can
9903 // never publish an answer this handle would read back.
9904 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
9905 // Refresh the MVCC frozen overlay so that reader() immediately sees the
9906 // updated role definitions without waiting for the next K-commit fold.
9907 self.fold_now();
9908 Ok(())
9909 }
9910
9911 fn view(&self) -> GraphView<'_> {
9912 GraphView {
9913 ids: &self.ids,
9914 syms: &self.syms,
9915 labels: &self.labels,
9916 props: self.props_view(),
9917 topo: self.topo_view(),
9918 edge_props: self.edge_props_view(),
9919 mask: None,
9920 prop_index: Some(&self.prop_index),
9921 }
9922 }
9923
9924 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
9925 GraphView {
9926 ids: &self.ids,
9927 syms: &self.syms,
9928 labels: &self.labels,
9929 props: self.props_view(),
9930 topo: self.topo_view(),
9931 edge_props: self.edge_props_view(),
9932 mask: Some(&mask.visible),
9933 prop_index: Some(&self.prop_index),
9934 }
9935 }
9936
9937 /// Execute a read-only Cypher query with a node visibility mask.
9938 ///
9939 /// Only nodes whose key is in `mask` are accessible: label scans, key
9940 /// lookups, and neighbor expansions all respect the mask. Edges where
9941 /// either endpoint is hidden are silently dropped.
9942 ///
9943 /// Returns `Err` with a "masked queries are read-only" message when
9944 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
9945 pub fn query_masked(
9946 &self,
9947 cypher: &str,
9948 params: &std::collections::BTreeMap<String, Value>,
9949 mask: &crate::mask::NodeMask,
9950 ) -> Result<ResultSet> {
9951 // Reject write statements up front.
9952 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9953 detail: format!("lex: {e}"),
9954 })?;
9955 if is_write_tokens(&tokens) {
9956 return Err(GraphError::MaskedReadOnly);
9957 }
9958 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9959 detail: format!("parse: {e}"),
9960 })?;
9961 // Each UNION part executes against the same masked view, so the mask
9962 // applies uniformly across the chain.
9963 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
9964 GraphError::QueryError {
9965 detail: format!("execute: {e}"),
9966 }
9967 })
9968 }
9969
9970 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
9971 let id = self.ids.get(key)?;
9972 Some(NodeRef { db: self, id })
9973 }
9974
9975 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
9976 ///
9977 /// Hidden nodes are never used as traversal intermediaries in either
9978 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
9979 /// only through a hidden node will not appear in results.
9980 ///
9981 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
9982 /// a visited visible node are appended to the result as stub rows
9983 /// (`label` column is `null`, same key+depth columns as visible rows).
9984 /// They are NOT added to the BFS frontier.
9985 ///
9986 /// Returns `None` when `key` does not exist (caller should 404).
9987 ///
9988 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
9989 /// stub rows are never produced on the role path.
9990 pub fn neighborhood_masked(
9991 &self,
9992 key: &str,
9993 depth: u32,
9994 edge_types: Option<&[&str]>,
9995 dir: Dir,
9996 mask: &crate::mask::NodeMask,
9997 ) -> Option<ResultSet> {
9998 let start_id = self.ids.get(key)?;
9999 let view = self.view_masked(mask);
10000 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10001 names
10002 .iter()
10003 .filter_map(|name| view.syms.get(name))
10004 .collect()
10005 });
10006 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
10007 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10008 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
10009 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
10010 visited.push((start_id, 0));
10011 for (nid, d) in &nb.nodes {
10012 let k = view.key_of(*nid);
10013 let label = view
10014 .label_of(*nid)
10015 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10016 rs.push_row(vec![
10017 Some(Value::Str(k.to_string())),
10018 Some(Value::Str(label.to_string())),
10019 Some(Value::Int(*d as i64)),
10020 ]);
10021 visited.push((*nid, *d));
10022 }
10023 // Stub mode: add hidden direct neighbours of each visited node as stubs.
10024 // Hidden nodes are edge-endpoints only — they are not added to the BFS
10025 // frontier, so the BFS never expands through them.
10026 if mask.mode() == crate::mask::MaskMode::Stub {
10027 let raw_view = self.view();
10028 let mut seen: std::collections::HashSet<u32> =
10029 visited.iter().map(|(id, _)| *id).collect();
10030 for (node_id, node_depth) in &visited {
10031 if *node_depth >= depth {
10032 continue;
10033 }
10034 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
10035 let nbr = if e.src == *node_id { e.dst } else { e.src };
10036 if !mask.contains_id(nbr) && seen.insert(nbr) {
10037 if let Some(k) = self.ids.key_of(nbr) {
10038 rs.push_row(vec![
10039 Some(Value::Str(k.to_string())),
10040 None,
10041 Some(Value::Int((*node_depth + 1) as i64)),
10042 ]);
10043 }
10044 }
10045 }
10046 }
10047 }
10048 Some(rs)
10049 }
10050
10051 /// [`neighborhood_masked`](Self::neighborhood_masked) with the **subject
10052 /// check** a scoped caller needs: a start key the mask hides answers exactly
10053 /// as an absent one does.
10054 ///
10055 /// `neighborhood_masked` expands from any existing key, hidden or not,
10056 /// because a full-token caller supplying a client mask already knows which
10057 /// keys exist. A scoped caller does not, so telling it apart a hidden key
10058 /// from an absent one would be an existence oracle.
10059 ///
10060 /// Expansion itself is unchanged: hidden nodes are neither returned nor used
10061 /// as traversal intermediaries, so a visible node reachable only through a
10062 /// hidden one stays out of the result.
10063 ///
10064 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
10065 pub fn neighborhood_scoped(
10066 &self,
10067 key: &str,
10068 depth: u32,
10069 edge_types: Option<&[&str]>,
10070 dir: Dir,
10071 mask: &crate::mask::NodeMask,
10072 ) -> Result<ResultSet> {
10073 if !mask.contains_node(self, key) {
10074 return Err(GraphError::KeyNotFound { key: key.into() });
10075 }
10076 self.neighborhood_masked(key, depth, edge_types, dir, mask)
10077 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
10078 }
10079
10080 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
10081 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
10082 let n = self.node_ref(key)?;
10083 Some(NodeInfo {
10084 key: n.key().to_string(),
10085 label: n.label().to_string(),
10086 props: n.props(),
10087 })
10088 }
10089
10090 /// Look up a node with mask awareness.
10091 ///
10092 /// | Key state | Omit mode | Stub mode |
10093 /// |-------------------|-----------------|------------------------|
10094 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
10095 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
10096 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
10097 ///
10098 /// **SECURITY**: only call from client-mask (full-token) paths.
10099 /// Role-token paths must use [`node_info`] after an explicit visibility check.
10100 pub fn node_info_masked(
10101 &self,
10102 key: &str,
10103 mask: &crate::mask::NodeMask,
10104 ) -> Option<MaskedNodeResult> {
10105 let id = self.ids.get(key)?;
10106 if mask.contains_id(id) {
10107 Some(MaskedNodeResult::Visible(self.node_info(key)?))
10108 } else {
10109 match mask.mode() {
10110 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
10111 crate::mask::MaskMode::Omit => None,
10112 }
10113 }
10114 }
10115
10116 /// Get edges for `key` with mask-aware hidden-endpoint handling.
10117 ///
10118 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
10119 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
10120 /// is `true` for each hidden endpoint.
10121 ///
10122 /// Unknown key → [`GraphError::KeyNotFound`].
10123 ///
10124 /// **SECURITY**: only call from client-mask (full-token) paths.
10125 pub fn node_edges_masked(
10126 &self,
10127 key: &str,
10128 mask: &crate::mask::NodeMask,
10129 ) -> Result<Vec<MaskedEdge>> {
10130 self.ensure_v8_base_sections_loaded();
10131 let id = self
10132 .ids
10133 .get(key)
10134 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10135 let derived: BTreeSet<(u32, u32, u32)> = self
10136 .engine
10137 .provenance_touching(id)
10138 .map(|(_rule, etype, src, dst)| (etype, src, dst))
10139 .collect();
10140 let mut edges = Vec::new();
10141 let tv = self.topo_view();
10142 for etype in tv.etypes() {
10143 // etype comes from the archived CSR (access_unchecked, no eager CRC).
10144 // A bit-flip in the large TOPOLOGY section can produce an etype id
10145 // that is not in the interner. Return Corrupt rather than panic.
10146 let edge_type = self
10147 .syms
10148 .resolve(etype)
10149 .ok_or_else(|| GraphError::Corrupt {
10150 detail: format!("v8: topology etype {etype} not in interner"),
10151 })?
10152 .to_string();
10153 for dir in [Direction::Out, Direction::In] {
10154 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10155 let nbr_restricted = !mask.contains_id(nbr);
10156 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
10157 continue;
10158 }
10159 let nbr_key = self
10160 .ids
10161 .key_of(nbr)
10162 .ok_or_else(|| GraphError::Corrupt {
10163 detail: format!("topology id {nbr} has no key"),
10164 })?
10165 .to_string();
10166 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
10167 match dir {
10168 Direction::Out => {
10169 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
10170 }
10171 Direction::In => {
10172 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
10173 }
10174 };
10175 edges.push(MaskedEdge {
10176 edge_type: edge_type.clone(),
10177 src_key,
10178 src_restricted,
10179 dst_key,
10180 dst_restricted,
10181 derived: derived.contains(&(etype, src_id, dst_id)),
10182 });
10183 }
10184 }
10185 }
10186 edges.sort_by(|a, b| {
10187 a.edge_type
10188 .cmp(&b.edge_type)
10189 .then(a.src_key.cmp(&b.src_key))
10190 .then(a.dst_key.cmp(&b.dst_key))
10191 });
10192 edges.dedup_by(|a, b| {
10193 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
10194 });
10195 Ok(edges)
10196 }
10197
10198 /// [`node_edges_masked`](Self::node_edges_masked) with the **subject check**
10199 /// a scoped caller needs, and a plain [`EdgeInfo`] list.
10200 ///
10201 /// `node_edges_masked` raises [`GraphError::KeyNotFound`] only when `key` is
10202 /// unknown; a key that exists but is hidden still yields its (filtered) edge
10203 /// list, which is correct for a full-token client mask and an existence
10204 /// oracle for a scoped one. Here a hidden subject answers exactly as an
10205 /// absent one does.
10206 ///
10207 /// Every edge naming a hidden endpoint is dropped, whatever the mask's
10208 /// [`MaskMode`](crate::mask::MaskMode): a scoped caller never sees a
10209 /// restricted stub, so there is nothing for it to render.
10210 ///
10211 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
10212 pub fn node_edges_scoped(
10213 &self,
10214 key: &str,
10215 mask: &crate::mask::NodeMask,
10216 ) -> Result<Vec<EdgeInfo>> {
10217 if !mask.contains_node(self, key) {
10218 return Err(GraphError::KeyNotFound { key: key.into() });
10219 }
10220 Ok(self
10221 .node_edges_masked(key, mask)?
10222 .into_iter()
10223 .filter(|e| !e.src_restricted && !e.dst_restricted)
10224 .map(|e| EdgeInfo {
10225 edge_type: e.edge_type,
10226 src_key: e.src_key,
10227 dst_key: e.dst_key,
10228 derived: e.derived,
10229 })
10230 .collect())
10231 }
10232
10233 /// Every directed edge incident on `key`, both directions, every etype.
10234 ///
10235 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
10236 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
10237 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
10238 /// Unknown key → [`GraphError::KeyNotFound`].
10239 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
10240 self.ensure_v8_base_sections_loaded();
10241 let id = self
10242 .ids
10243 .get(key)
10244 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10245 let derived: BTreeSet<(u32, u32, u32)> = self
10246 .engine
10247 .provenance_touching(id)
10248 .map(|(_rule, etype, src, dst)| (etype, src, dst))
10249 .collect();
10250 let mut edges = Vec::new();
10251 let tv = self.topo_view();
10252 for etype in tv.etypes() {
10253 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
10254 let edge_type = self
10255 .syms
10256 .resolve(etype)
10257 .ok_or_else(|| GraphError::Corrupt {
10258 detail: format!("v8: topology etype {etype} not in interner"),
10259 })?
10260 .to_string();
10261 for dir in [Direction::Out, Direction::In] {
10262 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10263 let (src, dst, src_key, dst_key) = match dir {
10264 Direction::Out => (
10265 id,
10266 nbr,
10267 key.to_string(),
10268 self.ids
10269 .key_of(nbr)
10270 .ok_or_else(|| GraphError::Corrupt {
10271 detail: format!("topology id {nbr} has no key"),
10272 })?
10273 .to_string(),
10274 ),
10275 Direction::In => (
10276 nbr,
10277 id,
10278 self.ids
10279 .key_of(nbr)
10280 .ok_or_else(|| GraphError::Corrupt {
10281 detail: format!("topology id {nbr} has no key"),
10282 })?
10283 .to_string(),
10284 key.to_string(),
10285 ),
10286 };
10287 edges.push(EdgeInfo {
10288 edge_type: edge_type.clone(),
10289 src_key,
10290 dst_key,
10291 derived: derived.contains(&(etype, src, dst)),
10292 });
10293 }
10294 }
10295 }
10296 edges.sort_by(|a, b| {
10297 a.edge_type
10298 .cmp(&b.edge_type)
10299 .then(a.src_key.cmp(&b.src_key))
10300 .then(a.dst_key.cmp(&b.dst_key))
10301 });
10302 // Self-loops appear in both Out and In; sort makes the pair adjacent
10303 // (sort key matches PartialEq for this case) so one pass drops the dup.
10304 edges.dedup();
10305 Ok(edges)
10306 }
10307
10308 // ── Backup ────────────────────────────────────────────────────────────────
10309
10310 /// Copy this store to `dest` as a consistent, verified snapshot.
10311 ///
10312 /// Copies every durable file in the database directory — `snapshot.bin`,
10313 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
10314 /// `roles.json` — into a freshly created `dest` directory using OS-level
10315 /// `copy` calls (no large in-process buffers).
10316 ///
10317 /// # Consistency guarantee
10318 ///
10319 /// The guarantee is **process-local**: the caller holds `&self`, which
10320 /// prevents any concurrent writer in the **same process** from modifying
10321 /// the files during the copy. Running `mushroomdb backup` against a
10322 /// directory that is **concurrently being written by another process** (e.g.
10323 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
10324 /// `verified: true` result reduces but does not eliminate the risk of a
10325 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
10326 /// consistent mid-write snapshot).
10327 ///
10328 /// **The safe path for a live-served store is `POST /backup` on the HTTP
10329 /// server.** That handler acquires the read lock on the shared database
10330 /// before calling this method, which is the correct cross-process
10331 /// synchronisation point because the server is the single process writing
10332 /// the files.
10333 ///
10334 /// After copying, opens the destination read-only and runs the CRC section
10335 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
10336 /// `BackupReport::verified` reflects whether both checks passed.
10337 ///
10338 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
10339 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
10340 // Derive source directory from snapshot_path (RealFs only).
10341 let src_dir = match self.fs.snapshot_path() {
10342 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
10343 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
10344 })?,
10345 None => {
10346 return Err(GraphError::Io(std::io::Error::other(
10347 "backup_to requires a real filesystem (RealFs)",
10348 )))
10349 }
10350 };
10351
10352 std::fs::create_dir_all(dest)?;
10353
10354 let mut files: Vec<String> = Vec::new();
10355 let mut bytes: u64 = 0;
10356
10357 // Helper: copy src_dir/name → dest/name if the file exists.
10358 let mut try_copy = |name: &str| -> std::io::Result<()> {
10359 let src_path = src_dir.join(name);
10360 if src_path.exists() {
10361 let n = std::fs::copy(&src_path, dest.join(name))?;
10362 bytes += n;
10363 files.push(name.to_string());
10364 }
10365 Ok(())
10366 };
10367
10368 try_copy("snapshot.bin")?;
10369 try_copy("snapshot.bin.bak")?;
10370 try_copy("wal.bin")?;
10371 try_copy("wal.floor")?;
10372 try_copy("wal.genesis")?;
10373 try_copy("roles.json")?;
10374 // The map from commit to wall-clock time. Without it the copy cannot
10375 // resolve a date to a commit, and answers `NoRecordedTime` for history
10376 // it holds in full (ledger row 86).
10377 try_copy("commit_times.bin")?;
10378 // Empty unless the store's last WAL-replacing snapshot was
10379 // interrupted. Then, without it, the copy is that snapshot beside the
10380 // WAL it was made from, with nothing to say so.
10381 try_copy("snapshot.intent")?;
10382
10383 // Copy WAL archives.
10384 let archives = self.fs.list_archives()?;
10385 for n in &archives {
10386 let name = format!("wal.{n}.archive");
10387 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
10388 bytes += n_bytes;
10389 files.push(name);
10390 }
10391
10392 files.sort();
10393
10394 // Post-copy verification: open dest and run CRC checks.
10395 let snap_in_dest = dest.join("snapshot.bin").exists();
10396 let crc_ok = if snap_in_dest {
10397 crate::verify_snapshot(dest)
10398 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
10399 .unwrap_or(false)
10400 } else {
10401 true // WAL-only store: nothing to CRC-check in snapshot
10402 };
10403 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
10404 let verified = crc_ok && opens_ok;
10405
10406 Ok(BackupReport {
10407 files,
10408 bytes,
10409 verified,
10410 })
10411 }
10412
10413 // ── Export helpers ────────────────────────────────────────────────────────
10414
10415 /// All live nodes, sorted by key (deterministic).
10416 ///
10417 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
10418 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
10419 self.ensure_v8_base_sections_loaded();
10420 let pv = self.props_view();
10421 let mut nodes = Vec::new();
10422 for id in 0..self.ids.len() as u32 {
10423 let Some(key) = self.ids.key_of(id) else {
10424 continue;
10425 };
10426 let Some(&sym) = self.labels.get(id as usize) else {
10427 continue;
10428 };
10429 if sym == u32::MAX {
10430 continue; // tombstoned
10431 }
10432 let Some(label) = self.syms.resolve(sym) else {
10433 continue;
10434 };
10435 let mut props = BTreeMap::new();
10436 for field in pv.field_names() {
10437 if let Some(vr) = pv.get(id, &field) {
10438 props.insert(field, vr.into_value());
10439 }
10440 }
10441 nodes.push(NodeInfo {
10442 key: key.to_string(),
10443 label: label.to_string(),
10444 props,
10445 });
10446 }
10447 nodes.sort_by(|a, b| a.key.cmp(&b.key));
10448 nodes
10449 }
10450
10451 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
10452 ///
10453 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
10454 /// Manual edges carry `derived: false` and `rule: None`.
10455 /// `weight` is the creating rule's `weight_prop` value read off the edge
10456 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
10457 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
10458 /// store state.
10459 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
10460 self.ensure_v8_base_sections_loaded();
10461
10462 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
10463 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
10464 for (rule_name, triples) in self.engine.provenance() {
10465 for &(etype, src, dst) in triples {
10466 prov.insert((etype, src, dst), rule_name.clone());
10467 }
10468 }
10469
10470 // rule_name → weight_prop, for O(1) lookup per derived edge.
10471 let weight_props: HashMap<&str, Option<&str>> = self
10472 .engine
10473 .rules()
10474 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
10475 .collect();
10476
10477 let tv = self.topo_view();
10478 let ep = self.edge_props_view();
10479 let mut edges = Vec::new();
10480
10481 for id in 0..self.ids.len() as u32 {
10482 let Some(key) = self.ids.key_of(id) else {
10483 continue;
10484 };
10485 let Some(&lsym) = self.labels.get(id as usize) else {
10486 continue;
10487 };
10488 if lsym == u32::MAX {
10489 continue; // tombstoned
10490 }
10491
10492 for etype_sym in tv.etypes() {
10493 // etype from archived CSR (access_unchecked, no eager CRC).
10494 // Skip edges whose etype is not in the interner; this can only
10495 // occur with a corrupt large TOPOLOGY section (bit-flip on an
10496 // etype field in the archived data). The function returns Vec,
10497 // not Result, so we continue rather than propagate.
10498 let Some(edge_type) = self.syms.resolve(etype_sym) else {
10499 continue;
10500 };
10501 let edge_type = edge_type.to_string();
10502 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10503 let Some(dst_key) = self.ids.key_of(nbr) else {
10504 continue; // skip corrupt entries
10505 };
10506 let prov_key = (etype_sym, id, nbr);
10507 let rule = prov.get(&prov_key).cloned();
10508 let derived = rule.is_some();
10509 let weight = rule
10510 .as_deref()
10511 .and_then(|rn| weight_props.get(rn).copied().flatten())
10512 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10513 Some(Value::Float(f)) => Some(f),
10514 Some(Value::Int(i)) => Some(i as f64),
10515 _ => None,
10516 });
10517 edges.push(ExportEdge {
10518 edge_type: edge_type.clone(),
10519 src: key.to_string(),
10520 dst: dst_key.to_string(),
10521 derived,
10522 rule,
10523 weight,
10524 });
10525 }
10526 }
10527 }
10528
10529 edges.sort_by(|a, b| {
10530 a.edge_type
10531 .cmp(&b.edge_type)
10532 .then(a.src.cmp(&b.src))
10533 .then(a.dst.cmp(&b.dst))
10534 });
10535 edges
10536 }
10537
10538 /// What each edge type *is*, without building one record per edge.
10539 ///
10540 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
10541 /// question by materialising every edge — three `String`s apiece, a
10542 /// provenance `HashMap` over every derived edge, and a final sort. That is
10543 /// the right shape for an export, and the wrong one for a summary: on a
10544 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
10545 /// produce nine lines. This walks the topology instead, summing neighbour
10546 /// slice lengths and collecting *label symbols* rather than label strings,
10547 /// so the per-edge cost is an integer add and a set insert on a set with
10548 /// as many members as the store has labels.
10549 ///
10550 /// The rule names come off the rule *definitions*, which each declare the
10551 /// `edge_type` they derive, so naming them costs one pass over the rules
10552 /// rather than one provenance lookup per edge. That is also why `rules`
10553 /// is a list: two rules may derive the same type — the association store
10554 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
10555 /// talent→job rule — and naming only one of them would be a half-truth.
10556 /// A type with no rules is one written by hand.
10557 ///
10558 /// `sample` is the first edge of the type in the store's own id order,
10559 /// which is insertion order: deterministic for a given store, and not the
10560 /// same as key order, which cannot be had without resolving a key per
10561 /// edge. Sorted by `edge_type`.
10562 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
10563 self.ensure_v8_base_sections_loaded();
10564
10565 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10566 for r in self.engine.rules() {
10567 rules_by_type
10568 .entry(r.edge_type.as_str())
10569 .or_default()
10570 .insert(r.name.as_str());
10571 }
10572
10573 let tv = self.topo_view();
10574 let node_count = self.ids.len() as u32;
10575 let mut out = Vec::new();
10576 for etype_sym in tv.etypes() {
10577 // An etype the interner cannot resolve means a corrupt TOPOLOGY
10578 // section; skip it rather than name it, as `all_edges_for_export`
10579 // does for the same reason.
10580 let Some(edge_type) = self.syms.resolve(etype_sym) else {
10581 continue;
10582 };
10583 let mut edges: u64 = 0;
10584 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
10585 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
10586 let mut sample: Option<(u32, u32)> = None;
10587 for id in 0..node_count {
10588 let Some(&lsym) = self.labels.get(id as usize) else {
10589 continue;
10590 };
10591 if lsym == u32::MAX {
10592 continue; // tombstoned
10593 }
10594 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
10595 let nbrs = nbrs.as_ref();
10596 if nbrs.is_empty() {
10597 continue;
10598 }
10599 edges += nbrs.len() as u64;
10600 src_syms.insert(lsym);
10601 for &nbr in nbrs {
10602 if let Some(&dsym) = self.labels.get(nbr as usize) {
10603 if dsym != u32::MAX {
10604 dst_syms.insert(dsym);
10605 }
10606 }
10607 }
10608 if sample.is_none() {
10609 sample = Some((id, nbrs[0]));
10610 }
10611 }
10612 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
10613 syms.iter()
10614 .filter_map(|&s| self.syms.resolve(s))
10615 .map(ToString::to_string)
10616 .collect()
10617 };
10618 out.push(EdgeTypeCensus {
10619 edge_type: edge_type.to_string(),
10620 edges,
10621 src_labels: resolve(&src_syms),
10622 dst_labels: resolve(&dst_syms),
10623 rules: rules_by_type
10624 .get(edge_type)
10625 .map(|rs| rs.iter().map(ToString::to_string).collect())
10626 .unwrap_or_default(),
10627 sample: sample.and_then(|(s, d)| {
10628 Some((
10629 self.ids.key_of(s)?.to_string(),
10630 self.ids.key_of(d)?.to_string(),
10631 ))
10632 }),
10633 });
10634 }
10635 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
10636 out
10637 }
10638
10639 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
10640 /// on each edge when given.
10641 ///
10642 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
10643 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
10644 /// `None` — callers that want a default weight (e.g. `1.0` for missing
10645 /// props) apply it themselves, matching the convention used internally
10646 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
10647 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
10648 ///
10649 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
10650 /// (manual + rule-derived edges). An unknown `edge_type` returns an
10651 /// empty vec.
10652 pub fn weighted_edges(
10653 &self,
10654 edge_type: &str,
10655 weight_prop: Option<&str>,
10656 ) -> Vec<(String, String, Option<f64>)> {
10657 let Some(etype_sym) = self.syms.get(edge_type) else {
10658 return Vec::new();
10659 };
10660 let tv = self.topo_view();
10661 let ep = self.edge_props_view();
10662 let mut out = Vec::new();
10663 for id in 0..self.ids.len() as u32 {
10664 let Some(key) = self.ids.key_of(id) else {
10665 continue;
10666 };
10667 let Some(&sym) = self.labels.get(id as usize) else {
10668 continue;
10669 };
10670 if sym == u32::MAX {
10671 continue; // tombstoned
10672 }
10673 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10674 let Some(dst_key) = self.ids.key_of(nbr) else {
10675 continue;
10676 };
10677 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10678 Some(Value::Float(f)) => Some(f),
10679 Some(Value::Int(i)) => Some(i as f64),
10680 _ => None,
10681 });
10682 out.push((key.to_string(), dst_key.to_string(), weight));
10683 }
10684 }
10685 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
10686 out
10687 }
10688
10689 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
10690 self.view()
10691 .nodes_with_label(label)
10692 .into_iter()
10693 .map(|id| NodeRef { db: self, id })
10694 .collect()
10695 }
10696
10697 /// Whether any of the first `limit` live nodes under `label`, in id
10698 /// order, carries `field`.
10699 ///
10700 /// Bounded for a caller that asks on every request: one props view, at
10701 /// most `limit` property reads, no value cloned and no `NodeRef` built,
10702 /// and it stops at the first carrier. The label column itself has no
10703 /// per-label index, so on a label with fewer than `limit` nodes the walk
10704 /// of that column runs to its end — integer compares, no property read.
10705 pub(crate) fn first_nodes_carry_prop(&self, label: &str, field: &str, limit: usize) -> bool {
10706 let Some(sym) = self.syms.get(label) else {
10707 return false;
10708 };
10709 let props = self.props_view();
10710 self.labels
10711 .iter()
10712 .enumerate()
10713 .filter(|&(_, &s)| s == sym)
10714 .take(limit)
10715 .any(|(id, _)| props.get(id as u32, field).is_some())
10716 }
10717
10718 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
10719 let view = self.view();
10720 view.nodes_with_label(label)
10721 .into_iter()
10722 .filter(|&id| {
10723 eval_filter(filter, &|field| {
10724 view.prop(id, field).map(|vr| vr.into_value())
10725 })
10726 })
10727 .map(|id| NodeRef { db: self, id })
10728 .collect()
10729 }
10730
10731 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
10732 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
10733 /// with `label = None` will use the native ANN path rather than the O(n)
10734 /// brute-force scan.
10735 pub fn has_vector_rule(&self, field: &str) -> bool {
10736 self.engine.hnsw_has_rule(field)
10737 }
10738
10739 /// How many HNSW graphs this handle has built from scratch since it was
10740 /// opened (one per side of an approximate rule).
10741 ///
10742 /// An open that restored every graph from the snapshot reports `0`.
10743 /// Exposed for tests that assert the open path reuses the persisted index
10744 /// rather than rebuilding it; not part of the stable surface.
10745 #[doc(hidden)]
10746 pub fn hnsw_build_count(&self) -> u64 {
10747 self.engine.hnsw_build_count()
10748 }
10749
10750 /// Whether the rule engine has built its candidate indexes on this handle.
10751 ///
10752 /// A store opened from a snapshot defers that full node scan until the
10753 /// first write or the first read that needs it. Exposed for tests that
10754 /// assert an open did not pay for it; not part of the stable surface.
10755 #[doc(hidden)]
10756 pub fn rule_indexes_populated(&self) -> bool {
10757 self.engine.indexes_populated()
10758 }
10759
10760 /// How many rules this handle still holds a lazily-decoded HNSW graph for.
10761 ///
10762 /// Zero before the first ANN query on a clean open, and again once the
10763 /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
10764 /// Exposed for tests that assert the lazy copies are released; not part of
10765 /// the stable surface.
10766 #[doc(hidden)]
10767 pub fn lazy_hnsw_len(&self) -> usize {
10768 self.engine.lazy_hnsw_len()
10769 }
10770
10771 /// Find nodes whose `field` vector is most similar to `q` (cosine
10772 /// similarity), returning up to `k` results with similarity ≥ `min`,
10773 /// sorted descending.
10774 ///
10775 /// When `label` is `None` the search spans all labels (via
10776 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
10777 /// `Some(lbl)` it restricts to nodes with that label.
10778 ///
10779 /// Uses the HNSW index when one is available (fast path); otherwise falls
10780 /// back to an O(n) brute-force scan.
10781 ///
10782 /// **The index supplies candidates, never scores.** Its own distances are
10783 /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
10784 /// every candidate is re-scored from the `f64` property vectors by
10785 /// [`exact_vector_similarity`] before `min`, the ordering and the reported
10786 /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
10787 /// the re-ordering cannot drop a true top-`k` member; see that constant for
10788 /// the rule. The score a caller receives is therefore the same number the
10789 /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
10790 /// finds an exact duplicate.
10791 pub fn find_similar_vector(
10792 &self,
10793 field: &str,
10794 label: Option<&str>,
10795 q: &[f64],
10796 k: usize,
10797 min: f64,
10798 ) -> Vec<(String, f64)> {
10799 self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
10800 .expect("find_similar_vector_filtered is infallible without where_")
10801 }
10802
10803 /// Like [`find_similar_vector`] but restricts results to nodes visible in
10804 /// `mask`. Hidden nodes never appear in results; the mask is applied
10805 /// **before** k-truncation so a caller still receives up to `k` visible
10806 /// hits.
10807 ///
10808 /// # HNSW path (widening beam)
10809 ///
10810 /// When an HNSW index covers the request, the beam starts at an over-fetch
10811 /// of `k × n / |visible|` (plus the rescore margin) when the mask's
10812 /// selectivity is known from the index length, otherwise at `k` plus that
10813 /// margin. If fewer than `k` visible candidates remain after the mask and
10814 /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
10815 /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
10816 /// or a beam that comes back short of its own width, falls through to the
10817 /// exhaustive masked scan rather than returning a short result.
10818 ///
10819 /// Every surviving candidate is re-scored from the `f64` property vectors,
10820 /// exactly as [`find_similar_vector`] does and for the same reason.
10821 ///
10822 /// # Brute-force path
10823 ///
10824 /// When no HNSW index covers the request, or the beam cannot admit `k`
10825 /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
10826 /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
10827 /// results (or all visible nodes if fewer than `k` exist).
10828 pub fn find_similar_vector_masked(
10829 &self,
10830 field: &str,
10831 label: Option<&str>,
10832 q: &[f64],
10833 k: usize,
10834 min: f64,
10835 mask: &crate::mask::NodeMask,
10836 ) -> Vec<(String, f64)> {
10837 self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
10838 .expect("find_similar_vector_filtered is infallible without where_")
10839 }
10840
10841 /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
10842 ///
10843 /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
10844 /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
10845 /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
10846 /// uses HNSW when an index covers the field.
10847 #[allow(clippy::too_many_arguments)]
10848 pub fn find_similar_vector_filtered(
10849 &self,
10850 field: &str,
10851 label: Option<&str>,
10852 q: &[f64],
10853 k: usize,
10854 min: f64,
10855 mask: Option<&crate::mask::NodeMask>,
10856 where_: Option<&PropPredicate>,
10857 exact: bool,
10858 ) -> Result<Vec<(String, f64)>> {
10859 self.find_similar_vector_as(
10860 field,
10861 label,
10862 q,
10863 k,
10864 min,
10865 mask,
10866 where_,
10867 exact,
10868 ExactnessCaller::Vector,
10869 )
10870 }
10871
10872 /// [`find_similar_vector_filtered`](Self::find_similar_vector_filtered)
10873 /// with the caller shape named, so the exactness warning can advise the
10874 /// signature that actually reached it. Everything else is identical.
10875 #[allow(clippy::too_many_arguments)]
10876 fn find_similar_vector_as(
10877 &self,
10878 field: &str,
10879 label: Option<&str>,
10880 q: &[f64],
10881 k: usize,
10882 min: f64,
10883 mask: Option<&crate::mask::NodeMask>,
10884 where_: Option<&PropPredicate>,
10885 exact: bool,
10886 caller: ExactnessCaller,
10887 ) -> Result<Vec<(String, f64)>> {
10888 if let Some(pred) = where_ {
10889 pred.validate_named("where")
10890 .map_err(|detail| GraphError::QueryError { detail })?;
10891 }
10892
10893 // Ensure any HNSW blobs retained from the snapshot are deserialized
10894 // before the first ANN query on a clean-open (no-WAL) path. The
10895 // section read has to come first: on a clean open nothing else has
10896 // called it, so without it `retained_hnsw_blobs` is empty,
10897 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
10898 // approximate query on the handle runs brute force — correct results,
10899 // silently off the index. Both calls are idempotent and cheap once hot.
10900 self.ensure_v8_base_sections_loaded();
10901 self.engine.ensure_hnsw_loaded();
10902 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
10903 if norm == 0.0 {
10904 return Ok(vec![]);
10905 }
10906 if let Some(m) = mask {
10907 if k == 0 || m.is_empty() {
10908 return Ok(vec![]);
10909 }
10910 }
10911 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
10912
10913 // `where` implies exact: a predicate must not ride a silent ANN.
10914 let skip_hnsw = exact || where_.is_some();
10915 if !skip_hnsw {
10916 if let Some(mask) = mask {
10917 if let Some(out) =
10918 self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask, caller)
10919 {
10920 return Ok(out);
10921 }
10922 } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
10923 return Ok(out);
10924 }
10925 }
10926
10927 let view = match mask {
10928 Some(m) => self.view_masked(m),
10929 None => self.view(),
10930 };
10931 let candidate_ids = Self::vector_candidates(&view, label, where_);
10932 Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
10933 }
10934
10935 /// Unmasked HNSW path. `None` when no populated index covers the request.
10936 fn find_similar_hnsw(
10937 &self,
10938 field: &str,
10939 label: Option<&str>,
10940 q_unit: &[f64],
10941 k: usize,
10942 min: f64,
10943 ) -> Option<Vec<(String, f64)>> {
10944 // Try HNSW fast path.
10945 // `None` label searches across all VectorSimilar rules covering `field`
10946 // (merging their results); `Some(lbl)` restricts to rules whose
10947 // dst_label matches. Returns `None` when no populated HNSW index
10948 // covers the request — the O(n) brute-force fallback handles that case.
10949 let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
10950 let hits = match label {
10951 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
10952 None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
10953 };
10954 // Candidates only: the index's `f32` similarity is discarded and
10955 // each hit is re-scored against the `f64` vectors.
10956 let view = self.view();
10957 let mut out: Vec<(String, f64)> = hits
10958 .into_iter()
10959 .filter_map(|(id, _)| {
10960 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
10961 if sim < min {
10962 return None;
10963 }
10964 Some((self.ids.key_of(id)?.to_string(), sim))
10965 })
10966 .collect();
10967 out.sort_by(|a, b| {
10968 b.1.partial_cmp(&a.1)
10969 .unwrap_or(std::cmp::Ordering::Equal)
10970 .then_with(|| a.0.cmp(&b.0))
10971 });
10972 out.truncate(k);
10973 Some(out)
10974 }
10975
10976 /// Masked HNSW widening beam. `None` when no index covers the request or
10977 /// the beam cannot admit `k` visible hits (caller falls through to brute).
10978 #[allow(clippy::too_many_arguments)]
10979 fn find_similar_hnsw_masked(
10980 &self,
10981 field: &str,
10982 label: Option<&str>,
10983 q_unit: &[f64],
10984 k: usize,
10985 min: f64,
10986 mask: &crate::mask::NodeMask,
10987 caller: ExactnessCaller,
10988 ) -> Option<Vec<(String, f64)>> {
10989 let index_len = match label {
10990 Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
10991 None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
10992 };
10993 let n = index_len?;
10994 // The `?` above is the coverage test: past it, an index exists and this
10995 // masked, non-exact call is about to ride it.
10996 self.note_ambiguous_exactness(field, label, caller);
10997 // Same ceiling the exact-rule widening loop in `hnsw_candidates`
10998 // consults — including the `with_ef_max` test hook.
10999 let cap = ef_max();
11000 let visible = mask.len();
11001 let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
11002 if visible > 0 && n > 0 {
11003 let over = k
11004 .saturating_mul(n)
11005 .div_ceil(visible)
11006 .saturating_add(VECTOR_RESCORE_MARGIN);
11007 ef = ef.max(over);
11008 }
11009 loop {
11010 let hits = match label {
11011 Some(lbl) => self
11012 .engine
11013 .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
11014 None => self
11015 .engine
11016 .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
11017 };
11018 let hits = hits?;
11019 let full = hits.len() == ef;
11020 let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
11021 if out.len() >= k {
11022 out.truncate(k);
11023 return Some(out);
11024 }
11025 // Short of its width (frontier exhausted) or at the ceiling:
11026 // a wider beam reaches nothing new, so the scan answers.
11027 if !full || ef >= cap {
11028 return None;
11029 }
11030 ef = ef.saturating_mul(2);
11031 }
11032 }
11033
11034 /// Say once, per `(field, label)` index and caller shape, that a masked
11035 /// search is answering approximately.
11036 ///
11037 /// A mask narrows *which nodes may be returned*. It does not choose a
11038 /// kernel — `exact=true` and a `where=` predicate do, and nothing else
11039 /// does. A caller who needed exact answers, passed `mask=` alone, and read
11040 /// the mask as a promise of exhaustiveness gets a correct-looking
11041 /// approximate answer and no signal at all; that is a silent wrong answer,
11042 /// and it has cost an integration team real time.
11043 ///
11044 /// The fix is a question, not a behaviour change. Making a mask imply
11045 /// `exact` would turn every existing masked caller's ANN into an O(n) GEMM
11046 /// without asking them, which is a worse trade than the ambiguity.
11047 ///
11048 /// Printed once per index for the reason the dimension-mismatch skip in
11049 /// `core_rules::hnsw` is: a line on every call is a line callers learn to
11050 /// scroll past.
11051 ///
11052 /// `caller` decides the advice. The same leg is reached from two signatures
11053 /// and only one of them has an `exact` argument to pass; see
11054 /// [`ExactnessCaller`].
11055 fn note_ambiguous_exactness(&self, field: &str, label: Option<&str>, caller: ExactnessCaller) {
11056 let entry = (field.to_string(), label.unwrap_or("").to_string(), caller);
11057 let first = match self.warned_ambiguous_exactness.lock() {
11058 Ok(mut seen) => seen.insert(entry),
11059 Err(poisoned) => poisoned.into_inner().insert(entry),
11060 };
11061 if !first {
11062 return;
11063 }
11064 AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(c.get().saturating_add(1)));
11065 let which = match label {
11066 Some(lbl) => format!(" (label `{lbl}`)"),
11067 None => String::new(),
11068 };
11069 let subject = caller.subject();
11070 let advice = caller.advice();
11071 let line = format!(
11072 "mushroomdb: {subject} on field `{field}`{which} is answering \
11073 approximately. A mask narrows which nodes may be returned; it does not \
11074 change which kernel runs, and an index covers this field. For an exact \
11075 answer over the same visible candidate set, {advice} Further masked \
11076 searches of this shape on this index are silent."
11077 );
11078 AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = Some(line.clone()));
11079 eprintln!("{line}");
11080 }
11081
11082 /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
11083 /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
11084 fn vector_candidates(
11085 view: &GraphView<'_>,
11086 label: Option<&str>,
11087 where_: Option<&PropPredicate>,
11088 ) -> Vec<u32> {
11089 if let (Some(lbl), Some(pred)) = (label, where_) {
11090 let indexed = view
11091 .prop_index
11092 .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
11093 if indexed {
11094 match (&pred.eq, &pred.in_) {
11095 (Some(eq), None) => {
11096 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
11097 return ids;
11098 }
11099 }
11100 (None, Some(allowed)) => {
11101 let mut seen = HashSet::new();
11102 let mut out = Vec::new();
11103 for v in allowed {
11104 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
11105 for id in ids {
11106 if seen.insert(id) {
11107 out.push(id);
11108 }
11109 }
11110 }
11111 }
11112 return out;
11113 }
11114 _ => {}
11115 }
11116 }
11117 }
11118
11119 let mut ids: Vec<u32> = match label {
11120 Some(lbl) => view
11121 .nodes_with_label(lbl)
11122 .into_iter()
11123 .filter(|&id| view.visible(id))
11124 .collect(),
11125 None => view.nodes_all(),
11126 };
11127 if let Some(pred) = where_ {
11128 ids.retain(|&id| match view.prop(id, &pred.field) {
11129 None => pred.holds(None),
11130 Some(vr) => pred.holds(Some(vr.as_value())),
11131 });
11132 }
11133 ids
11134 }
11135
11136 /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
11137 /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
11138 fn brute_vector_hits(
11139 &self,
11140 view: &GraphView<'_>,
11141 candidate_ids: impl IntoIterator<Item = u32>,
11142 field: &str,
11143 q_unit: &[f64],
11144 k: usize,
11145 min: f64,
11146 ) -> Vec<(String, f64)> {
11147 let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
11148 .into_iter()
11149 .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
11150 .collect();
11151 let packed =
11152 crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
11153 let scores = crate::exact_knn::gemv(&packed, q_unit);
11154 let mut scored: Vec<(String, f64)> = packed
11155 .ids
11156 .iter()
11157 .zip(scores.iter())
11158 .filter_map(|(&id, &sim)| {
11159 if sim < min {
11160 return None;
11161 }
11162 let key = self.ids.key_of(id)?.to_string();
11163 Some((key, sim))
11164 })
11165 .collect();
11166 scored.sort_by(|a, b| {
11167 b.1.partial_cmp(&a.1)
11168 .unwrap_or(std::cmp::Ordering::Equal)
11169 .then_with(|| a.0.cmp(&b.0))
11170 });
11171 scored.truncate(k);
11172 scored
11173 }
11174
11175 /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
11176 ///
11177 /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
11178 /// same unit and inequality as `find_similar_vector`. Self-matches are
11179 /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
11180 /// embeddings are omitted as both query and candidate. Duplicate keys are
11181 /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
11182 /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
11183 #[allow(clippy::type_complexity)]
11184 pub fn pairwise_similar(
11185 &self,
11186 keys: &[&str],
11187 field: &str,
11188 k: usize,
11189 min: f64,
11190 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11191 let mut seen = HashSet::new();
11192 let mut unique_ids = Vec::new();
11193 for key in keys {
11194 let Some(id) = self.ids.get(key) else {
11195 continue;
11196 };
11197 if seen.insert(id) {
11198 unique_ids.push(id);
11199 }
11200 }
11201 let max_n = crate::exact_knn::pairwise_max_n();
11202 if unique_ids.len() > max_n {
11203 return Err(GraphError::QueryError {
11204 detail: format!(
11205 "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
11206 unique_ids.len()
11207 ),
11208 });
11209 }
11210 if unique_ids.is_empty() {
11211 return Ok(Vec::new());
11212 }
11213
11214 let view = self.view();
11215 let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
11216 let mut counts: HashMap<usize, usize> = HashMap::new();
11217 for id in unique_ids {
11218 let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
11219 continue;
11220 };
11221 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
11222 if norm == 0.0 {
11223 continue;
11224 }
11225 *counts.entry(v.len()).or_default() += 1;
11226 rows.push((id, v));
11227 }
11228 if rows.is_empty() {
11229 return Ok(Vec::new());
11230 }
11231 let dim = counts
11232 .into_iter()
11233 .max_by_key(|&(d, c)| (c, d))
11234 .map(|(d, _)| d)
11235 .expect("rows non-empty");
11236 let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
11237 let n = packed.ids.len();
11238 if n == 0 {
11239 return Ok(Vec::new());
11240 }
11241 let src_keys: Vec<String> = packed
11242 .ids
11243 .iter()
11244 .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
11245 .collect();
11246
11247 let mut out = Vec::with_capacity(n);
11248 if n <= crate::exact_knn::pairwise_gram_max() {
11249 let sims = crate::exact_knn::gram(&packed);
11250 for i in 0..n {
11251 out.push(Self::topk_from_row(
11252 &src_keys,
11253 i,
11254 &sims[i * n..(i + 1) * n],
11255 k,
11256 min,
11257 ));
11258 }
11259 } else {
11260 for i in 0..n {
11261 let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
11262 let scores = crate::exact_knn::gemv(&packed, row);
11263 out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
11264 }
11265 }
11266 Ok(out)
11267 }
11268
11269 /// [`pairwise_similar`](Self::pairwise_similar) over the keys the mask
11270 /// admits — intersected **before** the matmul, never filtered after it.
11271 ///
11272 /// A hidden vector packed into the Gram is a row every visible key is
11273 /// scored against. It can take a visible neighbour's place in the top-`k`,
11274 /// and because the packed dimension is a majority vote over the candidate
11275 /// rows it can decide whether a visible pair is scored at all. Dropping
11276 /// hidden names from the finished answer leaves both effects standing, so
11277 /// the intersection happens first and the answer is byte-for-byte the one
11278 /// `pairwise_similar` gives for the visible keys alone.
11279 ///
11280 /// The caps therefore measure the **post-filter** count: a key set over
11281 /// [`PAIRWISE_MAX_N`](crate::PAIRWISE_MAX_N) unscoped can come under it
11282 /// scoped and succeed, because the work the cap refuses is work this call
11283 /// no longer does. A filtered count still over the cap is still refused.
11284 #[allow(clippy::type_complexity)]
11285 pub fn pairwise_similar_scoped(
11286 &self,
11287 keys: &[&str],
11288 field: &str,
11289 k: usize,
11290 min: f64,
11291 mask: &crate::mask::NodeMask,
11292 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11293 let visible: Vec<&str> = keys
11294 .iter()
11295 .copied()
11296 .filter(|key| mask.contains_node(self, key))
11297 .collect();
11298 self.pairwise_similar(&visible, field, k, min)
11299 }
11300
11301 /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
11302 /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
11303 /// still appear as `(src, [])`.
11304 fn topk_from_row(
11305 src_keys: &[String],
11306 i: usize,
11307 scores: &[f64],
11308 k: usize,
11309 min: f64,
11310 ) -> (String, Vec<(String, f64)>) {
11311 let mut neigh: Vec<(String, f64)> = scores
11312 .iter()
11313 .enumerate()
11314 .filter_map(|(j, &sim)| {
11315 if i == j || sim < min {
11316 return None;
11317 }
11318 Some((src_keys[j].clone(), sim))
11319 })
11320 .collect();
11321 neigh.sort_by(|a, b| {
11322 b.1.partial_cmp(&a.1)
11323 .unwrap_or(std::cmp::Ordering::Equal)
11324 .then_with(|| a.0.cmp(&b.0))
11325 });
11326 neigh.truncate(k);
11327 (src_keys[i].clone(), neigh)
11328 }
11329
11330 /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
11331 /// hits, order by score then key. The index's own `f32` similarity is discarded.
11332 fn score_masked_hnsw_hits(
11333 &self,
11334 hits: &[(u32, f64)],
11335 field: &str,
11336 q_unit: &[f64],
11337 min: f64,
11338 mask: &crate::mask::NodeMask,
11339 ) -> Vec<(String, f64)> {
11340 let view = self.view_masked(mask);
11341 let mut out: Vec<(String, f64)> = hits
11342 .iter()
11343 .copied()
11344 .filter(|&(id, _)| mask.contains_id(id))
11345 .filter_map(|(id, _)| {
11346 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
11347 if sim < min {
11348 return None;
11349 }
11350 Some((self.ids.key_of(id)?.to_string(), sim))
11351 })
11352 .collect();
11353 out.sort_by(|a, b| {
11354 b.1.partial_cmp(&a.1)
11355 .unwrap_or(std::cmp::Ordering::Equal)
11356 .then_with(|| a.0.cmp(&b.0))
11357 });
11358 out
11359 }
11360
11361 /// Read a single property from an edge.
11362 ///
11363 /// Returns `None` when the edge does not exist, the field is absent, or any
11364 /// of the string keys cannot be resolved to interned ids. Only edge props
11365 /// written by rules (weight fields) are accessible without a `set_edge_prop`
11366 /// binding; topology-only edges (no props set) return `None` for every field.
11367 pub fn get_edge_prop(
11368 &self,
11369 edge_type: &str,
11370 src_key: &str,
11371 dst_key: &str,
11372 field: &str,
11373 ) -> Option<Value> {
11374 let etype = self.syms.get(edge_type)?;
11375 let src = self.ids.get(src_key)?;
11376 let dst = self.ids.get(dst_key)?;
11377 self.edge_props_view().get(etype, src, dst, field)
11378 }
11379
11380 /// Lex → parse → plan → execute `cypher` over a read-only view.
11381 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
11382 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
11383 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
11384 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11385 detail: format!("lex: {e}"),
11386 })?;
11387 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
11388 detail: format!("parse: {e}"),
11389 })?;
11390 let t0 = std::time::Instant::now();
11391 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
11392 GraphError::QueryError {
11393 detail: format!("execute: {e}"),
11394 }
11395 });
11396 let elapsed_ms = t0.elapsed().as_millis() as u64;
11397 let threshold = self.slow_query_threshold_ms;
11398 if threshold > 0 && elapsed_ms >= threshold {
11399 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
11400 let entry = SlowQueryEntry {
11401 ms: elapsed_ms,
11402 query: cypher.to_string(),
11403 at_commit: self.commit_seq,
11404 };
11405 if let Ok(mut log) = self.slow_queries.lock() {
11406 if log.entries.len() == SLOW_QUERY_RING_CAP {
11407 log.entries.pop_front();
11408 }
11409 log.entries.push_back(entry);
11410 log.total += 1;
11411 }
11412 }
11413 result
11414 }
11415
11416 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
11417 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
11418 /// calling [`GraphDb::query`].
11419 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
11420 let map: BTreeMap<String, Value> = params
11421 .iter()
11422 .map(|(k, v)| (k.to_string(), v.clone()))
11423 .collect();
11424 self.query(cypher, &map)
11425 }
11426
11427 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
11428 ///
11429 /// All mutations flow through the same `insert_node` / `set_prop` /
11430 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
11431 /// fires and the WAL captures everything with one fsync per statement.
11432 ///
11433 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
11434 /// and `deleted` matching the write-result contract.
11435 ///
11436 /// **Mutation routing**: mutations are collected into a single
11437 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
11438 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
11439 /// over `self.view()` — the borrow is dropped before the batch is opened.
11440 ///
11441 /// **Limitations (v1)**:
11442 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
11443 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
11444 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
11445 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
11446 /// - Deleting a derived edge → named error "cannot delete derived edge".
11447 pub fn query_write(
11448 &mut self,
11449 cypher: &str,
11450 params: &BTreeMap<String, Value>,
11451 ) -> Result<ResultSet> {
11452 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11453 detail: format!("lex: {e}"),
11454 })?;
11455 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
11456 detail: format!("parse: {e}"),
11457 })?;
11458 self.exec_write_stmt(stmt, params)
11459 }
11460
11461 fn exec_write_stmt(
11462 &mut self,
11463 stmt: WriteStatement,
11464 params: &BTreeMap<String, Value>,
11465 ) -> Result<ResultSet> {
11466 match stmt {
11467 WriteStatement::Create(s) => self.exec_create(s, params),
11468 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
11469 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
11470 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
11471 WriteStatement::Merge(s) => self.exec_merge(s, params),
11472 }
11473 }
11474
11475 fn exec_create(
11476 &mut self,
11477 stmt: core_query::cypher::CreateStmt,
11478 params: &BTreeMap<String, Value>,
11479 ) -> Result<ResultSet> {
11480 // Extract the node key from props: require a string-valued `id` field.
11481 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
11482 for node in &stmt.nodes {
11483 let var = node.var.as_deref().unwrap_or("_cn0");
11484 let key = node
11485 .props
11486 .iter()
11487 .find(|(f, _)| f == "id")
11488 .and_then(|(_, v)| {
11489 if let Value::Str(s) = v {
11490 Some(s.clone())
11491 } else {
11492 None
11493 }
11494 })
11495 .ok_or_else(|| GraphError::QueryError {
11496 detail: format!(
11497 "CREATE node ({}:{}) requires a string 'id' property",
11498 var, node.label
11499 ),
11500 })?;
11501 var_to_key.insert(var.to_string(), key);
11502 }
11503
11504 let mut batch = self.batch();
11505 let mut created: usize = 0;
11506 for node in &stmt.nodes {
11507 let var = node.var.as_deref().unwrap_or("_cn0");
11508 let key = &var_to_key[var];
11509 batch.insert_node(&node.label, key, node.props.clone());
11510 created += 1;
11511 }
11512 for edge in &stmt.edges {
11513 let src_key = var_to_key
11514 .get(&edge.src_var)
11515 .ok_or_else(|| GraphError::QueryError {
11516 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
11517 })?;
11518 let dst_key = var_to_key
11519 .get(&edge.dst_var)
11520 .ok_or_else(|| GraphError::QueryError {
11521 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
11522 })?;
11523 batch.insert_edge(&edge.etype, src_key, dst_key);
11524 }
11525 batch.commit()?;
11526
11527 // Optional RETURN clause: project created bindings as a read result.
11528 if let Some(returns) = stmt.returns {
11529 // Each created node is looked up by its key via a separate MATCH pattern.
11530 // Multiple single-node patterns cross-join to produce 1 output row with
11531 // all variables bound (each pattern returns exactly 1 row).
11532 let patterns: Vec<Pattern> = stmt
11533 .nodes
11534 .iter()
11535 .map(|node| {
11536 let var = node.var.as_deref().unwrap_or("_cn0");
11537 let key = var_to_key[var].clone();
11538 Pattern {
11539 start: NodePat {
11540 var: Some(var.to_string()),
11541 label: Some(node.label.clone()),
11542 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
11543 },
11544 chain: vec![],
11545 shortest: false,
11546 }
11547 })
11548 .collect();
11549 let q = Query {
11550 matches: patterns,
11551 optional_clauses: vec![],
11552 where_expr: None,
11553 unwinds: vec![],
11554 post_unwind_where: None,
11555 stages: vec![],
11556 returns,
11557 distinct: false,
11558 order_by: vec![],
11559 skip: None,
11560 limit: None,
11561 };
11562 let ops = plan(&q).map_err(|e| GraphError::QueryError {
11563 detail: format!("plan: {e}"),
11564 })?;
11565 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
11566 GraphError::QueryError {
11567 detail: format!("execute: {e}"),
11568 }
11569 });
11570 }
11571
11572 let mut rs = write_result_set();
11573 rs.push_row(vec![
11574 Some(Value::Int(created as i64)),
11575 Some(Value::Int(0)),
11576 Some(Value::Int(0)),
11577 ]);
11578 Ok(rs)
11579 }
11580
11581 fn exec_match_set(
11582 &mut self,
11583 stmt: core_query::cypher::MatchSetStmt,
11584 params: &BTreeMap<String, Value>,
11585 ) -> Result<ResultSet> {
11586 let project_returns = stmt.returns.clone();
11587 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
11588 // so the post-write projection can look them up by key.
11589 let mut set_vars: Vec<String> = Vec::new();
11590 for s in &stmt.sets {
11591 if !set_vars.contains(&s.var) {
11592 set_vars.push(s.var.clone());
11593 }
11594 }
11595 let rel_vars = pattern_rel_vars(&stmt.matches);
11596 // `count` is the engine's, on an edge: it is the insert-count §5.13
11597 // maintains, and a `SET` that overwrote it would make the number mean
11598 // whatever the last writer said rather than how many times the pair was
11599 // inserted. Refused by name here, before the match runs, so the caller
11600 // is told what is actually wrong instead of meeting the executor's
11601 // generic "did not resolve to a node key" — and so the answer does not
11602 // depend on whether the pattern happened to match a row. The same name
11603 // on a *node* is an ordinary property and is untouched.
11604 for s in &stmt.sets {
11605 if s.field == EDGE_COUNT_PROP && rel_vars.iter().any(|r| r == &s.var) {
11606 return Err(GraphError::QueryError {
11607 detail: format!(
11608 "cannot SET {}.{EDGE_COUNT_PROP}: `{EDGE_COUNT_PROP}` is a reserved edge \
11609 property holding the pair's insert count",
11610 s.var
11611 ),
11612 });
11613 }
11614 }
11615 let mut lookup_vars = set_vars.clone();
11616 for v in pattern_node_vars(&stmt.matches) {
11617 add_var(&mut lookup_vars, &v);
11618 }
11619 if let Some(ref returns) = project_returns {
11620 for v in ret_node_vars(returns) {
11621 if !rel_vars.iter().any(|r| r == &v) {
11622 add_var(&mut lookup_vars, &v);
11623 }
11624 }
11625 }
11626
11627 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
11628 // SET values are projected as ScalarExpr items so that arithmetic expressions
11629 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
11630 let mut set_returns: Vec<RetItem> = lookup_vars
11631 .iter()
11632 .map(|v| RetItem {
11633 value: RetVal::Var(v.clone()),
11634 alias: None,
11635 })
11636 .collect();
11637 // One computed column per SET clause; alias is `__sv_<i>`.
11638 let set_val_cols: Vec<String> = stmt
11639 .sets
11640 .iter()
11641 .enumerate()
11642 .map(|(i, _)| format!("__sv_{i}"))
11643 .collect();
11644 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11645 set_returns.push(RetItem {
11646 value: RetVal::ScalarExpr(sc.value.clone()),
11647 alias: Some(col.clone()),
11648 });
11649 }
11650 // Capture relationship types while r is bound; SET does not change them.
11651 for r in &rel_vars {
11652 set_returns.push(RetItem {
11653 value: RetVal::FuncCall {
11654 name: "type".into(),
11655 args: vec![Operand::Var(r.clone())],
11656 },
11657 alias: Some(rel_type_alias(r)),
11658 });
11659 }
11660
11661 let read_q = Query {
11662 matches: stmt.matches.clone(),
11663 optional_clauses: vec![],
11664 where_expr: stmt.where_expr.clone(),
11665 unwinds: vec![],
11666 post_unwind_where: None,
11667 stages: vec![],
11668 returns: set_returns,
11669 distinct: false,
11670 order_by: vec![],
11671 skip: None,
11672 limit: None,
11673 };
11674 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11675 detail: format!("plan: {e}"),
11676 })?;
11677 // MATCH phase is read-only; borrow ends before batch opens.
11678 //
11679 // When a role-scoped write is in flight, run the MATCH read through
11680 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
11681 // zero-rows (no SetProp ops generated, no existence-oracle 403).
11682 // Full-authority writes (pending_write_authz=None) keep view().
11683 let match_rs = {
11684 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11685 if let Some(ref mask) = mask_opt {
11686 execute(&self.view_masked(mask), &ops, &Params(params))
11687 } else {
11688 execute(&self.view(), &ops, &Params(params))
11689 }
11690 }
11691 .map_err(|e| GraphError::QueryError {
11692 detail: format!("execute: {e}"),
11693 })?;
11694
11695 // Collect (key, field, value) for each matched row × each SET clause.
11696 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
11697 for row_i in 0..match_rs.len() {
11698 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11699 let key = match match_rs.get(row_i, &sc.var) {
11700 Some(Value::Str(k)) => k.clone(),
11701 _ => {
11702 return Err(GraphError::QueryError {
11703 detail: format!(
11704 "SET variable '{}' did not resolve to a node key",
11705 sc.var
11706 ),
11707 })
11708 }
11709 };
11710 // The SET value was already evaluated by the executor.
11711 let value = match match_rs.get(row_i, col) {
11712 Some(v) => v.clone(),
11713 None => {
11714 return Err(GraphError::QueryError {
11715 detail: format!(
11716 "SET value for {}.{} evaluated to null",
11717 sc.var, sc.field
11718 ),
11719 })
11720 }
11721 };
11722 set_ops.push((key, sc.field.clone(), value));
11723 }
11724 }
11725
11726 // Apply as one atomic batch.
11727 let props_set = set_ops.len();
11728 let mut batch = self.batch();
11729 for (key, field, value) in set_ops {
11730 batch.set_prop(&key, &field, value);
11731 }
11732 batch.commit()?;
11733
11734 if let Some(returns) = project_returns {
11735 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
11736 }
11737
11738 let mut rs = write_result_set();
11739 rs.push_row(vec![
11740 Some(Value::Int(0)),
11741 Some(Value::Int(props_set as i64)),
11742 Some(Value::Int(0)),
11743 ]);
11744 Ok(rs)
11745 }
11746
11747 fn exec_match_delete(
11748 &mut self,
11749 stmt: core_query::cypher::MatchDeleteStmt,
11750 params: &BTreeMap<String, Value>,
11751 ) -> Result<ResultSet> {
11752 // Collect unique node vars needed to identify edge endpoints.
11753 let mut node_vars: Vec<String> = Vec::new();
11754 for ed in &stmt.deletes {
11755 if !node_vars.contains(&ed.src_var) {
11756 node_vars.push(ed.src_var.clone());
11757 }
11758 if !node_vars.contains(&ed.dst_var) {
11759 node_vars.push(ed.dst_var.clone());
11760 }
11761 }
11762
11763 // Synthesize read query.
11764 let returns: Vec<RetItem> = node_vars
11765 .iter()
11766 .map(|v| RetItem {
11767 value: RetVal::Var(v.clone()),
11768 alias: None,
11769 })
11770 .collect();
11771 let read_q = Query {
11772 matches: stmt.matches,
11773 optional_clauses: vec![],
11774 where_expr: stmt.where_expr,
11775 unwinds: vec![],
11776 post_unwind_where: None,
11777 stages: vec![],
11778 returns,
11779 distinct: false,
11780 order_by: vec![],
11781 skip: None,
11782 limit: None,
11783 };
11784 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11785 detail: format!("plan: {e}"),
11786 })?;
11787 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11788 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11789 let match_rs = {
11790 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11791 if let Some(ref mask) = mask_opt {
11792 execute(&self.view_masked(mask), &ops, &Params(params))
11793 } else {
11794 execute(&self.view(), &ops, &Params(params))
11795 }
11796 }
11797 .map_err(|e| GraphError::QueryError {
11798 detail: format!("execute: {e}"),
11799 })?;
11800
11801 // Collect (etype, src_key, dst_key) for each row × each delete target.
11802 let mut del_ops: Vec<(String, String, String)> = Vec::new();
11803 for row_i in 0..match_rs.len() {
11804 for ed in &stmt.deletes {
11805 let src_key = match match_rs.get(row_i, &ed.src_var) {
11806 Some(Value::Str(k)) => k.clone(),
11807 _ => {
11808 return Err(GraphError::QueryError {
11809 detail: format!(
11810 "DELETE src variable '{}' did not resolve to a node key",
11811 ed.src_var
11812 ),
11813 })
11814 }
11815 };
11816 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
11817 Some(Value::Str(k)) => k.clone(),
11818 _ => {
11819 return Err(GraphError::QueryError {
11820 detail: format!(
11821 "DELETE dst variable '{}' did not resolve to a node key",
11822 ed.dst_var
11823 ),
11824 })
11825 }
11826 };
11827 del_ops.push((ed.etype.clone(), src_key, dst_key));
11828 }
11829 }
11830
11831 // Apply as one atomic batch.
11832 let deleted = del_ops.len();
11833 let mut batch = self.batch();
11834 for (etype, src_key, dst_key) in del_ops {
11835 batch.delete_edge(&etype, &src_key, &dst_key);
11836 }
11837 batch.commit().map_err(|e| match e {
11838 GraphError::RuleOwned { .. } => GraphError::QueryError {
11839 detail: "cannot delete derived edge; retract via the rule or change the property"
11840 .to_string(),
11841 },
11842 other => other,
11843 })?;
11844
11845 let mut rs = write_result_set();
11846 rs.push_row(vec![
11847 Some(Value::Int(0)),
11848 Some(Value::Int(0)),
11849 Some(Value::Int(deleted as i64)),
11850 ]);
11851 Ok(rs)
11852 }
11853
11854 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
11855 ///
11856 /// Collects the matching node keys via an ephemeral read query, then calls
11857 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
11858 /// the executor first checks that the node has no incident edges; if any
11859 /// remain it returns a named error matching openCypher semantics.
11860 fn exec_match_delete_node(
11861 &mut self,
11862 stmt: MatchDeleteNodeStmt,
11863 params: &BTreeMap<String, Value>,
11864 ) -> Result<ResultSet> {
11865 // Build a read query returning only the node keys we need.
11866 let returns: Vec<RetItem> = stmt
11867 .node_vars
11868 .iter()
11869 .map(|v| RetItem {
11870 value: RetVal::Var(v.clone()),
11871 alias: None,
11872 })
11873 .collect();
11874 let read_q = Query {
11875 matches: stmt.matches,
11876 optional_clauses: vec![],
11877 where_expr: stmt.where_expr,
11878 unwinds: vec![],
11879 post_unwind_where: None,
11880 stages: vec![],
11881 returns,
11882 distinct: false,
11883 order_by: vec![],
11884 skip: None,
11885 limit: None,
11886 };
11887 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11888 detail: format!("plan: {e}"),
11889 })?;
11890 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11891 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11892 let match_rs = {
11893 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11894 if let Some(ref mask) = mask_opt {
11895 execute(&self.view_masked(mask), &ops, &Params(params))
11896 } else {
11897 execute(&self.view(), &ops, &Params(params))
11898 }
11899 }
11900 .map_err(|e| GraphError::QueryError {
11901 detail: format!("execute: {e}"),
11902 })?;
11903
11904 // Collect unique node keys to delete (deduplicate across rows × vars).
11905 let mut keys: Vec<String> = Vec::new();
11906 for row_i in 0..match_rs.len() {
11907 for var in &stmt.node_vars {
11908 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
11909 if !keys.contains(k) {
11910 keys.push(k.clone());
11911 }
11912 }
11913 }
11914 }
11915
11916 if !stmt.detach {
11917 // openCypher bare DELETE: error if any matched node has incident edges.
11918 for key in &keys {
11919 if let Some(id) = self.ids.get(key) {
11920 let tv = self.topo_view();
11921 let has_edges = tv.etypes().any(|et| {
11922 !tv.neighbors(et, Direction::Out, id).is_empty()
11923 || !tv.neighbors(et, Direction::In, id).is_empty()
11924 });
11925 if has_edges {
11926 return Err(GraphError::QueryError {
11927 detail: format!(
11928 "Cannot delete node `{key}` because it still has incident edges. \
11929 Use DETACH DELETE to remove the node and all its edges."
11930 ),
11931 });
11932 }
11933 }
11934 }
11935 }
11936
11937 let mut nodes_deleted = 0i64;
11938 let mut edges_deleted = 0i64;
11939 for key in keys {
11940 match self.delete_node(&key) {
11941 Ok(report) => {
11942 nodes_deleted += 1;
11943 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
11944 }
11945 Err(GraphError::KeyNotFound { .. }) => {
11946 // Node may have been deleted by an earlier iteration (e.g., via
11947 // multiple MATCH rows for the same node). Safe to skip.
11948 }
11949 Err(e) => return Err(e),
11950 }
11951 }
11952
11953 let mut rs = write_result_set();
11954 rs.push_row(vec![
11955 Some(Value::Int(0)),
11956 Some(Value::Int(0)),
11957 Some(Value::Int(nodes_deleted + edges_deleted)),
11958 ]);
11959 Ok(rs)
11960 }
11961
11962 /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
11963 /// the pattern named one, or the executing role's sole namespace when it
11964 /// did not. A role bound to two or more namespaces cannot choose, and is
11965 /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
11966 /// refuses a named `ns` the role cannot write.
11967 fn merge_create_props(
11968 &self,
11969 key_field: &str,
11970 key_value: &Value,
11971 named_ns: Option<&Value>,
11972 ) -> Result<Vec<(String, Value)>> {
11973 let mut props = vec![(key_field.to_string(), key_value.clone())];
11974 if let Some(ns) = named_ns {
11975 props.push((NS_PROP.to_string(), ns.clone()));
11976 return Ok(props);
11977 }
11978 if let Some(ns) = self.merge_create_stamp_ns()? {
11979 props.push((NS_PROP.to_string(), Value::Str(ns)));
11980 }
11981 Ok(props)
11982 }
11983
11984 /// The namespace a role-scoped MERGE create stamps when the pattern does
11985 /// not name `ns`. `None` = unscoped / full authority, so the node lands in
11986 /// `default`.
11987 fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
11988 let Some(authz) = self.pending_write_authz.as_ref() else {
11989 return Ok(None);
11990 };
11991 let Some(def) = self.role_def_for(&authz.role) else {
11992 return Ok(None);
11993 };
11994 match def.namespaces.as_deref() {
11995 Some([only]) => Ok(Some(only.clone())),
11996 Some(_) => Err(GraphError::RoleWriteDenied {
11997 reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
11998 }),
11999 None => Ok(None),
12000 }
12001 }
12002
12003 fn exec_merge(
12004 &mut self,
12005 stmt: core_query::cypher::MergeStmt,
12006 params: &BTreeMap<String, Value>,
12007 ) -> Result<ResultSet> {
12008 // MERGE: check if a node with the given key already exists.
12009 let key = match &stmt.key_value {
12010 Value::Str(s) => s.clone(),
12011 _ => {
12012 return Err(GraphError::QueryError {
12013 detail: format!(
12014 "MERGE key value must be a string (got {:?})",
12015 stmt.key_value
12016 ),
12017 })
12018 }
12019 };
12020
12021 if let Some(var) = stmt.var.as_deref() {
12022 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
12023 if sc.var != var {
12024 return Err(GraphError::QueryError {
12025 detail: format!(
12026 "SET variable '{}' does not match MERGE variable '{var}'",
12027 sc.var
12028 ),
12029 });
12030 }
12031 }
12032 }
12033
12034 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
12035 //
12036 // MERGE scope precondition: check create OR update scope for the
12037 // declared label BEFORE calling `has_node` (timing-oracle closure,
12038 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
12039 // unscoped roles — the scope denial fires without touching the key store).
12040 //
12041 // Clone to avoid holding a borrow on `self.pending_write_authz` while
12042 // also calling `self.ids.get(key)`.
12043 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
12044 let has_create = authz.scope.create_labels.contains(&stmt.label);
12045 let has_update = authz.scope.update_labels.contains(&stmt.label);
12046 if !has_create && !has_update {
12047 // Scope-before-lookup: 403 without has_node call (timing oracle
12048 // closure — see test_merge_unscoped_no_key_lookup).
12049 return Err(GraphError::RoleWriteDenied {
12050 reason: format!(
12051 "role-bound token: label '{}' not in write scope (create_labels)",
12052 stmt.label
12053 ),
12054 });
12055 }
12056 // Key lookup under mask.
12057 match self.ids.get(key.as_str()) {
12058 Some(id) if authz.mask.contains_id(id) => {
12059 // Visible: must have update scope to proceed to match arm.
12060 if !has_update {
12061 return Err(GraphError::RoleWriteDenied {
12062 reason: format!(
12063 "role-bound token: label '{}' not in write scope (update_labels)",
12064 stmt.label
12065 ),
12066 });
12067 }
12068 true // existed = true → match arm
12069 }
12070 Some(_) => {
12071 // Hidden: same error as absent to the role (spec §3.1/§3.3).
12072 return Err(GraphError::RoleWriteDenied {
12073 reason: "role-bound token: target node not visible".into(),
12074 });
12075 }
12076 None => {
12077 // Absent: must have create scope to proceed to the create arm.
12078 //
12079 // Update-only roles (create_labels empty, update_labels set):
12080 // return the SAME "not visible" error as the hidden-key branch
12081 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
12082 // "confirm existence of hidden nodes: No").
12083 //
12084 // Create-scoped roles (has_create=true): absent → create arm
12085 // as before. The accepted structural key-existence disclosure
12086 // (§THREAT-MODEL) applies only when the role holds create scope.
12087 if !has_create {
12088 return Err(GraphError::RoleWriteDenied {
12089 reason: "role-bound token: target node not visible".into(),
12090 });
12091 }
12092 false // existed = false → create arm
12093 }
12094 }
12095 } else {
12096 // Full authority: use the existing non-masked has_node check.
12097 self.has_node(&key)
12098 };
12099
12100 let existed = merge_existed;
12101 let create_props = if existed {
12102 None
12103 } else {
12104 Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
12105 };
12106 let mut created = 0i64;
12107 if create_props.is_some() || !stmt.on_match.is_empty() {
12108 let mut batch = self.batch();
12109 if let Some(props) = create_props {
12110 batch.insert_node(&stmt.label, &key, props);
12111 for sc in &stmt.on_create {
12112 let value = resolve_merge_set_value(&sc.value, params)?;
12113 batch.set_prop(&key, &sc.field, value);
12114 }
12115 created = 1;
12116 } else {
12117 for sc in &stmt.on_match {
12118 let value = resolve_merge_set_value(&sc.value, params)?;
12119 batch.set_prop(&key, &sc.field, value);
12120 }
12121 }
12122 batch.commit()?;
12123 }
12124
12125 // Refresh the role mask so the just-created node is visible to this
12126 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
12127 // (apply_schema subset rule), so the new node's label is already in the
12128 // role's read scope — this never widens beyond the role's declared labels.
12129 if !existed {
12130 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
12131 let new_mask = self.mask_for_role(&role)?;
12132 if let Some(a) = self.pending_write_authz.as_mut() {
12133 a.mask = new_mask;
12134 }
12135 }
12136 }
12137
12138 // Optional RETURN clause: project the node (created or matched) as a read result.
12139 if let Some(returns) = stmt.returns {
12140 let var = stmt.var.as_deref().unwrap_or("_mn0");
12141 let q = Query {
12142 matches: vec![Pattern {
12143 start: NodePat {
12144 var: Some(var.to_string()),
12145 label: Some(stmt.label.clone()),
12146 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
12147 },
12148 chain: vec![],
12149 shortest: false,
12150 }],
12151 optional_clauses: vec![],
12152 where_expr: None,
12153 unwinds: vec![],
12154 post_unwind_where: None,
12155 stages: vec![],
12156 returns,
12157 distinct: false,
12158 order_by: vec![],
12159 skip: None,
12160 limit: None,
12161 };
12162 let ops = plan(&q).map_err(|e| GraphError::QueryError {
12163 detail: format!("plan: {e}"),
12164 })?;
12165 // Use view_masked when a role-scoped write is in flight so the
12166 // post-merge projection is consistent with the masked read phase.
12167 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
12168 return (if let Some(ref mask) = mask_opt {
12169 execute(&self.view_masked(mask), &ops, &Params(params))
12170 } else {
12171 execute(&self.view(), &ops, &Params(params))
12172 })
12173 .map_err(|e| GraphError::QueryError {
12174 detail: format!("execute: {e}"),
12175 });
12176 }
12177
12178 let mut rs = write_result_set();
12179 rs.push_row(vec![
12180 Some(Value::Int(created)),
12181 Some(Value::Int(0)),
12182 Some(Value::Int(0)),
12183 ]);
12184 Ok(rs)
12185 }
12186
12187 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
12188 /// annotated with rule name, edge type, direction, and weight.
12189 /// Results are sorted by (rule, edge_type).
12190 /// Returns `Err(KeyNotFound)` if either key is unknown.
12191 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
12192 self.ensure_v8_base_sections_loaded();
12193 let id_a = self
12194 .ids
12195 .get(key_a)
12196 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
12197 let id_b = self
12198 .ids
12199 .get(key_b)
12200 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
12201
12202 let mut results = Vec::new();
12203
12204 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
12205 // rather than O(total provenance).
12206 let scan = if self.engine.provenance_touching_len(id_a)
12207 <= self.engine.provenance_touching_len(id_b)
12208 {
12209 id_a
12210 } else {
12211 id_b
12212 };
12213 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
12214 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
12215 continue;
12216 }
12217 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
12218 continue;
12219 };
12220 let edge_type = match self.syms.resolve(etype) {
12221 Some(s) => s.to_string(),
12222 None => continue,
12223 };
12224 // Provenance (src, dst) ids come from the archived PROVENANCE section
12225 // (large, no eager CRC). A corrupt section can produce ids that are
12226 // out of range; return Corrupt rather than panic.
12227 let src_key = self
12228 .ids
12229 .key_of(src)
12230 .ok_or_else(|| GraphError::Corrupt {
12231 detail: format!("v8: provenance src id {src} not in id table"),
12232 })?
12233 .to_string();
12234 let dst_key = self
12235 .ids
12236 .key_of(dst)
12237 .ok_or_else(|| GraphError::Corrupt {
12238 detail: format!("v8: provenance dst id {dst} not in id table"),
12239 })?
12240 .to_string();
12241 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
12242 self.edge_props_view()
12243 .get(etype, src, dst, prop)
12244 .and_then(|v| {
12245 if let Value::Float(f) = v {
12246 Some(f)
12247 } else {
12248 None
12249 }
12250 })
12251 });
12252 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
12253 // still have a score: recompute it from the predicate so explain
12254 // never reports "no score" for an edge the engine scored. Via-hop
12255 // rules score over their via set, not over (src, dst), so leave
12256 // those None rather than report a number the rule did not produce.
12257 let weight = stored.or_else(|| {
12258 if rule_def.via_edge.is_some() {
12259 return None;
12260 }
12261 let props_view = build_props_view(&self.props, &self.base);
12262 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
12263 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12264 let src_view = NodeView {
12265 key: &src_key,
12266 props: &src_get,
12267 };
12268 let dst_view = NodeView {
12269 key: &dst_key,
12270 props: &dst_get,
12271 };
12272 evaluate(&rule_def.predicate, &src_view, &dst_view)
12273 });
12274 results.push(Explanation {
12275 rule: rule_name.to_string(),
12276 edge_type,
12277 src_key,
12278 dst_key,
12279 weight,
12280 predicate: PredicateSummary {
12281 approximate: rule_def.approximate,
12282 ..PredicateSummary::from(&rule_def.predicate)
12283 },
12284 via_edge: rule_def.via_edge.clone(),
12285 });
12286 }
12287
12288 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
12289 Ok(results)
12290 }
12291
12292 /// [`explain`](Self::explain) with the scoped read contract (§5.3): both
12293 /// endpoints are subject-checked, and any explanation whose evidence runs
12294 /// through a hidden node is **dropped entirely, not redacted**.
12295 ///
12296 /// A plain two-node rule's evidence is the pair itself, so once both
12297 /// subjects are visible there is nothing left to hide. A **via-hop** rule is
12298 /// different: it fires `src → dst` because some node carrying `via_label`
12299 /// sits between them, and [`Explanation`] carries the hop's edge *type*
12300 /// (`via_edge`) and never the hop's key. There is no field to blank, so a
12301 /// redacted explanation would still say "these two are linked through
12302 /// something you cannot see" — which discloses that the something exists.
12303 /// The explanation is therefore kept only when at least one **visible** via
12304 /// node satisfies the rule on its own.
12305 ///
12306 /// The weight is the **visible corpus's** number, not the store's: a via-hop
12307 /// rule stores the max over every via it hopped through, so the stored value
12308 /// can be a score only a hidden via produced. It is recomputed over the
12309 /// visible vias alone.
12310 ///
12311 /// Hidden or unknown `key_a` or `key_b` → [`GraphError::KeyNotFound`].
12312 pub fn explain_scoped(
12313 &self,
12314 key_a: &str,
12315 key_b: &str,
12316 mask: &crate::mask::NodeMask,
12317 ) -> Result<Vec<Explanation>> {
12318 for key in [key_a, key_b] {
12319 if !mask.contains_node(self, key) {
12320 return Err(GraphError::KeyNotFound { key: key.into() });
12321 }
12322 }
12323 Ok(self
12324 .explain(key_a, key_b)?
12325 .into_iter()
12326 .filter_map(|e| self.scoped_explanation(e, mask))
12327 .collect())
12328 }
12329
12330 /// `e` as a caller limited to `mask` may have it, or `None` when it must be
12331 /// dropped entirely.
12332 ///
12333 /// Every non-via-hop explanation passes through untouched: its only nodes
12334 /// are the two subjects, which [`explain_scoped`](Self::explain_scoped) has
12335 /// already checked, and its weight is scored over that pair alone.
12336 ///
12337 /// A via-hop explanation is kept only when some via node the caller may see
12338 /// satisfies the rule on its own — and then its weight is recomputed as the
12339 /// max over exactly those vias. The engine writes the max over **all** of
12340 /// them (`core-rules::engine`, `best = prev.max(score)`), so passing the
12341 /// stored number through would let a hidden node set a figure the caller
12342 /// reads: the same disclosure dropping the explanation exists to prevent.
12343 ///
12344 /// A rule that stores no weight still reports none. The recomputed score is
12345 /// a sanitised version of a number `explain` already returned, never a new
12346 /// one — a scoped read must not say more than the unscoped read it narrows.
12347 fn scoped_explanation(
12348 &self,
12349 e: Explanation,
12350 mask: &crate::mask::NodeMask,
12351 ) -> Option<Explanation> {
12352 let Some(via_edge) = e.via_edge.clone() else {
12353 return Some(e);
12354 };
12355 let Some(rule_def) = self.engine.rules().find(|r| r.name == e.rule) else {
12356 // The rule is gone but its provenance is not; nothing can vouch for
12357 // the hop, so nothing is shown.
12358 return None;
12359 };
12360 let Some(via_label) = rule_def.via_label.as_deref() else {
12361 return Some(e);
12362 };
12363 let (Some(src), Some(dst)) = (self.ids.get(&e.src_key), self.ids.get(&e.dst_key)) else {
12364 return None;
12365 };
12366 let (Some(via_etype), Some(via_sym)) = (self.syms.get(&via_edge), self.syms.get(via_label))
12367 else {
12368 return None;
12369 };
12370 let via_dir = rule_def.via_dir.unwrap_or(Direction::Out);
12371 let props_view = build_props_view(&self.props, &self.base);
12372 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12373 let dst_view = NodeView {
12374 key: &e.dst_key,
12375 props: &dst_get,
12376 };
12377 // The rule's own namespace test, the one the engine applies to each via
12378 // candidate (`core-rules::engine::rule_sees_node`). Without it a
12379 // visible, out-of-namespace via — right label, satisfying predicate —
12380 // vouches for a hop the engine never made, and an explanation whose real
12381 // evidence is a hidden in-namespace node is kept.
12382 let rule_sees = |id: u32| match rule_def.namespace.as_deref() {
12383 None => true,
12384 Some(ns) => {
12385 let value = props_view.get(id, NS_PROP).map(|vr| vr.into_value());
12386 namespace_of_value(value.as_ref()) == ns
12387 }
12388 };
12389 let best = self
12390 .topo_view()
12391 .neighbors(via_etype, via_dir, src)
12392 .iter()
12393 .copied()
12394 .filter_map(|via| {
12395 if !mask.contains_id(via) {
12396 return None;
12397 }
12398 if self.labels.get(via as usize).copied() != Some(via_sym) {
12399 return None;
12400 }
12401 if !rule_sees(via) {
12402 return None;
12403 }
12404 let via_key = self.ids.key_of(via)?;
12405 let via_get = |field: &str| props_view.get(via, field).map(|vr| vr.into_value());
12406 let via_view = NodeView {
12407 key: via_key,
12408 props: &via_get,
12409 };
12410 evaluate(&rule_def.predicate, &via_view, &dst_view)
12411 })
12412 .fold(None::<f64>, |best, score| {
12413 Some(match best {
12414 None => score,
12415 Some(prev) => prev.max(score),
12416 })
12417 })?;
12418 let weight = e.weight.map(|_| best);
12419 Some(Explanation { weight, ..e })
12420 }
12421
12422 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
12423 let id = self
12424 .ids
12425 .get(key)
12426 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12427 let Some(sym) = self.syms.get(edge_type) else {
12428 return Ok(Vec::new());
12429 };
12430 self.topo_view()
12431 .neighbors(sym, dir, id)
12432 .iter()
12433 .map(|&n| {
12434 self.ids
12435 .key_of(n)
12436 .map(|k| k.to_string())
12437 .ok_or_else(|| GraphError::Corrupt {
12438 detail: format!("topology id {n} has no key"),
12439 })
12440 })
12441 .collect::<Result<Vec<_>>>()
12442 }
12443
12444 /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
12445 /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
12446 pub fn degree(
12447 &self,
12448 key: &str,
12449 edge_type: Option<&str>,
12450 direction: crate::algo::AlgoDir,
12451 ) -> Result<u64> {
12452 let id = self
12453 .ids
12454 .get(key)
12455 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12456 let topo = self.topo_view();
12457 Ok(Self::unique_directed_degree(
12458 &topo, &self.syms, id, edge_type, direction,
12459 ))
12460 }
12461
12462 /// [`degree`](Self::degree) summing each pair's **insert count** instead of
12463 /// counting each pair once (§5.13).
12464 ///
12465 /// The unique degree asks how many neighbours there are; this asks how many
12466 /// times they were inserted. A pair with no recorded count contributes 1,
12467 /// so on a store that never called
12468 /// [`enable_multiplicity`](Self::enable_multiplicity) this returns exactly
12469 /// what [`degree`](Self::degree) returns rather than erroring — the
12470 /// distinction is a readout preference, not a demand the store cannot meet.
12471 ///
12472 /// `AlgoDir::Both` still sums out + in, so a pair visible on both sides
12473 /// still contributes twice: multiplicity changes what a pair is worth, never
12474 /// how a direction is counted.
12475 pub fn degree_multiplicity(
12476 &self,
12477 key: &str,
12478 edge_type: Option<&str>,
12479 direction: crate::algo::AlgoDir,
12480 ) -> Result<u64> {
12481 let id = self
12482 .ids
12483 .get(key)
12484 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12485 Ok(Self::multiplicity_directed_degree(
12486 &self.topo_view(),
12487 &self.edge_props_view(),
12488 &self.syms,
12489 id,
12490 edge_type,
12491 direction,
12492 None,
12493 ))
12494 }
12495
12496 /// [`degree_multiplicity`](Self::degree_multiplicity) under a scope.
12497 ///
12498 /// The sum covers **visible pairs only**. A hidden neighbour's inserts stay
12499 /// out of it for the reason
12500 /// [`degree_scoped`](Self::degree_scoped) documents, and more sharply: an
12501 /// unscoped multiplicity count discloses not only that a hidden neighbour
12502 /// exists but how often it was written.
12503 ///
12504 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
12505 pub fn degree_scoped_multiplicity(
12506 &self,
12507 key: &str,
12508 edge_type: Option<&str>,
12509 direction: crate::algo::AlgoDir,
12510 mask: &crate::mask::NodeMask,
12511 ) -> Result<u64> {
12512 let id = self
12513 .ids
12514 .get(key)
12515 .filter(|&id| mask.contains_id(id))
12516 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12517 Ok(Self::multiplicity_directed_degree(
12518 &self.topo_view(),
12519 &self.edge_props_view(),
12520 &self.syms,
12521 id,
12522 edge_type,
12523 direction,
12524 Some(mask),
12525 ))
12526 }
12527
12528 /// [`degree`](Self::degree) counting **only neighbours the mask admits**.
12529 ///
12530 /// The filter is a correctness requirement, not an optimisation: an
12531 /// unfiltered count discloses the existence of a hidden neighbour to a
12532 /// caller who cannot see it, which is the same leak
12533 /// [`node_edges_scoped`](Self::node_edges_scoped) exists to prevent —
12534 /// reached by arithmetic instead of by name.
12535 ///
12536 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`]. Unknown
12537 /// `edge_type` is still 0, as it is unscoped.
12538 pub fn degree_scoped(
12539 &self,
12540 key: &str,
12541 edge_type: Option<&str>,
12542 direction: crate::algo::AlgoDir,
12543 mask: &crate::mask::NodeMask,
12544 ) -> Result<u64> {
12545 let id = self
12546 .ids
12547 .get(key)
12548 .filter(|&id| mask.contains_id(id))
12549 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12550 let topo = self.topo_view();
12551 Ok(Self::visible_directed_degree(
12552 &topo, &self.syms, id, edge_type, direction, mask,
12553 ))
12554 }
12555
12556 /// Unique directed degree for a subset or a label scan.
12557 ///
12558 /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
12559 /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
12560 /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
12561 #[allow(clippy::too_many_arguments)]
12562 pub fn degrees(
12563 &self,
12564 keys: Option<&[String]>,
12565 label: Option<&str>,
12566 where_: Option<&PropPredicate>,
12567 edge_type: Option<&str>,
12568 direction: crate::algo::AlgoDir,
12569 limit: Option<usize>,
12570 ) -> Result<Vec<(String, u64)>> {
12571 self.degrees_inner(
12572 keys, label, where_, edge_type, direction, limit, None, false,
12573 )
12574 }
12575
12576 /// [`degrees`](Self::degrees) reporting each row's **insert-count** sum
12577 /// instead of its unique neighbour count, as
12578 /// [`degree_multiplicity`](Self::degree_multiplicity) does for one key.
12579 ///
12580 /// The sort is still degree descending, key ascending — over the counts this
12581 /// reading produces — and `limit` still applies after it.
12582 #[allow(clippy::too_many_arguments)]
12583 pub fn degrees_multiplicity(
12584 &self,
12585 keys: Option<&[String]>,
12586 label: Option<&str>,
12587 where_: Option<&PropPredicate>,
12588 edge_type: Option<&str>,
12589 direction: crate::algo::AlgoDir,
12590 limit: Option<usize>,
12591 ) -> Result<Vec<(String, u64)>> {
12592 self.degrees_inner(keys, label, where_, edge_type, direction, limit, None, true)
12593 }
12594
12595 /// [`degrees_scoped`](Self::degrees_scoped) reporting insert counts.
12596 ///
12597 /// Both filters apply: a hidden key stays out of the result, and every
12598 /// row's sum covers its **visible** pairs only.
12599 #[allow(clippy::too_many_arguments)]
12600 pub fn degrees_scoped_multiplicity(
12601 &self,
12602 keys: Option<&[String]>,
12603 label: Option<&str>,
12604 where_: Option<&PropPredicate>,
12605 edge_type: Option<&str>,
12606 direction: crate::algo::AlgoDir,
12607 limit: Option<usize>,
12608 mask: &crate::mask::NodeMask,
12609 ) -> Result<Vec<(String, u64)>> {
12610 self.degrees_inner(
12611 keys,
12612 label,
12613 where_,
12614 edge_type,
12615 direction,
12616 limit,
12617 Some(mask),
12618 true,
12619 )
12620 }
12621
12622 /// [`degrees`](Self::degrees) with the scope applied on both sides: a hidden
12623 /// key is omitted from the input — whether it arrived in `keys` or came out
12624 /// of the `label`/`where_` scan — and every row's count is the count of its
12625 /// **visible** neighbours, for the reason
12626 /// [`degree_scoped`](Self::degree_scoped) documents.
12627 ///
12628 /// Unlike `degree_scoped`, a hidden key here is not
12629 /// [`GraphError::KeyNotFound`]: `degrees` already drops unknown keys
12630 /// silently, so hidden and absent stay one answer by staying out of the
12631 /// result. `limit` still applies after the sort, and so counts visible rows.
12632 #[allow(clippy::too_many_arguments)]
12633 pub fn degrees_scoped(
12634 &self,
12635 keys: Option<&[String]>,
12636 label: Option<&str>,
12637 where_: Option<&PropPredicate>,
12638 edge_type: Option<&str>,
12639 direction: crate::algo::AlgoDir,
12640 limit: Option<usize>,
12641 mask: &crate::mask::NodeMask,
12642 ) -> Result<Vec<(String, u64)>> {
12643 self.degrees_inner(
12644 keys,
12645 label,
12646 where_,
12647 edge_type,
12648 direction,
12649 limit,
12650 Some(mask),
12651 false,
12652 )
12653 }
12654
12655 /// The body shared by [`degrees`](Self::degrees) and
12656 /// [`degrees_scoped`](Self::degrees_scoped). `mask = None` is the unscoped
12657 /// contract unchanged.
12658 #[allow(clippy::too_many_arguments)]
12659 fn degrees_inner(
12660 &self,
12661 keys: Option<&[String]>,
12662 label: Option<&str>,
12663 where_: Option<&PropPredicate>,
12664 edge_type: Option<&str>,
12665 direction: crate::algo::AlgoDir,
12666 limit: Option<usize>,
12667 mask: Option<&crate::mask::NodeMask>,
12668 multiplicity: bool,
12669 ) -> Result<Vec<(String, u64)>> {
12670 if let Some(pred) = where_ {
12671 pred.validate_named("where")
12672 .map_err(|detail| GraphError::QueryError { detail })?;
12673 }
12674 if matches!(keys, Some(ks) if ks.is_empty()) {
12675 return Ok(Vec::new());
12676 }
12677 let view = self.view();
12678 let ids: Vec<u32> = match keys {
12679 Some(ks) => {
12680 let mut seen = HashSet::new();
12681 let mut out = Vec::new();
12682 for k in ks {
12683 let Some(id) = view.ids.get(k) else {
12684 continue;
12685 };
12686 if !seen.insert(id) {
12687 continue;
12688 }
12689 if let Some(pred) = where_ {
12690 let holds = match view.prop(id, &pred.field) {
12691 None => pred.holds(None),
12692 Some(vr) => pred.holds(Some(vr.as_value())),
12693 };
12694 if !holds {
12695 continue;
12696 }
12697 }
12698 out.push(id);
12699 }
12700 out
12701 }
12702 None => Self::vector_candidates(&view, label, where_),
12703 };
12704 let mut out: Vec<(String, u64)> = ids
12705 .into_iter()
12706 // A hidden candidate leaves as quietly as an unknown key does.
12707 .filter(|&id| mask.is_none_or(|m| m.contains_id(id)))
12708 .filter_map(|id| {
12709 let key = self.ids.key_of(id)?.to_string();
12710 let deg = match (multiplicity, mask) {
12711 // The same `view` the unique arms read, so the per-row
12712 // rebuild F9 measured is gone and all three arms agree on
12713 // the state they are reading.
12714 (true, m) => Self::multiplicity_directed_degree(
12715 &view.topo,
12716 &view.edge_props,
12717 view.syms,
12718 id,
12719 edge_type,
12720 direction,
12721 m,
12722 ),
12723 (false, Some(m)) => Self::visible_directed_degree(
12724 &view.topo, view.syms, id, edge_type, direction, m,
12725 ),
12726 (false, None) => Self::unique_directed_degree(
12727 &view.topo, view.syms, id, edge_type, direction,
12728 ),
12729 };
12730 Some((key, deg))
12731 })
12732 .collect();
12733 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
12734 if let Some(lim) = limit {
12735 out.truncate(lim);
12736 }
12737 Ok(out)
12738 }
12739
12740 /// Unique neighbour count for `id` across `edge_type` (or all types) and
12741 /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
12742 fn unique_directed_degree(
12743 topo: &TopologyView<'_>,
12744 syms: &Interner,
12745 id: u32,
12746 edge_type: Option<&str>,
12747 direction: crate::algo::AlgoDir,
12748 ) -> u64 {
12749 let dirs: &[Direction] = match direction {
12750 crate::algo::AlgoDir::Out => &[Direction::Out],
12751 crate::algo::AlgoDir::In => &[Direction::In],
12752 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12753 };
12754 match edge_type {
12755 Some(name) => {
12756 let Some(et) = syms.get(name) else {
12757 return 0;
12758 };
12759 dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
12760 }
12761 None => topo
12762 .etypes()
12763 .map(|et| {
12764 dirs.iter()
12765 .map(|&d| topo.degree(et, d, id) as u64)
12766 .sum::<u64>()
12767 })
12768 .sum(),
12769 }
12770 }
12771
12772 /// [`unique_directed_degree`](Self::unique_directed_degree) counting only
12773 /// neighbours `mask` admits.
12774 ///
12775 /// Same shape, one substitution: `topo.degree` is a length, so it cannot be
12776 /// filtered; the neighbour list it measures can. `Both` still sums out + in,
12777 /// so a node visible on both sides still counts twice — the filter changes
12778 /// which neighbours are counted, never how a degree is defined.
12779 fn visible_directed_degree(
12780 topo: &TopologyView<'_>,
12781 syms: &Interner,
12782 id: u32,
12783 edge_type: Option<&str>,
12784 direction: crate::algo::AlgoDir,
12785 mask: &crate::mask::NodeMask,
12786 ) -> u64 {
12787 let dirs: &[Direction] = match direction {
12788 crate::algo::AlgoDir::Out => &[Direction::Out],
12789 crate::algo::AlgoDir::In => &[Direction::In],
12790 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12791 };
12792 let visible = |et: u32| -> u64 {
12793 dirs.iter()
12794 .map(|&d| {
12795 topo.neighbors(et, d, id)
12796 .iter()
12797 .filter(|&&n| mask.contains_id(n))
12798 .count() as u64
12799 })
12800 .sum()
12801 };
12802 match edge_type {
12803 Some(name) => syms.get(name).map_or(0, visible),
12804 None => topo.etypes().map(visible).sum(),
12805 }
12806 }
12807
12808 /// Sum of the insert counts of `id`'s pairs (§5.13), over `edge_type` (or
12809 /// all types) and `direction`, restricted to what `mask` admits when one is
12810 /// given.
12811 ///
12812 /// The same neighbour lists the unique reading measures, with each entry
12813 /// worth its pair's count rather than worth 1 — so the filter decides which
12814 /// pairs are in the sum and the count decides what each contributes. A
12815 /// direction decides which way round the pair is addressed: an `In`
12816 /// neighbour `n` of `id` is the pair `(et, n, id)`.
12817 ///
12818 /// Takes its views as parameters, exactly as the unique helpers do, because
12819 /// it is called once per row from a label scan. `edge_props_view()` reaches
12820 /// into the mmap'd base's rkyv section on every call, so building the two
12821 /// views inside made an N-row `degrees(multiplicity=True)` do N section
12822 /// accesses where the unique reading does one: worth 2.57 ms of 16.68 ms
12823 /// over 20 000 rows, about 0.13 us per row (defect #29,
12824 /// `tests/f9_bench.rs`). Most of that call's cost is the per-neighbour
12825 /// count lookup and is inherent, so this is a hoist, not a rescue.
12826 ///
12827 /// The views are exactly `self.view()`'s own `topo` and `edge_props`, so a
12828 /// caller that already has a view passes its halves and reads the same
12829 /// state it reads everything else from.
12830 fn multiplicity_directed_degree(
12831 topo: &TopologyView<'_>,
12832 edge_props: &EdgePropsView<'_>,
12833 syms: &Interner,
12834 id: u32,
12835 edge_type: Option<&str>,
12836 direction: crate::algo::AlgoDir,
12837 mask: Option<&crate::mask::NodeMask>,
12838 ) -> u64 {
12839 let dirs: &[Direction] = match direction {
12840 crate::algo::AlgoDir::Out => &[Direction::Out],
12841 crate::algo::AlgoDir::In => &[Direction::In],
12842 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12843 };
12844 let count_of = |et: u32, src: u32, dst: u32| -> u64 {
12845 match edge_props.get(et, src, dst, EDGE_COUNT_PROP) {
12846 Some(Value::Int(n)) if n > 0 => n as u64,
12847 _ => 1,
12848 }
12849 };
12850 let per_etype = |et: u32| -> u64 {
12851 dirs.iter()
12852 .map(|&d| {
12853 topo.neighbors(et, d, id)
12854 .iter()
12855 .filter(|&&n| mask.is_none_or(|m| m.contains_id(n)))
12856 .map(|&n| match d {
12857 Direction::Out => count_of(et, id, n),
12858 Direction::In => count_of(et, n, id),
12859 })
12860 .sum::<u64>()
12861 })
12862 .sum()
12863 };
12864 match edge_type {
12865 Some(name) => syms.get(name).map_or(0, per_etype),
12866 None => topo.etypes().map(per_etype).sum(),
12867 }
12868 }
12869
12870 /// Return the last-change commit sequence for `key`, or `None` if the node
12871 /// does not exist or has never been mutated since the last V5-V7 snapshot
12872 /// (horizon-bounded for legacy stores).
12873 ///
12874 /// The returned sequence is a monotonically increasing counter that starts
12875 /// at 1 for the first commit after `open` and increments with every
12876 /// successful write. WAL replay at open also assigns sequences (1..N for N
12877 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
12878 ///
12879 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
12880 /// in the snapshot but not touched by any WAL frame will return `None`
12881 /// (horizon-bounded: CAS against such nodes is only safe after the first
12882 /// V8 snapshot or after the node is next mutated).
12883 pub fn last_changed(&self, key: &str) -> Option<u64> {
12884 let id = self.ids.get(key)?;
12885 self.last_change.get(&id).copied()
12886 }
12887
12888 /// Which loaded store this handle is.
12889 ///
12890 /// Paired with [`commit_seq`](GraphDb::commit_seq) it identifies a graph
12891 /// state outright, which `commit_seq` alone does not: two stores of the same
12892 /// age share a sequence, and a reload can return to one. Memos of dense
12893 /// node ids that the handle does not own are stamped with both; see
12894 /// [`StoreStamp`](crate::mask::StoreStamp).
12895 pub(crate) fn store_id(&self) -> crate::mask::StoreId {
12896 self.store_id
12897 }
12898
12899 /// The current commit sequence (number of successful commits since open,
12900 /// including WAL replay frames). Useful for recording a baseline before
12901 /// a read-modify-write cycle.
12902 pub fn commit_seq(&self) -> u64 {
12903 self.commit_seq
12904 }
12905
12906 /// Check that all `preconds` are satisfied against the current db state.
12907 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
12908 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
12909 for precond in preconds {
12910 match precond {
12911 Precondition::NodeUnchangedSince { key, expected } => {
12912 // Missing entry means the node predates the WAL window or
12913 // does not exist; treat as 0 (before any commit).
12914 let actual = self.last_changed(key).unwrap_or_default();
12915 if actual != *expected {
12916 return Err(GraphError::CasConflict {
12917 key: key.clone(),
12918 expected: *expected,
12919 actual,
12920 });
12921 }
12922 }
12923 Precondition::NodeAbsent { key } => {
12924 // Node must not exist (not live).
12925 if self.ids.get(key).is_some() {
12926 let actual = self.last_changed(key).unwrap_or(0);
12927 return Err(GraphError::CasConflict {
12928 key: key.clone(),
12929 expected: u64::MAX,
12930 actual,
12931 });
12932 }
12933 }
12934 }
12935 }
12936 Ok(())
12937 }
12938
12939 /// Apply a batch of mutations with compare-and-set preconditions.
12940 ///
12941 /// All preconditions are checked atomically before any operation is applied.
12942 /// If any precondition fails, the entire batch is rejected with
12943 /// [`GraphError::CasConflict`] and no WAL frame is written.
12944 ///
12945 /// # Returns
12946 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
12947 ///
12948 /// # Errors
12949 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
12950 /// - Any error that [`write_batch`] would return for the ops themselves.
12951 pub fn write_batch_cas(
12952 &mut self,
12953 preconds: Vec<Precondition>,
12954 ops: Vec<BatchOp>,
12955 ) -> Result<(usize, usize)> {
12956 self.check_preconditions(&preconds)?;
12957 self.commit_logged_batch(ops, None, None).map(inserted_pair)
12958 }
12959
12960 /// Update the per-node last-change map for a WAL record at commit `seq`.
12961 ///
12962 /// Called after a successful apply to record which nodes were touched.
12963 /// For replay, called with the WAL-frame's replayed seq.
12964 ///
12965 /// Touch definition (see [`Precondition`] doc):
12966 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
12967 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
12968 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
12969 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
12970 /// - Batch → recurse into inner records.
12971 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
12972 match rec {
12973 WalRecord::InsertNode { key, .. }
12974 | WalRecord::SetProp { key, .. }
12975 | WalRecord::RemoveProp { key, .. } => {
12976 if let Some(id) = self.ids.get(key) {
12977 self.last_change.insert(id, seq);
12978 }
12979 }
12980 WalRecord::InsertNodeId { key, .. } => {
12981 if let Some(id) = self.ids.get(key) {
12982 self.last_change.insert(id, seq);
12983 }
12984 }
12985 WalRecord::SetPropId { id, .. } => {
12986 self.last_change.insert(*id, seq);
12987 }
12988 WalRecord::InsertEdge {
12989 src_key, dst_key, ..
12990 }
12991 | WalRecord::DeleteEdge {
12992 src_key, dst_key, ..
12993 } => {
12994 if let Some(src_id) = self.ids.get(src_key) {
12995 self.last_change.insert(src_id, seq);
12996 }
12997 if let Some(dst_id) = self.ids.get(dst_key) {
12998 self.last_change.insert(dst_id, seq);
12999 }
13000 }
13001 WalRecord::InsertEdgeId { src, dst, .. } => {
13002 self.last_change.insert(*src, seq);
13003 self.last_change.insert(*dst, seq);
13004 }
13005 // A count record touches the pair, so it touches both endpoints —
13006 // the same reading `InsertEdgeId` gets, because a duplicate insert
13007 // that raises the count *is* a mutation of that pair. The opt-in
13008 // declaration touches nothing.
13009 WalRecord::SetEdgeCount { src, dst, .. } if !rec.is_multiplicity_decl() => {
13010 self.last_change.insert(*src, seq);
13011 self.last_change.insert(*dst, seq);
13012 }
13013 WalRecord::SetEdgeCount { .. } => {}
13014 // DeleteNode: node is tombstoned; last_changed(key) returns None for
13015 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
13016 // History markers: state no-ops; the underlying mutation already
13017 // touched the relevant nodes' last_change entries.
13018 WalRecord::DeleteNode { .. }
13019 | WalRecord::DerivedEdgeAdded { .. }
13020 | WalRecord::DerivedEdgeRetracted { .. }
13021 | WalRecord::Intern { .. }
13022 | WalRecord::CreateRule { .. }
13023 | WalRecord::DeleteRule { .. }
13024 | WalRecord::RebuildRule { .. }
13025 | WalRecord::CreateView { .. }
13026 | WalRecord::DeleteView { .. }
13027 | WalRecord::EnableFulltext { .. }
13028 | WalRecord::DisableFulltext { .. }
13029 | WalRecord::EnableIndex { .. }
13030 | WalRecord::DisableIndex { .. } => {}
13031 // RenameNode: node id is stable; update last_change via the new key.
13032 // Called after apply(), so ids already reflects new_key.
13033 WalRecord::RenameNode { new_key, .. } => {
13034 if let Some(id) = self.ids.get(new_key) {
13035 self.last_change.insert(id, seq);
13036 }
13037 }
13038 WalRecord::Batch(inner) => {
13039 for inner_rec in inner {
13040 self.update_last_change_from_rec(inner_rec, seq);
13041 }
13042 }
13043 }
13044 }
13045
13046 pub fn node_count(&self) -> usize {
13047 self.ids.len()
13048 }
13049
13050 /// Configure archive retention: keep the `N` newest WAL archives at each
13051 /// [`snapshot_with`] call when `archive_wal: true`.
13052 ///
13053 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
13054 /// `Some(0)` or `None` → unlimited (no pruning).
13055 ///
13056 /// Pruning only ever happens inside [`snapshot_with`]; this method only
13057 /// stores the policy. Archives below the retention limit are deleted
13058 /// oldest-first. The horizon floor is updated so that
13059 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
13060 /// in pruned archives rather than silently returning wrong data.
13061 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
13062 self.wal_archive_retention = keep;
13063 }
13064
13065 /// Delete any WAL archives that are fully below the current horizon floor.
13066 ///
13067 /// Orphaned archives arise when the floor is written first during retention
13068 /// pruning and then a crash interrupts the archive-delete sequence. The
13069 /// opening cleanup ensures no subsequent read path sees stale data.
13070 ///
13071 /// Under the monotonic naming scheme, the archive name N equals the
13072 /// cumulative end-frame index of the archive in global commit space (i.e.
13073 /// the archive covers global frames `[prev_n, N)`). An archive is
13074 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
13075 /// below the floor and have already been counted in it.
13076 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
13077 if self.wal_horizon_floor == 0 {
13078 // Floor at 0 means no pruning has ever occurred; nothing to clean.
13079 return Ok(());
13080 }
13081 let archive_ns = self.fs.list_archives()?;
13082 for n in archive_ns {
13083 if n <= self.wal_horizon_floor {
13084 // Archive N ends at global frame N; all its frames are below
13085 // the floor (floor already accounts for them) → orphaned.
13086 self.fs.delete_archive(n).map_err(GraphError::Io)?;
13087 } else {
13088 // Archives are sorted ascending; first one above floor stops scan.
13089 break;
13090 }
13091 }
13092 Ok(())
13093 }
13094
13095 /// Collect all WAL frames from surviving archives (oldest-first) then the
13096 /// live WAL into one flat list, and return the total along with the number
13097 /// of archive frames at the front of the list.
13098 ///
13099 /// Commit indices into the returned list are LOCAL (0 = first frame of
13100 /// oldest surviving archive). To obtain the GLOBAL index add
13101 /// `self.wal_horizon_floor`.
13102 /// How many frames the surviving archives hold, without materialising them.
13103 ///
13104 /// The same count `all_frames` puts at the front of its list. Used to seed
13105 /// [`wal_frames_written`](GraphDb::wal_frames_written) at open without
13106 /// decoding the live WAL a second time; free on a store with no archives,
13107 /// which is most of them.
13108 fn archive_frame_count(&self) -> Result<u64> {
13109 let mut n = 0u64;
13110 for a in self.fs.list_archives()? {
13111 let bytes = self.fs.read_archive(a)?;
13112 let (frames, _) = decode_all(&bytes);
13113 n += frames.len() as u64;
13114 }
13115 Ok(n)
13116 }
13117
13118 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
13119 let archive_ns = self.fs.list_archives()?;
13120 let mut all: Vec<WalRecord> = Vec::new();
13121 for n in archive_ns {
13122 let bytes = self.fs.read_archive(n)?;
13123 let (frames, _) = decode_all(&bytes);
13124 all.extend(frames);
13125 }
13126 let archive_count = all.len() as u64;
13127 let live_bytes = self.fs.read(FileId::Wal)?;
13128 let (live_frames, _) = decode_all(&live_bytes);
13129 all.extend(live_frames);
13130 Ok((all, archive_count))
13131 }
13132
13133 /// Return the total number of committed WAL frames visible in the current
13134 /// horizon window, including frames in surviving WAL archives.
13135 ///
13136 /// This is the exclusive upper bound for valid `at_commit` indices in
13137 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
13138 ///
13139 /// Returns the horizon floor when all surviving history is empty.
13140 pub fn wal_total_commits(&self) -> Result<u64> {
13141 // The floor that belongs with these frames: both are read from disk
13142 // here. A handle that has not refreshed since another process moved
13143 // the floor would otherwise number new frames from its old one (row 81).
13144 let floor = self.fs.read_horizon_floor()?;
13145 let (frames, _) = self.all_frames()?;
13146 Ok(floor + frames.len() as u64)
13147 }
13148
13149 /// The global frame index of the first commit reachable through surviving
13150 /// archives (0 when no archives have been pruned).
13151 pub fn wal_horizon_floor(&self) -> u64 {
13152 self.wal_horizon_floor
13153 }
13154
13155 /// Return the per-node change history for `key` by scanning the on-disk WAL.
13156 ///
13157 /// ## Horizon
13158 ///
13159 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
13160 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
13161 /// zero-cost contract; a durable history log is out of scope.
13162 ///
13163 /// ## Derived edges
13164 ///
13165 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
13166 /// history. Only edges written directly by the application are recorded.
13167 ///
13168 /// ## Deleted nodes
13169 ///
13170 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
13171 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
13172 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
13173 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
13174 ///
13175 /// ## Dense-id edge entries and tombstoned partners
13176 ///
13177 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
13178 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
13179 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
13180 /// Build commit-bounded alias intervals for `queried_key`.
13181 ///
13182 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
13183 /// A record written under `key` at commit `c` matches the queried identity iff
13184 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
13185 ///
13186 /// Each alias entry carries both a lower and an upper bound so that key-reuse
13187 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
13188 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
13189 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
13190 /// only identity-2's events (commits 7–9 under "a") are in scope.
13191 ///
13192 /// Only **forward aliasing**: querying the *new* key surfaces events written
13193 /// under the *old* key. The reverse direction is not supported.
13194 fn build_key_alias_intervals(
13195 &self,
13196 frames: &[core_storage::wal::WalRecord],
13197 queried_key: &str,
13198 floor: u64,
13199 ) -> Vec<(String, u64, Option<u64>)> {
13200 self.key_alias_intervals(frames, queried_key, floor, false)
13201 }
13202
13203 /// [`Self::build_key_alias_intervals`], and with `current_holder_only`
13204 /// the queried key itself is bounded too: it counts from the commit the
13205 /// node that has it now acquired it. An earlier node that held the same
13206 /// key and was deleted is then no part of the answer — neither its edges
13207 /// nor its deletion.
13208 fn key_alias_intervals(
13209 &self,
13210 frames: &[core_storage::wal::WalRecord],
13211 queried_key: &str,
13212 floor: u64,
13213 current_holder_only: bool,
13214 ) -> Vec<(String, u64, Option<u64>)> {
13215 use core_storage::wal::WalRecord;
13216
13217 // Pre-pass: build reverse_rename and key_starts maps.
13218 // Every rename into a key, in commit order. A key can be renamed into
13219 // more than once — `a → b`, `b → a`, `a → b` — and which rename led
13220 // to the occupancy being followed depends on when.
13221 let mut reverse_rename: HashMap<String, Vec<(String, u64)>> = HashMap::new();
13222 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
13223
13224 for (local_i, frame) in frames.iter().enumerate() {
13225 let commit = floor + local_i as u64;
13226 let records: &[WalRecord] = match frame {
13227 WalRecord::Batch(inner) => inner.as_slice(),
13228 single => std::slice::from_ref(single),
13229 };
13230 for rec in records {
13231 match rec {
13232 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
13233 key_starts.entry(key.clone()).or_default().push(commit);
13234 }
13235 WalRecord::RenameNode { old_key, new_key } => {
13236 // new_key came into existence at this commit.
13237 key_starts.entry(new_key.clone()).or_default().push(commit);
13238 // Record the reverse rename: new_key was introduced by renaming old_key.
13239 reverse_rename
13240 .entry(new_key.clone())
13241 .or_default()
13242 .push((old_key.clone(), commit));
13243 }
13244 _ => {}
13245 }
13246 }
13247 }
13248
13249 // Build alias intervals by following the reverse rename chain.
13250 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
13251 let mut current_key = queried_key.to_string();
13252 let mut current_valid_until: Option<u64> = None;
13253
13254 loop {
13255 // valid_from: the most recent commit where current_key was assigned to this
13256 // identity. For aliases (valid_until = Some(vu)), find the last start event
13257 // for the key strictly before vu — this is where the alias's occupancy by
13258 // this identity began, correctly excluding prior identities that reused the key.
13259 let valid_from = if let Some(vu) = current_valid_until {
13260 key_starts
13261 .get(¤t_key)
13262 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
13263 .unwrap_or(floor)
13264 } else if current_holder_only {
13265 key_starts
13266 .get(¤t_key)
13267 .and_then(|starts| starts.last().copied())
13268 .unwrap_or(floor)
13269 } else {
13270 // Queried key — no upper bound; may have been introduced at any commit.
13271 floor
13272 };
13273
13274 result.push((current_key.clone(), valid_from, current_valid_until));
13275
13276 // The rename that led here: the latest into this key, and for an
13277 // alias the latest before the alias stopped being this node's.
13278 // Each step goes strictly back in time, so the walk ends — taking
13279 // the last rename whatever its commit sent `a → b → a` round for
13280 // ever.
13281 let led_here = reverse_rename
13282 .get(¤t_key)
13283 .and_then(|renames| {
13284 renames
13285 .iter()
13286 .rev()
13287 .find(|(_, at)| current_valid_until.is_none_or(|until| *at < until))
13288 })
13289 // And only if that rename is how this occupancy of the key
13290 // began. A key renamed into, deleted and inserted fresh
13291 // starts a new node at the insert; the older rename led to
13292 // the node that was deleted.
13293 .filter(|(_, at)| {
13294 (current_valid_until.is_none() && !current_holder_only) || *at == valid_from
13295 });
13296 match led_here {
13297 Some((old_key, rename_commit)) => {
13298 current_valid_until = Some(*rename_commit);
13299 current_key = old_key.clone();
13300 }
13301 None => break,
13302 }
13303 }
13304
13305 result
13306 }
13307
13308 /// Returns true if `record_key` matches any alias interval that covers `commit`.
13309 fn aliases_match(
13310 intervals: &[(String, u64, Option<u64>)],
13311 record_key: &str,
13312 commit: u64,
13313 ) -> bool {
13314 intervals
13315 .iter()
13316 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
13317 }
13318
13319 /// Return the change history of node `key` by scanning the on-disk WAL.
13320 ///
13321 /// ## Horizon
13322 ///
13323 /// History reaches back only as far as the retained WAL. The returned
13324 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
13325 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
13326 /// oldest commit still reachable). When `horizon > 0`, older events were
13327 /// pruned and are not in `items`.
13328 pub fn node_history(
13329 &self,
13330 key: &str,
13331 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
13332 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
13333 use core_storage::wal::WalRecord;
13334
13335 // The floor that belongs with these frames: both are read from disk
13336 // here. A handle that has not refreshed since another process moved
13337 // the floor would otherwise number new frames from its old one (row 81).
13338 let floor = self.fs.read_horizon_floor()?;
13339 let (frames, _) = self.all_frames()?;
13340 let total_commits = floor + frames.len() as u64;
13341
13342 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
13343 let alias_intervals = self.build_key_alias_intervals(&frames, key, floor);
13344
13345 let mut out: Vec<HistoryEntry> = Vec::new();
13346
13347 for (local_i, frame) in frames.iter().enumerate() {
13348 let commit = floor + local_i as u64;
13349 // Collect the inner records to process — Batch is one commit, single records are one commit.
13350 let records: &[WalRecord] = match frame {
13351 WalRecord::Batch(inner) => inner.as_slice(),
13352 single => std::slice::from_ref(single),
13353 };
13354
13355 for rec in records {
13356 let change = match rec {
13357 WalRecord::InsertNode { label, key: k, .. }
13358 if Self::aliases_match(&alias_intervals, k, commit) =>
13359 {
13360 Some(HistoryChange::NodeInserted {
13361 label: label.clone(),
13362 })
13363 }
13364 WalRecord::InsertNodeId { label, key: k, .. }
13365 if Self::aliases_match(&alias_intervals, k, commit) =>
13366 {
13367 let label_str = match self.syms.resolve(*label) {
13368 Some(s) => s.to_string(),
13369 None => continue,
13370 };
13371 Some(HistoryChange::NodeInserted { label: label_str })
13372 }
13373 WalRecord::SetProp {
13374 key: k,
13375 field,
13376 value,
13377 } if Self::aliases_match(&alias_intervals, k, commit) => {
13378 Some(HistoryChange::PropSet {
13379 field: field.clone(),
13380 value: value.clone(),
13381 })
13382 }
13383 WalRecord::SetPropId { id, field, value } => {
13384 // Use key_of_historical (not key_of) so a node's prop_set
13385 // events remain visible after the node is later deleted:
13386 // key_of returns None for a tombstoned id, which would
13387 // silently drop every PropSet between insert and delete.
13388 // Mirrors the InsertEdgeId arm below and edge_history's
13389 // own id-keyed arms.
13390 match self.ids.key_of_historical(*id) {
13391 // key_of_historical returns the last-known (possibly
13392 // post-rename, possibly post-delete) key; compare to queried key.
13393 Some(resolved) if resolved == key => {
13394 let field_str = match self.syms.resolve(*field) {
13395 Some(s) => s.to_string(),
13396 None => continue,
13397 };
13398 Some(HistoryChange::PropSet {
13399 field: field_str,
13400 value: value.clone(),
13401 })
13402 }
13403 _ => None,
13404 }
13405 }
13406 WalRecord::RemoveProp { key: k, field }
13407 if Self::aliases_match(&alias_intervals, k, commit) =>
13408 {
13409 Some(HistoryChange::PropRemoved {
13410 field: field.clone(),
13411 })
13412 }
13413 WalRecord::InsertEdge {
13414 edge_type,
13415 src_key,
13416 dst_key,
13417 } => {
13418 if Self::aliases_match(&alias_intervals, src_key, commit) {
13419 Some(HistoryChange::EdgeAdded {
13420 edge_type: edge_type.clone(),
13421 other: dst_key.clone(),
13422 outgoing: true,
13423 })
13424 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13425 Some(HistoryChange::EdgeAdded {
13426 edge_type: edge_type.clone(),
13427 other: src_key.clone(),
13428 outgoing: false,
13429 })
13430 } else {
13431 None
13432 }
13433 }
13434 WalRecord::InsertEdgeId { etype, src, dst } => {
13435 let etype_str = match self.syms.resolve(*etype) {
13436 Some(s) => s.to_string(),
13437 None => continue,
13438 };
13439 // key_of_historical (not key_of): an edge added before
13440 // either endpoint was later deleted must still resolve —
13441 // see the SetPropId arm above and edge_history's
13442 // InsertEdgeId arm, which use the same lookup for the
13443 // same reason.
13444 let src_key = self.ids.key_of_historical(*src);
13445 let dst_key = self.ids.key_of_historical(*dst);
13446 if src_key == Some(key) {
13447 let other = match dst_key {
13448 Some(s) => s.to_string(),
13449 None => continue,
13450 };
13451 Some(HistoryChange::EdgeAdded {
13452 edge_type: etype_str,
13453 other,
13454 outgoing: true,
13455 })
13456 } else if dst_key == Some(key) {
13457 let other = match src_key {
13458 Some(s) => s.to_string(),
13459 None => continue,
13460 };
13461 Some(HistoryChange::EdgeAdded {
13462 edge_type: etype_str,
13463 other,
13464 outgoing: false,
13465 })
13466 } else {
13467 None
13468 }
13469 }
13470 WalRecord::DeleteEdge {
13471 edge_type,
13472 src_key,
13473 dst_key,
13474 } => {
13475 if Self::aliases_match(&alias_intervals, src_key, commit) {
13476 Some(HistoryChange::EdgeRemoved {
13477 edge_type: edge_type.clone(),
13478 other: dst_key.clone(),
13479 outgoing: true,
13480 })
13481 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13482 Some(HistoryChange::EdgeRemoved {
13483 edge_type: edge_type.clone(),
13484 other: src_key.clone(),
13485 outgoing: false,
13486 })
13487 } else {
13488 None
13489 }
13490 }
13491 WalRecord::DeleteNode { key: k }
13492 if Self::aliases_match(&alias_intervals, k, commit) =>
13493 {
13494 Some(HistoryChange::NodeDeleted)
13495 }
13496 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
13497 _ => None,
13498 };
13499
13500 if let Some(change) = change {
13501 out.push(HistoryEntry { commit, change });
13502 }
13503 }
13504 }
13505
13506 Ok(HistoryResult {
13507 items: out,
13508 total_commits,
13509 horizon: floor,
13510 })
13511 }
13512
13513 /// Return the per-edge change history between nodes `a` and `b` by scanning
13514 /// the on-disk WAL.
13515 ///
13516 /// ## Horizon
13517 ///
13518 /// History reaches back only to the last WAL-truncating snapshot, exactly
13519 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
13520 /// `total_commits` (= number of WAL frames), which is the exclusive upper
13521 /// bound for valid commit indices.
13522 ///
13523 /// ## Derived edges
13524 ///
13525 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13526 /// WAL markers written by `log_then_apply_with` after each rule-firing
13527 /// mutation. The `rule` field of those events carries the rule name.
13528 ///
13529 /// ## DeleteNode
13530 ///
13531 /// When a node is deleted, its manual incident edges are swept inline without
13532 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
13533 /// events for either endpoint and synthesises `Retracted(rule:None)` events
13534 /// for each manual edge that was active at that point. Derived edges active at
13535 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
13536 /// the engine appends immediately after the `DeleteNode` record; those events
13537 /// carry correct rule attribution and are emitted by the marker arm, not the
13538 /// synthetic sweep.
13539 ///
13540 /// ## Masks
13541 ///
13542 /// Like `node_history`, this method has no mask parameter and returns WAL
13543 /// history regardless of any role mask. For masked history semantics, apply
13544 /// the mask at the caller level.
13545 pub fn edge_history(
13546 &self,
13547 a: &str,
13548 b: &str,
13549 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
13550 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
13551 use core_storage::wal::WalRecord;
13552
13553 // The floor that belongs with these frames: both are read from disk
13554 // here. A handle that has not refreshed since another process moved
13555 // the floor would otherwise number new frames from its old one (row 81).
13556 let floor = self.fs.read_horizon_floor()?;
13557 let (frames, _) = self.all_frames()?;
13558 let total_commits = floor + frames.len() as u64;
13559
13560 // Resolve all historical names for a and b (handles RenameNode in the WAL).
13561 // Intervals are commit-bounded so recycled keys don't contaminate histories.
13562 let alias_a = self.build_key_alias_intervals(&frames, a, floor);
13563 let alias_b = self.build_key_alias_intervals(&frames, b, floor);
13564
13565 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
13566 // The is_derived flag is used by the DeleteNode sweep: manual edges are
13567 // swept with a synthetic Retracted(rule:None); derived edges are skipped
13568 // because the engine writes a DerivedEdgeRetracted marker immediately after
13569 // the DeleteNode record, which carries the correct rule attribution.
13570 let mut active: Vec<(String, String, String, bool)> = Vec::new();
13571 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
13572
13573 for (local_i, frame) in frames.iter().enumerate() {
13574 let commit = floor + local_i as u64;
13575 let records: &[WalRecord] = match frame {
13576 WalRecord::Batch(inner) => inner.as_slice(),
13577 single => std::slice::from_ref(single),
13578 };
13579
13580 for rec in records {
13581 match rec {
13582 WalRecord::InsertEdge {
13583 edge_type,
13584 src_key,
13585 dst_key,
13586 } => {
13587 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13588 && Self::aliases_match(&alias_b, dst_key, commit);
13589 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13590 && Self::aliases_match(&alias_a, dst_key, commit);
13591 if is_ab || is_ba {
13592 active.push((
13593 edge_type.clone(),
13594 src_key.clone(),
13595 dst_key.clone(),
13596 false,
13597 ));
13598 out.push(EdgeHistoryEvent {
13599 edge_type: edge_type.clone(),
13600 commit,
13601 event: EdgeEvent::Added,
13602 rule: None,
13603 });
13604 }
13605 }
13606 WalRecord::InsertEdgeId { etype, src, dst } => {
13607 let etype_str = match self.syms.resolve(*etype) {
13608 Some(s) => s.to_string(),
13609 None => continue,
13610 };
13611 // Use key_of_historical so tombstoned nodes (deleted
13612 // later in the WAL) still resolve during the scan.
13613 let src_key = self.ids.key_of_historical(*src);
13614 let dst_key = self.ids.key_of_historical(*dst);
13615 let is_ab = src_key == Some(a) && dst_key == Some(b);
13616 let is_ba = src_key == Some(b) && dst_key == Some(a);
13617 if is_ab || is_ba {
13618 let src_str = src_key.unwrap().to_string();
13619 let dst_str = dst_key.unwrap().to_string();
13620 active.push((etype_str.clone(), src_str, dst_str, false));
13621 out.push(EdgeHistoryEvent {
13622 edge_type: etype_str,
13623 commit,
13624 event: EdgeEvent::Added,
13625 rule: None,
13626 });
13627 }
13628 }
13629 WalRecord::DeleteEdge {
13630 edge_type,
13631 src_key,
13632 dst_key,
13633 } => {
13634 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13635 && Self::aliases_match(&alias_b, dst_key, commit);
13636 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13637 && Self::aliases_match(&alias_a, dst_key, commit);
13638 if is_ab || is_ba {
13639 // Remove the first matching active entry (flag ignored).
13640 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
13641 et == edge_type && s == src_key && d == dst_key
13642 }) {
13643 active.remove(pos);
13644 }
13645 out.push(EdgeHistoryEvent {
13646 edge_type: edge_type.clone(),
13647 commit,
13648 event: EdgeEvent::Retracted,
13649 rule: None,
13650 });
13651 }
13652 }
13653 WalRecord::DeleteNode { key: k }
13654 if Self::aliases_match(&alias_a, k, commit)
13655 || Self::aliases_match(&alias_b, k, commit) =>
13656 {
13657 // Sweep: implicitly retract only MANUAL active edges.
13658 // Derived active edges are skipped here because the rule
13659 // engine appends a DerivedEdgeRetracted marker immediately
13660 // after this DeleteNode record; that marker produces the
13661 // single correctly-attributed Retracted event. Derived
13662 // entries are dropped from `active` (the marker arm's
13663 // idempotent retain finds nothing to remove).
13664 for (et, _, _, is_derived) in active.drain(..) {
13665 if !is_derived {
13666 out.push(EdgeHistoryEvent {
13667 edge_type: et,
13668 commit,
13669 event: EdgeEvent::Retracted,
13670 rule: None,
13671 });
13672 }
13673 // Derived: drop silently; marker carries the Retracted event.
13674 }
13675 }
13676 WalRecord::DerivedEdgeAdded {
13677 rule,
13678 edge_type: et,
13679 src_key,
13680 dst_key,
13681 } => {
13682 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13683 && Self::aliases_match(&alias_b, dst_key, commit);
13684 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13685 && Self::aliases_match(&alias_a, dst_key, commit);
13686 if is_ab || is_ba {
13687 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
13688 out.push(EdgeHistoryEvent {
13689 edge_type: et.clone(),
13690 commit,
13691 event: EdgeEvent::Added,
13692 rule: Some(rule.clone()),
13693 });
13694 }
13695 }
13696 WalRecord::DerivedEdgeRetracted {
13697 rule,
13698 edge_type: et,
13699 src_key,
13700 dst_key,
13701 } => {
13702 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13703 && Self::aliases_match(&alias_b, dst_key, commit);
13704 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13705 && Self::aliases_match(&alias_a, dst_key, commit);
13706 if is_ab || is_ba {
13707 // Push unconditionally: a derived edge whose Added marker
13708 // predates the history horizon has no `active` entry, but
13709 // the retraction is still a real in-window event.
13710 // Remove from active idempotently if present.
13711 active.retain(|(aet, s, d, _)| {
13712 !(aet == et && s == src_key && d == dst_key)
13713 });
13714 out.push(EdgeHistoryEvent {
13715 edge_type: et.clone(),
13716 commit,
13717 event: EdgeEvent::Retracted,
13718 rule: Some(rule.clone()),
13719 });
13720 }
13721 }
13722 // All other records (InsertNode, SetProp, CreateRule, etc.)
13723 // do not affect edges between a and b.
13724 _ => {}
13725 }
13726 }
13727 }
13728
13729 Ok(HistoryResult {
13730 items: out,
13731 total_commits,
13732 horizon: floor,
13733 })
13734 }
13735
13736 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
13737 /// (in either direction) at the WAL commit `at_commit`.
13738 ///
13739 /// ## Horizon
13740 ///
13741 /// Valid commit indices are `0..total_commits` where `total_commits` is the
13742 /// number of WAL frames. An `at_commit >= total_commits` is outside the
13743 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
13744 ///
13745 /// ## Derived edges
13746 ///
13747 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13748 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
13749 /// and therefore includes derived edges in its point-in-time evaluation,
13750 /// matching `edge_history`'s fidelity.
13751 ///
13752 /// ## Which nodes
13753 ///
13754 /// `a` and `b` name the nodes that hold those keys now, and the answer
13755 /// follows each of them back through its renames. A node that held one of
13756 /// the keys earlier and was deleted is another node: for a commit at
13757 /// which that earlier node was linked, the answer is `false`.
13758 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
13759 use core_storage::wal::WalRecord;
13760
13761 // The floor that belongs with these frames: both are read from disk
13762 // here. A handle that has not refreshed since another process moved
13763 // the floor would otherwise number new frames from its old one (row 81).
13764 let floor = self.fs.read_horizon_floor()?;
13765 let (frames, _) = self.all_frames()?;
13766 let total_commits = floor + frames.len() as u64;
13767
13768 // Horizon floor: commits in pruned archives are unreachable.
13769 if at_commit < floor {
13770 return Err(GraphError::CommitOutOfRange {
13771 commit: at_commit,
13772 total: total_commits,
13773 floor,
13774 });
13775 }
13776 if at_commit >= total_commits {
13777 return Err(GraphError::CommitOutOfRange {
13778 commit: at_commit,
13779 total: total_commits,
13780 floor,
13781 });
13782 }
13783
13784 // Resolve all historical names for a and b (handles RenameNode in the WAL).
13785 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
13786 let alias_a = self.key_alias_intervals(&frames, a, floor, true);
13787 let alias_b = self.key_alias_intervals(&frames, b, floor, true);
13788 // The two nodes themselves, when both are live. A record that names
13789 // its endpoints by dense id is then matched by id: a retired id keeps
13790 // the key it last had, and matching on that string took an earlier,
13791 // deleted holder of the key for the node that has it now.
13792 let live_ids = self.ids.get(a).zip(self.ids.get(b));
13793
13794 // Local index into surviving frames (0 = first frame of oldest archive).
13795 let local_commit = at_commit - floor;
13796
13797 // Replay local frames 0..=local_commit, tracking active edges between
13798 // the two nodes: the edge type and whether it runs from `a` to `b`.
13799 // Tracked by which node, not by the key each record names it with: an
13800 // edge written while a node was called one thing and deleted while it
13801 // was called another is the same edge.
13802 let mut active: BTreeSet<(String, bool)> = BTreeSet::new();
13803
13804 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13805 let commit = floor + local_i as u64;
13806 let records: &[WalRecord] = match frame {
13807 WalRecord::Batch(inner) => inner.as_slice(),
13808 single => std::slice::from_ref(single),
13809 };
13810
13811 for rec in records {
13812 match rec {
13813 WalRecord::InsertEdge {
13814 edge_type: et,
13815 src_key,
13816 dst_key,
13817 } => {
13818 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13819 && Self::aliases_match(&alias_b, dst_key, commit);
13820 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13821 && Self::aliases_match(&alias_a, dst_key, commit);
13822 if is_ab {
13823 active.insert((et.clone(), true));
13824 }
13825 if is_ba {
13826 active.insert((et.clone(), false));
13827 }
13828 }
13829 WalRecord::InsertEdgeId { etype, src, dst } => {
13830 let etype_str = match self.syms.resolve(*etype) {
13831 Some(s) => s.to_string(),
13832 None => continue,
13833 };
13834 // Use key_of_historical so tombstoned nodes resolve.
13835 let src_key = self.ids.key_of_historical(*src);
13836 let dst_key = self.ids.key_of_historical(*dst);
13837 let (is_ab, is_ba) = match live_ids {
13838 Some((id_a, id_b)) => {
13839 (*src == id_a && *dst == id_b, *src == id_b && *dst == id_a)
13840 }
13841 None => (
13842 src_key == Some(a) && dst_key == Some(b),
13843 src_key == Some(b) && dst_key == Some(a),
13844 ),
13845 };
13846 if is_ab {
13847 active.insert((etype_str.clone(), true));
13848 }
13849 if is_ba {
13850 active.insert((etype_str, false));
13851 }
13852 }
13853 WalRecord::DeleteEdge {
13854 edge_type: et,
13855 src_key,
13856 dst_key,
13857 } => {
13858 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13859 && Self::aliases_match(&alias_b, dst_key, commit);
13860 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13861 && Self::aliases_match(&alias_a, dst_key, commit);
13862 if is_ab {
13863 active.remove(&(et.clone(), true));
13864 }
13865 if is_ba {
13866 active.remove(&(et.clone(), false));
13867 }
13868 }
13869 WalRecord::DeleteNode { key: k }
13870 if Self::aliases_match(&alias_a, k, commit)
13871 || Self::aliases_match(&alias_b, k, commit) =>
13872 {
13873 // One of the two nodes is gone, and every edge
13874 // between them with it.
13875 active.clear();
13876 }
13877 WalRecord::DerivedEdgeAdded {
13878 edge_type: et,
13879 src_key,
13880 dst_key,
13881 ..
13882 } => {
13883 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13884 && Self::aliases_match(&alias_b, dst_key, commit);
13885 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13886 && Self::aliases_match(&alias_a, dst_key, commit);
13887 if is_ab {
13888 active.insert((et.clone(), true));
13889 }
13890 if is_ba {
13891 active.insert((et.clone(), false));
13892 }
13893 }
13894 WalRecord::DerivedEdgeRetracted {
13895 edge_type: et,
13896 src_key,
13897 dst_key,
13898 ..
13899 } => {
13900 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13901 && Self::aliases_match(&alias_b, dst_key, commit);
13902 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13903 && Self::aliases_match(&alias_a, dst_key, commit);
13904 if is_ab {
13905 active.remove(&(et.clone(), true));
13906 }
13907 if is_ba {
13908 active.remove(&(et.clone(), false));
13909 }
13910 }
13911 _ => {}
13912 }
13913 }
13914 }
13915
13916 Ok(active.iter().any(|(et, _)| et == edge_type))
13917 }
13918
13919 /// Every edge incident to `key` — either endpoint — that existed at WAL
13920 /// commit `commit`, from ONE scan of the WAL.
13921 ///
13922 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
13923 /// "what did K's relationships look like at commit C" with one call instead
13924 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
13925 /// The two agree edge for edge.
13926 ///
13927 /// Results are sorted by `(edge_type, src_key, dst_key)`.
13928 ///
13929 /// ## Horizon
13930 ///
13931 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
13932 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
13933 /// `was_linked`. An unknown key is not an error — it simply had no edges.
13934 ///
13935 /// ## Derived edges
13936 ///
13937 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
13938 /// attribution, so a rule-owned edge comes back with `derived: true` and
13939 /// `rule: Some(name)`.
13940 ///
13941 /// ## Renames
13942 ///
13943 /// `key` is matched through the same commit-bounded alias intervals
13944 /// `edge_history` uses, so querying a node's *current* key surfaces edges
13945 /// written under an earlier name. Endpoint keys in the result are reported
13946 /// under the name the node carries today, so they can be fed straight back
13947 /// into `node_info`, `explain` or another `edges_at`.
13948 ///
13949 /// ## Masks
13950 ///
13951 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
13952 /// any role mask. Apply masking at the caller level.
13953 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
13954 use core_storage::wal::WalRecord;
13955
13956 // Floor and frames from the same read of the disk (row 81).
13957 let floor = self.fs.read_horizon_floor()?;
13958 let (frames, _) = self.all_frames()?;
13959 let total_commits = floor + frames.len() as u64;
13960
13961 // Horizon floor: commits in pruned archives are unreachable.
13962 if commit < floor || commit >= total_commits {
13963 return Err(GraphError::CommitOutOfRange {
13964 commit,
13965 total: total_commits,
13966 floor,
13967 });
13968 }
13969
13970 // Commit-bounded historical names of `key` (handles RenameNode).
13971 let alias = self.build_key_alias_intervals(&frames, key, floor);
13972
13973 // Forward rename chain, for reporting endpoints under their current
13974 // names: old key → [(commit, new key)] in ascending commit order.
13975 // Built over the whole WAL, not just the prefix up to `commit`, because
13976 // a rename after `commit` still changes what the node is called today.
13977 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
13978 for (local_i, frame) in frames.iter().enumerate() {
13979 let c = floor + local_i as u64;
13980 let records: &[WalRecord] = match frame {
13981 WalRecord::Batch(inner) => inner.as_slice(),
13982 single => std::slice::from_ref(single),
13983 };
13984 for rec in records {
13985 if let WalRecord::RenameNode { old_key, new_key } = rec {
13986 renames
13987 .entry(old_key.clone())
13988 .or_default()
13989 .push((c, new_key.clone()));
13990 }
13991 }
13992 }
13993
13994 // The name a node written as `k` at commit `from` carries today.
13995 // Follows the first rename at or after `from`, then keeps going. The
13996 // iteration cap bounds a rename cycle inside a single batch.
13997 let canon = |k: &str, from: u64| -> String {
13998 if renames.is_empty() {
13999 return k.to_string();
14000 }
14001 let mut cur = k.to_string();
14002 let mut at = from;
14003 for _ in 0..64 {
14004 match renames
14005 .get(&cur)
14006 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
14007 {
14008 Some((c, new)) => {
14009 at = *c;
14010 cur = new.clone();
14011 }
14012 None => break,
14013 }
14014 }
14015 cur
14016 };
14017
14018 let local_commit = commit - floor;
14019 // (edge_type, src_key, dst_key) → (derived, rule)
14020 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
14021 BTreeMap::new();
14022
14023 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
14024 let c = floor + local_i as u64;
14025 let records: &[WalRecord] = match frame {
14026 WalRecord::Batch(inner) => inner.as_slice(),
14027 single => std::slice::from_ref(single),
14028 };
14029
14030 for rec in records {
14031 match rec {
14032 WalRecord::InsertEdge {
14033 edge_type,
14034 src_key,
14035 dst_key,
14036 } => {
14037 if Self::aliases_match(&alias, src_key, c)
14038 || Self::aliases_match(&alias, dst_key, c)
14039 {
14040 active.insert(
14041 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
14042 (false, None),
14043 );
14044 }
14045 }
14046 WalRecord::InsertEdgeId { etype, src, dst } => {
14047 let Some(etype_str) = self.syms.resolve(*etype) else {
14048 continue;
14049 };
14050 // `key_of_historical` resolves tombstoned ids too, and
14051 // already returns the node's current key — no rename
14052 // canonicalisation needed on this arm.
14053 let (Some(src_key), Some(dst_key)) = (
14054 self.ids.key_of_historical(*src),
14055 self.ids.key_of_historical(*dst),
14056 ) else {
14057 continue;
14058 };
14059 if src_key == key || dst_key == key {
14060 active.insert(
14061 (
14062 etype_str.to_string(),
14063 src_key.to_string(),
14064 dst_key.to_string(),
14065 ),
14066 (false, None),
14067 );
14068 }
14069 }
14070 WalRecord::DeleteEdge {
14071 edge_type,
14072 src_key,
14073 dst_key,
14074 } => {
14075 if Self::aliases_match(&alias, src_key, c)
14076 || Self::aliases_match(&alias, dst_key, c)
14077 {
14078 active.remove(&(
14079 edge_type.clone(),
14080 canon(src_key, c),
14081 canon(dst_key, c),
14082 ));
14083 }
14084 }
14085 WalRecord::DeleteNode { key: k } => {
14086 if active.is_empty() {
14087 continue;
14088 }
14089 if Self::aliases_match(&alias, k, c) {
14090 // Our node is gone; every incident edge goes with it.
14091 active.clear();
14092 } else {
14093 // A partner is gone; its edges to us go with it.
14094 let ck = canon(k, c);
14095 active.retain(|(_, s, d), _| *s != ck && *d != ck);
14096 }
14097 }
14098 WalRecord::DerivedEdgeAdded {
14099 rule,
14100 edge_type,
14101 src_key,
14102 dst_key,
14103 } => {
14104 if Self::aliases_match(&alias, src_key, c)
14105 || Self::aliases_match(&alias, dst_key, c)
14106 {
14107 active.insert(
14108 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
14109 (true, Some(rule.clone())),
14110 );
14111 }
14112 }
14113 WalRecord::DerivedEdgeRetracted {
14114 edge_type,
14115 src_key,
14116 dst_key,
14117 ..
14118 } => {
14119 if Self::aliases_match(&alias, src_key, c)
14120 || Self::aliases_match(&alias, dst_key, c)
14121 {
14122 active.remove(&(
14123 edge_type.clone(),
14124 canon(src_key, c),
14125 canon(dst_key, c),
14126 ));
14127 }
14128 }
14129 // InsertNode, SetProp, CreateRule, … do not move edges.
14130 _ => {}
14131 }
14132 }
14133 }
14134
14135 // BTreeMap iteration is already (edge_type, src, dst) order.
14136 Ok(active
14137 .into_iter()
14138 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
14139 edge_type,
14140 src_key,
14141 dst_key,
14142 derived,
14143 rule,
14144 })
14145 .collect())
14146 }
14147
14148 /// The derived edges that would be retracted and derived if `key.field`
14149 /// were set to `value` — computed WITHOUT writing anything.
14150 ///
14151 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
14152 /// provenance, its candidate indexes, the topology and the property columns
14153 /// are all cloned first, the change is applied to the clone, and the real
14154 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
14155 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
14156 /// it emits are the answer, so rule semantics — predicates, top-k,
14157 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
14158 ///
14159 /// Works on a read-only handle.
14160 ///
14161 /// **While a rule's vector index is still building** (`RuleStats::building`)
14162 /// the clone carries no pending-build state, so this reports the edges that
14163 /// rule would derive — which the live store will not derive until its
14164 /// backfill runs. Right about the end state, early about the timing.
14165 ///
14166 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
14167 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
14168 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
14169 /// (the node already holds `value`, or no rule watches `field`) returns
14170 /// empty lists.
14171 ///
14172 /// ## Cost
14173 ///
14174 /// One clone of the property columns, the topology overlay, the symbol
14175 /// interner, the edge properties and the provenance map, plus one candidate
14176 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
14177 /// directory, but it is not free — this is an interactive "what if", not a
14178 /// hot path.
14179 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
14180 // The engine's provenance, HNSW and IVF state live in the mmap'd base
14181 // until something asks for them. On a store opened cold from a snapshot
14182 // this is the first ask, and without it the clone below starts from an
14183 // empty provenance map: nothing to retract, so `lost` comes back empty.
14184 self.ensure_v8_base_sections_loaded();
14185
14186 let empty = WhatIf {
14187 lost: Vec::new(),
14188 gained: Vec::new(),
14189 };
14190
14191 if let Some(view_name) = self.view_store.view_for_prop(field) {
14192 return Err(GraphError::ViewPropReadOnly {
14193 view_name: view_name.to_string(),
14194 });
14195 }
14196 MutPreview::new(self).check_live_key(key)?;
14197 let id = self
14198 .ids
14199 .get(key)
14200 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
14201
14202 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
14203 if rules.is_empty() {
14204 return Ok(empty);
14205 }
14206
14207 // No rule watches this field → no derivation can change.
14208 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
14209 return Ok(empty);
14210 }
14211
14212 let old_value = build_props_view(&self.props, &self.base)
14213 .get(id, field)
14214 .map(|vr| vr.into_value());
14215 if old_value.as_ref() == Some(&value) {
14216 return Ok(empty);
14217 }
14218
14219 // --- Clone every piece of state the re-derivation writes to. ---
14220 // `what_if` mutates its own throwaway copies and writes nothing, so it
14221 // takes owned clones rather than sharing the `Arc`s. Deref-clone, not
14222 // `Arc::clone`: sharing here would make `make_mut` copy on first touch
14223 // anyway, and an owned local keeps the rest of this function unchanged.
14224 let mut props = (*self.props).clone();
14225 let mut topo = (*self.topo).clone();
14226 let mut syms = (*self.syms).clone();
14227 let mut edge_props = (*self.edge_props).clone();
14228
14229 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
14230 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
14231 for r in &rules {
14232 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
14233 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
14234 }
14235 // `provenance()` decodes retained snapshot bytes on first use; the
14236 // engine clone needs the real map, not an empty one.
14237 let provenance = self.engine.provenance().clone();
14238 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
14239
14240 // Build the candidate indexes from the state BEFORE the change, exactly
14241 // as apply() sees them: `on_node_changed` withdraws the node under its
14242 // old value and refiles it under the new one, so the index must not
14243 // already reflect the change.
14244 engine.reindex_all_load_state(
14245 &self.ids,
14246 &syms,
14247 &self.labels,
14248 build_props_view(&self.props, &self.base),
14249 self.engine.export_ivf_state(),
14250 self.engine.export_hnsw_state_passthrough(),
14251 );
14252 engine.set_emit_deltas(true);
14253
14254 // --- Apply the hypothetical change and re-derive. ---
14255 props.set(id, field, value);
14256 {
14257 let mut gm = make_graph_mut(
14258 &self.ids,
14259 &mut syms,
14260 &self.labels,
14261 build_props_view(&props, &self.base),
14262 &mut topo,
14263 &self.base,
14264 &mut edge_props,
14265 );
14266 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
14267 }
14268
14269 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
14270 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
14271 for d in engine.drain_deltas() {
14272 let edge = EdgeAt {
14273 edge_type: d.edge_type,
14274 src_key: d.src_key,
14275 dst_key: d.dst_key,
14276 derived: true,
14277 rule: Some(d.rule),
14278 };
14279 if d.fired {
14280 gained.insert(edge);
14281 } else {
14282 lost.insert(edge);
14283 }
14284 }
14285 // An edge retracted and re-derived within the same re-derivation (top-k
14286 // churn) is not a change the caller would see.
14287 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
14288 for e in churn {
14289 lost.remove(&e);
14290 gained.remove(&e);
14291 }
14292
14293 Ok(WhatIf {
14294 lost: lost.into_iter().collect(),
14295 gained: gained.into_iter().collect(),
14296 })
14297 }
14298
14299 pub fn edge_count(&self) -> u64 {
14300 self.corrected_edge_count()
14301 }
14302
14303 /// Total edge count, using the base CSR's actual row lengths when available
14304 /// (rather than the snapshot header's `edge_count` field, which is wrong on
14305 /// stores snapshotted while two rules owned one edge — ledger row 107).
14306 fn corrected_edge_count(&self) -> u64 {
14307 match self.base_edge_count_from_rows {
14308 // V8 base open: use the count we walked from the CSR rows at open.
14309 // Subtract tombstones (overlay deletions of base edges) and add the
14310 // overlay's own edges, exactly as TopologyView::edge_count does but
14311 // with the correct base count rather than the snapshot header's.
14312 Some(base_rows) => {
14313 let tombstones = self.topo.tombstone_count();
14314 base_rows.saturating_sub(tombstones) + self.topo.edge_count()
14315 }
14316 // No V8 base (WAL-only store or not yet refreshed): fall back to the
14317 // full topology view, which uses the snapshot header's count. The
14318 // header is correct on stores that were never double-owned, so this
14319 // path is safe for every store that has not gone through row-107 damage.
14320 None => self.topo_view().edge_count(),
14321 }
14322 }
14323
14324 /// Re-walk the current base CSR's out-rows to refresh `base_edge_count_from_rows`.
14325 ///
14326 /// Called after every snapshot write so the cached count stays aligned with the
14327 /// new base. Without this, `corrected_edge_count` would carry the count from
14328 /// the previous open while the overlay (reset to empty by `snapshot_with`) is
14329 /// no longer the full topology.
14330 fn refresh_base_edge_count(&mut self) {
14331 self.base_edge_count_from_rows =
14332 self.base
14333 .as_ref()
14334 .and_then(|b| b.topology().ok())
14335 .map(|csr| {
14336 csr.etypes
14337 .iter()
14338 .map(|e| {
14339 e.out_adj
14340 .rows
14341 .iter()
14342 .map(|r| r.neighbors.len() as u64)
14343 .sum::<u64>()
14344 })
14345 .sum::<u64>()
14346 });
14347 }
14348
14349 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
14350 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
14351 pub fn stats(&self) -> Stats {
14352 self.ensure_v8_base_sections_loaded();
14353 let building = self.engine.builds_in_progress();
14354 let rules: Vec<RuleStats> = self
14355 .engine
14356 .rules()
14357 .map(|r| RuleStats {
14358 name: r.name.clone(),
14359 edges: self
14360 .engine
14361 .provenance()
14362 .get(&r.name)
14363 .map(|s| s.len() as u64)
14364 .unwrap_or(0),
14365 tripped: self.engine.is_tripped(&r.name),
14366 fires: self.engine.fire_count(&r.name),
14367 approximate: r.approximate,
14368 building: building.iter().find(|b| b.rule == r.name).cloned(),
14369 })
14370 .collect();
14371 Stats {
14372 nodes_live: self.ids.live_len(),
14373 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
14374 edges: self.corrected_edge_count(),
14375 rules,
14376 chain_truncations: self.engine.chain_truncations(),
14377 history_floor: self.wal_horizon_floor,
14378 namespaces: self.namespace_stats(),
14379 }
14380 }
14381
14382 /// On-disk size of the WAL file in bytes.
14383 ///
14384 /// Reads file metadata without loading WAL contents. Returns `Err` for
14385 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
14386 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
14387 let path = self.fs.wal_path().ok_or_else(|| {
14388 std::io::Error::new(
14389 std::io::ErrorKind::Unsupported,
14390 "wal_path not available for this Fs implementation",
14391 )
14392 })?;
14393 Ok(std::fs::metadata(path)?.len())
14394 }
14395
14396 /// Set the slow-query threshold. Queries whose execution time equals or
14397 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
14398 ///
14399 /// Use this setter in tests — the environment variable
14400 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
14401 /// threads.
14402 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
14403 self.slow_query_threshold_ms = ms;
14404 }
14405
14406 /// Snapshot of the slow-query ring buffer and lifetime counter.
14407 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
14408 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
14409 SlowQuerySnapshot {
14410 threshold_ms: self.slow_query_threshold_ms,
14411 count: log.total,
14412 last: log.entries.iter().cloned().collect(),
14413 }
14414 }
14415
14416 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
14417 /// to compute uptime.
14418 pub fn started_at(&self) -> std::time::Instant {
14419 self.started_at
14420 }
14421
14422 /// The on-disk snapshot version a store that has opted in to nothing
14423 /// writes — the **floor**, not the whole answer.
14424 ///
14425 /// It is not "the version this binary writes", and it is not "the version
14426 /// this binary reads". Since v0.6.10 this binary writes 9 **or** 10
14427 /// depending on the store — [`snapshot::version_for`] decides, and a store
14428 /// that has called [`enable_multiplicity`](Self::enable_multiplicity)
14429 /// writes 10 — and it reads 5 through 10. A caller comparing a store's
14430 /// stamp against this value must use `>=`, not `==`, or it will report an
14431 /// opted-in store as needing a migration *down*; `cli::run_migrate` is the
14432 /// worked example.
14433 ///
14434 /// The name is kept for compatibility: it is public API reachable from the
14435 /// CLI and from any embedder, and respelling it would break them for a
14436 /// doc-level clarification.
14437 ///
14438 /// [`snapshot::version_for`]: core_storage::snapshot::version_for
14439 pub fn format_version() -> u16 {
14440 core_storage::snapshot::VERSION
14441 }
14442
14443 /// Test-support: total bytes appended (SimFs only usage).
14444 pub fn fs_total_appended(&self) -> usize
14445 where
14446 F: FsIntrospect,
14447 {
14448 self.fs.total_appended()
14449 }
14450
14451 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
14452 pub fn fs_sync_count(&self) -> usize
14453 where
14454 F: FsIntrospect,
14455 {
14456 self.fs.sync_count()
14457 }
14458
14459 /// Consume the db, returning its fs (for crash simulation).
14460 pub fn into_fs(self) -> F {
14461 self.fs
14462 }
14463
14464 pub fn snapshot(&mut self) -> Result<()> {
14465 self.snapshot_with(SnapshotOptions::default())
14466 }
14467
14468 /// Snapshot with explicit options.
14469 ///
14470 /// # `keep_wal`
14471 ///
14472 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
14473 /// - The WAL is replaced with a minimal baseline containing one
14474 /// `EnableFulltext` record per active declaration. All pre-snapshot
14475 /// history is discarded; `open_at` can only reach post-snapshot commits.
14476 ///
14477 /// When `keep_wal` is `true`:
14478 /// - The WAL is left intact. All pre-snapshot commits remain reachable
14479 /// via `open_at`. The existing WAL already contains the original
14480 /// `EnableFulltext` records, so no baseline re-write is needed; the
14481 /// recovery guards in `apply()` silently skip any duplicate records on
14482 /// replay.
14483 /// - Crash window: a crash after the snapshot write but before the next
14484 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
14485 /// snapshot is loaded and the WAL replayed idempotently over it — safe
14486 /// because every `apply()` arm is idempotent when replayed over an
14487 /// already-current snapshot.
14488 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
14489 if self.read_only {
14490 return Err(GraphError::ReadOnly);
14491 }
14492 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
14493 // appending ends up holding a descriptor on an unlinked inode and loses
14494 // commits it believes durable. Snapshotting therefore requires the
14495 // cross-process write lock, exactly as appending does. Unlike the WAL
14496 // append path this does not go through `log_then_apply_with`, so both
14497 // guards are repeated here.
14498 if self.degraded {
14499 return Err(GraphError::Io(std::io::Error::other(
14500 "database degraded after group-commit fsync failure; reopen required",
14501 )));
14502 }
14503 if self.lock_denied {
14504 return Err(GraphError::Busy { holder: None });
14505 }
14506 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
14507 // Used by the archive path's conservative genesis-chain check: if a prior
14508 // snapshot exists but wal.truncated does not, we cannot distinguish a
14509 // legacy store (may have been truncated in an older code version) from a
14510 // new store that only used keep_wal=true. Conservative: refuse genesis in
14511 // both cases. Must be sampled here, before the snapshot write below.
14512 //
14513 // `snapshot_preserved_history` is the one case where the answer is not a
14514 // guess: a snapshot *this handle* took, on a store that had none when it
14515 // opened, and that kept the WAL. The proxy defers to it, because
14516 // otherwise `enable_multiplicity` — whose forced snapshot is exactly
14517 // that — would permanently disqualify the store from a genesis chain it
14518 // is fully entitled to (defect #23).
14519 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false)
14520 && !self.snapshot_preserved_history;
14521 // The genesis marker, for the first archive of a store whose history
14522 // has never been cut — written now, before anything is replaced.
14523 //
14524 // It says that this store's archives and WAL together reach back to
14525 // its first write, and that is as true now, with no archive yet, as
14526 // it will be when this snapshot has made one. It used to be written
14527 // last, after the snapshot, the rename and the new WAL, and a
14528 // snapshot interrupted anywhere before that never got another
14529 // chance: the next one found the interrupted one's `snapshot.bin`,
14530 // could not tell it from a truncating snapshot's, and declined. Every
14531 // commit in an archive was `out of range` from then on (row 98).
14532 //
14533 // Both conditions as before. No archive exists, and no snapshot did
14534 // before this one: a truncating snapshot always leaves `snapshot.bin`,
14535 // so one found here means the WAL may not start at the beginning, and
14536 // the chain is not claimed (never silently wrong data). The exception
14537 // is a snapshot this handle took itself with the WAL kept, which
14538 // `snapshot_preserved_history` records (defect #23). On SimFs
14539 // (`snapshot_path() == None`) there is never a prior snapshot.
14540 //
14541 // A marker left by a snapshot that then fails is still true. What
14542 // makes it false removes it: a truncating snapshot, and a retention
14543 // sweep that prunes an archive.
14544 if opts.archive_wal
14545 && !had_prior_snapshot
14546 && !self.archive_genesis_chain
14547 && self.fs.list_archives()?.is_empty()
14548 {
14549 self.fs.write_genesis_marker()?;
14550 self.archive_genesis_chain = true;
14551 }
14552 // Which version this store writes. V9 unless it has opted in to
14553 // multiplicity, in which case V10 — the stamp that makes a reader which
14554 // does not know WAL discriminant 23 refuse the open instead of
14555 // truncating the WAL at the first such frame. The container is
14556 // identical either way; only these two header bytes move.
14557 let snapshot_version = core_storage::snapshot::version_for(self.multiplicity);
14558 // A snapshot that leaves the WAL where it is records how much of it
14559 // it holds, so the next open does not apply those writes again on top
14560 // of the state they produced. This handle holds the write lock, so
14561 // every byte in the WAL now is a write it has applied.
14562 let replaces_wal = opts.archive_wal || !opts.keep_wal;
14563 let wal_now = self.fs.read(FileId::Wal)?;
14564 let wal_mark = if replaces_wal {
14565 Vec::new()
14566 } else {
14567 core_storage::v8::WalMark::of(&wal_now).encode()
14568 };
14569 let standing_intent = if replaces_wal {
14570 self.standing_intent(&wal_now)?.0
14571 } else {
14572 None
14573 };
14574 let stop = self.snapshot_stop.take();
14575 let stopped = |at: SnapshotStop| -> Result<()> {
14576 if stop == Some(at) {
14577 return Err(GraphError::Io(std::io::Error::other(format!(
14578 "snapshot stopped {at:?} (test hook)"
14579 ))));
14580 }
14581 Ok(())
14582 };
14583 self.ensure_v8_base_sections_loaded();
14584 // Ensure provenance is decoded before to_persist() clones it.
14585 self.engine.ensure_provenance_loaded_mut();
14586 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
14587 let rule_defs = rule_defs_typed
14588 .iter()
14589 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
14590 .collect();
14591 // Collect HNSW state and IVF state. When indexes are not yet
14592 // populated (clean open, no mutation since open), pass the retained
14593 // raw bytes through directly so that migrate/snapshot does not
14594 // silently discard fitted approximate-rule indexes.
14595 let hnsw_state = self.engine.export_hnsw_state_passthrough();
14596 let ivf_bytes = if !self.engine.indexes_populated() {
14597 // Pass retained IVF bytes through unchanged (no re-encode).
14598 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
14599 } else {
14600 // Indexes live: encode from current state.
14601 let raw_ivf = self.engine.export_ivf_state();
14602 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
14603 .into_iter()
14604 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
14605 (
14606 name,
14607 core_storage::snapshot::PerRuleIvfState {
14608 src: core_storage::snapshot::SideIvfState {
14609 centroids: sc,
14610 clusters: sa,
14611 drift: sd,
14612 },
14613 dst: core_storage::snapshot::SideIvfState {
14614 centroids: dc,
14615 clusters: da,
14616 drift: dd,
14617 },
14618 },
14619 )
14620 })
14621 .collect();
14622 if ivf_state_map.is_empty() {
14623 Vec::new()
14624 } else {
14625 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
14626 }
14627 };
14628 let view_defs: Vec<Vec<u8>> = self
14629 .view_store
14630 .views()
14631 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
14632 .collect();
14633 if self.base.is_some() {
14634 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
14635 // write it atomically, remap it as the new base, then clear the overlay.
14636 let meta = V8Meta {
14637 labels: (*self.labels).clone(),
14638 edge_props: (*self.edge_props).clone(),
14639 rule_defs,
14640 provenance,
14641 rule_tripped,
14642 rule_fires,
14643 ivf_bytes,
14644 view_defs,
14645 wal_truncated: !opts.keep_wal,
14646 hnsw: hnsw_state,
14647 last_change: self.last_change.clone(),
14648 fulltext_blob: self.fulltext_blob(),
14649 wal_mark: wal_mark.clone(),
14650 };
14651 let mut buf: Vec<u8> = Vec::new();
14652 {
14653 // Clone the Arc so the old base stays alive while we encode.
14654 // The borrow of archived_csr (into old_base's mmap) is released
14655 // at the end of this block, before we replace self.base.
14656 let old_base = self.base.clone().expect("is_some checked above");
14657 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
14658 detail: format!("v8 snapshot: topology section: {e:?}"),
14659 })?;
14660 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
14661 detail: format!("v8 snapshot: columns section: {e:?}"),
14662 })?;
14663 // `None` when the base predates V9 — the migration path: its
14664 // string columns still carry their own tables and this snapshot
14665 // is the rewrite that collapses them into section 12.
14666 let archived_strings =
14667 old_base
14668 .string_table()
14669 .transpose()
14670 .map_err(|e| GraphError::Corrupt {
14671 detail: format!("v8 snapshot: strings section: {e:?}"),
14672 })?;
14673 let archived_edge_props =
14674 old_base
14675 .edge_props_section()
14676 .map_err(|e| GraphError::Corrupt {
14677 detail: format!("v8 snapshot: edge_props section: {e:?}"),
14678 })?;
14679 let edge_props_raw =
14680 old_base
14681 .edge_props_raw_bytes()
14682 .map_err(|e| GraphError::Corrupt {
14683 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
14684 })?;
14685 encode_v8(
14686 Some(archived_csr),
14687 Some(archived_cols),
14688 archived_strings,
14689 Some((archived_edge_props, edge_props_raw)),
14690 // Never the old snapshot's provenance bytes. The encoder
14691 // passes them through when the live map is empty, taking
14692 // empty for "never loaded" — but it is loaded above, so
14693 // empty means every rule's edges are gone, and passing
14694 // the old section through brought them back as owned by
14695 // rules that no longer derive them (ledger row 95).
14696 None,
14697 &self.topo,
14698 &self.props,
14699 &self.ids,
14700 &self.syms,
14701 &meta,
14702 &mut buf,
14703 )?;
14704 }
14705 core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14706 if replaces_wal {
14707 // Before the snapshot: what is about to be written, and the
14708 // WAL it holds. From here until the WAL is replaced a crash
14709 // leaves that WAL beside the new snapshot, and this is how
14710 // the next open knows not to apply it again (ledger row 92).
14711 //
14712 // An intent already standing — the last snapshot was
14713 // interrupted too, and this store opens because of it — is
14714 // kept beside the new one until the new snapshot is down. If
14715 // this attempt fails before then, it is still what names the
14716 // snapshot on disk.
14717 let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14718 if let Some(standing) = &standing_intent {
14719 intent.extend_from_slice(&standing.encode());
14720 }
14721 self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14722 // Until this snapshot empties it. If it does not get that far
14723 // and this handle goes on writing, the next append looks at
14724 // whether the intent is still true.
14725 self.intent_unsettled = true;
14726 }
14727 stopped(SnapshotStop::BeforeSnapshotWrite)?;
14728 self.fs.write_atomic(FileId::Snapshot, &buf)?;
14729 // Remap the freshly-written snapshot as the new base.
14730 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
14731 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14732 core_storage::v8::MappedBase::map(&snap_path)
14733 } else {
14734 core_storage::v8::MappedBase::from_bytes(buf)
14735 }
14736 .map_err(|e| GraphError::Corrupt {
14737 detail: format!("v8 snapshot: remap new base: {e:?}"),
14738 })?;
14739 self.base = Some(Arc::new(new_base));
14740 // Clear the overlay and prop tombstones — all data is now in the new base.
14741 self.topo = Arc::new(Topology::new());
14742 self.props = Arc::new(core_storage::columns::ColumnStore::new());
14743 // Keep the cached edge count aligned with the new base so that
14744 // corrected_edge_count() is correct for the rest of this session.
14745 self.refresh_base_edge_count();
14746 } else {
14747 // Legacy path (V5–V7 stores without a V8 base).
14748 //
14749 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
14750 // clone and no encode_v8_from_state intermediate clones. The big
14751 // structures (self.topo, self.props) are borrowed, not cloned.
14752 // self.edge_props is moved (not cloned) because we immediately clear it
14753 // when we remap the new V8 snapshot as self.base (see below).
14754 //
14755 // Eliminates from peak RSS vs. the old SnapshotState path:
14756 // • self.topo.clone() (~topology HashMap footprint)
14757 // • self.props.clone() (~column-store footprint)
14758 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
14759 let mut meta = V8Meta {
14760 labels: (*self.labels).clone(),
14761 wal_truncated: !opts.keep_wal,
14762 // Moved, not cloned, for the encode — and moved straight back
14763 // below, before anything that can fail. They are released
14764 // only once the new snapshot is mapped and reads go through
14765 // it. Left out of the handle across the write, a snapshot
14766 // that failed there went on with no edge properties at all,
14767 // and the next one that worked wrote that down.
14768 edge_props: std::mem::take(Arc::make_mut(&mut self.edge_props)),
14769 rule_defs,
14770 provenance,
14771 rule_tripped,
14772 rule_fires,
14773 ivf_bytes,
14774 view_defs,
14775 hnsw: hnsw_state,
14776 last_change: self.last_change.clone(),
14777 fulltext_blob: self.fulltext_blob(),
14778 wal_mark: wal_mark.clone(),
14779 };
14780 let mut buf = Vec::new();
14781 let encoded = encode_v8(
14782 None,
14783 None,
14784 None,
14785 None,
14786 None,
14787 &self.topo,
14788 &self.props,
14789 &self.ids,
14790 &self.syms,
14791 &meta,
14792 &mut buf,
14793 );
14794 self.edge_props = Arc::new(std::mem::take(&mut meta.edge_props));
14795 drop(meta);
14796 encoded?;
14797 core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14798 if replaces_wal {
14799 // Before the snapshot: what is about to be written, and the
14800 // WAL it holds. From here until the WAL is replaced a crash
14801 // leaves that WAL beside the new snapshot, and this is how
14802 // the next open knows not to apply it again (ledger row 92).
14803 //
14804 // An intent already standing — the last snapshot was
14805 // interrupted too, and this store opens because of it — is
14806 // kept beside the new one until the new snapshot is down. If
14807 // this attempt fails before then, it is still what names the
14808 // snapshot on disk.
14809 let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14810 if let Some(standing) = &standing_intent {
14811 intent.extend_from_slice(&standing.encode());
14812 }
14813 self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14814 // Until this snapshot empties it. If it does not get that far
14815 // and this handle goes on writing, the next append looks at
14816 // whether the intent is still true.
14817 self.intent_unsettled = true;
14818 }
14819 stopped(SnapshotStop::BeforeSnapshotWrite)?;
14820 self.fs.write_atomic(FileId::Snapshot, &buf)?;
14821 // Remap the freshly-written V8 snapshot as self.base.
14822 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
14823 // On SimFs (tests): pass buf to from_bytes.
14824 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14825 drop(buf);
14826 core_storage::v8::MappedBase::map(&snap_path)
14827 } else {
14828 core_storage::v8::MappedBase::from_bytes(buf)
14829 }
14830 .map_err(|e| GraphError::Corrupt {
14831 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
14832 })?;
14833 self.base = Some(Arc::new(new_base));
14834 // Free the large heap-allocated decoded state — all data is now in the
14835 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
14836 self.edge_props = Arc::new(Default::default());
14837 self.topo = Arc::new(Topology::new());
14838 self.props = Arc::new(core_storage::columns::ColumnStore::new());
14839 // Keep the cached edge count aligned with the new base (same as the
14840 // V8 merge path above).
14841 self.refresh_base_edge_count();
14842 }
14843
14844 stopped(SnapshotStop::AfterSnapshotWrite)?;
14845 if opts.archive_wal {
14846 // History-preserving snapshot (Task 4):
14847 // 1. Snapshot already written above (write_atomic → fsynced).
14848 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
14849 // Crash window B: crash here leaves archive present, WAL
14850 // absent. Reopen: snapshot loaded (full state), no WAL
14851 // replay. Archive is NOT replayed into live state — it is
14852 // pre-snapshot by construction. Safe.
14853 // 3. Write new minimal baseline WAL (write_atomic), with no
14854 // fallible call between it and the rename.
14855 // Crash window C: a crash between the two leaves the new
14856 // archive and no live WAL. Same as window B — handled above.
14857 // 4. Prune old archives (retention), update horizon floor.
14858 // Pruning invalidates the genesis chain; delete marker.
14859 // (The genesis marker is written before step 1; see above.
14860 // Steps 2 and 3 are skipped when the WAL holds nothing new.)
14861 //
14862 // Sample existing archives BEFORE the rename: the last one names
14863 // where this one starts.
14864 let existing_archives = self.fs.list_archives()?;
14865
14866 // Compute a globally-monotonic archive name: the name equals the
14867 // cumulative end-frame index of the archive in global commit space.
14868 //
14869 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
14870 // commit_seq is seeded from max(last_change), which underestimates
14871 // the WAL depth when trailing commits (e.g. insert_edge) do not
14872 // update last_change. A session-2 archive could then receive a name
14873 // ≤ the session-1 archive, causing incorrect sort order or collision.
14874 //
14875 // Instead: read and decode the live WAL here (before the rename) to
14876 // get its exact frame count, then add it to the last known global
14877 // end-frame index (the name of the most recent existing archive, or
14878 // wal_horizon_floor if no archives exist). This is O(WAL size) but
14879 // snapshot is already serialising the full graph state, so the cost
14880 // is dominated.
14881 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
14882 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
14883 let archive_n = existing_archives
14884 .last()
14885 .copied()
14886 .unwrap_or(self.wal_horizon_floor)
14887 + live_frames_for_name.len() as u64;
14888 // What the WAL is replaced with: the store's declarations and
14889 // nothing else. Built before the rename, because it is also what
14890 // says whether there is anything to archive.
14891 let mut baseline_wal: Vec<u8> = Vec::new();
14892 // The multiplicity opt-in is a declaration like the two below it,
14893 // and it is re-emitted for the same reason: truncation must not
14894 // silently opt the store back out and stop counting.
14895 if self.multiplicity {
14896 baseline_wal
14897 .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14898 }
14899 for (label, field) in self.fulltext.enabled_pairs() {
14900 let rec = WalRecord::EnableFulltext {
14901 label: label.clone(),
14902 field: field.clone(),
14903 };
14904 baseline_wal.extend_from_slice(&encode_record(&rec));
14905 }
14906 for (label, field) in self.prop_index.enabled_pairs() {
14907 let rec = WalRecord::EnableIndex {
14908 label: label.clone(),
14909 field: field.clone(),
14910 };
14911 baseline_wal.extend_from_slice(&encode_record(&rec));
14912 }
14913 // A WAL that already holds exactly that has had nothing written
14914 // to it since the last snapshot put it there, and there is
14915 // nothing to archive. Archiving it anyway destroyed history: an
14916 // archive is named for the commit it ends at, so with no commit
14917 // since the last one this one took the last one's name, and the
14918 // rename put an empty file over it — after which the commit
14919 // numbers were handed out again and a read by commit answered
14920 // with a later state (row 100). The WAL stays where it is; it is
14921 // rewritten only if it ends in bytes that are no record. A store
14922 // with no archive yet has no name to collide with and is archived
14923 // as before.
14924 let (baseline_frames, _) = decode_all(&baseline_wal);
14925 //
14926 // An empty WAL has nothing to archive whatever the replacement
14927 // holds: it is what a kill between the rename and the write of
14928 // the replacement leaves, and on a store with a declaration it is
14929 // not the replacement. The rewrite below puts the declarations
14930 // back.
14931 let nothing_to_archive = (live_frames_for_name.is_empty()
14932 || live_frames_for_name == baseline_frames)
14933 && !self.fs.list_archives()?.is_empty();
14934 stopped(SnapshotStop::BeforeWalReplacement)?;
14935 if nothing_to_archive {
14936 if live_wal_bytes_for_name != baseline_wal {
14937 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14938 }
14939 } else {
14940 // And never over an archive that exists, whatever led to the
14941 // name: a snapshot that cannot be taken costs a longer open,
14942 // an archive written over costs the store's history.
14943 if existing_archives.contains(&archive_n) {
14944 return Err(GraphError::Corrupt {
14945 detail: format!(
14946 "snapshot: wal.{archive_n}.archive already exists; \
14947 not archiving the WAL over it"
14948 ),
14949 });
14950 }
14951 self.fs.archive_wal(archive_n)?;
14952
14953 // The replacement WAL goes in **immediately**, with no fallible call
14954 // between it and the rename above.
14955 //
14956 // The rename is what removes the store's live declarations — the
14957 // multiplicity opt-in, and every `EnableFulltext` / `EnableIndex` —
14958 // and this write is what puts them back. Every call that used to sit
14959 // in between (the genesis marker, the retention sweep's reads, the
14960 // floor write, the archive deletes) was a `?` that could leave the
14961 // store with neither, so a single transient `Err` was enough to lose
14962 // a declaration that no rebuild can recover (defect #22).
14963 //
14964 // Ordering alone cannot close the crash window between two
14965 // filesystem calls; for the multiplicity declaration the V10 stamp
14966 // does that on the open path. What ordering does close is the much
14967 // wider window in which an ordinary I/O error did it — and that half
14968 // covers all three declarations, not just the one with a stamp.
14969 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14970 }
14971
14972 // The genesis marker was written before the snapshot, above.
14973
14974 // Retention pruning: keep newest `keep` archives; delete oldest.
14975 // Pruning is the ONLY deletion site for archives.
14976 //
14977 // Crash-safety ordering (C1 fix):
14978 // 1. Count frames in surplus archives (reads only — no mutation).
14979 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
14980 // rename (atomic). A crash after this point leaves orphaned
14981 // archives on disk, but the floor is correct. The opening
14982 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
14983 // the next open, so the store is always safe to reopen.
14984 // 3. Delete the genesis marker (floor > 0 already blocks open_at
14985 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
14986 // 4. Delete surplus archives. A crash between any two deletes
14987 // leaves the floor committed and orphaned archives cleaned at
14988 // next open — never a stale floor with a missing archive prefix.
14989 if let Some(keep) = self.wal_archive_retention {
14990 if keep > 0 {
14991 let archives = self.fs.list_archives()?;
14992 // archives is sorted ascending (oldest first)
14993 if archives.len() as u32 > keep {
14994 let surplus = archives.len() - keep as usize;
14995 // Step 1: count pruned frames (reads, no mutation).
14996 let mut pruned_frames = 0u64;
14997 for &n in &archives[..surplus] {
14998 let bytes = self.fs.read_archive(n)?;
14999 let (frames, _) = decode_all(&bytes);
15000 pruned_frames += frames.len() as u64;
15001 }
15002 // Step 2: advance and persist floor FIRST.
15003 self.wal_horizon_floor += pruned_frames;
15004 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
15005 // The time map must not outlive the commits it
15006 // describes: an entry below the new floor would resolve
15007 // a date to a commit the engine can no longer replay,
15008 // which is worse than having no entry at all.
15009 self.commit_times.truncate_below(self.wal_horizon_floor);
15010 self.rewrite_commit_times();
15011 // Step 3: delete genesis marker (floor > 0 already
15012 // blocks open_at; this is belt-and-suspenders cleanup).
15013 if pruned_frames > 0 && self.archive_genesis_chain {
15014 self.fs.delete_genesis_marker()?;
15015 self.archive_genesis_chain = false;
15016 }
15017 // Step 4: delete surplus archives. Crash here →
15018 // orphaned archives; cleaned at next open.
15019 for &n in &archives[..surplus] {
15020 self.fs.delete_archive(n)?;
15021 }
15022 }
15023 }
15024 }
15025 } else if opts.keep_wal {
15026 // keep_wal=true: WAL is left untouched. The existing WAL already
15027 // contains the EnableFulltext records from the original enable calls;
15028 // replay is idempotent (guards in apply() skip already-live entries).
15029 // No baseline re-write is needed or safe here — the full WAL history
15030 // must remain intact for open_at to reach pre-snapshot commits.
15031 } else {
15032 // keep_wal=false (default): truncate by replacing the WAL with a
15033 // minimal baseline of one EnableFulltext record per active pair.
15034 //
15035 // Crash-ordering: write_atomic is atomic.
15036 // • Crash before snapshot write → WAL unchanged. Safe.
15037 // • Crash after snapshot write but before this WAL write → full
15038 // pre-snapshot WAL still present; open_with replays idempotently.
15039 // • Crash after both writes → normal post-snapshot state.
15040 //
15041 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
15042 // for any archives taken AFTER this point (their WAL slices would
15043 // not start at genesis). Delete any existing genesis marker so that
15044 // open_at refuses archive-resident commits. Future sessions are
15045 // covered by had_prior_snapshot: snapshot.bin written here persists
15046 // across sessions and prevents a later archiving session from
15047 // incorrectly claiming a complete genesis chain.
15048 if self.archive_genesis_chain {
15049 self.fs.delete_genesis_marker()?;
15050 self.archive_genesis_chain = false;
15051 }
15052 // And this handle can no longer prove the WAL is whole: it is about
15053 // to truncate it itself. Same-session archives after this point get
15054 // the conservative answer, exactly as cross-session ones do.
15055 self.snapshot_preserved_history = false;
15056 // The frames this discards keep their numbers. Everything the store
15057 // could still address — its archives and the live WAL — ends at
15058 // `wal_total_commits()`. That becomes the floor, and the replacement
15059 // WAL continues from it, exactly as the archive path's does after a
15060 // retention sweep.
15061 //
15062 // Leaving the floor where it was renumbered the new WAL over the
15063 // old one: `stats` reported `history_floor: 0`, which every reader
15064 // prints as "complete, nothing pruned", and a commit index a caller
15065 // already held resolved to an unrelated frame — a plausible graph,
15066 // not an error (ledger row 80).
15067 //
15068 // The floor is persisted FIRST, for the reason the retention sweep
15069 // gives: a crash after it leaves the old WAL and any archives on
15070 // disk under a floor that already disowns them. The next open
15071 // removes the archives as orphans and replays the WAL over the
15072 // snapshot idempotently. No number from before the snapshot is
15073 // answered in that state: all of them are below the floor.
15074 //
15075 // What that state does do is count the old WAL's frames again,
15076 // above the floor, until the next truncating snapshot — the same
15077 // history under new numbers, and a total inflated by its length.
15078 // Never a reused number, but not the original ones either. Closing
15079 // that needs the floor to land with the snapshot in one write,
15080 // which is a format change (ledger row 80, "what is left").
15081 let discarded_through = self.wal_total_commits()?;
15082 if discarded_through > self.wal_horizon_floor {
15083 self.fs.write_horizon_floor(discarded_through)?;
15084 self.wal_horizon_floor = discarded_through;
15085 }
15086 let mut baseline_wal: Vec<u8> = Vec::new();
15087 // The multiplicity opt-in is a declaration like the two below it,
15088 // and it is re-emitted for the same reason: truncation must not
15089 // silently opt the store back out and stop counting.
15090 if self.multiplicity {
15091 baseline_wal
15092 .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
15093 }
15094 for (label, field) in self.fulltext.enabled_pairs() {
15095 let rec = WalRecord::EnableFulltext {
15096 label: label.clone(),
15097 field: field.clone(),
15098 };
15099 baseline_wal.extend_from_slice(&encode_record(&rec));
15100 }
15101 for (label, field) in self.prop_index.enabled_pairs() {
15102 let rec = WalRecord::EnableIndex {
15103 label: label.clone(),
15104 field: field.clone(),
15105 };
15106 baseline_wal.extend_from_slice(&encode_record(&rec));
15107 }
15108 stopped(SnapshotStop::BeforeWalReplacement)?;
15109 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
15110 // Every archive now ends at or below the floor. They are the
15111 // history this snapshot was asked to discard; removing them here is
15112 // what `cleanup_orphaned_archives` would do at the next open.
15113 for n in self.fs.list_archives()? {
15114 if n <= self.wal_horizon_floor {
15115 self.fs.delete_archive(n)?;
15116 }
15117 }
15118 // This branch discards history rather than archiving it: the frames
15119 // the map describes are gone, and every entry it holds is below the
15120 // floor. A date before the floor has no commit to resolve to.
15121 //
15122 // A store that cannot answer a date says so by name
15123 // (`NoRecordedTime`). That is the honest state after discarding the
15124 // history the dates addressed.
15125 self.commit_times = core_storage::commit_times::CommitTimes::default();
15126 self.rewrite_commit_times();
15127 }
15128 // The WAL is the one that goes with the snapshot now. The intent has
15129 // done its work and must not outlive it.
15130 if replaces_wal {
15131 stopped(SnapshotStop::AfterWalReplacement)?;
15132 self.fs.write_atomic(FileId::SnapshotIntent, &[])?;
15133 self.intent_unsettled = false;
15134 }
15135 // After snapshot the overlay may have changed (V8 merge path clears
15136 // self.topo and self.props). Refresh the MVCC fold so future readers
15137 // see the post-snapshot state rather than stale overlay data.
15138 self.fold_now();
15139 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
15140 // markers this handle uses to detect other processes' work must be
15141 // re-taken from disk. Skipping this would make our own snapshot look
15142 // like a peer's on the next staleness check and force a needless
15143 // reload.
15144 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
15145 // A snapshot can replace the live WAL with a baseline, which has its
15146 // own frames. Re-derive the frame cursor from what the store now holds
15147 // (the floor, plus what is reachable above it) rather than carrying
15148 // the pre-snapshot count forward. `wal_total_commits` is the same
15149 // sequence the history surfaces index, and the snapshot has already
15150 // paid a far larger cost than one decode.
15151 self.wal_frames_written = self.wal_total_commits()?;
15152 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
15153 Ok(())
15154 }
15155}
15156
15157/// What a batch node insert does when its key is already taken.
15158///
15159/// A mirror rebuild writes a frame onto a store that already has content, so
15160/// "the key exists" is a routine answer rather than a failure. The decision is
15161/// made during the batch's existing validate pass, from one id-map lookup per
15162/// row, so the frame stays atomic and re-ingest stays O(n).
15163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15164pub enum OnConflict {
15165 /// Refuse the whole frame with [`GraphError::DuplicateKey`]. The default,
15166 /// and the only behaviour before v0.6.10.
15167 #[default]
15168 Error,
15169 /// Leave the stored node exactly as it is — properties, label and edges —
15170 /// and count it in [`BatchOutcome::skipped`].
15171 Skip,
15172 /// Keep the key and make the node's properties **exactly** the supplied
15173 /// props: supplied fields are set, fields absent from the supplied props
15174 /// are removed. A supplied label that differs from the stored one, and a
15175 /// supplied `ns` that would move the node, are row errors — relabelling is
15176 /// [`GraphDb::rename_node`], not a side effect of a rebuild.
15177 ///
15178 /// Two properties are outside "exactly", both because they are not the
15179 /// caller's to supply:
15180 ///
15181 /// - `ns` is immutable, so an omitted `ns` leaves the node where it is
15182 /// rather than moving it to `default`;
15183 /// - a property a **view** owns is kept, not removed. Supplying one is a
15184 /// row error, so omitting it cannot be a request to delete it, and
15185 /// refusing the row instead would make `Replace` impossible for the whole
15186 /// population a view has written to. Each such field kept is counted in
15187 /// [`BatchOutcome::kept_view_owned`] — the row is still `replaced` and
15188 /// still raises no row error, so that count is the only signal a caller
15189 /// gets that the stored node carries a field its frame did not describe.
15190 Replace,
15191}
15192
15193/// What one [`OnConflict::Replace`] row resolves to.
15194///
15195/// The property writes that make the node exactly the supplied props —
15196/// `Some(value)` is a set, `None` a removal — paired with how many view-owned
15197/// fields the row kept instead of removing, which is the one way the result is
15198/// not exactly the supplied props. See [`MutPreview::plan_replace`].
15199type ReplacePlan = (Vec<(String, Option<Value>)>, usize);
15200
15201/// What one committed batch did.
15202///
15203/// [`BatchBuilder::commit`] returns the first two fields as a tuple; the rest
15204/// exist for [`OnConflict`] and are always zero / empty without it.
15205#[derive(Clone, Debug, Default, PartialEq, Eq)]
15206pub struct BatchOutcome {
15207 /// Node records actually written.
15208 pub nodes_inserted: usize,
15209 /// Edge records actually written. A duplicate edge is a silent no-op under
15210 /// every policy — adjacency is a set — and is not counted.
15211 pub edges_inserted: usize,
15212 /// Rows whose key was taken and whose policy was [`OnConflict::Skip`].
15213 pub skipped: usize,
15214 /// Rows whose key was taken and whose policy was [`OnConflict::Replace`].
15215 pub replaced: usize,
15216 /// View-owned properties an [`OnConflict::Replace`] row **kept** although
15217 /// the caller did not supply them — counted per field, so one row that
15218 /// keeps two contributes two.
15219 ///
15220 /// This is the one respect in which `Replace` does not make a node's props
15221 /// exactly the supplied ones (see [`OnConflict::Replace`]). Those rows
15222 /// still count in `replaced` and still raise no `row_errors`, because
15223 /// nothing went wrong: a view's property is not the caller's to supply or
15224 /// to remove. A mirror rebuild that needs its copy to be byte-exact reads
15225 /// this to learn that the store kept fields its frame did not describe.
15226 pub kept_view_owned: usize,
15227 /// `(row, why)` for rows an [`OnConflict::Replace`] refused. `row` counts
15228 /// node-insert ops in this batch from zero, which for a caller that queues
15229 /// its nodes in order is the index of the offending node. The rest of the
15230 /// frame still commits; the refused row changes nothing.
15231 pub row_errors: Vec<(usize, String)>,
15232}
15233
15234/// The `(nodes_inserted, edges_inserted)` pair every pre-0.6.10 commit entry
15235/// point returns. Keeps those signatures unchanged now that the validate pass
15236/// produces a [`BatchOutcome`].
15237fn inserted_pair(outcome: BatchOutcome) -> (usize, usize) {
15238 (outcome.nodes_inserted, outcome.edges_inserted)
15239}
15240
15241/// One entry of a frame the validate pass has decided on, before
15242/// [`GraphDb::rewrite_wal_dense_planned`] turns it into dense-id records.
15243///
15244/// Almost every entry is already a finished [`WalRecord`]. The exception is a
15245/// duplicate edge insert: its count names a dense triple, and on the batch path
15246/// the endpoints and the edge type may all be created by earlier records in the
15247/// *same* frame, so no id for them exists until the dense rewrite allocates it.
15248/// Carrying the keys this far and resolving them there is what lets the count
15249/// survive the shape a mirror rebuild writes (defect #24).
15250enum PlannedRec {
15251 Rec(WalRecord),
15252 DuplicateCount {
15253 edge_type: String,
15254 src_key: String,
15255 dst_key: String,
15256 },
15257}
15258
15259/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
15260///
15261/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
15262/// callers can build a set of mutations without holding `&mut GraphDb` and
15263/// hand them off to the group-committing writer for durable, batched I/O.
15264pub enum BatchOp {
15265 InsertNode {
15266 label: String,
15267 key: String,
15268 props: Vec<(String, Value)>,
15269 },
15270 InsertEdge {
15271 edge_type: String,
15272 src_key: String,
15273 dst_key: String,
15274 },
15275 SetProp {
15276 key: String,
15277 field: String,
15278 value: Value,
15279 },
15280 RemoveProp {
15281 key: String,
15282 field: String,
15283 },
15284 DeleteEdge {
15285 edge_type: String,
15286 src_key: String,
15287 dst_key: String,
15288 },
15289 DeleteNode {
15290 key: String,
15291 },
15292 CreateRule(RuleDef),
15293 DeleteRule {
15294 name: String,
15295 },
15296 /// Rename a node's key. Validated: old must exist, new must not.
15297 RenameNode {
15298 old_key: String,
15299 new_key: String,
15300 },
15301 /// Insert an edge, auto-creating any missing endpoint as a plain node with
15302 /// `placeholder_label` and no props. Rules fire and last-change is updated
15303 /// for each created endpoint (normal InsertNode semantics in the batch frame).
15304 InsertEdgeUpsert {
15305 edge_type: String,
15306 src_key: String,
15307 dst_key: String,
15308 placeholder_label: String,
15309 },
15310 /// Insert `key`, or — when the key is already taken — do what `on_conflict`
15311 /// says. Queued by [`BatchBuilder::insert_node_on_conflict`]; `Error`
15312 /// queues a plain [`BatchOp::InsertNode`] instead, so this variant only
15313 /// ever carries `Skip` or `Replace`.
15314 InsertNodeOnConflict {
15315 label: String,
15316 key: String,
15317 props: Vec<(String, Value)>,
15318 on_conflict: OnConflict,
15319 },
15320}
15321
15322/// Three-way node visibility status used by `check_single_op_authz`.
15323enum NodeAuthzStatus {
15324 /// Node exists in the store and is in the role's read mask.
15325 Visible(String), // carries the node's label
15326 /// Node exists in the store but is NOT in the role's read mask.
15327 Hidden,
15328 /// Node does not exist in the store.
15329 Absent,
15330}
15331
15332/// Overlay of ops already accepted earlier in the same batch. Never written
15333/// back to the database — validation only.
15334#[derive(Default)]
15335struct Overlay {
15336 extra_keys: BTreeSet<String>,
15337 /// Label of each node inserted earlier in this batch. The store does not
15338 /// have these keys yet, so `label_of` cannot answer for them, and
15339 /// `OnConflict::Replace` has to compare labels.
15340 extra_labels: BTreeMap<String, String>,
15341 deleted_keys: BTreeSet<String>,
15342 /// `new key → store key` for every node an earlier op in this batch
15343 /// renamed. Such a key is in `extra_keys`, but unlike one a batch inserts
15344 /// it has a stored node behind it — under the old key until the batch
15345 /// commits — and its properties, label and edges are read through this.
15346 renamed_from: BTreeMap<String, String>,
15347 extra_props: BTreeMap<(String, String), Value>,
15348 removed_props: BTreeSet<(String, String)>,
15349 extra_edges: BTreeSet<(String, String, String)>,
15350 deleted_edges: BTreeSet<(String, String, String)>,
15351 extra_rules: BTreeSet<String>,
15352 deleted_rules: BTreeSet<String>,
15353 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
15354 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
15355 /// sees only the rules already committed to the engine. Keyed by name so a
15356 /// later `DeleteRule` in the same batch drops the arc with the rule.
15357 extra_rule_arcs: BTreeMap<String, (String, String)>,
15358}
15359
15360/// Read-only view of live db state plus a batch overlay. Shared by single-op
15361/// public methods (empty overlay) and `commit_batch`.
15362struct MutPreview<'a, F: Fs> {
15363 db: &'a GraphDb<F>,
15364 overlay: Overlay,
15365}
15366
15367/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
15368/// `None` if `target` is unreachable.
15369///
15370/// Used for rule-chain cycle detection, where an arc is "a rule hops over
15371/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
15372/// reported path is stable for a given rule set, and iterative so a pathological
15373/// rule graph cannot overflow the stack.
15374fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
15375 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
15376 for (from, to) in arcs {
15377 adj.entry(from.as_str()).or_default().insert(to.as_str());
15378 }
15379 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
15380 let mut visited: BTreeSet<&str> = BTreeSet::new();
15381 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
15382 visited.insert(start);
15383 queue.push_back(start);
15384 while let Some(node) = queue.pop_front() {
15385 if node == target {
15386 let mut path = vec![node.to_string()];
15387 let mut cur = node;
15388 while let Some(&p) = parent.get(cur) {
15389 path.push(p.to_string());
15390 cur = p;
15391 }
15392 path.reverse();
15393 return Some(path);
15394 }
15395 for &next in adj.get(node).into_iter().flatten() {
15396 if visited.insert(next) {
15397 parent.insert(next, node);
15398 queue.push_back(next);
15399 }
15400 }
15401 }
15402 None
15403}
15404
15405impl<'a, F: Fs> MutPreview<'a, F> {
15406 fn new(db: &'a GraphDb<F>) -> Self {
15407 Self {
15408 db,
15409 overlay: Overlay::default(),
15410 }
15411 }
15412
15413 fn has_key(&self, key: &str) -> bool {
15414 if self.overlay.extra_keys.contains(key) {
15415 return true;
15416 }
15417 if self.overlay.deleted_keys.contains(key) {
15418 return false;
15419 }
15420 self.db.ids.get(key).is_some()
15421 }
15422
15423 /// The key the store holds `key`'s node under, or `None` when the batch
15424 /// sees no stored node there: `key` is deleted, or was first inserted in
15425 /// this batch. A key an earlier op renamed stands for the node still
15426 /// stored under its old key.
15427 fn store_key<'k>(&'k self, key: &'k str) -> Option<&'k str> {
15428 if self.overlay.deleted_keys.contains(key) {
15429 return None;
15430 }
15431 if let Some(origin) = self.overlay.renamed_from.get(key) {
15432 return Some(origin.as_str());
15433 }
15434 if self.overlay.extra_keys.contains(key) {
15435 return None;
15436 }
15437 Some(key)
15438 }
15439
15440 fn has_prop(&self, key: &str, field: &str) -> bool {
15441 if !self.has_key(key) {
15442 return false;
15443 }
15444 let k = (key.to_string(), field.to_string());
15445 if self.overlay.removed_props.contains(&k) {
15446 return false;
15447 }
15448 if self.overlay.extra_props.contains_key(&k) {
15449 return true;
15450 }
15451 // Fresh identity (first insert in this batch, or delete+reinsert):
15452 // ignore props still sitting on the soon-to-be-tombstoned slot.
15453 let Some(stored) = self.store_key(key) else {
15454 return false;
15455 };
15456 self.db.get_prop(stored, field).is_some()
15457 }
15458
15459 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15460 let k = (
15461 edge_type.to_string(),
15462 src_key.to_string(),
15463 dst_key.to_string(),
15464 );
15465 if self.overlay.deleted_edges.contains(&k) {
15466 return false;
15467 }
15468 if self.overlay.extra_edges.contains(&k) {
15469 return true;
15470 }
15471 // A key created in this batch (including reinsert) has no db edges.
15472 let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15473 else {
15474 return false;
15475 };
15476 let Some(src) = self.db.ids.get(src_key) else {
15477 return false;
15478 };
15479 let Some(dst) = self.db.ids.get(dst_key) else {
15480 return false;
15481 };
15482 let Some(sym) = self.db.syms.get(edge_type) else {
15483 return false;
15484 };
15485 self.db
15486 .topo_view()
15487 .neighbors(sym, Direction::Out, src)
15488 .binary_search(&dst)
15489 .is_ok()
15490 }
15491
15492 fn has_rule(&self, name: &str) -> bool {
15493 if self.overlay.extra_rules.contains(name) {
15494 return true;
15495 }
15496 if self.overlay.deleted_rules.contains(name) {
15497 return false;
15498 }
15499 self.db.engine.rules().any(|r| r.name == name)
15500 }
15501
15502 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15503 let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15504 else {
15505 return false;
15506 };
15507 let Some(src) = self.db.ids.get(src_key) else {
15508 return false;
15509 };
15510 let Some(dst) = self.db.ids.get(dst_key) else {
15511 return false;
15512 };
15513 let Some(et) = self.db.syms.get(edge_type) else {
15514 return false;
15515 };
15516 // extra_rules is deliberately not consulted: a CreateRule earlier in
15517 // this batch has not fired, so it contributes no provenance. That is
15518 // the documented rule-window gap (see GraphDb::batch).
15519 if self.overlay.deleted_rules.is_empty() {
15520 return self.db.engine.is_owned(et, src, dst);
15521 }
15522 for (rule, triples) in self.db.engine.provenance() {
15523 if self.overlay.deleted_rules.contains(rule) {
15524 continue;
15525 }
15526 if triples.contains(&(et, src, dst)) {
15527 return true;
15528 }
15529 }
15530 false
15531 }
15532
15533 /// The refusals a node creation makes, in the order it makes them.
15534 ///
15535 /// A view owns its property, and creating a node that carries one is a
15536 /// write to it exactly as `set_prop` is — so it is refused here, at the one
15537 /// choke-point `GraphDb::insert_node`, `BatchOp::InsertNode` and the
15538 /// no-conflict arm of `BatchOp::InsertNodeOnConflict` all pass through.
15539 ///
15540 /// Leaving creation exempt was not harmless. The value was stored and
15541 /// served: a created node the view has no reason to revisit keeps the
15542 /// caller's number for the life of the handle, and the backfill at the next
15543 /// open overwrites it — so the store answered `deg = 777` before a restart
15544 /// and `deg = 0` after, for a property every other surface calls read-only.
15545 /// It also split one op two ways: supplying a view-owned field under
15546 /// `OnConflict::Replace` was already a row error on a taken key while the
15547 /// same field on a fresh key was accepted.
15548 ///
15549 /// Checked before the key, like [`MutPreview::prepare_remove_prop`], so the
15550 /// answer does not depend on whether the key exists.
15551 fn check_insert_node(&self, key: &str, props: &[(String, Value)]) -> Result<()> {
15552 for (field, _) in props {
15553 if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15554 return Err(GraphError::ViewPropReadOnly {
15555 view_name: view_name.to_string(),
15556 });
15557 }
15558 }
15559 if self.has_key(key) {
15560 Err(GraphError::DuplicateKey { key: key.into() })
15561 } else {
15562 Ok(())
15563 }
15564 }
15565
15566 fn check_live_key(&self, key: &str) -> Result<()> {
15567 if self.has_key(key) {
15568 Ok(())
15569 } else {
15570 Err(GraphError::KeyNotFound { key: key.into() })
15571 }
15572 }
15573
15574 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15575 for k in [src_key, dst_key] {
15576 if !self.has_key(k) {
15577 return Err(GraphError::KeyNotFound { key: k.into() });
15578 }
15579 }
15580 if self.is_rule_owned(edge_type, src_key, dst_key) {
15581 return Err(GraphError::RuleOwned {
15582 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
15583 });
15584 }
15585 // A user-written edge stays inside one namespace. Derived edges do not
15586 // come through here — the engine adds them directly — and the rule
15587 // scoping check is what keeps those pure.
15588 let src_ns = self.namespace_in_batch(src_key);
15589 let dst_ns = self.namespace_in_batch(dst_key);
15590 if src_ns != dst_ns {
15591 return Err(GraphError::CrossNamespace {
15592 src: src_key.to_string(),
15593 src_ns,
15594 dst: dst_key.to_string(),
15595 dst_ns,
15596 });
15597 }
15598 Ok(!self.has_edge(edge_type, src_key, dst_key))
15599 }
15600
15601 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
15602 // A view owns its property, and the refusal has to live here rather
15603 // than on `GraphDb::remove_prop`: `BatchOp::RemoveProp` never meets
15604 // that one, and it is what the HTTP `DELETE /node/{key}/prop/{field}`
15605 // route, `Batch::remove_prop` and the CLI all submit. This is the one
15606 // choke-point every removal passes, exactly as it is for `ns` below.
15607 // Checked before the key, so the answer does not depend on whether the
15608 // key exists — which is also what `GraphDb::remove_prop` answered when
15609 // it carried the only copy of this guard.
15610 if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15611 return Err(GraphError::ViewPropReadOnly {
15612 view_name: view_name.to_string(),
15613 });
15614 }
15615 self.check_live_key(key)?;
15616 // Removing `ns` is changing the namespace — to `default`, the namespace
15617 // an absent property names. It goes through this one choke-point and NOT
15618 // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
15619 // the immutability rule has to be stated here as well. Without it the
15620 // node silently lands in `default` on the next open: the cross-namespace
15621 // edge guard is defeated and a default-bound role reads a tenant's node.
15622 if field == NS_PROP {
15623 let from = self.namespace_in_batch(key);
15624 if from != NS_DEFAULT {
15625 return Err(GraphError::NamespaceImmutable {
15626 key: key.to_string(),
15627 from,
15628 to: NS_DEFAULT.to_string(),
15629 });
15630 }
15631 // Already in `default`: the removal changes no namespace. It is the
15632 // no-op `set_prop` to the current namespace is, not an error.
15633 return Ok(false);
15634 }
15635 Ok(self.has_prop(key, field))
15636 }
15637
15638 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15639 for k in [src_key, dst_key] {
15640 if !self.has_key(k) {
15641 return Err(GraphError::KeyNotFound { key: k.into() });
15642 }
15643 }
15644 // Provenance-owned OR a live rule would derive this pair. User-first
15645 // edges that a later rule matches are not in `owned`, but deleting
15646 // them would leave a hole `rebuild_rule` immediately fills.
15647 if self.is_rule_owned(edge_type, src_key, dst_key) {
15648 return Err(GraphError::RuleOwned {
15649 detail: format!(
15650 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15651 delete or change the owning rule"
15652 ),
15653 });
15654 }
15655 if self.would_derive(edge_type, src_key, dst_key) {
15656 return Err(GraphError::RuleOwned {
15657 detail: format!(
15658 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15659 delete or change the owning rule, or a live rule would re-derive it"
15660 ),
15661 });
15662 }
15663 Ok(self.has_edge(edge_type, src_key, dst_key))
15664 }
15665
15666 /// True if any live rule (minus overlay-deleted names) would derive
15667 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
15668 /// CreateRule names in `extra_rules` are ignored — same documented
15669 /// same-batch rule-window as [`Self::is_rule_owned`].
15670 ///
15671 /// Mirrored by `guard_matches` in `memory/forget.rs`, which names the
15672 /// rules this refused for: change the two together (ledger row 67).
15673 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15674 if src_key == dst_key {
15675 return false;
15676 }
15677 let Some(src_label) = self.label_of(src_key) else {
15678 return false;
15679 };
15680 let Some(dst_label) = self.label_of(dst_key) else {
15681 return false;
15682 };
15683 for rule in self.db.engine.rules() {
15684 if self.overlay.deleted_rules.contains(&rule.name) {
15685 continue;
15686 }
15687 if rule.edge_type != edge_type {
15688 continue;
15689 }
15690 if rule.src_label != src_label || rule.dst_label != dst_label {
15691 continue;
15692 }
15693 let src_props = |f: &str| self.prop_value(src_key, f);
15694 let dst_props = |f: &str| self.prop_value(dst_key, f);
15695 let src_view = NodeView {
15696 key: src_key,
15697 props: &src_props,
15698 };
15699 let dst_view = NodeView {
15700 key: dst_key,
15701 props: &dst_props,
15702 };
15703 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
15704 return true;
15705 }
15706 }
15707 false
15708 }
15709
15710 fn label_of(&self, key: &str) -> Option<String> {
15711 if self.overlay.deleted_keys.contains(key) {
15712 return None;
15713 }
15714 // Fresh identities created in this batch have no stored label in the
15715 // overlay; they cannot be provenance-owned yet either. A key this
15716 // batch renamed has its label on the node stored under the old key.
15717 let stored = self
15718 .overlay
15719 .renamed_from
15720 .get(key)
15721 .map_or(key, String::as_str);
15722 let id = self.db.ids.get(stored)?;
15723 let sym = self.db.labels.get(id as usize).copied()?;
15724 if sym == u32::MAX {
15725 return None;
15726 }
15727 self.db.syms.resolve(sym).map(str::to_string)
15728 }
15729
15730 /// The label `key` carries as this batch sees it — including a node
15731 /// inserted earlier in the same batch, which the store does not have yet.
15732 fn label_in_batch(&self, key: &str) -> Option<String> {
15733 if self.overlay.deleted_keys.contains(key) {
15734 return None;
15735 }
15736 if let Some(label) = self.overlay.extra_labels.get(key) {
15737 return Some(label.clone());
15738 }
15739 self.label_of(key)
15740 }
15741
15742 /// The property writes that make `key`'s props exactly `props`, or why the
15743 /// row is refused.
15744 ///
15745 /// `Some(value)` is a set and `None` is a removal. `store_fields` is every
15746 /// field name the store knows, hoisted by the caller so a frame of N
15747 /// replaces reads the field list once rather than N times.
15748 ///
15749 /// The second half of the pair is how many view-owned fields this row kept
15750 /// rather than removed — the one part of "exactly the supplied props" that
15751 /// does not hold, and the caller's only signal that it did not.
15752 ///
15753 /// The refusals are row errors, not frame errors: a mirror rebuild should
15754 /// learn which of its rows disagree with the store without losing the rows
15755 /// that agree.
15756 fn plan_replace(
15757 &self,
15758 label: &str,
15759 key: &str,
15760 props: &[(String, Value)],
15761 store_fields: &[String],
15762 ) -> std::result::Result<ReplacePlan, String> {
15763 // A different label is a relabel, and a rebuild does not relabel: that
15764 // is `rename_node` or an explicit write, never a side effect here.
15765 let stored = self.label_in_batch(key).unwrap_or_default();
15766 if stored != label {
15767 return Err(format!(
15768 "node {key}: on_conflict=\"replace\" will not relabel {stored:?} to {label:?}; \
15769 relabelling is rename_node or an explicit write"
15770 ));
15771 }
15772 // `ns` is immutable. Replace removes what the supplied props omit, so
15773 // an omitted `ns` is a move to `default` exactly as a different `ns` is
15774 // a move to that one; both are the same refusal.
15775 let from = self.namespace_in_batch(key);
15776 let to = match props.iter().find(|(field, _)| field == NS_PROP) {
15777 Some((_, Value::Str(ns))) => ns.clone(),
15778 Some((_, value)) => {
15779 return Err(format!(
15780 "node {key}: {NS_PROP} must be a string naming a namespace, got {value:?}"
15781 ));
15782 }
15783 None => NS_DEFAULT.to_string(),
15784 };
15785 if to != from {
15786 return Err(format!(
15787 "node {key}: {NS_PROP} is immutable; on_conflict=\"replace\" cannot move it \
15788 from {from:?} to {to:?}"
15789 ));
15790 }
15791
15792 if let Some(why) = self.supplied_view_owned_prop(key, props) {
15793 return Err(why);
15794 }
15795
15796 let supplied: BTreeSet<&str> = props.iter().map(|(field, _)| field.as_str()).collect();
15797 let mut writes = Vec::new();
15798 for (field, value) in props {
15799 // `ns` names the namespace the node is already in, so the write is
15800 // the no-op the dense-rewrite seam would drop anyway.
15801 if field == NS_PROP {
15802 continue;
15803 }
15804 // Already exactly this value: a rebuild of an unchanged row should
15805 // cost no WAL record.
15806 if self.prop_value(key, field).as_ref() == Some(value) {
15807 continue;
15808 }
15809 writes.push((field.clone(), Some(value.clone())));
15810 }
15811 // Everything the node still carries that the supplied props do not.
15812 // `ns` is never removed: it is immutable, and the check above has
15813 // already established the node stays where it is.
15814 let overlay_fields = self
15815 .overlay
15816 .extra_props
15817 .keys()
15818 .filter(|(k, _)| k == key)
15819 .map(|(_, field)| field.as_str());
15820 //
15821 // A view-owned field is filtered out rather than refused. It is not the
15822 // caller's to supply (supplying one is still the row error above) and
15823 // so it is not part of what "exactly the supplied ones" ranges over:
15824 // omitting it is not a request to delete it. Refusing here instead
15825 // would make `replace` impossible for every node a view has written to
15826 // — which on a store carrying a view is the whole population a mirror
15827 // rebuild has to cover.
15828 let omitted: BTreeSet<&str> = store_fields
15829 .iter()
15830 .map(String::as_str)
15831 .chain(overlay_fields)
15832 .filter(|field| {
15833 *field != NS_PROP && !supplied.contains(field) && self.has_prop(key, field)
15834 })
15835 .collect();
15836 // The view-owned half is kept, and counted: the row still commits and
15837 // still reports no error, so without this number a mirror rebuild is
15838 // told it got exactly what it asked for when it did not (defect #18).
15839 let (stale, kept): (Vec<&str>, Vec<&str>) = omitted
15840 .into_iter()
15841 .partition(|field| self.db.view_store.view_for_prop(field).is_none());
15842 writes.extend(stale.into_iter().map(|field| (field.to_string(), None)));
15843 Ok((writes, kept.len()))
15844 }
15845
15846 /// The row error a supplied view-owned field earns, or `None`.
15847 ///
15848 /// Shared by [`MutPreview::plan_replace`] and the no-conflict arm of
15849 /// `BatchOp::InsertNodeOnConflict` so that one op answers a supplied
15850 /// view-owned field the same way whether or not the key was already taken.
15851 fn supplied_view_owned_prop(&self, key: &str, props: &[(String, Value)]) -> Option<String> {
15852 props.iter().find_map(|(field, _)| {
15853 self.db.view_store.view_for_prop(field).map(|view_name| {
15854 format!(
15855 "node {key}: property {field:?} is owned by view {view_name:?} and is \
15856 read-only"
15857 )
15858 })
15859 })
15860 }
15861
15862 /// The namespace `key` is in as this batch sees it — including a node
15863 /// inserted earlier in the same batch, which the store does not have yet.
15864 fn namespace_in_batch(&self, key: &str) -> String {
15865 namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
15866 }
15867
15868 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
15869 if !self.has_key(key) {
15870 return None;
15871 }
15872 let k = (key.to_string(), field.to_string());
15873 if self.overlay.removed_props.contains(&k) {
15874 return None;
15875 }
15876 if let Some(v) = self.overlay.extra_props.get(&k) {
15877 return Some(v.clone());
15878 }
15879 self.db.get_prop(self.store_key(key)?, field)
15880 }
15881
15882 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
15883 def.validate()
15884 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
15885 if self.has_rule(&def.name) {
15886 return Err(GraphError::RuleInvalid {
15887 detail: format!("rule {:?} already exists", def.name),
15888 });
15889 }
15890 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
15891 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
15892 // `edge_type`". A cycle in that graph is a rule set that would re-fire
15893 // itself forever; the engine's depth cap would silently truncate it
15894 // instead, leaving an arbitrary partial result. Reject it here, the one
15895 // place that sees the whole rule set.
15896 //
15897 // Rules accepted earlier in the same batch count too: the overlay
15898 // carries their arcs, so a cycle cannot be assembled one op at a time.
15899 if let Some(via) = def.via_edge.as_deref() {
15900 if via == def.edge_type {
15901 return Err(GraphError::RuleInvalid {
15902 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
15903 });
15904 }
15905 let mut arcs: Vec<(String, String)> = self
15906 .db
15907 .engine
15908 .rules()
15909 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
15910 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
15911 .collect();
15912 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
15913 arcs.push((via.to_string(), def.edge_type.clone()));
15914 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
15915 return Err(GraphError::RuleInvalid {
15916 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
15917 });
15918 }
15919 }
15920 Ok(())
15921 }
15922
15923 fn check_delete_rule(&self, name: &str) -> Result<()> {
15924 if self.has_rule(name) {
15925 Ok(())
15926 } else {
15927 Err(GraphError::RuleNotFound { name: name.into() })
15928 }
15929 }
15930
15931 fn note_insert_node(&mut self, label: &str, key: &str, props: &[(String, Value)]) {
15932 self.overlay.deleted_keys.remove(key);
15933 self.overlay.renamed_from.remove(key);
15934 self.overlay.extra_keys.insert(key.to_string());
15935 self.overlay
15936 .extra_labels
15937 .insert(key.to_string(), label.to_string());
15938 self.overlay.extra_props.retain(|(k, _), _| k != key);
15939 self.overlay.removed_props.retain(|(k, _)| k != key);
15940 for (field, value) in props {
15941 self.overlay
15942 .extra_props
15943 .insert((key.to_string(), field.clone()), value.clone());
15944 }
15945 }
15946
15947 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15948 let k = (
15949 edge_type.to_string(),
15950 src_key.to_string(),
15951 dst_key.to_string(),
15952 );
15953 self.overlay.deleted_edges.remove(&k);
15954 self.overlay.extra_edges.insert(k);
15955 }
15956
15957 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
15958 let k = (key.to_string(), field.to_string());
15959 self.overlay.removed_props.remove(&k);
15960 self.overlay.extra_props.insert(k, value.clone());
15961 }
15962
15963 fn note_remove_prop(&mut self, key: &str, field: &str) {
15964 let k = (key.to_string(), field.to_string());
15965 self.overlay.extra_props.remove(&k);
15966 self.overlay.removed_props.insert(k);
15967 }
15968
15969 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15970 let k = (
15971 edge_type.to_string(),
15972 src_key.to_string(),
15973 dst_key.to_string(),
15974 );
15975 self.overlay.extra_edges.remove(&k);
15976 self.overlay.deleted_edges.insert(k);
15977 }
15978
15979 fn note_delete_node(&mut self, key: &str) {
15980 self.overlay.extra_keys.remove(key);
15981 self.overlay.renamed_from.remove(key);
15982 self.overlay.deleted_keys.insert(key.to_string());
15983 self.overlay.extra_props.retain(|(k, _), _| k != key);
15984 self.overlay.removed_props.retain(|(k, _)| k != key);
15985 self.overlay
15986 .extra_edges
15987 .retain(|(_, s, d)| s != key && d != key);
15988 self.overlay
15989 .deleted_edges
15990 .retain(|(_, s, d)| s != key && d != key);
15991 }
15992
15993 fn note_create_rule(&mut self, def: &RuleDef) {
15994 self.overlay.deleted_rules.remove(&def.name);
15995 self.overlay.extra_rules.insert(def.name.clone());
15996 // Rules accepted earlier in this batch are not in the engine yet, so
15997 // the cycle check would not see their arcs. Keep the arc, not just the
15998 // name, so a batch cannot smuggle in a cycle one op at a time.
15999 if let Some(via) = def.via_edge.clone() {
16000 self.overlay
16001 .extra_rule_arcs
16002 .insert(def.name.clone(), (via, def.edge_type.clone()));
16003 }
16004 }
16005
16006 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
16007 if !self.has_key(old) {
16008 return Err(GraphError::KeyNotFound { key: old.into() });
16009 }
16010 if self.has_key(new) {
16011 return Err(GraphError::DuplicateKey { key: new.into() });
16012 }
16013 Ok(())
16014 }
16015
16016 fn note_rename_node(&mut self, old: &str, new: &str) {
16017 // The stored node `new` will stand for: the one `old` stood for if an
16018 // earlier op renamed it here, `old` itself if the store has it, and
16019 // none if `old` was first inserted in this batch.
16020 let origin = match self.overlay.renamed_from.remove(old) {
16021 Some(origin) => Some(origin),
16022 None if self.overlay.extra_keys.contains(old) => None,
16023 None => Some(old.to_string()),
16024 };
16025 // Mark old as deleted so subsequent batch ops cannot reference it.
16026 self.overlay.extra_keys.remove(old);
16027 self.overlay.deleted_keys.insert(old.to_string());
16028 // Mark new as extra so subsequent batch ops can reference it.
16029 self.overlay.deleted_keys.remove(new);
16030 self.overlay.extra_keys.insert(new.to_string());
16031 // Migrate any overlay props from old key to new key.
16032 let new_str = new.to_string();
16033 let transferred: Vec<((String, String), Value)> = self
16034 .overlay
16035 .extra_props
16036 .iter()
16037 .filter(|((k, _), _)| k.as_str() == old)
16038 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
16039 .collect();
16040 self.overlay
16041 .extra_props
16042 .retain(|(k, _), _| k.as_str() != old);
16043 for (k, v) in transferred {
16044 self.overlay.extra_props.insert(k, v);
16045 }
16046 // Migrate removed_props.
16047 let transferred_removed: Vec<(String, String)> = self
16048 .overlay
16049 .removed_props
16050 .iter()
16051 .filter(|(k, _)| k.as_str() == old)
16052 .map(|(_, f)| (new_str.clone(), f.clone()))
16053 .collect();
16054 self.overlay
16055 .removed_props
16056 .retain(|(k, _)| k.as_str() != old);
16057 for k in transferred_removed {
16058 self.overlay.removed_props.insert(k);
16059 }
16060 if let Some(label) = self.overlay.extra_labels.remove(old) {
16061 self.overlay.extra_labels.insert(new_str.clone(), label);
16062 }
16063 // Edges the batch added or deleted follow the node to its new key.
16064 let follow = |k: String| if k == old { new_str.clone() } else { k };
16065 self.overlay.extra_edges = std::mem::take(&mut self.overlay.extra_edges)
16066 .into_iter()
16067 .map(|(t, s, d)| (t, follow(s), follow(d)))
16068 .collect();
16069 self.overlay.deleted_edges = std::mem::take(&mut self.overlay.deleted_edges)
16070 .into_iter()
16071 .map(|(t, s, d)| (t, follow(s), follow(d)))
16072 .collect();
16073 if let Some(origin) = origin {
16074 self.overlay.renamed_from.insert(new_str, origin);
16075 }
16076 }
16077
16078 fn note_delete_rule(&mut self, name: &str) {
16079 self.overlay.extra_rules.remove(name);
16080 // Drop its chain arc too: a rule created and then deleted in the same
16081 // batch must not make a later, legal rule look like a cycle.
16082 self.overlay.extra_rule_arcs.remove(name);
16083 self.overlay.deleted_rules.insert(name.to_string());
16084 // Treat the deleted rule's current provenance as gone so a later
16085 // delete_edge of those triples is a no-op (matches sequential).
16086 //
16087 // Provenance names nodes by the key the store holds them under; the
16088 // overlay names them as the batch does. A node an earlier op renamed
16089 // is recorded under its new key, or a later read of that key would go
16090 // through to the store and find the rule's edge still there.
16091 let now: BTreeMap<&str, &str> = self
16092 .overlay
16093 .renamed_from
16094 .iter()
16095 .map(|(new, stored)| (stored.as_str(), new.as_str()))
16096 .collect();
16097 if let Some(triples) = self.db.engine.provenance().get(name) {
16098 for &(et, s, d) in triples {
16099 let Some(etype) = self.db.syms.resolve(et) else {
16100 continue;
16101 };
16102 let Some(src) = self.db.ids.key_of(s) else {
16103 continue;
16104 };
16105 let Some(dst) = self.db.ids.key_of(d) else {
16106 continue;
16107 };
16108 let src = now.get(src).copied().unwrap_or(src);
16109 let dst = now.get(dst).copied().unwrap_or(dst);
16110 let k = (etype.to_string(), src.to_string(), dst.to_string());
16111 self.overlay.extra_edges.remove(&k);
16112 self.overlay.deleted_edges.insert(k);
16113 }
16114 }
16115 }
16116}
16117
16118/// Collects mutations and commits them as one WAL `Batch` frame.
16119///
16120/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
16121/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
16122/// See [`GraphDb::batch`] for validation and atomicity rules.
16123pub struct BatchBuilder<'a, F: Fs> {
16124 db: &'a mut GraphDb<F>,
16125 ops: Vec<BatchOp>,
16126}
16127
16128impl<'a, F: Fs> BatchBuilder<'a, F> {
16129 pub fn insert_node(
16130 &mut self,
16131 label: &str,
16132 key: &str,
16133 props: Vec<(String, Value)>,
16134 ) -> &mut Self {
16135 self.ops.push(BatchOp::InsertNode {
16136 label: label.into(),
16137 key: key.into(),
16138 props,
16139 });
16140 self
16141 }
16142
16143 /// Queue a node insert whose answer to a taken key is `on_conflict`.
16144 ///
16145 /// [`OnConflict::Error`] queues exactly the op [`insert_node`](Self::insert_node)
16146 /// does, so the default path is unchanged.
16147 pub fn insert_node_on_conflict(
16148 &mut self,
16149 label: &str,
16150 key: &str,
16151 props: Vec<(String, Value)>,
16152 on_conflict: OnConflict,
16153 ) -> &mut Self {
16154 self.ops.push(match on_conflict {
16155 OnConflict::Error => BatchOp::InsertNode {
16156 label: label.into(),
16157 key: key.into(),
16158 props,
16159 },
16160 on_conflict => BatchOp::InsertNodeOnConflict {
16161 label: label.into(),
16162 key: key.into(),
16163 props,
16164 on_conflict,
16165 },
16166 });
16167 self
16168 }
16169
16170 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16171 self.ops.push(BatchOp::InsertEdge {
16172 edge_type: edge_type.into(),
16173 src_key: src_key.into(),
16174 dst_key: dst_key.into(),
16175 });
16176 self
16177 }
16178
16179 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
16180 self.ops.push(BatchOp::SetProp {
16181 key: key.into(),
16182 field: field.into(),
16183 value,
16184 });
16185 self
16186 }
16187
16188 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
16189 self.ops.push(BatchOp::RemoveProp {
16190 key: key.into(),
16191 field: field.into(),
16192 });
16193 self
16194 }
16195
16196 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16197 self.ops.push(BatchOp::DeleteEdge {
16198 edge_type: edge_type.into(),
16199 src_key: src_key.into(),
16200 dst_key: dst_key.into(),
16201 });
16202 self
16203 }
16204
16205 pub fn delete_node(&mut self, key: &str) -> &mut Self {
16206 self.ops.push(BatchOp::DeleteNode { key: key.into() });
16207 self
16208 }
16209
16210 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
16211 self.ops.push(BatchOp::CreateRule(def));
16212 self
16213 }
16214
16215 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
16216 self.ops.push(BatchOp::DeleteRule { name: name.into() });
16217 self
16218 }
16219
16220 /// Queue a node-rename in this batch.
16221 ///
16222 /// Validation (old exists, new not taken) runs at commit time.
16223 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
16224 self.ops.push(BatchOp::RenameNode {
16225 old_key: old_key.into(),
16226 new_key: new_key.into(),
16227 });
16228 self
16229 }
16230
16231 /// Queue an edge insert with endpoint auto-creation.
16232 ///
16233 /// Any missing endpoint is created as a plain node `{key, label:
16234 /// placeholder_label, no props}` inside this batch frame. Rules fire and
16235 /// last-change is updated for each auto-created node.
16236 pub fn insert_edge_upsert(
16237 &mut self,
16238 edge_type: &str,
16239 src_key: &str,
16240 dst_key: &str,
16241 placeholder_label: &str,
16242 ) -> &mut Self {
16243 self.ops.push(BatchOp::InsertEdgeUpsert {
16244 edge_type: edge_type.into(),
16245 src_key: src_key.into(),
16246 dst_key: dst_key.into(),
16247 placeholder_label: placeholder_label.into(),
16248 });
16249 self
16250 }
16251
16252 /// Validate every queued op, then log one `Batch` frame and apply.
16253 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
16254 /// A second `commit()` after a successful one is an empty-batch no-op
16255 /// (queued ops were taken).
16256 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
16257 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
16258 ///
16259 /// **Rule-window limitation:** batch validation cannot see edges that a
16260 /// rule created earlier in the *same* batch will derive at apply time, so
16261 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
16262 /// where sequential calls would return `Err(RuleOwned)`. State integrity
16263 /// is unaffected (idempotent apply, provenance intact). Create rules in
16264 /// their own batch, or sequentially, when later ops may touch derived
16265 /// edges.
16266 /// Validate every queued op and commit atomically.
16267 ///
16268 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
16269 /// WAL records actually written (duplicate edges are silent no-ops and are
16270 /// NOT counted). Both are 0 when the batch is empty or all-noop.
16271 pub fn commit(&mut self) -> Result<(usize, usize)> {
16272 let ops = std::mem::take(&mut self.ops);
16273 self.db.commit_batch(ops)
16274 }
16275
16276 /// [`commit`](Self::commit) with the full [`BatchOutcome`] — the counts a
16277 /// caller needs when its rows carry an [`OnConflict`] policy.
16278 pub fn commit_outcome(&mut self) -> Result<BatchOutcome> {
16279 let ops = std::mem::take(&mut self.ops);
16280 self.db.commit_logged_batch(ops, None, None)
16281 }
16282
16283 /// Same as [`commit`](Self::commit) but tail the inner events with
16284 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
16285 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
16286 let ops = std::mem::take(&mut self.ops);
16287 self.db
16288 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
16289 .map(inserted_pair)
16290 }
16291}
16292
16293pub struct NodeRef<'a, F: Fs> {
16294 db: &'a GraphDb<F>,
16295 id: u32,
16296}
16297
16298impl<'a, F: Fs> NodeRef<'a, F> {
16299 pub fn key(&self) -> &str {
16300 self.db.ids.key_of(self.id).expect("dense ids")
16301 }
16302
16303 pub fn label(&self) -> &str {
16304 let sym = self
16305 .db
16306 .labels
16307 .get(self.id as usize)
16308 .copied()
16309 .filter(|&s| s != u32::MAX)
16310 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16311 self.db.syms.resolve(sym).expect("interned label symbol")
16312 }
16313
16314 pub fn prop(&self, field: &str) -> Option<Value> {
16315 self.db
16316 .props_view()
16317 .get(self.id, field)
16318 .map(|vr| vr.into_value())
16319 }
16320
16321 /// All stored fields for this node, sorted by field name.
16322 ///
16323 /// Reads from the full base+overlay view so that props stored only in the
16324 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
16325 pub fn props(&self) -> BTreeMap<String, Value> {
16326 let mut out = BTreeMap::new();
16327 let pv = self.db.props_view();
16328 for field in pv.field_names() {
16329 if let Some(vr) = pv.get(self.id, &field) {
16330 out.insert(field, vr.into_value());
16331 }
16332 }
16333 out
16334 }
16335
16336 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
16337 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
16338 let view = self.db.view();
16339 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
16340 names
16341 .iter()
16342 .filter_map(|name| view.syms.get(name))
16343 .collect()
16344 });
16345 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
16346 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
16347 for (nid, d) in nb.nodes {
16348 let key = view.key_of(nid);
16349 let label = view
16350 .label_of(nid)
16351 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16352 rs.push_row(vec![
16353 Some(Value::Str(key.to_string())),
16354 Some(Value::Str(label.to_string())),
16355 Some(Value::Int(d as i64)),
16356 ]);
16357 }
16358 rs
16359 }
16360
16361 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
16362 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
16363 let view = self.db.view();
16364 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
16365 for e in expand(&view, self.id, None, Dir::Both) {
16366 // Skip edges with unknown etypes (only possible from corrupt large
16367 // TOPOLOGY section; function returns BTreeMap not Result).
16368 let Some(etype) = view.syms.resolve(e.etype) else {
16369 continue;
16370 };
16371 let etype = etype.to_string();
16372 let nbr = if e.src == self.id { e.dst } else { e.src };
16373 groups
16374 .entry(etype)
16375 .or_default()
16376 .insert(view.key_of(nbr).to_string());
16377 }
16378 groups
16379 .into_iter()
16380 .map(|(k, v)| (k, v.into_iter().collect()))
16381 .collect()
16382 }
16383}
16384
16385#[cfg(test)]
16386mod tests {
16387 use super::*;
16388 use core_rules::Predicate;
16389
16390 fn tmp_dir(name: &str) -> std::path::PathBuf {
16391 let d =
16392 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
16393 let _ = std::fs::remove_dir_all(&d);
16394 d
16395 }
16396
16397 fn fk_rule() -> RuleDef {
16398 RuleDef {
16399 name: "works_at".into(),
16400 src_label: "Person".into(),
16401 dst_label: "Org".into(),
16402 predicate: Predicate::KeyMatch {
16403 field: "org_id".into(),
16404 },
16405 edge_type: "WORKS_AT".into(),
16406 weight_prop: None,
16407 max_edges: None,
16408 approximate: false,
16409 via_label: None,
16410 via_edge: None,
16411 via_dir: None,
16412 namespace: None,
16413 }
16414 }
16415
16416 /// Regression guard for the no-views delta-copy fast path.
16417 ///
16418 /// When no views are defined, `pending_deltas_since().to_vec()` must never
16419 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
16420 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
16421 /// a count of 0 after the entire sequence proves the guard fires correctly.
16422 #[test]
16423 fn no_delta_copy_when_no_views() {
16424 DELTA_COPY_COUNT.with(|c| c.set(0));
16425 let dir = tmp_dir("no-delta-copy");
16426 {
16427 let mut db = GraphDb::open(&dir).unwrap();
16428 // Insert 50 Org + 50 Person nodes with FK links.
16429 for i in 0..50u32 {
16430 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16431 }
16432 for i in 0..50u32 {
16433 db.insert_node(
16434 "Person",
16435 &format!("p{i}"),
16436 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16437 )
16438 .unwrap();
16439 }
16440 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
16441 db.create_rule(fk_rule()).unwrap();
16442
16443 // Counter must stay 0 — no views, no copies.
16444 let copies = DELTA_COPY_COUNT.with(|c| c.get());
16445 assert_eq!(
16446 copies, 0,
16447 "pending_deltas_since().to_vec() called despite no views"
16448 );
16449
16450 // Derived edges must still be correct (the guard skips only the
16451 // empty delta propagation loop, not the rule application itself).
16452 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
16453 assert_eq!(
16454 nbrs,
16455 vec!["o0"],
16456 "rule must derive edges even with no views"
16457 );
16458 }
16459 let _ = std::fs::remove_dir_all(&dir);
16460 }
16461
16462 /// Gating regression: subscribe AFTER a backfill must see no stale events.
16463 /// subscribe BEFORE a backfill must see every edge-fire event.
16464 #[test]
16465 fn subscribe_after_backfill_no_stale_events() {
16466 let dir = tmp_dir("sub-after-backfill");
16467 {
16468 let mut db = GraphDb::open(&dir).unwrap();
16469 for i in 0..10u32 {
16470 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16471 db.insert_node(
16472 "Person",
16473 &format!("p{i}"),
16474 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16475 )
16476 .unwrap();
16477 }
16478 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
16479 db.create_rule(fk_rule()).unwrap();
16480
16481 // Subscribe AFTER the backfill — queue must be empty (no stale events).
16482 let sub = db.subscribe_all_rules().unwrap();
16483 // No events should have queued for the prior backfill.
16484 assert!(
16485 sub.try_recv().is_none(),
16486 "subscribe after backfill must see no stale events"
16487 );
16488
16489 // Inserting a new node now should fire an event (emit_deltas is now true).
16490 db.insert_node("Org", "o_new", vec![]).unwrap();
16491 db.insert_node(
16492 "Person",
16493 "p_new",
16494 vec![("org_id".into(), Value::Str("o_new".into()))],
16495 )
16496 .unwrap();
16497 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
16498 assert!(
16499 ev.is_some(),
16500 "edge-fire event must arrive after subscribe (emit_deltas=true)"
16501 );
16502 }
16503 let _ = std::fs::remove_dir_all(&dir);
16504 }
16505
16506 /// Gating regression: subscribe BEFORE a backfill → events flow.
16507 #[test]
16508 fn subscribe_before_backfill_events_flow() {
16509 let dir = tmp_dir("sub-before-backfill");
16510 {
16511 let mut db = GraphDb::open(&dir).unwrap();
16512 // Subscribe FIRST — emit_deltas becomes true.
16513 let sub = db.subscribe_all_rules().unwrap();
16514
16515 for i in 0..5u32 {
16516 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16517 db.insert_node(
16518 "Person",
16519 &format!("p{i}"),
16520 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16521 )
16522 .unwrap();
16523 }
16524 // Backfill fires with emit_deltas=true → events queued.
16525 db.create_rule(fk_rule()).unwrap();
16526
16527 // Should receive at least one edge-fired event from the backfill.
16528 let mut received = 0usize;
16529 while sub.try_recv().is_some() {
16530 received += 1;
16531 }
16532 assert!(
16533 received > 0,
16534 "subscribe before backfill must receive edge-fire events (got 0)"
16535 );
16536 }
16537 let _ = std::fs::remove_dir_all(&dir);
16538 }
16539
16540 /// Companion: when a view IS defined, the delta path fires and view values update.
16541 #[test]
16542 fn delta_copy_fires_when_view_exists() {
16543 use core_rules::ViewSource;
16544 DELTA_COPY_COUNT.with(|c| c.set(0));
16545 let dir = tmp_dir("delta-copy-with-view");
16546 {
16547 let mut db = GraphDb::open(&dir).unwrap();
16548 db.insert_node("Org", "o1", vec![]).unwrap();
16549 db.insert_node(
16550 "Person",
16551 "p1",
16552 vec![("org_id".into(), Value::Str("o1".into()))],
16553 )
16554 .unwrap();
16555 // Declare a Degree view so is_empty() returns false.
16556 db.create_view(ViewDef {
16557 name: "degree_out".into(),
16558 label: "Person".into(),
16559 view_prop: "degree_out".into(),
16560 source: ViewSource::Degree {
16561 edge_type: "WORKS_AT".into(),
16562 direction: Direction::Out,
16563 },
16564 })
16565 .unwrap();
16566 db.create_rule(fk_rule()).unwrap();
16567
16568 // At least one delta copy should have happened (CreateRule backfill).
16569 let copies = DELTA_COPY_COUNT.with(|c| c.get());
16570 assert!(
16571 copies > 0,
16572 "expected delta copy to fire when a view is defined"
16573 );
16574
16575 // View value should be computed: p1 has one WORKS_AT out-edge.
16576 let info = db.node_info("p1").unwrap();
16577 let degree = info.props.get("degree_out");
16578 assert!(
16579 degree.is_some(),
16580 "view prop should be written to node props"
16581 );
16582 }
16583 let _ = std::fs::remove_dir_all(&dir);
16584 }
16585
16586 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
16587 /// derived-edge-driven view values reflect the as-of state rather than just
16588 /// the initial backfill written at `CreateView` time.
16589 ///
16590 /// Base WAL frames (indices 0..=5 before history markers):
16591 /// 0: insert Org "o1"
16592 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
16593 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
16594 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
16595 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
16596 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
16597 ///
16598 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
16599 /// no-op), so the total commit count is higher than the base frame count.
16600 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
16601 ///
16602 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
16603 /// initial backfill value (0) instead of reflecting the replayed derived edges.
16604 #[test]
16605 fn open_at_derived_edge_view_values_correct() {
16606 use core_rules::ViewSource;
16607 let dir = tmp_dir("open-at-view-rebuild");
16608 {
16609 let mut db = GraphDb::open(&dir).unwrap();
16610 // frame 0
16611 db.insert_node("Org", "o1", vec![]).unwrap();
16612 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
16613 db.create_view(ViewDef {
16614 name: "employee_count".into(),
16615 label: "Org".into(),
16616 view_prop: "emp".into(),
16617 source: ViewSource::Degree {
16618 edge_type: "WORKS_AT".into(),
16619 direction: Direction::In,
16620 },
16621 })
16622 .unwrap();
16623 // frame 2: create rule — no Persons yet; backfill is a no-op
16624 db.create_rule(fk_rule()).unwrap();
16625 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
16626 db.insert_node(
16627 "Person",
16628 "p1",
16629 vec![("org_id".into(), Value::Str("o1".into()))],
16630 )
16631 .unwrap();
16632 // frame 4: p2 — degree = 2
16633 db.insert_node(
16634 "Person",
16635 "p2",
16636 vec![("org_id".into(), Value::Str("o1".into()))],
16637 )
16638 .unwrap();
16639 // frame 5: p3 — degree = 3
16640 db.insert_node(
16641 "Person",
16642 "p3",
16643 vec![("org_id".into(), Value::Str("o1".into()))],
16644 )
16645 .unwrap();
16646 // Sanity: normal open sees degree = 3.
16647 assert_eq!(
16648 db.get_view_prop("o1", "emp"),
16649 Some(Value::Int(3)),
16650 "normal db must show degree 3 after 3 derived edges"
16651 );
16652 } // WAL flushed
16653
16654 // Re-open normally to get the authoritative reference value.
16655 let normal_db = GraphDb::open(&dir).unwrap();
16656 let normal_emp = normal_db.get_view_prop("o1", "emp");
16657 assert_eq!(
16658 normal_emp,
16659 Some(Value::Int(3)),
16660 "re-opened normal db must show degree 3"
16661 );
16662
16663 // Latest as-of (last WAL commit): must match the normal open.
16664 // History-marker frames are appended after each rule-fire, so the total
16665 // commit count is computed dynamically rather than hardcoded.
16666 let total = crate::wal_commit_count_at(&dir).unwrap();
16667 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
16668 assert_eq!(
16669 aof_latest.get_view_prop("o1", "emp"),
16670 normal_emp,
16671 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
16672 );
16673
16674 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
16675 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
16676 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
16677 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
16678 assert_eq!(
16679 aof_mid.get_view_prop("o1", "emp"),
16680 Some(Value::Int(1)),
16681 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
16682 );
16683
16684 let _ = std::fs::remove_dir_all(&dir);
16685 }
16686
16687 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
16688 /// as-of instances never commit, so distribute_events never runs and any
16689 /// subscription would wait forever.
16690 #[test]
16691 fn subscribe_on_as_of_returns_read_only_error() {
16692 let dir = tmp_dir("sub-as-of-read-only");
16693 {
16694 let mut db = GraphDb::open(&dir).unwrap();
16695 db.insert_node("Org", "o1", vec![]).unwrap();
16696 db.create_rule(fk_rule()).unwrap();
16697 }
16698 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
16699
16700 assert!(
16701 matches!(
16702 aof.subscribe_all_rules(),
16703 Err(core_storage::GraphError::ReadOnly)
16704 ),
16705 "subscribe_all_rules on as-of must return ReadOnly"
16706 );
16707 assert!(
16708 matches!(
16709 aof.subscribe_writes(),
16710 Err(core_storage::GraphError::ReadOnly)
16711 ),
16712 "subscribe_writes on as-of must return ReadOnly"
16713 );
16714 assert!(
16715 matches!(
16716 aof.subscribe_rule("works_at"),
16717 Err(core_storage::GraphError::ReadOnly)
16718 ),
16719 "subscribe_rule on as-of must return ReadOnly"
16720 );
16721 let _ = std::fs::remove_dir_all(&dir);
16722 }
16723
16724 /// Regression: a failed dense WAL rewrite must not leave speculative
16725 /// interns in `syms`. If it does, the next successful mutation logs an
16726 /// `Intern` record with an inflated id; replay (which never saw the
16727 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
16728 #[test]
16729 fn dense_rewrite_error_rolls_back_speculative_interns() {
16730 let dir = tmp_dir("dense-rewrite-rollback");
16731 {
16732 let mut db = GraphDb::open(&dir).unwrap();
16733 db.insert_node("Person", "a", vec![]).unwrap();
16734
16735 // Bypass MutPreview validation to hit the rewrite's own error path
16736 // (same shape as an id-exhaustion failure mid-rewrite). The
16737 // InsertEdge arm interns the edge type before it resolves keys.
16738 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
16739 edge_type: "ORPHAN_TYPE".into(),
16740 src_key: "missing".into(),
16741 dst_key: "a".into(),
16742 }]);
16743 assert!(err.is_err(), "rewrite of a missing src key must fail");
16744 assert_eq!(
16745 db.syms.get("ORPHAN_TYPE"),
16746 None,
16747 "failed rewrite must roll back speculative interns"
16748 );
16749
16750 // A later successful mutation must produce a replayable WAL.
16751 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
16752 }
16753 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
16754 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
16755 let _ = std::fs::remove_dir_all(&dir);
16756 }
16757}