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}
1841
1842/// One group of deferred event notifications, held until the group fsync
1843/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1844struct DeferredEvent {
1845 rec: core_storage::WalRecord,
1846 engine_deltas: Vec<EngineEdgeDelta>,
1847 seq: u64,
1848 ingest: Option<(String, usize)>,
1849}
1850
1851/// Options for [`GraphDb::open_with_options`].
1852#[derive(Clone, Copy, Debug)]
1853pub struct OpenOptions {
1854 /// Rewrite an old-format snapshot to the current VERSION after a
1855 /// successful load (default `true`). The old snapshot is kept as
1856 /// `snapshot.bin.bak` until the next clean open at the current version,
1857 /// at which point the `.bak` is deleted.
1858 ///
1859 /// Set to `false` to open a store without touching any on-disk files
1860 /// (useful for read-only inspection of a store at an older format).
1861 pub auto_migrate: bool,
1862
1863 /// Write the valid WAL prefix back over a torn tail on open (default
1864 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1865 ///
1866 /// Set to `false` for an unattended reader. The valid prefix is still
1867 /// decoded and replayed in memory, but nothing is written: a reader that
1868 /// opens while another process is mid-append would otherwise discard a
1869 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1870 /// every prompt, passes `false` for exactly this reason.
1871 pub repair_wal: bool,
1872
1873 /// Open without ever writing to the store (default `false`).
1874 ///
1875 /// A read-only handle:
1876 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1877 /// `snapshot()`;
1878 /// - performs no disk write at open — no WAL repair write-back and no
1879 /// auto-migration rewrite, whatever the other two flags say;
1880 /// - never takes the cross-process write lock, so it opens immediately even
1881 /// while another process is writing, and never makes a writer wait.
1882 ///
1883 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1884 /// normally, so a read-only handle can follow another process's commits.
1885 pub read_only: bool,
1886}
1887
1888impl Default for OpenOptions {
1889 fn default() -> Self {
1890 Self {
1891 auto_migrate: true,
1892 repair_wal: true,
1893 read_only: false,
1894 }
1895 }
1896}
1897
1898/// How long a writer polls for the cross-process write lock before giving up
1899/// with [`GraphError::Busy`].
1900///
1901/// Long enough to ride out another process's commit (a batch apply plus one
1902/// fsync), short enough that a stuck peer surfaces as an error rather than a
1903/// hang.
1904pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1905
1906/// Refusal when a `MERGE` create cannot choose a namespace.
1907///
1908/// A role bound to two or more namespaces cannot have its create arm land in
1909/// `default`, and the statement did not name `ns`. The role must name one.
1910pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1911 "role-bound token: MERGE create requires the role to name one namespace";
1912
1913/// Interval between poll attempts while waiting for the cross-process lock.
1914pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1915
1916/// Why `load_from_disk` is running, which decides whether it may repair.
1917#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1918enum LoadOrigin {
1919 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1920 /// the signature of a crash and truncating it is correct, and archives
1921 /// orphaned by an interrupted prune can be swept.
1922 Open,
1923 /// A reload driven by [`GraphDb::refresh`], because another process
1924 /// replaced the snapshot. Nothing here is crash recovery — the store is
1925 /// live and someone else is writing it — so this origin writes nothing.
1926 Reload,
1927}
1928
1929/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1930///
1931/// `None` at the call site = full authority (today's zero-cost behavior).
1932/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1933/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1934/// record is built. A denial returns an error with no WAL frame written.
1935///
1936/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1937/// hidden-node existence to callers.
1938#[derive(Clone, Debug)]
1939pub struct WriteAuthz {
1940 pub role: String,
1941 pub scope: WriteScope,
1942 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1943 /// Always `Omit`-mode — never `Stub`.
1944 pub mask: crate::mask::NodeMask,
1945}
1946
1947/// The error every role surface gives when `roles.json` did not parse at open.
1948///
1949/// One text, so `mask_for_role` and [`GraphDb::roles_checked`] cannot drift
1950/// apart on the same cause.
1951fn roles_poisoned() -> GraphError {
1952 GraphError::Corrupt {
1953 detail: "roles.json was corrupt at open; fix the file and re-open to restore role access"
1954 .into(),
1955 }
1956}
1957
1958/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1959///
1960/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1961/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1962/// syncs the directory entry. This is the only correct path for writing the
1963/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1964/// the directory sync.
1965pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1966 use core_storage::fs::{FileId, Fs as _};
1967 RealFs::new(dir)
1968 .map_err(core_storage::GraphError::Io)?
1969 .write_atomic(FileId::SnapshotBak, bytes)
1970 .map_err(core_storage::GraphError::Io)
1971}
1972
1973/// Return the on-disk snapshot format version without decoding the full snapshot.
1974///
1975/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1976/// snapshot file exists (WAL-only store). Returns an error if the header is
1977/// malformed.
1978pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1979 use std::io::Read as _;
1980 let path = dir.join("snapshot.bin");
1981 let mut header = [0u8; 6];
1982 let n = match std::fs::File::open(&path) {
1983 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1984 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1985 Err(e) => return Err(core_storage::GraphError::Io(e)),
1986 };
1987 core_storage::snapshot::peek_version(&header[..n])
1988}
1989
1990/// Where [`GraphDb::stop_next_snapshot`] makes the next snapshot fail, leaving
1991/// the files as a crash at that point would. For tests of what an interrupted
1992/// snapshot leaves behind; not part of the stable surface.
1993#[doc(hidden)]
1994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1995pub enum SnapshotStop {
1996 /// The intent is written; `snapshot.bin` is still the old one.
1997 BeforeSnapshotWrite,
1998 /// `snapshot.bin` is the new one; nothing else has moved.
1999 AfterSnapshotWrite,
2000 /// As above, and the history floor has been advanced; the WAL is still
2001 /// the one the snapshot was made from.
2002 BeforeWalReplacement,
2003 /// The WAL is replaced; the intent has not been emptied.
2004 AfterWalReplacement,
2005}
2006
2007/// Options for [`GraphDb::snapshot_with`].
2008#[derive(Debug, Clone, Default)]
2009pub struct SnapshotOptions {
2010 /// When `true`, the WAL is preserved after the snapshot write.
2011 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
2012 /// When `false` (the default), the WAL is truncated to a minimal
2013 /// baseline so cold-start replay stays fast.
2014 pub keep_wal: bool,
2015 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
2016 /// before a fresh WAL baseline is written (history-preserving snapshot).
2017 ///
2018 /// This is the feature opt-in: `false` (the default) leaves the existing
2019 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
2020 /// precedence over `keep_wal` when both are set.
2021 ///
2022 /// Archives can be scanned by [`GraphDb::node_history`],
2023 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
2024 /// [`GraphDb::open_at`], extending the reachable history horizon across
2025 /// snapshot boundaries.
2026 pub archive_wal: bool,
2027}
2028
2029/// Derive the scan-label sym for the commit-skip fast-path.
2030///
2031/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
2032/// or `IndexIntersect`) with a concrete label string, then interns it.
2033///
2034/// Returns `None` in all cases where skipping is unsafe:
2035/// - Any `Expand` op is present (edge traversal; edges change results regardless
2036/// of node labels).
2037/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
2038/// - No recognizable leading scan op is found.
2039///
2040/// This is the conservative v0.4.3 boundary. The caller stores the result in
2041/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
2042fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
2043 // Any Expand → must always re-execute (edges can change join results).
2044 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
2045 return None;
2046 }
2047 for op in ops {
2048 match op {
2049 PlanOp::ScanLabel {
2050 label: Some(label), ..
2051 } => return Some(syms.intern(label)),
2052 PlanOp::IndexScan {
2053 label: Some(label), ..
2054 } => return Some(syms.intern(label)),
2055 PlanOp::IndexIntersect {
2056 label: Some(label), ..
2057 } => return Some(syms.intern(label)),
2058 _ => {}
2059 }
2060 }
2061 None
2062}
2063
2064/// How an as-of read is restricted — the argument to
2065/// [`GraphDb::query_at_scoped`].
2066///
2067/// Every variant is resolved against the graph **as it was at the requested
2068/// commit**, not against the current graph.
2069#[derive(Debug, Clone, Copy)]
2070pub enum AsOfScope<'a> {
2071 /// Everything the named role may see. The role *definition* is the current
2072 /// one — `roles.json` is a sidecar and has no past version — but its
2073 /// `keys` and `labels` are resolved against the as-of graph.
2074 Role(&'a str),
2075 /// An explicit node-key allow-list. Keys that did not exist at that commit
2076 /// resolve to nothing.
2077 Keys(&'a [String]),
2078 /// A role intersected with a client-supplied allow-list. The intersection
2079 /// is the never-widen rule: a client mask can only narrow a role.
2080 RoleAndKeys(&'a str, &'a [String]),
2081 /// Every live node in one namespace, as the graph was at that commit.
2082 ///
2083 /// A namespace cannot change — it is set at insert and immutable — so the
2084 /// answer is simply "the nodes that existed then and are in this
2085 /// namespace". A name no node uses resolves to nothing, never to
2086 /// everything.
2087 Namespace(&'a str),
2088}
2089
2090impl GraphDb<RealFs> {
2091 /// Open the database at `dir` with default options.
2092 ///
2093 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
2094 /// Old-format snapshots (V5, V6) are automatically migrated to the
2095 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
2096 pub fn open(dir: &std::path::Path) -> Result<Self> {
2097 Self::open_with_options(dir, OpenOptions::default())
2098 }
2099
2100 /// Open the database at `dir` with explicit options.
2101 ///
2102 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
2103 /// snapshot is an older format version, this function:
2104 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
2105 /// + fsynced) before any modification.
2106 /// 2. Rewrites `snapshot.bin` at the current format version via
2107 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
2108 ///
2109 /// If migration fails the error is returned and the original files are
2110 /// intact (the `.bak` was written before the new snapshot was attempted).
2111 ///
2112 /// A clean open that finds the snapshot already at the current version
2113 /// deletes any leftover `.bak` file.
2114 ///
2115 /// WAL-only stores (no snapshot) are never auto-migrated on open.
2116 ///
2117 /// `opts.repair_wal` controls the other write this function can make; see
2118 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
2119 /// no file on disk.
2120 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
2121 Self::open_dir(dir, opts, true)
2122 }
2123
2124 /// Open without taking the cross-process write lock for the handle's
2125 /// lifetime.
2126 ///
2127 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
2128 /// its handle open indefinitely, so it takes the lock per write instead of
2129 /// keeping every other process out of the store for as long as it runs.
2130 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
2131 Self::open_dir(dir, OpenOptions::default(), false)
2132 }
2133
2134 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2135 // Header-only peek — 6 bytes, no full decode.
2136 let snap_version = snapshot_version_at(dir)?;
2137
2138 // Full load: decode snapshot + replay WAL + rebuild indexes.
2139 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
2140
2141 // A read-only handle writes nothing at open, so it never migrates —
2142 // the old-format snapshot is loaded and left exactly as it is.
2143 if opts.auto_migrate && !opts.read_only {
2144 match snap_version {
2145 Some(ver) if ver < core_storage::snapshot::VERSION => {
2146 let _tm = std::time::Instant::now();
2147 // Copy the original snapshot to .bak at OS level — no in-memory
2148 // buffer required for a 2+ GiB file.
2149 //
2150 // Crash-safety: snapshot.bin remains intact (write_atomic inside
2151 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
2152 // A torn .bak on crash is acceptable because the original
2153 // snapshot.bin is the authoritative source until after the rename.
2154 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
2155 .map_err(core_storage::GraphError::Io)?;
2156 trace_migrate!("bak copy done", _tm);
2157 // Rewrite snapshot at current version; keep WAL intact.
2158 db.snapshot_with(SnapshotOptions {
2159 keep_wal: true,
2160 ..SnapshotOptions::default()
2161 })?;
2162 trace_migrate!("snapshot_with done", _tm);
2163 }
2164 Some(_) => {
2165 // Already current version: remove any leftover .bak.
2166 let bak = dir.join("snapshot.bin.bak");
2167 if bak.exists() {
2168 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
2169 }
2170 }
2171 None => {
2172 // WAL-only store — nothing to migrate on open.
2173 }
2174 }
2175 }
2176
2177 Ok(db)
2178 }
2179
2180 /// Open a read-only view of the database as it existed after `commit`.
2181 ///
2182 /// Commit indices are 0-based over the current WAL: commit 0 is the state
2183 /// after the first WAL frame, commit N-1 is the state after the N-th (most
2184 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
2185 ///
2186 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
2187 /// so as-of can only reach commits recorded in the current WAL (those
2188 /// written after the most recent snapshot, or all commits if no snapshot
2189 /// was ever taken). Commit 0 in `open_at` always refers to the first
2190 /// frame in the WAL that exists on disk, not the first ever write to the
2191 /// database. When the on-disk snapshot recorded that it truncated the
2192 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
2193 /// before frame replay, so the as-of view includes all pre-snapshot data.
2194 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
2195 /// are ignored and replay is WAL-only, as before.
2196 ///
2197 /// **Read-only.** Every mutation method and `snapshot()` on the returned
2198 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
2199 /// `stats()` work normally.
2200 ///
2201 /// # Errors
2202 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
2203 /// when the WAL is empty after a snapshot).
2204 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
2205 Self::open_at_with(RealFs::new(dir)?, commit)
2206 }
2207
2208 /// Run a **read-only** Cypher query against the graph as it existed at
2209 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
2210 /// of this store's directory at that commit and executes the read there.
2211 ///
2212 /// The current instance is unaffected. Write statements are rejected (the
2213 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
2214 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
2215 /// state. Prefer this over holding many historical instances open.
2216 ///
2217 /// # Errors
2218 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
2219 /// - A query error for a malformed or write query.
2220 pub fn query_at(
2221 &self,
2222 commit: u64,
2223 cypher: &str,
2224 params: &std::collections::BTreeMap<String, Value>,
2225 ) -> Result<ResultSet> {
2226 let temporal = self.open_at_for_read(commit, cypher)?;
2227 temporal.query(cypher, params)
2228 }
2229
2230 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
2231 ///
2232 /// The **graph** is as of `commit`; the **role definition** is as it is
2233 /// now, because `roles.json` is a sidecar and is never a WAL record — it
2234 /// has no past version to read. A role's `keys` and `labels` are resolved
2235 /// against the commit-`commit` graph, so a role that may see a label sees
2236 /// exactly the nodes that carried it then, and an explicit key that did
2237 /// not exist yet resolves to nothing.
2238 ///
2239 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
2240 /// only narrow what a role may see, never widen it.
2241 ///
2242 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
2243 /// them.
2244 ///
2245 /// # Errors
2246 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2247 /// range; the error carries that range.
2248 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2249 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2250 /// - A query error for a malformed or write query.
2251 pub fn query_at_scoped(
2252 &self,
2253 commit: u64,
2254 cypher: &str,
2255 params: &std::collections::BTreeMap<String, Value>,
2256 scope: AsOfScope<'_>,
2257 ) -> Result<ResultSet> {
2258 let temporal = self.open_at_for_read(commit, cypher)?;
2259 let mask = temporal.mask_at_scope(scope)?;
2260 temporal.query_masked(cypher, params, &mask)
2261 }
2262
2263 /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
2264 /// whatever `scope` resolves to.
2265 ///
2266 /// This is what a surface needs when a caller passes `namespace` beside a
2267 /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
2268 /// restriction, and the namespace is a second one that composes with it
2269 /// rather than replacing it. The intersection is the never-widen rule — a
2270 /// namespace can only narrow what the scope already allows — and both legs
2271 /// are resolved against the graph as it was at `commit`.
2272 ///
2273 /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
2274 pub fn query_at_scoped_in_namespace(
2275 &self,
2276 commit: u64,
2277 cypher: &str,
2278 params: &std::collections::BTreeMap<String, Value>,
2279 scope: AsOfScope<'_>,
2280 namespace: &str,
2281 ) -> Result<ResultSet> {
2282 let temporal = self.open_at_for_read(commit, cypher)?;
2283 let mask = temporal
2284 .mask_at_scope(scope)?
2285 .intersect(&temporal.mask_for_namespace(namespace));
2286 temporal.query_masked(cypher, params, &mask)
2287 }
2288
2289 /// Run a **read-only** Cypher query at `commit`, restricted by a
2290 /// [`Scope`](crate::mask::Scope).
2291 ///
2292 /// [`AsOfScope`] names *one* restriction — a role, a key list, a namespace,
2293 /// or a role-and-keys pair. A `Scope` is the general shape a handle carries,
2294 /// and nesting can give it several role or namespace legs at once, so it
2295 /// cannot be spelled as an `AsOfScope`. This is the entry point a scoped
2296 /// handle uses for time travel; `query_at_scoped` stays the way to ask for
2297 /// one named restriction.
2298 ///
2299 /// Both the graph and the scope's key and namespace legs are resolved
2300 /// against `commit`; a role's *definition* is the current one, because
2301 /// `roles.json` is a sidecar with no past version — the same split
2302 /// [`GraphDb::query_at_scoped`] documents.
2303 ///
2304 /// The scope resolves **cold** here: a temporal handle is its own store, so
2305 /// its ids could never be served to a live read, but filling the scope's
2306 /// one-entry key memo from a handle thrown away at the end of this call
2307 /// would evict the live entry for nothing. See
2308 /// [`Scope::resolve_uncached`](crate::mask::Scope::resolve_uncached).
2309 ///
2310 /// # Errors
2311 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2312 /// range; the error carries that range.
2313 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2314 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2315 /// - A query error for a malformed or write query.
2316 pub fn query_at_with_scope(
2317 &self,
2318 commit: u64,
2319 cypher: &str,
2320 params: &std::collections::BTreeMap<String, Value>,
2321 scope: &crate::mask::Scope,
2322 ) -> Result<ResultSet> {
2323 let temporal = self.open_at_for_read(commit, cypher)?;
2324 let mask = scope.resolve_uncached(&temporal)?;
2325 temporal.query_masked(cypher, params, &mask)
2326 }
2327
2328 /// Open the temporal view for a time-travel read and refuse write Cypher.
2329 ///
2330 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2331 /// resolve the commit and reject writes identically.
2332 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2333 let dir = self.fs.dir().to_path_buf();
2334 let temporal = Self::open_at(&dir, commit)?;
2335 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2336 detail: format!("lex: {e}"),
2337 })?) {
2338 return Err(GraphError::QueryError {
2339 detail: "query_at is read-only: write statements are not permitted in a \
2340 time-travel query"
2341 .into(),
2342 });
2343 }
2344 Ok(temporal)
2345 }
2346}
2347
2348impl<F: Fs> GraphDb<F> {
2349 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2350 pub fn open_with(fs: F) -> Result<Self> {
2351 Self::open_with_repair(fs, true)
2352 }
2353
2354 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2355 /// prefix without writing the truncation back. See
2356 /// [`OpenOptions::repair_wal`].
2357 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2358 Self::open_generic(
2359 fs,
2360 OpenOptions {
2361 repair_wal,
2362 ..OpenOptions::default()
2363 },
2364 true,
2365 )
2366 }
2367
2368 /// Shared open path.
2369 ///
2370 /// `hold_lock` requests the cross-process write lock for the whole handle
2371 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2372 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2373 /// `false` and takes the lock per write instead, so that a long-lived
2374 /// server does not keep every other process out of the store.
2375 ///
2376 /// A read-only open never takes the lock regardless of `hold_lock`.
2377 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2378 let mut db = Self::new_empty(fs, opts);
2379 db.read_only = opts.read_only;
2380 if hold_lock && !opts.read_only {
2381 if !db.poll_lock(WRITE_LOCK_WAIT)? {
2382 return Err(GraphError::Busy { holder: None });
2383 }
2384 db.holds_lifetime_lock = true;
2385 }
2386 db.load_from_disk(LoadOrigin::Open)?;
2387 Ok(db)
2388 }
2389
2390 /// A handle with no state loaded: every field at its empty value, the
2391 /// filesystem and options in place. Only [`load_from_disk`] makes it
2392 /// usable.
2393 fn new_empty(fs: F, opts: OpenOptions) -> Self {
2394 Self {
2395 fs,
2396 ids: Arc::new(IdMap::new()),
2397 syms: Arc::new(Interner::new()),
2398 topo: Arc::new(Topology::new()),
2399 props: Arc::new(ColumnStore::new()),
2400 labels: Arc::new(Vec::new()),
2401 ns_names: vec![NS_DEFAULT.to_string()],
2402 node_ns: Vec::new(),
2403 edge_props: Arc::new(EdgeProps::new()),
2404 engine: RuleEngine::new(),
2405 view_store: ViewStore::new(),
2406 fulltext: Arc::new(FulltextIndex::new()),
2407 prop_index: PropertyIndex::new(),
2408 multiplicity: false,
2409 event_sink: None,
2410 fsync: FsyncPolicy::Strict,
2411 commit_seq: 0,
2412 commit_times: core_storage::commit_times::CommitTimes::default(),
2413 commit_times_poisoned: false,
2414 fulltext_rebuild_follows: false,
2415 fulltext_loaded: false,
2416 fulltext_reindexed_a_node: false,
2417 base_wal_truncated: false,
2418 fulltext_reindexed_at_open: 0,
2419 replaying_at_open: false,
2420 intent_unsettled: false,
2421 snapshot_stop: None,
2422 commit_time_override: None,
2423 roles: Some(vec![]),
2424 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2425 store_id: crate::mask::StoreId::next(),
2426 subscriptions: Vec::new(),
2427 query_subscriptions: Vec::new(),
2428 sub_capacity: DEFAULT_SUB_CAPACITY,
2429 read_only: false,
2430 total_wal_commits: 0,
2431 base: None,
2432 fold_overlay: None,
2433 delta_tail: Vec::new(),
2434 commits_since_fold: 0,
2435 defer_events: false,
2436 deferred_events: Vec::new(),
2437 degraded: false,
2438 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2439 v8_sections_mutex: std::sync::Mutex::new(()),
2440 last_change: HashMap::new(),
2441 wal_archive_retention: None,
2442 wal_horizon_floor: 0,
2443 archive_genesis_chain: false,
2444 // Nothing is proven until `load_from_disk` has looked at the store.
2445 snapshot_preserved_history: false,
2446 pending_write_authz: None,
2447 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2448 .ok()
2449 .and_then(|v| v.parse().ok())
2450 .unwrap_or(100),
2451 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2452 entries: std::collections::VecDeque::new(),
2453 total: 0,
2454 }),
2455 warned_ambiguous_exactness: std::sync::Mutex::new(HashSet::new()),
2456 started_at: std::time::Instant::now(),
2457 wal_consumed: 0,
2458 wal_frames_written: 0,
2459 snapshot_ident: None,
2460 open_opts: opts,
2461 holds_lifetime_lock: false,
2462 lock_denied: false,
2463 pinned: false,
2464 }
2465 }
2466
2467 /// Return every field describing stored graph state to its empty value,
2468 /// leaving this handle's own identity alone.
2469 ///
2470 /// Preserved on purpose: the filesystem, open options, lock ownership, the
2471 /// event sink and subscriptions, fsync policy, degraded flag, and the
2472 /// slow-query configuration and log. A caller that registered a sink or a
2473 /// subscription keeps it across a reload.
2474 fn reset_for_reload(&mut self) {
2475 self.ids = Arc::new(IdMap::new());
2476 self.syms = Arc::new(Interner::new());
2477 self.topo = Arc::new(Topology::new());
2478 self.props = Arc::new(ColumnStore::new());
2479 self.labels = Arc::new(Vec::new());
2480 self.ns_names = vec![NS_DEFAULT.to_string()];
2481 self.node_ns = Vec::new();
2482 self.edge_props = Arc::new(EdgeProps::new());
2483 self.engine = RuleEngine::new();
2484 self.view_store = ViewStore::new();
2485 self.fulltext = Arc::new(FulltextIndex::new());
2486 self.prop_index = PropertyIndex::new();
2487 // Cleared like every other declaration: a reload replays the store's own
2488 // WAL, and the opt-in comes back from it or not at all.
2489 self.multiplicity = false;
2490 self.commit_seq = 0;
2491 self.commit_times = core_storage::commit_times::CommitTimes::default();
2492 self.commit_times_poisoned = false;
2493 self.fulltext_rebuild_follows = false;
2494 self.fulltext_loaded = false;
2495 self.fulltext_reindexed_a_node = false;
2496 self.base_wal_truncated = false;
2497 self.fulltext_reindexed_at_open = 0;
2498 self.replaying_at_open = false;
2499 self.commit_time_override = None;
2500 self.roles = Some(vec![]);
2501 // A fresh cache, not a cleared one: any reader snapshot still holding
2502 // the old `Arc` keeps it to itself, so nothing it memoised against the
2503 // pre-reload store can be read back through this handle.
2504 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2505 // The same move for memos this handle does not own. `commit_seq` is
2506 // zeroed just above and reseeded from `max(last_change)`, which a
2507 // delete-only commit leaves where it was — so a reload can land back on
2508 // a sequence a caller's `Scope` already cached a mask at. A new id is
2509 // what makes that entry stop matching.
2510 self.store_id = crate::mask::StoreId::next();
2511 self.total_wal_commits = 0;
2512 self.base = None;
2513 self.fold_overlay = None;
2514 self.delta_tail = Vec::new();
2515 self.commits_since_fold = 0;
2516 self.deferred_events = Vec::new();
2517 self.v8_sections_loaded
2518 .store(false, std::sync::atomic::Ordering::Release);
2519 self.last_change = HashMap::new();
2520 self.wal_horizon_floor = 0;
2521 self.archive_genesis_chain = false;
2522 // Re-derived by `load_from_disk` from the store it is about to read.
2523 self.snapshot_preserved_history = false;
2524 self.pending_write_authz = None;
2525 self.wal_consumed = 0;
2526 self.wal_frames_written = 0;
2527 self.snapshot_ident = None;
2528 }
2529
2530 /// Load the snapshot base and replay the WAL into an empty handle — the
2531 /// whole of what opening a store does after the struct exists.
2532 ///
2533 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2534 /// rebuild a handle in place, without ownership of `F`, when another
2535 /// process replaces the snapshot underneath it.
2536 ///
2537 /// `origin` decides whether the two repair writes this function can make
2538 /// are appropriate; see [`LoadOrigin`].
2539 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2540 // Both writes below are crash recovery, and only an open is entitled to
2541 // perform them. A read-only handle promises to touch nothing, and a
2542 // reload driven by `refresh` is looking at a store another process is
2543 // actively writing: what looks like a torn tail there is a peer
2544 // mid-append, and what looks like an orphaned archive may be one that
2545 // peer is about to reference.
2546 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2547 let repair_wal = self.open_opts.repair_wal && may_repair;
2548 let db = self;
2549 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2550 db.archive_genesis_chain = db.fs.has_genesis_marker();
2551 // Opening cleanup: remove orphaned archives — archives whose frames all
2552 // fall below the horizon floor. Orphans arise when a crash interrupted
2553 // the retention-prune sequence after the floor was written but before
2554 // all surplus archives were deleted. Safe to delete: floor already
2555 // accounts for their frames.
2556 if may_repair {
2557 db.cleanup_orphaned_archives()?;
2558 }
2559 let _t0 = std::time::Instant::now();
2560 // Peek 6 bytes to determine snapshot version without reading the full
2561 // file. For RealFs this is a true partial read (O(1)); for SimFs the
2562 // default impl reads all bytes and truncates (still correct).
2563 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2564 // V8, V9 and V10 share the mmap-able container; V9 only adds section 12
2565 // and V10 adds nothing but its version stamp. A version outside that set
2566 // falls through to the full-read path below, where `snapshot::decode`
2567 // either handles it (V5–V7) or refuses it by name — which is what stops
2568 // an older binary before it reaches the WAL.
2569 let is_v8 = snap_header.len() >= 6
2570 && &snap_header[0..4] == b"GDB1"
2571 && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
2572 snap_header[4],
2573 snap_header[5],
2574 ]));
2575 // The version this store is stamped with, or `None` when it has never
2576 // been snapshotted. Read from the same six bytes, with no second read.
2577 let snapshot_version = if snap_header.len() >= 6 && &snap_header[0..4] == b"GDB1" {
2578 Some(u16::from_le_bytes([snap_header[4], snap_header[5]]))
2579 } else {
2580 None
2581 };
2582 // No snapshot means no snapshot has ever truncated the WAL, so this
2583 // handle can prove the history is whole. Once a snapshot exists that
2584 // this handle did not take, it cannot: see `snapshot_preserved_history`.
2585 db.snapshot_preserved_history = snap_header.is_empty();
2586 if is_v8 {
2587 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2588 // No 2.4GB heap Vec is allocated on RealFs.
2589 let mapped = Arc::new(
2590 if let Some(snap_path) = db.fs.snapshot_path() {
2591 core_storage::v8::MappedBase::map(&snap_path)
2592 } else {
2593 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2594 core_storage::v8::MappedBase::from_bytes(snap_bytes)
2595 }
2596 .map_err(|e| GraphError::Corrupt {
2597 detail: format!("v8: mmap open: {e:?}"),
2598 })?,
2599 );
2600 db.restore_v8_base(Arc::clone(&mapped))?;
2601 trace_open!("restore_v8_base", _t0);
2602 db.base = Some(mapped);
2603 trace_open!("base assigned", _t0);
2604 // Only a snapshot from a writer that could leave a deleted node's
2605 // edges behind is searched for them.
2606 if !db.base.as_ref().is_some_and(|base| base.edges_swept()) {
2607 db.drop_edges_of_retired_nodes();
2608 }
2609 db.load_fulltext_from_base();
2610 trace_open!("full-text index read from the snapshot", _t0);
2611 } else if !snap_header.is_empty() {
2612 // Legacy V5-V7: full read required for decode.
2613 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2614 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2615 db.restore_snapshot_state(state)?;
2616 }
2617 }
2618 // else: snap_header is empty = no snapshot file, fresh store.
2619 //
2620 // Seed commit_seq from the highest seq persisted in last_change so that
2621 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2622 // already stored in the snapshot. Without this, a db with one snapshot
2623 // commit would save last_change["a"]=1, then on reopen the first WAL
2624 // frame would replay at seq=1 again — colliding and making WAL-tail
2625 // mutations indistinguishable from the snapshot baseline.
2626 //
2627 // Safety invariant (seq-recycling):
2628 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2629 // last_change because they belonged to a previous db lifetime — a new
2630 // db starts at commit_seq=0 with an empty last_change. Therefore no
2631 // CAS precondition can carry a recycled seq as its `expected` value
2632 // and accidentally match a live node's last_change entry.
2633 //
2634 // `expected:0` on a deleted-then-reinserted node:
2635 // After deletion, last_changed() returns None; callers that call
2636 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2637 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2638 // expected=0 correctly conflicts. The only way to observe actual=0 in
2639 // a CasConflict would be a caller that invented expected=0 without ever
2640 // calling last_changed() — unreachable via the documented API contract.
2641 if let Some(&max_seq) = db.last_change.values().max() {
2642 db.commit_seq = db.commit_seq.max(max_seq);
2643 }
2644 let bytes = db.fs.read(FileId::Wal)?;
2645 let (records, valid_len) = decode_all(&bytes);
2646 // The valid prefix is replayed either way; `repair_wal` only decides
2647 // whether the truncation is written back. A reader that races a live
2648 // appender must not persist a truncation the writer never asked for.
2649 if valid_len < bytes.len() && repair_wal {
2650 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2651 }
2652 // WAL-present path: build indexes eagerly BEFORE replay so that the
2653 // first replayed record does not trigger the lazy-init guard (which
2654 // would call reindex_all_load_state on an empty graph, defeating the
2655 // point of restoring IVF/HNSW blobs from the snapshot).
2656 if !records.is_empty() {
2657 db.ensure_v8_base_sections_loaded();
2658 trace_open!("lazy sections loaded (WAL path)", _t0);
2659 }
2660 // Scoped to this call: `refresh()` also replays frames and is *not*
2661 // followed by a rebuild, so its backfill must still run.
2662 //
2663 // And only when a rebuild does follow. An index read from the snapshot
2664 // is kept, so a pair declared after that snapshot is indexed by its
2665 // own backfill or not at all.
2666 // Every decoded frame occupies an index, replayed or not: markers
2667 // are state no-ops but they are not index no-ops.
2668 let decoded_frames = records.len() as u64;
2669 // A snapshot that kept its WAL says how much of it it already holds.
2670 // Those frames are counted, and not applied a second time.
2671 //
2672 // So does the intent of a snapshot that was to replace its WAL and
2673 // did not get that far: the same state, reached by a crash.
2674 let (interrupted, intent_left_over) = db.interrupted_snapshot(&bytes)?;
2675 // An intent that is not about this snapshot and this WAL is left over
2676 // from a snapshot that finished, or never wrote its snapshot. It has
2677 // to go before this handle appends: once new frames follow, a WAL can
2678 // come to begin with the bytes an old intent names. It goes at the
2679 // first append and not here, because only an appender holds the write
2680 // lock, and a handle without it may be looking at a peer's snapshot
2681 // half way through.
2682 db.intent_unsettled = intent_left_over;
2683 let held = db
2684 .base
2685 .as_ref()
2686 .and_then(|base| base.wal_mark())
2687 .or(interrupted)
2688 .filter(|mark| mark.matches(&bytes))
2689 .map(|mark| (mark.len as usize, decode_all(&bytes[..mark.len as usize])))
2690 // The mark was taken at the end of a whole WAL, so it ends on a
2691 // frame boundary; one that does not is not this WAL's.
2692 .filter(|(len, (_, consumed))| consumed == len)
2693 .map(|(_, (held, _))| held.len());
2694 let marked_wal_is_here = held.is_some();
2695 let held = held.unwrap_or(0);
2696 // A snapshot that kept its WAL has its index read on the strength of
2697 // its mark. If the WAL beside it is not the one marked — it lost its
2698 // tail, or was repaired shorter — the whole of it replays over the
2699 // snapshot, and what that produces is not what the index was read
2700 // for: a replayed insert can relabel a node and leave a posting under
2701 // a field its new label does not index. The index is set aside and
2702 // built after the replay, as it was before it could be read.
2703 if db.fulltext_loaded && !db.base_wal_truncated && !marked_wal_is_here {
2704 db.fulltext = Arc::new(core_storage::fulltext::FulltextIndex::new());
2705 db.fulltext_loaded = false;
2706 }
2707 db.fulltext_rebuild_follows = !db.fulltext_loaded;
2708 db.fulltext_reindexed_a_node = false;
2709 db.replaying_at_open = true;
2710 let replayed = db.apply_frames_over(records, held);
2711 db.fulltext_rebuild_follows = false;
2712 db.replaying_at_open = false;
2713 let replayed = replayed?;
2714 // ── The multiplicity declaration, recovered from the stamp ───────────
2715 //
2716 // The opt-in is re-emitted into every baseline WAL a snapshot writes, so
2717 // ordinarily the replay above has already found it. But
2718 // `snapshot_with(archive_wal)` renames the live WAL away and writes its
2719 // replacement afterwards, and between those two points the store holds
2720 // no live declaration at all. A crash there — or a single `Err` from any
2721 // call in between — used to opt the store back out on the next open
2722 // (defect #22): it would stop counting and write a **V9** snapshot while
2723 // the archives still carried discriminant 23, which is the exact state
2724 // the V10 stamp exists to prevent.
2725 //
2726 // The V10 stamp is what carries the conclusion. The archive clause is a
2727 // scope restriction, not a second proof — an earlier version of this
2728 // comment, and defect #22, claimed otherwise, and defect #33 corrects
2729 // it. Taking the two in order:
2730 //
2731 // **The stamp.** `snapshot_with` stamps the snapshot from
2732 // `self.multiplicity` *before* it touches the WAL, and nothing rewrites
2733 // a V10 snapshot at V9 while the store believes it is opted in. So a
2734 // V10 stamp says this store reached `enable_multiplicity` far enough to
2735 // write the snapshot — and, decisively, that every older binary already
2736 // refuses this store by name. Opting in here can cost such a reader
2737 // nothing it was not already being told.
2738 //
2739 // **What the archive clause does not prove.** It is *not* evidence that
2740 // the archive was taken while the store was opted in. A store can
2741 // archive at V9 and opt in afterwards, leaving a V10 snapshot standing
2742 // beside an archive whose WAL carries no declaration at all — see
2743 // `a_failed_opt_in_beside_an_archive_comes_back_opted_in`. The inference
2744 // held in the success case by coincidence, not by construction.
2745 //
2746 // **What it does buy: scope.** Without it the recovery would also fire
2747 // on a store that reached the V10 snapshot write and then failed with no
2748 // archive in sight. That store must stay opted out, and can: no WAL was
2749 // renamed away, nothing carries discriminant 23, and its next snapshot
2750 // rewrites at V9, which puts it back within reach of every older reader.
2751 // An archive is the marker for the one state that is not recoverable
2752 // that way — a WAL renamed away that may hold the only copy of the
2753 // declaration. `no_crash_leaves_discriminant_23_unguarded` pins that
2754 // line: it sweeps a workload with no archives at all and refuses a
2755 // V10-implies-enabled rule.
2756 //
2757 // **The invariant, whichever way the clause goes:** the recovery never
2758 // opts in a store whose snapshot is not V10. A V9 store has made no
2759 // promise to an older reader, so opting it in would start writing
2760 // discriminant 23 behind a stamp that does not guard it. Pinned by
2761 // `the_recovery_never_opts_in_a_store_whose_snapshot_is_not_v10` and
2762 // `the_recovery_does_not_opt_a_store_in_by_itself`.
2763 //
2764 // What this recovery cannot do is make the opt-in atomic; it is not,
2765 // and `enable_multiplicity` says so. See defects #32-#34.
2766 if !db.multiplicity
2767 && snapshot_version == Some(core_storage::snapshot::VERSION_10)
2768 && !db.fs.list_archives()?.is_empty()
2769 {
2770 db.multiplicity = true;
2771 }
2772 // The cursor sits at the end of the valid prefix, not the end of the
2773 // file: a torn or still-being-written tail is unconsumed by definition
2774 // and stays visible to `is_stale` until it decodes.
2775 db.wal_consumed = valid_len as u64;
2776 // The frame cursor counts the same sequence `all_frames` returns:
2777 // surviving archives first, then the live WAL, offset by the floor.
2778 // Counting the archives separately rather than calling
2779 // `wal_total_commits` keeps the live WAL from being decoded twice on
2780 // every open, and costs nothing on a store that has never archived.
2781 db.wal_frames_written = db.wal_horizon_floor + db.archive_frame_count()? + decoded_frames;
2782 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2783 trace_open!("wal replay done", _t0);
2784 // Rebuild view values after WAL replay only when there is no V8 base.
2785 // With a V8 base, view values are correct in the snapshot and are updated
2786 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2787 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2788 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2789 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2790 // base value).
2791 if db.base.is_none() {
2792 let topo_view = TopologyView::owned(&db.topo);
2793 db.view_store.rebuild_all(
2794 Arc::make_mut(&mut db.props),
2795 &topo_view,
2796 &db.ids,
2797 &db.syms,
2798 &db.labels,
2799 );
2800 }
2801 // The full-text index after WAL replay. One read from the snapshot has
2802 // had the replay applied to it record by record and is kept — unless
2803 // the replay indexed a node the snapshot already held, which doubles
2804 // that node's postings. Then, and wherever no index was read, it is
2805 // built from the declared pairs and the stored values.
2806 db.fulltext_loaded = db.fulltext_loaded && !db.fulltext_reindexed_a_node;
2807 if db.fulltext_loaded {
2808 #[cfg(debug_assertions)]
2809 {
2810 let mut rebuilt = (*db.fulltext).clone();
2811 rebuilt.rebuild_all(
2812 &db.ids,
2813 &db.labels,
2814 &db.syms,
2815 build_props_view(&db.props, &db.base),
2816 );
2817 debug_assert!(
2818 rebuilt == *db.fulltext,
2819 "the full-text index read from the snapshot is not the one a rebuild gives"
2820 );
2821 }
2822 } else {
2823 Arc::make_mut(&mut db.fulltext).rebuild_all(
2824 &db.ids,
2825 &db.labels,
2826 &db.syms,
2827 build_props_view(&db.props, &db.base),
2828 );
2829 }
2830 trace_open!("full-text index ready", _t0);
2831 db.prop_index.rebuild_all(
2832 &db.ids,
2833 &db.labels,
2834 &db.syms,
2835 build_props_view(&db.props, &db.base),
2836 );
2837 // Namespaces: one pass over the `ns` column, after the snapshot is
2838 // restored and the WAL replayed. Replay maintains `node_ns` record by
2839 // record as well; this pass is what makes a snapshot-only open right,
2840 // and it reads nothing on a store with no `ns` column.
2841 db.rebuild_node_ns();
2842 // A mid-build snapshot's HNSW blob carries `complete == false`.
2843 // Register it so `serve`'s ticker sees work without waiting for a write.
2844 db.register_outstanding_index_builds();
2845 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2846 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2847 db.roles = Self::load_roles_from_fs(&db.fs)?;
2848 // The time sidecar. Absent is the normal case for any store written
2849 // before v0.6.11 and is not an error; unreadable is recorded so date
2850 // queries can say "damaged" rather than "none recorded".
2851 db.load_commit_times_from_fs();
2852 // Capture the initial MVCC fold so reader() is ready immediately.
2853 db.fold_now();
2854 trace_open!("open_with complete", _t0);
2855 Ok(replayed)
2856 }
2857
2858 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2859 /// replay does — same `apply` calls, same per-frame delta drain, same
2860 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2861 /// edges appear identically whether a frame arrives at open, from a local
2862 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2863 ///
2864 /// Returns the number of frames applied.
2865 ///
2866 /// Deltas are drained and discarded per frame: replayed frames are already
2867 /// reflected on disk, so they are not news to a subscriber, and draining
2868 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2869 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2870 self.apply_frames_over(records, 0)
2871 }
2872
2873 /// [`Self::apply_frames`] over a base that already holds the first `held`
2874 /// records: the state a snapshot that kept its WAL was taken at. Those
2875 /// are numbered like any other frame and not applied, because applying a
2876 /// write to the state it produced is not always a no-op — an insert
2877 /// under a key since renamed or deleted makes a second node. A
2878 /// declaration among them is applied all the same: the pairs a store has
2879 /// declared are known from the WAL — and, when a snapshot carries a
2880 /// full-text index that is read, from that index too.
2881 fn apply_frames_over(&mut self, records: Vec<WalRecord>, held: usize) -> Result<usize> {
2882 if records.is_empty() {
2883 return Ok(0);
2884 }
2885 // Materialize any state retained in the mmap base before the first
2886 // frame lands, so a replayed record cannot trip the lazy-init guard and
2887 // rebuild indexes from an empty graph. Both calls are idempotent.
2888 //
2889 // Not before a declaration, though. Every truncating snapshot re-emits
2890 // one record for each declared pair, so a snapshotted store with any
2891 // declaration always has frames to replay; counting those as writes
2892 // made every open of such a store pay the scan a store with no
2893 // declaration defers to its first write.
2894 let mut retained_consumed = false;
2895 let applied = records.len();
2896 // A live commit turns on the engine's report of the edges its rules
2897 // add and retract, which is what keeps a view over a derived edge
2898 // type current. Replay has to do the same, or a store with a snapshot
2899 // — where nothing recomputes the views afterwards — reopens with the
2900 // counts the snapshot had.
2901 let emitted_before = self.engine.emit_deltas();
2902 for (i, rec) in records.into_iter().enumerate() {
2903 if !self.view_store.is_empty() && !self.engine.emit_deltas() {
2904 self.engine.set_emit_deltas(true);
2905 }
2906 // A frame the snapshot already holds is counted and not applied,
2907 // unless it is a declaration.
2908 let apply = i >= held || is_declaration(&rec);
2909 if apply && !retained_consumed && !is_declaration(&rec) {
2910 self.ensure_v8_base_sections_loaded();
2911 self.engine.consume_retained_state_eager(
2912 &self.ids,
2913 &self.syms,
2914 &self.labels,
2915 build_props_view(&self.props, &self.base),
2916 );
2917 retained_consumed = true;
2918 }
2919 if apply {
2920 self.apply(&rec)?;
2921 }
2922 let _ = self.engine.drain_deltas();
2923 // Track commit_seq during replay so last_change entries are
2924 // consistent with the seqs assigned by log_then_apply_with on
2925 // subsequent live commits. After N replayed frames, commit_seq=N;
2926 // live commits begin at N+1.
2927 self.commit_seq += 1;
2928 let replay_seq = self.commit_seq;
2929 self.update_last_change_from_rec(&rec, replay_seq);
2930 }
2931 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2932 // this assert catches the regression in debug builds immediately.
2933 debug_assert_eq!(
2934 self.engine.pending_delta_count(),
2935 0,
2936 "pending_deltas non-empty after replay — \
2937 per-frame drain must run inside the loop to keep memory O(1)"
2938 );
2939 // T2 note: the per-frame drain IS the suppression seam for replay.
2940 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2941 // replaying subscribers; the mechanism is already in place.
2942 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2943 self.engine.set_emit_deltas(emitted_before);
2944 Ok(applied)
2945 }
2946
2947 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2948 //
2949 // mushroomdb is many-readers / one-writer across processes. Writers take an
2950 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2951 // Every handle tracks how much of the WAL it has consumed, so it can pick
2952 // up another process's commits by decoding only the new tail rather than
2953 // reopening. See `docs/site/concurrency.md`.
2954
2955 /// Whether the store on disk has moved ahead of (or out from under) this
2956 /// handle's in-memory state.
2957 ///
2958 /// True when the WAL's length differs from this handle's cursor — another
2959 /// process committed, or is mid-append — or when the snapshot file's
2960 /// identity changed. Costs two metadata lookups and reads no file contents,
2961 /// so it is cheap enough for a read path to call.
2962 ///
2963 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2964 /// pinned to one commit and later commits are deliberately invisible to it.
2965 pub fn is_stale(&self) -> Result<bool> {
2966 if self.pinned {
2967 return Ok(false);
2968 }
2969 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2970 return Ok(true);
2971 }
2972 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2973 }
2974
2975 /// Bring this handle up to date with every commit other processes have made,
2976 /// and return how many frames were applied.
2977 ///
2978 /// The WAL tail is decoded from this handle's cursor and applied through the
2979 /// same path the open replay uses, so rules fire and derived edges appear
2980 /// exactly as they would on a fresh open. Interners, id maps and indexes
2981 /// stay valid for the same reason.
2982 ///
2983 /// A frame another process is still writing is left alone: a trailing
2984 /// partial frame is a wait, not a corruption, and the handle stays stale
2985 /// until that frame is complete. Nothing is written to disk, so a read-only
2986 /// handle can refresh freely.
2987 ///
2988 /// When the snapshot file's identity changed, or the WAL is shorter than
2989 /// this handle's cursor, the WAL no longer continues our state — another
2990 /// process snapshotted or archived. The handle is then rebuilt from disk
2991 /// with the options it was opened with, and the return value is the number
2992 /// of frames in the new WAL.
2993 ///
2994 /// Returns 0 for an as-of view, which never follows later commits.
2995 ///
2996 /// # Errors
2997 ///
2998 /// An error here leaves the handle **degraded**: it got partway through
2999 /// applying the tail, or partway through a reload, so its in-memory state
3000 /// no longer matches any point on disk. Further mutations are refused and
3001 /// the handle must be reopened. Nothing on disk was damaged — the store
3002 /// itself is fine, and a fresh open recovers it.
3003 pub fn refresh(&mut self) -> Result<u64> {
3004 if self.pinned {
3005 return Ok(0);
3006 }
3007 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
3008 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
3009 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
3010 // The WAL no longer continues our state: rebuild from disk. State
3011 // is cleared first, so a failed load leaves an empty handle — mark
3012 // it degraded rather than let a caller read an empty graph as if
3013 // it were the store's contents.
3014 self.reset_for_reload();
3015 return match self.load_from_disk(LoadOrigin::Reload) {
3016 Ok(frames) => Ok(frames as u64),
3017 Err(e) => {
3018 self.degraded = true;
3019 Err(e)
3020 }
3021 };
3022 }
3023 if wal_len == self.wal_consumed {
3024 return Ok(0);
3025 }
3026 let tail = self
3027 .fs
3028 .read_range(FileId::Wal, self.wal_consumed)
3029 .map_err(GraphError::Io)?;
3030 let (records, valid_len) = decode_all(&tail);
3031 let decoded_frames = records.len() as u64;
3032 let applied = match self.apply_frames(records) {
3033 Ok(n) => n,
3034 Err(e) => {
3035 // Some frames landed and some did not, and the cursor cannot
3036 // say how many. Advancing it would skip the rest; leaving it
3037 // would replay what already applied. Neither is recoverable in
3038 // place, so refuse further writes and require a reopen.
3039 self.degraded = true;
3040 return Err(e);
3041 }
3042 };
3043 // Advance by the bytes actually decoded, never by the file length: an
3044 // incomplete trailing frame stays unconsumed for the next refresh.
3045 self.wal_consumed += valid_len as u64;
3046 self.wal_frames_written += decoded_frames;
3047 // The peer that wrote those frames also stamped them. Absorbing the
3048 // frames without the stamps leaves this handle resolving dates from a
3049 // prefix of the store's history, and — while our own map is still
3050 // empty — one commit away from rewriting the peer's file out of
3051 // existence (`first` below decides on the map, and the map is what we
3052 // just brought up to date).
3053 self.load_commit_times_from_fs();
3054 if applied > 0 {
3055 // Peer commits must reach `reader()` snapshots taken from here on.
3056 // A full fold is what open does; refresh does not build per-commit
3057 // deltas, so there is nothing cheaper that stays correct.
3058 self.fold_now();
3059 }
3060 Ok(applied as u64)
3061 }
3062
3063 /// Byte offset of the WAL prefix this handle has applied.
3064 ///
3065 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
3066 #[doc(hidden)]
3067 pub fn wal_consumed(&self) -> u64 {
3068 self.wal_consumed
3069 }
3070
3071 /// Rewind the WAL cursor after the group-commit drain thread truncated a
3072 /// failed group off the tail, so the cursor still describes the file.
3073 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
3074 self.wal_consumed = len;
3075 }
3076
3077 /// One non-blocking attempt at the cross-process write lock.
3078 ///
3079 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
3080 /// in-process write guard. That ordering is what keeps a busy peer in
3081 /// another process from stalling this process's readers.
3082 ///
3083 /// A handle that owns the lock for its lifetime always succeeds.
3084 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
3085 if self.holds_lifetime_lock {
3086 return Ok(true);
3087 }
3088 self.fs.try_lock_exclusive().map_err(GraphError::Io)
3089 }
3090
3091 /// Poll for the cross-process write lock until `wait` elapses.
3092 ///
3093 /// One attempt is always made, so a zero wait is a single try. Returns
3094 /// `false` when the lock is still held elsewhere at the deadline; nothing
3095 /// has been written and retrying later is safe.
3096 ///
3097 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
3098 /// handle outright. [`SharedDb`](crate::SharedDb) polls
3099 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
3100 /// that it holds no in-process guard while it waits.
3101 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
3102 let deadline = std::time::Instant::now() + wait;
3103 loop {
3104 if self.try_cross_process_lock()? {
3105 return Ok(true);
3106 }
3107 let now = std::time::Instant::now();
3108 if now >= deadline {
3109 return Ok(false);
3110 }
3111 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
3112 }
3113 }
3114
3115 /// Open a cross-process write scope, given the outcome of an already-made
3116 /// lock attempt.
3117 ///
3118 /// The caller polls for the lock first — outside any in-process guard — and
3119 /// passes what it got. On success this refreshes, so the writes about to
3120 /// happen land on top of every other process's commits. On failure the
3121 /// handle refuses WAL-appending mutations and `snapshot()` with
3122 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
3123 /// closes the scope, so a caller holding a guard cannot write behind
3124 /// another process's back.
3125 ///
3126 /// A handle that already owns the lock for its lifetime skips the refresh:
3127 /// no other process can have written, so there is nothing to pick up.
3128 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
3129 self.lock_denied = !acquired;
3130 if !acquired || self.holds_lifetime_lock {
3131 return Ok(());
3132 }
3133 if let Err(e) = self.refresh() {
3134 // Do not hold a lock we cannot use: release it and let the caller
3135 // see the underlying failure.
3136 let _ = self.fs.unlock();
3137 self.lock_denied = true;
3138 return Err(e);
3139 }
3140 Ok(())
3141 }
3142
3143 /// Close a cross-process write scope opened by
3144 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
3145 /// clear the Busy latch. Safe to call when the lock was never taken.
3146 pub(crate) fn end_write_lock(&mut self) {
3147 self.lock_denied = false;
3148 if !self.holds_lifetime_lock {
3149 // Releasing a lock we do not hold is a no-op; a failure to release
3150 // is reported by the OS closing the descriptor at handle drop.
3151 let _ = self.fs.unlock();
3152 }
3153 }
3154
3155 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
3156 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
3157 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
3158 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
3159 /// Restore all persisted state from a decoded snapshot. Shared by
3160 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
3161 fn restore_snapshot_state(
3162 &mut self,
3163 state: core_storage::snapshot::SnapshotState,
3164 ) -> Result<()> {
3165 self.ids = Arc::new(state.ids);
3166 self.syms = Arc::new(state.syms);
3167 self.topo = Arc::new(state.topo);
3168 self.props = Arc::new(state.props);
3169 self.labels = Arc::new(state.labels);
3170 self.edge_props = Arc::new(state.edge_props);
3171 // Cross-section label integrity for V5/V7 snapshots: same invariants as
3172 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
3173 // out-of-range sym ids, or a sentinel label on a live node would otherwise
3174 // open successfully and panic later in `NodeRef::label()` or
3175 // `neighborhood_masked()`. Catching it here turns those into typed
3176 // `GraphError::Corrupt` at open time.
3177 {
3178 let ids_len = self.ids.len();
3179 if self.labels.len() != ids_len {
3180 return Err(GraphError::Corrupt {
3181 detail: format!(
3182 "snapshot: labels vec has {} entries but id table has {} total slots",
3183 self.labels.len(),
3184 ids_len,
3185 ),
3186 });
3187 }
3188 let syms_len = self.syms.len() as u32;
3189 for (i, &sym) in self.labels.iter().enumerate() {
3190 let is_tombstoned = self.ids.is_tombstoned(i as u32);
3191 if sym == u32::MAX {
3192 if !is_tombstoned {
3193 return Err(GraphError::Corrupt {
3194 detail: format!(
3195 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
3196 ),
3197 });
3198 }
3199 } else if sym >= syms_len {
3200 return Err(GraphError::Corrupt {
3201 detail: format!(
3202 "snapshot: label at id slot {i} references sym {sym} \
3203 which is out of interner range ({syms_len})"
3204 ),
3205 });
3206 }
3207 }
3208 }
3209 let defs: Vec<RuleDef> = state
3210 .rule_defs
3211 .iter()
3212 .map(|b| {
3213 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3214 detail: format!("snapshot rule_def deserialize: {e}"),
3215 })
3216 })
3217 .collect::<Result<Vec<_>>>()?;
3218 self.engine =
3219 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
3220 // Candidate indexes are rebuilt lazily on the first mutation (see
3221 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
3222 // snapshot are retained without deserializing so that:
3223 // - clean-open (empty WAL): indexes stay empty; blobs load on first
3224 // ANN query via ensure_hnsw_loaded, or on first mutation via the
3225 // lazy-init guard which calls reindex_all_load_state (the scan
3226 // skips the HNSW build for every side the blob supplies).
3227 // - WAL-present: open_with calls consume_retained_state_eager before
3228 // replay so HNSW/IVF are live before any record fires the hooks.
3229 let ivf_bytes = if state.ivf_state.is_empty() {
3230 Vec::new()
3231 } else {
3232 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
3233 };
3234 // Store blobs without eagerly deserializing them.
3235 // `self.ids` is the snapshot's id table at this point — WAL replay has
3236 // not run — so its length is the line an interrupted build is detected
3237 // against.
3238 let snapshot_ids = self.ids.len() as u32;
3239 self.engine
3240 .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
3241 // Restore view defs from snapshot (V5).
3242 // The ColumnStore already contains view values from the snapshot;
3243 // use restore_view (no collision check, no backfill) so the store
3244 // is aware of the definitions. rebuild_all runs after WAL replay.
3245 for def_bytes in &state.view_defs {
3246 let def: ViewDef =
3247 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3248 detail: format!("snapshot view_def deserialize: {e}"),
3249 })?;
3250 self.view_store
3251 .restore_view(def)
3252 .map_err(|e| GraphError::Corrupt {
3253 detail: format!("snapshot view restore: {e}"),
3254 })?;
3255 }
3256 Ok(())
3257 }
3258
3259 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
3260 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
3261 ///
3262 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
3263 /// deserialization and view rebuild have access to all column data.
3264 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
3265 self.ids = Arc::new(archived_to_idmap(mapped.ids().map_err(|e| {
3266 GraphError::Corrupt {
3267 detail: format!("v8: ids section: {e:?}"),
3268 }
3269 })?));
3270 self.syms = Arc::new(archived_to_interner(mapped.syms().map_err(|e| {
3271 GraphError::Corrupt {
3272 detail: format!("v8: syms section: {e:?}"),
3273 }
3274 })?));
3275
3276 // C1: self.props is left as an empty overlay. Column reads go through
3277 // props_view() (ColumnsView::with_base), which consults the archived base
3278 // section zero-copy. This avoids the O(columns) heap copy at every open.
3279
3280 // self.topo deliberately left as Topology::new() — overlay path.
3281
3282 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
3283 detail: format!("v8: meta section: {e:?}"),
3284 })?)
3285 .map_err(|e| GraphError::Corrupt {
3286 detail: format!("v8: meta decode: {e:?}"),
3287 })?;
3288 self.base_wal_truncated = meta.wal_truncated;
3289 self.labels = Arc::new(meta.labels);
3290 // Cross-section label integrity: labels must cover every id slot (live
3291 // and tombstoned), every non-sentinel sym must be within the interner's
3292 // bound, and no live (non-tombstoned) node may carry the u32::MAX
3293 // sentinel label. Without this check, a crafted snapshot where the META
3294 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
3295 // sym ids, or a sentinel label on a live node, would open successfully
3296 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
3297 // related read paths. Catching the inconsistency here converts those
3298 // panics into typed `GraphError::Corrupt` at open time.
3299 {
3300 let ids_len = self.ids.len();
3301 if self.labels.len() != ids_len {
3302 return Err(GraphError::Corrupt {
3303 detail: format!(
3304 "v8: labels section has {} entries but id table has {} total slots",
3305 self.labels.len(),
3306 ids_len,
3307 ),
3308 });
3309 }
3310 let syms_len = self.syms.len() as u32;
3311 for (i, &sym) in self.labels.iter().enumerate() {
3312 let is_tombstoned = self.ids.is_tombstoned(i as u32);
3313 if sym == u32::MAX {
3314 // Sentinel is only valid for tombstoned slots.
3315 if !is_tombstoned {
3316 return Err(GraphError::Corrupt {
3317 detail: format!(
3318 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
3319 ),
3320 });
3321 }
3322 } else if sym >= syms_len {
3323 return Err(GraphError::Corrupt {
3324 detail: format!(
3325 "v8: label at id slot {i} references sym {sym} \
3326 which is out of interner range ({syms_len})"
3327 ),
3328 });
3329 }
3330 }
3331 }
3332 // C3: self.edge_props stays as an empty overlay. Reads go through
3333 // edge_props_view() which consults the mmap'd base section zero-copy
3334 // via EdgePropsView::with_base. No heap decode at open time.
3335
3336 // Restore rule engine.
3337 let (rule_def_bytes, rule_tripped, rule_fires) =
3338 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
3339 GraphError::Corrupt {
3340 detail: format!("v8: rules_meta section: {e:?}"),
3341 }
3342 })?);
3343 let defs: Vec<RuleDef> = rule_def_bytes
3344 .iter()
3345 .map(|b| {
3346 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3347 detail: format!("v8: rule_def deserialize: {e}"),
3348 })
3349 })
3350 .collect::<Result<Vec<_>>>()?;
3351 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
3352 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
3353 // `ensure_v8_base_sections_loaded` reads them on first use from
3354 // `self.base` (set by the caller immediately after this returns).
3355 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
3356
3357 // Restore view definitions.
3358 let view_defs =
3359 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
3360 detail: format!("v8: views section: {e:?}"),
3361 })?);
3362 for def_bytes in &view_defs {
3363 let def: ViewDef =
3364 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3365 detail: format!("v8: view_def deserialize: {e}"),
3366 })?;
3367 self.view_store
3368 .restore_view(def)
3369 .map_err(|e| GraphError::Corrupt {
3370 detail: format!("v8: view restore: {e}"),
3371 })?;
3372 }
3373 // Load the last-change map from section 11 (small section; load eagerly).
3374 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
3375 // in that case and `decode_last_change_bytes` returns an empty map.
3376 let last_change_raw = mapped
3377 .last_change_bytes()
3378 .map_err(|e| GraphError::Corrupt {
3379 detail: format!("v8: last_change section: {e:?}"),
3380 })?;
3381 self.last_change = decode_last_change_bytes(last_change_raw);
3382
3383 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
3384 // the file. Pure bounds check — no bytes read, no page faults triggered.
3385 // Catches truncated snapshots at open time before the lazy deferred reads.
3386 mapped.validate_section_bounds().map_err(|e| match e {
3387 GraphError::Corrupt { detail } => GraphError::Corrupt {
3388 detail: format!("v8: section bounds: {detail}"),
3389 },
3390 other => other,
3391 })?;
3392 Ok(())
3393 }
3394
3395 /// Read provenance, HNSW, and IVF sections from the mmap base into the
3396 /// engine's retained fields on first call. Subsequent calls are a no-op
3397 /// (AtomicBool fast-path).
3398 ///
3399 /// Must be called before any code path that reads or mutates engine
3400 /// provenance, HNSW, or IVF state:
3401 /// - WAL replay (before `consume_retained_state_eager`)
3402 /// - First mutation (`log_then_apply_with`)
3403 /// - Read-only paths (`stats`, `explain`, `node_edges`)
3404 /// - Snapshot (`snapshot_with`)
3405 ///
3406 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
3407 fn ensure_v8_base_sections_loaded(&self) {
3408 use std::sync::atomic::Ordering;
3409 if self.v8_sections_loaded.load(Ordering::Acquire) {
3410 return;
3411 }
3412 let _guard = self
3413 .v8_sections_mutex
3414 .lock()
3415 .expect("v8 sections mutex poisoned");
3416 if self.v8_sections_loaded.load(Ordering::Acquire) {
3417 return; // another caller populated while we waited
3418 }
3419 let _t = std::time::Instant::now();
3420 if let Some(base) = &self.base {
3421 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
3422 // Bounds are already validated at open time (restore_v8_base →
3423 // validate_section_bounds) — unreachable post-validate_section_bounds;
3424 // unwrap_or_default is a safety belt against impossible errors.
3425 let prov_bytes = base
3426 .provenance_raw_bytes()
3427 .map(|b| b.to_vec())
3428 .unwrap_or_default();
3429 self.engine.store_provenance_bytes(prov_bytes);
3430 // HNSW: decode rkyv blobs into owned map.
3431 let hnsw_state = base
3432 .hnsw_section()
3433 .map(archived_hnsw_to_owned)
3434 .unwrap_or_default();
3435 // IVF: raw bincode bytes; deserialized on first mutation/query.
3436 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
3437 // Called before WAL replay on a WAL-present open (`open_with`) and
3438 // before any write on a clean one, so this is the snapshot's count.
3439 let snapshot_ids = self.ids.len() as u32;
3440 self.engine
3441 .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
3442 }
3443 self.v8_sections_loaded.store(true, Ordering::Release);
3444 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
3445 eprintln!(
3446 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
3447 _t.elapsed()
3448 );
3449 }
3450 }
3451
3452 /// Return a `TopologyView` that merges the mmap'd base (when present) with
3453 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
3454 /// the full merged topology without going through `self.view()`.
3455 fn topo_view(&self) -> TopologyView<'_> {
3456 match self.base {
3457 None => TopologyView::owned(&self.topo),
3458 Some(ref base) => {
3459 // SAFETY: base lives as long as self; section bounds validated at open.
3460 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
3461 let archived = base
3462 .topology()
3463 .expect("base topology section bounds validated at open");
3464 TopologyView::with_base(&self.topo, archived)
3465 }
3466 }
3467 }
3468
3469 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
3470 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
3471 /// overlay first, then fall through to the archived base section zero-copy.
3472 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
3473 match self.base {
3474 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
3475 Some(ref base) => {
3476 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
3477 let archived = base
3478 .columns()
3479 .expect("base columns section bounds validated at open");
3480 core_storage::v8::seam::ColumnsView::with_base_cached(
3481 &self.props,
3482 archived,
3483 base.mixed_cache(),
3484 )
3485 .with_shared_strings(base_string_table(base))
3486 }
3487 }
3488 }
3489
3490 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
3491 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
3492 ///
3493 /// Reads consult the overlay first (for post-snapshot mutations), then fall
3494 /// through to the archived base section zero-copy. Tombstones in the
3495 /// overlay mask deleted-from-base entries.
3496 fn edge_props_view(&self) -> EdgePropsView<'_> {
3497 match self.base {
3498 None => EdgePropsView::owned(&self.edge_props),
3499 Some(ref base) => {
3500 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
3501 let archived = base
3502 .edge_props_section()
3503 .expect("base edge_props section bounds validated at open");
3504 EdgePropsView::with_base(&self.edge_props, archived)
3505 }
3506 }
3507 }
3508
3509 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
3510 // An as-of view never writes and is pinned to one commit: it takes no
3511 // cross-process lock and does not follow later commits.
3512 let mut db = Self::new_empty(
3513 fs,
3514 OpenOptions {
3515 repair_wal: false,
3516 auto_migrate: false,
3517 read_only: true,
3518 },
3519 );
3520 db.pinned = true; // read_only is set after replay, but pinning is immediate
3521 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
3522 db.archive_genesis_chain = db.fs.has_genesis_marker();
3523 // Same orphaned-archive cleanup as open_with: floor was written first
3524 // during pruning, so a crash may have left stale archives below floor.
3525 db.cleanup_orphaned_archives()?;
3526 // Collect archive frames (oldest-first) and live WAL frames.
3527 // Archives represent pre-snapshot history; the snapshot captures the
3528 // cumulative state at the time of archiving. Crash-window guarantee:
3529 // A: crash before rename → WAL intact, no archive. Reopen: normal.
3530 // B: crash after rename, before new WAL → archive present, WAL
3531 // absent. Reopen: snapshot loaded (full state), no WAL replay.
3532 // C: crash after new baseline WAL written → normal post-archive.
3533 let archive_ns = db.fs.list_archives()?;
3534 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
3535 for n in &archive_ns {
3536 let arc_bytes = db.fs.read_archive(*n)?;
3537 let (arc_frames, _) = decode_all(&arc_bytes);
3538 archive_frames_all.extend(arc_frames);
3539 }
3540 let total_archive_frames = archive_frames_all.len() as u64;
3541
3542 let live_bytes = db.fs.read(FileId::Wal)?;
3543 let (live_records, _valid_len) = decode_all(&live_bytes);
3544 let total_surviving = total_archive_frames + live_records.len() as u64;
3545 // Global total including any pruned history below the horizon floor.
3546 let total = db.wal_horizon_floor + total_surviving;
3547
3548 // Horizon and range check.
3549 if commit < db.wal_horizon_floor {
3550 return Err(GraphError::CommitOutOfRange {
3551 commit,
3552 total,
3553 floor: db.wal_horizon_floor,
3554 });
3555 }
3556 if commit >= total {
3557 return Err(GraphError::CommitOutOfRange {
3558 commit,
3559 total,
3560 floor: db.wal_horizon_floor,
3561 });
3562 }
3563
3564 // Local index into surviving frames (0 = first frame of oldest archive).
3565 let local = commit - db.wal_horizon_floor;
3566
3567 if local < total_archive_frames {
3568 // Target commit is in an archive. Correct replay from empty state
3569 // is only possible when the archive chain is an uninterrupted
3570 // genesis chain (first archive taken from a fresh store, no prior
3571 // WAL truncation) and no archives have been pruned (floor == 0).
3572 //
3573 // If either condition is violated the prefix needed to reconstruct
3574 // the requested state is gone; refuse rather than return wrong data.
3575 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
3576 return Err(GraphError::CommitOutOfRange {
3577 commit,
3578 total,
3579 floor: db.wal_horizon_floor,
3580 });
3581 }
3582 // Replay all archive frames up to and including the target commit
3583 // from an empty database state. Archives must be replayed in order
3584 // so that dense-id intern tables are built up correctly.
3585 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
3586 db.apply(&rec)?;
3587 let _ = db.engine.drain_deltas();
3588 }
3589 } else {
3590 // Target commit is in the live WAL: load snapshot as base, then
3591 // replay the needed live WAL prefix.
3592 //
3593 // Base state: a truncating snapshot (wal_truncated=true) compacts
3594 // all pre-truncation / pre-archive commits. Dense-id records in
3595 // the live WAL reference ids/interns that the snapshot provides.
3596 //
3597 // A `keep_wal` snapshot records `wal_truncated=false`: it is the
3598 // state at the head, not at the start of the WAL, so it is no base
3599 // for a prefix. That is right when the live WAL reaches the store's
3600 // first commit and can be replayed from nothing. It is wrong when
3601 // the WAL starts above that — the floor has moved, or archives sit
3602 // in front of it — because the snapshot that *was* its base has
3603 // been overwritten. Replaying the prefix over nothing then answers
3604 // with a graph missing everything older than the WAL. The base is
3605 // gone, so the read is refused (ledger row 82).
3606 let base_is_gone = db.wal_horizon_floor > 0 || total_archive_frames > 0;
3607 // A snapshot interrupted before its WAL was replaced is, like a
3608 // `keep_wal` one, the state at the END of the live WAL, whatever
3609 // it says about truncation: no base for a prefix of that WAL.
3610 let live_wal = db.fs.read(FileId::Wal)?;
3611 let snapshot_is_wal_end = db.interrupted_snapshot(&live_wal)?.0.is_some();
3612 // Peek 6 bytes (same pattern as open_with).
3613 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3614 let is_v8 = snap_header.len() >= 6
3615 && &snap_header[0..4] == b"GDB1"
3616 && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
3617 snap_header[4],
3618 snap_header[5],
3619 ]));
3620 if is_v8 {
3621 let state = if let Some(snap_path) = db.fs.snapshot_path() {
3622 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3623 GraphError::Corrupt {
3624 detail: format!("v8: open_at mmap: {e:?}"),
3625 }
3626 })?;
3627 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3628 } else {
3629 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3630 core_storage::snapshot::decode(&snap_bytes)?
3631 };
3632 if let Some(state) = state {
3633 if state.wal_truncated && !snapshot_is_wal_end {
3634 db.restore_snapshot_state(state)?;
3635 db.drop_edges_of_retired_nodes();
3636 } else if base_is_gone {
3637 return Err(GraphError::CommitOutOfRange {
3638 commit,
3639 total,
3640 floor: db.wal_horizon_floor,
3641 });
3642 }
3643 }
3644 } else if !snap_header.is_empty() {
3645 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3646 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3647 if state.wal_truncated && !snapshot_is_wal_end {
3648 db.restore_snapshot_state(state)?;
3649 db.drop_edges_of_retired_nodes();
3650 } else if base_is_gone {
3651 return Err(GraphError::CommitOutOfRange {
3652 commit,
3653 total,
3654 floor: db.wal_horizon_floor,
3655 });
3656 }
3657 }
3658 }
3659 // else: snap_header empty = no snapshot file.
3660 let live_local = local - total_archive_frames;
3661 for rec in live_records.into_iter().take((live_local + 1) as usize) {
3662 db.apply(&rec)?;
3663 let _ = db.engine.drain_deltas();
3664 }
3665 }
3666 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3667 // post-loop assert in open_with.
3668 debug_assert_eq!(
3669 db.engine.pending_delta_count(),
3670 0,
3671 "pending_deltas non-empty after open_at replay — \
3672 per-frame drain must run inside the loop to keep memory O(1)"
3673 );
3674 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3675 // Rebuild view values after WAL replay so derived-edge-driven views
3676 // reflect the as-of state. open_at always uses the legacy path (no V8
3677 // base), so topo_view is always owned.
3678 {
3679 let topo_view = TopologyView::owned(&db.topo);
3680 db.view_store.rebuild_all(
3681 Arc::make_mut(&mut db.props),
3682 &topo_view,
3683 &db.ids,
3684 &db.syms,
3685 &db.labels,
3686 );
3687 }
3688 // Rebuild full-text index for as-of view (mirrors open_with pattern).
3689 Arc::make_mut(&mut db.fulltext).rebuild_all(
3690 &db.ids,
3691 &db.labels,
3692 &db.syms,
3693 build_props_view(&db.props, &db.base),
3694 );
3695 db.prop_index.rebuild_all(
3696 &db.ids,
3697 &db.labels,
3698 &db.syms,
3699 build_props_view(&db.props, &db.base),
3700 );
3701 // Namespaces on the temporal handle, built by the same pass the live
3702 // open uses, so an as-of mask narrows by the namespaces of that commit.
3703 db.rebuild_node_ns();
3704 // Load roles sidecar (current roles, not point-in-time).
3705 db.roles = Self::load_roles_from_fs(&db.fs)?;
3706 db.read_only = true;
3707 db.total_wal_commits = total;
3708 // Capture initial fold so reader() is immediately usable.
3709 db.fold_now();
3710 Ok(db)
3711 }
3712
3713 /// Whether this instance is a read-only as-of view.
3714 pub fn is_read_only(&self) -> bool {
3715 self.read_only
3716 }
3717
3718 // ── MVCC epoch reader ─────────────────────────────────────────────────────
3719
3720 /// Clone the current overlay state into a new `FrozenOverlay` and reset
3721 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3722 /// the end of `open_with` / `open_at_with` to prime the reader.
3723 fn fold_now(&mut self) {
3724 // Eight `Arc::clone`s — refcount bumps, O(1). This used to deep-copy the
3725 // whole overlay: `IdMap` alone is a `HashMap<String, u32>` plus a
3726 // `Vec<String>`, so every node key was copied twice, on every open,
3727 // after every snapshot, every FOLD_EVERY_K commits on the write path,
3728 // and — with no commit threshold — on every `refresh()` that applied a
3729 // peer commit. A refreshing reader now pays nothing for a fold.
3730 //
3731 // `props` and `topo` were already cheap for a different reason: on a
3732 // snapshotted store `restore_v8_base` leaves them as empty overlays over
3733 // the zero-copy mmap. `ids`, `syms` and `fulltext` were not, and that
3734 // inconsistency was the defect.
3735 let frozen = crate::reader::FrozenOverlay {
3736 ids: std::sync::Arc::clone(&self.ids),
3737 syms: std::sync::Arc::clone(&self.syms),
3738 topo: std::sync::Arc::clone(&self.topo),
3739 props: std::sync::Arc::clone(&self.props),
3740 labels: std::sync::Arc::clone(&self.labels),
3741 edge_props: std::sync::Arc::clone(&self.edge_props),
3742 roles: self.roles.clone().map(std::sync::Arc::new),
3743 fulltext: std::sync::Arc::clone(&self.fulltext),
3744 };
3745 self.fold_overlay = Some(Arc::new(frozen));
3746 self.delta_tail.clear();
3747 self.commits_since_fold = 0;
3748 // The fold holds every view value as it stands; what replay or a
3749 // refresh touched on the way here is not owed to any delta.
3750 self.view_store.clear_touched();
3751 }
3752
3753 /// The view properties the commit of `rec` left on the nodes it touched,
3754 /// for the reader's delta tail — or `None` when the commit should be
3755 /// folded instead of described.
3756 ///
3757 /// Folded: a commit that creates or deletes a view, which writes its
3758 /// property on every node of a label, and one that moves far more view
3759 /// values than it has records — a write to a hub moves a view on every
3760 /// neighbour. A delta listing those would be held, and replayed by every
3761 /// reader, until the next fold.
3762 ///
3763 /// Not folded: a commit whose view values are in proportion to its
3764 /// records, however many. A bulk load gives each node it inserts a
3765 /// starting value, and the reader replays each of those inserts anyway. A
3766 /// fold is eight `Arc` clones to take, but it shares the store's maps
3767 /// with the reader, so the commit after it copies them in full; a load
3768 /// that folded on every batch paid that on every batch.
3769 fn view_values_of_commit(
3770 &mut self,
3771 rec: &WalRecord,
3772 ) -> Option<Vec<(u32, String, Option<Value>)>> {
3773 /// View values a commit may carry beyond those its own records
3774 /// account for.
3775 const MAX_BEYOND_RECORDS: usize = 1024;
3776
3777 fn declares_a_view(rec: &WalRecord) -> bool {
3778 match rec {
3779 WalRecord::CreateView { .. } | WalRecord::DeleteView { .. } => true,
3780 WalRecord::Batch(inner) => inner.iter().any(declares_a_view),
3781 _ => false,
3782 }
3783 }
3784
3785 fn leaf_records(rec: &WalRecord) -> usize {
3786 match rec {
3787 WalRecord::Batch(inner) => inner.iter().map(leaf_records).sum(),
3788 _ => 1,
3789 }
3790 }
3791
3792 let mut touched = self.view_store.take_touched();
3793 if declares_a_view(rec) {
3794 return None;
3795 }
3796 if touched.is_empty() {
3797 return Some(Vec::new());
3798 }
3799 touched.sort_unstable();
3800 touched.dedup();
3801 let views = self.view_store.views().count();
3802 let budget = MAX_BEYOND_RECORDS + leaf_records(rec).saturating_mul(views);
3803 let cols = build_props_view(&self.props, &self.base);
3804 let mut values = Vec::new();
3805 for &node in &touched {
3806 // A node deleted later in the same commit: the reader's replay of
3807 // the delete clears its properties, the view's among them.
3808 if self.ids.key_of(node).is_none() {
3809 continue;
3810 }
3811 let label = self.labels.get(node as usize).copied();
3812 for def in self.view_store.views() {
3813 if label.is_none() || self.syms.get(&def.label) != label {
3814 continue;
3815 }
3816 if values.len() == budget {
3817 return None;
3818 }
3819 let value = cols.get(node, &def.view_prop).map(|v| v.into_value());
3820 values.push((node, def.view_prop.clone(), value));
3821 }
3822 }
3823 Some(values)
3824 }
3825
3826 /// Capture a lock-free reader snapshot of the current db state.
3827 ///
3828 /// The read lock is held only for the duration of this call (to clone a
3829 /// handful of `Arc` handles). Subsequent query operations run without any
3830 /// lock.
3831 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3832 crate::reader::ReaderSnapshot::new(
3833 self.fold_overlay
3834 .clone()
3835 .expect("fold_overlay is always Some after open_with; call reader() after open"),
3836 self.base.clone(),
3837 self.delta_tail.clone(),
3838 // The snapshot's effective state is exactly this handle's state at
3839 // this commit, so it shares the memo and its version key.
3840 self.commit_seq,
3841 Arc::clone(&self.role_masks),
3842 )
3843 }
3844
3845 /// Append a delta the reader cannot apply, so that a corrupt overlay is
3846 /// reachable from a test.
3847 ///
3848 /// Compiled only under `test-hooks`, which the server's dev-dependency on
3849 /// this crate turns on. One call permanently corrupts every
3850 /// [`ReaderSnapshot`](crate::reader::ReaderSnapshot) taken from the handle,
3851 /// so it must not be in the published surface: `#[doc(hidden)]` hides it
3852 /// from rustdoc and from nothing else. The feature gate — not
3853 /// `#[cfg(test)]` — because its only callers are in `crates/server/tests`,
3854 /// a different crate, exactly as `core_rules`'s index counters are.
3855 ///
3856 /// [`ReaderSnapshot::effective`](crate::reader::ReaderSnapshot) folds the
3857 /// delta tail into a clone of the frozen overlay and answers
3858 /// [`GraphError::Corrupt`] when a record will not apply. Nothing a caller
3859 /// can do produces that state — `apply_one`'s failures are disagreements
3860 /// between the tail and the fold it is applied to, which the write path
3861 /// cannot create — so the `Corrupt` arm of every scoped reader method was
3862 /// reachable only by inspection until this hook existed. An `Intern` record
3863 /// claiming an id the frozen interner will not hand back is the smallest
3864 /// such disagreement.
3865 ///
3866 /// Only the tail is touched. This handle's own state is untouched and
3867 /// `commit_seq` does not move, so a role mask already memoised at this
3868 /// version stays memoised — which is exactly the state in which the HTTP
3869 /// role branches reach a scoped read with a corrupt overlay under them.
3870 #[cfg(any(test, feature = "test-hooks"))]
3871 #[doc(hidden)]
3872 pub fn push_unapplyable_delta_for_test(&mut self) {
3873 self.delta_tail.push(Arc::new(crate::reader::CommitDelta {
3874 records: vec![WalRecord::Intern {
3875 id: u32::MAX,
3876 text: "delta-tail-corruption".into(),
3877 }],
3878 derived_inserts: Vec::new(),
3879 derived_deletes: Vec::new(),
3880 view_values: Vec::new(),
3881 }));
3882 }
3883
3884 /// Total number of WAL commits at the time [`open_at`] was called.
3885 /// Returns 0 for normal (non-as-of) instances.
3886 pub fn total_wal_commits(&self) -> u64 {
3887 self.total_wal_commits
3888 }
3889
3890 /// Apply a record to in-memory state. Used by both live writes and replay,
3891 /// so replay is definitionally identical to the original execution.
3892 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3893 // Before the record mutates anything: a store restored from a snapshot
3894 // defers building its candidate indexes until the first write, and that
3895 // build is a full node scan. Left where it used to fire — inside the
3896 // engine hook, after `props.set` and the label assignment — the scan
3897 // read the half-applied record and took the in-flight node's vector for
3898 // one the snapshot should have carried, which read as an interrupted
3899 // vector-index build and cost a full `RebuildRule` on the first
3900 // embedded write after every reopen. Hoisted here the scan sees exactly
3901 // the persisted state; the record's own hook then files its vector
3902 // through the ordinary insert path a line later.
3903 //
3904 // A declaration writes no node and no edge, and its arm reads no rule
3905 // index, so it does not start the scan.
3906 if !is_declaration(rec) {
3907 self.populate_indexes_before_write();
3908 }
3909 match rec {
3910 WalRecord::InsertNode { label, key, props } => {
3911 let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
3912 let sym = Arc::make_mut(&mut self.syms).intern(label);
3913 if self.labels.len() <= id as usize {
3914 // gap slots are sentinels, never valid label symbols
3915 Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
3916 }
3917 Arc::make_mut(&mut self.labels)[id as usize] = sym;
3918 let mut ns_name = NS_DEFAULT.to_string();
3919 for (field, value) in props {
3920 if field == NS_PROP {
3921 ns_name = namespace_of_value(Some(value)).to_string();
3922 }
3923 Arc::make_mut(&mut self.props).set(id, field, value.clone());
3924 }
3925 self.set_node_ns(id, &ns_name);
3926 // Initialize view values for the new node before the engine runs so
3927 // delta-based increments start from a known zero baseline.
3928 self.view_store.init_node_views(
3929 id,
3930 Arc::make_mut(&mut self.props),
3931 &self.syms,
3932 &self.labels,
3933 );
3934 // Fire rules for the newly inserted node.
3935 let cursor = self.engine.pending_delta_count();
3936 let mut eng = std::mem::take(&mut self.engine);
3937 {
3938 let mut gm = make_graph_mut(
3939 &self.ids,
3940 Arc::make_mut(&mut self.syms),
3941 &self.labels,
3942 build_props_view(&self.props, &self.base),
3943 Arc::make_mut(&mut self.topo),
3944 &self.base,
3945 Arc::make_mut(&mut self.edge_props),
3946 );
3947 eng.on_node_changed(id, None, &mut gm);
3948 }
3949 self.engine = eng;
3950 // Process derived-edge deltas for view maintenance.
3951 // Fast path: skip the O(delta_count) allocation when no views exist.
3952 if !self.view_store.is_empty() {
3953 #[cfg(test)]
3954 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3955 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3956 for d in &new_deltas {
3957 self.view_store.on_edge_changed(
3958 d.etype_sym,
3959 d.src_id,
3960 d.dst_id,
3961 d.fired,
3962 Arc::make_mut(&mut self.props),
3963 &build_topo_view(&self.topo, &self.base),
3964 &self.ids,
3965 &self.syms,
3966 &self.labels,
3967 base_columns(&self.base),
3968 );
3969 }
3970 }
3971 // Full-text index maintenance: index enabled fields for this label.
3972 if self.fulltext.has_label(label) {
3973 for (field, value) in props {
3974 if self.fulltext.is_enabled(label, field) {
3975 self.unindex_before_reindexing(id, field);
3976 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
3977 }
3978 }
3979 }
3980 // Property (equality) index maintenance.
3981 if self.prop_index.has_label(label) {
3982 for (field, value) in props {
3983 self.prop_index.set(label, field, id, value);
3984 }
3985 }
3986 }
3987 WalRecord::InsertEdge {
3988 edge_type,
3989 src_key,
3990 dst_key,
3991 } => {
3992 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3993 detail: format!("wal replay references unknown key {src_key}"),
3994 })?;
3995 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3996 detail: format!("wal replay references unknown key {dst_key}"),
3997 })?;
3998 let etype = Arc::make_mut(&mut self.syms).intern(edge_type);
3999 // Skip if the edge is already visible in the merged base+overlay
4000 // view. This keeps WAL replay idempotent when the WAL contains
4001 // pre-snapshot records that are already encoded in a V8 base
4002 // (keep_wal=true opens and crash-before-truncation scenarios).
4003 if self.base.is_some()
4004 && self
4005 .topo_view()
4006 .neighbors(etype, Direction::Out, src)
4007 .contains(&dst)
4008 {
4009 return Ok(());
4010 }
4011 Arc::make_mut(&mut self.topo).add_edge(etype, src, dst);
4012 // View maintenance for manual edge insert.
4013 self.view_store.on_edge_changed(
4014 etype,
4015 src,
4016 dst,
4017 true,
4018 Arc::make_mut(&mut self.props),
4019 &build_topo_view(&self.topo, &self.base),
4020 &self.ids,
4021 &self.syms,
4022 &self.labels,
4023 base_columns(&self.base),
4024 );
4025 // Rule engine: via-hop rules must update when user edges change.
4026 let cursor = self.engine.pending_delta_count();
4027 let mut eng = std::mem::take(&mut self.engine);
4028 {
4029 let mut gm = make_graph_mut(
4030 &self.ids,
4031 Arc::make_mut(&mut self.syms),
4032 &self.labels,
4033 build_props_view(&self.props, &self.base),
4034 Arc::make_mut(&mut self.topo),
4035 &self.base,
4036 Arc::make_mut(&mut self.edge_props),
4037 );
4038 eng.on_edge_changed(edge_type, src, dst, &mut gm);
4039 }
4040 self.engine = eng;
4041 if !self.view_store.is_empty() {
4042 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4043 for d in &new_deltas {
4044 self.view_store.on_edge_changed(
4045 d.etype_sym,
4046 d.src_id,
4047 d.dst_id,
4048 d.fired,
4049 Arc::make_mut(&mut self.props),
4050 &build_topo_view(&self.topo, &self.base),
4051 &self.ids,
4052 &self.syms,
4053 &self.labels,
4054 base_columns(&self.base),
4055 );
4056 }
4057 }
4058 }
4059 WalRecord::SetProp { key, field, value } => {
4060 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
4061 detail: format!("wal replay references unknown key {key}"),
4062 })?;
4063 let old_value = build_props_view(&self.props, &self.base)
4064 .get(id, field)
4065 .map(|vr| vr.into_value());
4066 Arc::make_mut(&mut self.props).set(id, field, value.clone());
4067 // Fire rules for the changed field.
4068 let cursor = self.engine.pending_delta_count();
4069 let mut eng = std::mem::take(&mut self.engine);
4070 {
4071 let mut gm = make_graph_mut(
4072 &self.ids,
4073 Arc::make_mut(&mut self.syms),
4074 &self.labels,
4075 build_props_view(&self.props, &self.base),
4076 Arc::make_mut(&mut self.topo),
4077 &self.base,
4078 Arc::make_mut(&mut self.edge_props),
4079 );
4080 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
4081 }
4082 self.engine = eng;
4083 // Derived-edge deltas → view updates.
4084 if !self.view_store.is_empty() {
4085 #[cfg(test)]
4086 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4087 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4088 for d in &new_deltas {
4089 self.view_store.on_edge_changed(
4090 d.etype_sym,
4091 d.src_id,
4092 d.dst_id,
4093 d.fired,
4094 Arc::make_mut(&mut self.props),
4095 &build_topo_view(&self.topo, &self.base),
4096 &self.ids,
4097 &self.syms,
4098 &self.labels,
4099 base_columns(&self.base),
4100 );
4101 }
4102 }
4103 // Neighbor-aggregate views that read `field` must also update.
4104 self.view_store.on_prop_changed(
4105 id,
4106 field,
4107 Arc::make_mut(&mut self.props),
4108 &build_topo_view(&self.topo, &self.base),
4109 &self.ids,
4110 &self.syms,
4111 &self.labels,
4112 base_columns(&self.base),
4113 );
4114 // Full-text index maintenance: update tokens for this field if indexed.
4115 if self.fulltext.field_indexed(field) {
4116 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4117 if sym == u32::MAX {
4118 None
4119 } else {
4120 self.syms.resolve(sym)
4121 }
4122 });
4123 if let Some(label) = label_opt {
4124 if self.fulltext.is_enabled(label, field) {
4125 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4126 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4127 }
4128 }
4129 }
4130 // Property (equality) index maintenance: re-key this node's value.
4131 if self.prop_index.field_indexed(field) {
4132 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4133 if sym == u32::MAX {
4134 None
4135 } else {
4136 self.syms.resolve(sym)
4137 }
4138 });
4139 if let Some(label) = label_opt {
4140 self.prop_index.set(label, field, id, value);
4141 }
4142 }
4143 }
4144 WalRecord::Intern { id, text } => {
4145 if let Some(existing) = self.syms.get(text) {
4146 if existing != *id {
4147 return Err(GraphError::Corrupt {
4148 detail: format!(
4149 "wal intern mismatch for {text:?}: have {existing}, record {id}"
4150 ),
4151 });
4152 }
4153 } else {
4154 let got = Arc::make_mut(&mut self.syms).intern(text);
4155 if got != *id {
4156 return Err(GraphError::Corrupt {
4157 detail: format!(
4158 "wal intern assigned {got} for {text:?}, record wanted {id}"
4159 ),
4160 });
4161 }
4162 }
4163 }
4164 WalRecord::InsertNodeId { label, key, props } => {
4165 let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
4166 if self.labels.len() <= id as usize {
4167 Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
4168 }
4169 Arc::make_mut(&mut self.labels)[id as usize] = *label;
4170 let label_str = self
4171 .syms
4172 .resolve(*label)
4173 .ok_or_else(|| GraphError::Corrupt {
4174 detail: format!("wal InsertNodeId unknown label intern {label}"),
4175 })?
4176 .to_string();
4177 let mut ns_name = NS_DEFAULT.to_string();
4178 for (field_sym, value) in props {
4179 let field =
4180 self.syms
4181 .resolve(*field_sym)
4182 .ok_or_else(|| GraphError::Corrupt {
4183 detail: format!(
4184 "wal InsertNodeId unknown field intern {field_sym}"
4185 ),
4186 })?;
4187 if field == NS_PROP {
4188 ns_name = namespace_of_value(Some(value)).to_string();
4189 }
4190 Arc::make_mut(&mut self.props).set(id, field, value.clone());
4191 }
4192 self.set_node_ns(id, &ns_name);
4193 self.view_store.init_node_views(
4194 id,
4195 Arc::make_mut(&mut self.props),
4196 &self.syms,
4197 &self.labels,
4198 );
4199 let cursor = self.engine.pending_delta_count();
4200 let mut eng = std::mem::take(&mut self.engine);
4201 {
4202 let mut gm = make_graph_mut(
4203 &self.ids,
4204 Arc::make_mut(&mut self.syms),
4205 &self.labels,
4206 build_props_view(&self.props, &self.base),
4207 Arc::make_mut(&mut self.topo),
4208 &self.base,
4209 Arc::make_mut(&mut self.edge_props),
4210 );
4211 eng.on_node_changed(id, None, &mut gm);
4212 }
4213 self.engine = eng;
4214 if !self.view_store.is_empty() {
4215 #[cfg(test)]
4216 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4217 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4218 for d in &new_deltas {
4219 self.view_store.on_edge_changed(
4220 d.etype_sym,
4221 d.src_id,
4222 d.dst_id,
4223 d.fired,
4224 Arc::make_mut(&mut self.props),
4225 &build_topo_view(&self.topo, &self.base),
4226 &self.ids,
4227 &self.syms,
4228 &self.labels,
4229 base_columns(&self.base),
4230 );
4231 }
4232 }
4233 if self.fulltext.has_label(&label_str) {
4234 for (field_sym, value) in props {
4235 let Some(field) = self.syms.resolve(*field_sym).map(str::to_string) else {
4236 continue;
4237 };
4238 let field = field.as_str();
4239 if self.fulltext.is_enabled(&label_str, field) {
4240 self.unindex_before_reindexing(id, field);
4241 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4242 }
4243 }
4244 }
4245 if self.prop_index.has_label(&label_str) {
4246 for (field_sym, value) in props {
4247 let Some(field) = self.syms.resolve(*field_sym) else {
4248 continue;
4249 };
4250 self.prop_index.set(&label_str, field, id, value);
4251 }
4252 }
4253 }
4254 WalRecord::InsertEdgeId { etype, src, dst } => {
4255 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
4256 // already be tombstoned. Skip rather than attaching edges to
4257 // dead ids (DeleteNode keys the live re-insert, not the old id).
4258 if self.ids.is_tombstoned(*src)
4259 || self.ids.is_tombstoned(*dst)
4260 || self.ids.key_of(*src).is_none()
4261 || self.ids.key_of(*dst).is_none()
4262 {
4263 return Ok(());
4264 }
4265 // Skip if already visible in the merged view (same idempotency
4266 // guard as InsertEdge above: prevents double-counting when
4267 // pre-snapshot WAL records are replayed over a V8 base).
4268 if self.base.is_some()
4269 && self
4270 .topo_view()
4271 .neighbors(*etype, Direction::Out, *src)
4272 .contains(dst)
4273 {
4274 return Ok(());
4275 }
4276 Arc::make_mut(&mut self.topo).add_edge(*etype, *src, *dst);
4277 self.view_store.on_edge_changed(
4278 *etype,
4279 *src,
4280 *dst,
4281 true,
4282 Arc::make_mut(&mut self.props),
4283 &build_topo_view(&self.topo, &self.base),
4284 &self.ids,
4285 &self.syms,
4286 &self.labels,
4287 base_columns(&self.base),
4288 );
4289 // Rule engine: via-hop rules fire when user via-edges are inserted.
4290 // Resolve etype back to string so on_edge_changed can match rules by name.
4291 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
4292 let cursor = self.engine.pending_delta_count();
4293 let mut eng = std::mem::take(&mut self.engine);
4294 {
4295 let mut gm = make_graph_mut(
4296 &self.ids,
4297 Arc::make_mut(&mut self.syms),
4298 &self.labels,
4299 build_props_view(&self.props, &self.base),
4300 Arc::make_mut(&mut self.topo),
4301 &self.base,
4302 Arc::make_mut(&mut self.edge_props),
4303 );
4304 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
4305 }
4306 self.engine = eng;
4307 if !self.view_store.is_empty() {
4308 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4309 for d in &new_deltas {
4310 self.view_store.on_edge_changed(
4311 d.etype_sym,
4312 d.src_id,
4313 d.dst_id,
4314 d.fired,
4315 Arc::make_mut(&mut self.props),
4316 &build_topo_view(&self.topo, &self.base),
4317 &self.ids,
4318 &self.syms,
4319 &self.labels,
4320 base_columns(&self.base),
4321 );
4322 }
4323 }
4324 }
4325 }
4326 WalRecord::SetPropId { id, field, value } => {
4327 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
4328 return Ok(());
4329 }
4330 let field_str = self
4331 .syms
4332 .resolve(*field)
4333 .ok_or_else(|| GraphError::Corrupt {
4334 detail: format!("wal SetPropId unknown field intern {field}"),
4335 })?
4336 .to_string();
4337 let old_value = build_props_view(&self.props, &self.base)
4338 .get(*id, &field_str)
4339 .map(|vr| vr.into_value());
4340 Arc::make_mut(&mut self.props).set(*id, &field_str, value.clone());
4341 let cursor = self.engine.pending_delta_count();
4342 let mut eng = std::mem::take(&mut self.engine);
4343 {
4344 let mut gm = make_graph_mut(
4345 &self.ids,
4346 Arc::make_mut(&mut self.syms),
4347 &self.labels,
4348 build_props_view(&self.props, &self.base),
4349 Arc::make_mut(&mut self.topo),
4350 &self.base,
4351 Arc::make_mut(&mut self.edge_props),
4352 );
4353 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
4354 }
4355 self.engine = eng;
4356 if !self.view_store.is_empty() {
4357 #[cfg(test)]
4358 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4359 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4360 for d in &new_deltas {
4361 self.view_store.on_edge_changed(
4362 d.etype_sym,
4363 d.src_id,
4364 d.dst_id,
4365 d.fired,
4366 Arc::make_mut(&mut self.props),
4367 &build_topo_view(&self.topo, &self.base),
4368 &self.ids,
4369 &self.syms,
4370 &self.labels,
4371 base_columns(&self.base),
4372 );
4373 }
4374 }
4375 self.view_store.on_prop_changed(
4376 *id,
4377 &field_str,
4378 Arc::make_mut(&mut self.props),
4379 &build_topo_view(&self.topo, &self.base),
4380 &self.ids,
4381 &self.syms,
4382 &self.labels,
4383 base_columns(&self.base),
4384 );
4385 if self.fulltext.field_indexed(&field_str) {
4386 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4387 if sym == u32::MAX {
4388 None
4389 } else {
4390 self.syms.resolve(sym)
4391 }
4392 });
4393 if let Some(label) = label_opt {
4394 if self.fulltext.is_enabled(label, &field_str) {
4395 Arc::make_mut(&mut self.fulltext).remove_node_field(*id, &field_str);
4396 Arc::make_mut(&mut self.fulltext).add_tokens(*id, &field_str, value);
4397 }
4398 }
4399 }
4400 if self.prop_index.field_indexed(&field_str) {
4401 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4402 if sym == u32::MAX {
4403 None
4404 } else {
4405 self.syms.resolve(sym)
4406 }
4407 });
4408 if let Some(label) = label_opt {
4409 self.prop_index.set(label, &field_str, *id, value);
4410 }
4411 }
4412 }
4413 WalRecord::CreateRule { def_bytes } => {
4414 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4415 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4416 })?;
4417 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
4418 // so the engine already has it; silently skip to avoid a spurious
4419 // RuleInvalid error in the crash window between snapshot write and WAL
4420 // truncation.
4421 if self.engine.rules().any(|r| r.name == def.name) {
4422 return Ok(());
4423 }
4424 let cursor = self.engine.pending_delta_count();
4425 let mut eng = std::mem::take(&mut self.engine);
4426 let result = {
4427 let mut gm = make_graph_mut(
4428 &self.ids,
4429 Arc::make_mut(&mut self.syms),
4430 &self.labels,
4431 build_props_view(&self.props, &self.base),
4432 Arc::make_mut(&mut self.topo),
4433 &self.base,
4434 Arc::make_mut(&mut self.edge_props),
4435 );
4436 eng.create_rule(def, &mut gm)
4437 };
4438 self.engine = eng;
4439 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
4440 // Derived-edge fires from backfill → view updates.
4441 // Fast path: skip O(edge_count) allocation when no views exist.
4442 if !self.view_store.is_empty() {
4443 #[cfg(test)]
4444 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4445 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4446 for d in &new_deltas {
4447 self.view_store.on_edge_changed(
4448 d.etype_sym,
4449 d.src_id,
4450 d.dst_id,
4451 d.fired,
4452 Arc::make_mut(&mut self.props),
4453 &build_topo_view(&self.topo, &self.base),
4454 &self.ids,
4455 &self.syms,
4456 &self.labels,
4457 base_columns(&self.base),
4458 );
4459 }
4460 }
4461 }
4462 WalRecord::DeleteRule { name } => {
4463 // Replay-over-snapshot idempotency: the snapshot already captured the
4464 // post-delete state so the rule is absent; silently skip to avoid a
4465 // spurious RuleNotFound error in the crash window between snapshot write
4466 // and WAL truncation.
4467 if !self.engine.rules().any(|r| r.name == *name) {
4468 return Ok(());
4469 }
4470 let cursor = self.engine.pending_delta_count();
4471 let mut eng = std::mem::take(&mut self.engine);
4472 let result = {
4473 let mut gm = make_graph_mut(
4474 &self.ids,
4475 Arc::make_mut(&mut self.syms),
4476 &self.labels,
4477 build_props_view(&self.props, &self.base),
4478 Arc::make_mut(&mut self.topo),
4479 &self.base,
4480 Arc::make_mut(&mut self.edge_props),
4481 );
4482 eng.delete_rule(name, &mut gm)
4483 };
4484 self.engine = eng;
4485 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4486 // Derived-edge retractions → view updates.
4487 if !self.view_store.is_empty() {
4488 #[cfg(test)]
4489 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4490 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4491 for d in &new_deltas {
4492 self.view_store.on_edge_changed(
4493 d.etype_sym,
4494 d.src_id,
4495 d.dst_id,
4496 d.fired,
4497 Arc::make_mut(&mut self.props),
4498 &build_topo_view(&self.topo, &self.base),
4499 &self.ids,
4500 &self.syms,
4501 &self.labels,
4502 base_columns(&self.base),
4503 );
4504 }
4505 }
4506 }
4507 WalRecord::RemoveProp { key, field } => {
4508 // Recovery-safe: unknown key or already-absent field is a
4509 // clean no-op. Crash-window replay over a snapshot that
4510 // already applied this record must not Err.
4511 let Some(id) = self.ids.get(key) else {
4512 return Ok(());
4513 };
4514 // Read old value through the seam for rule retraction.
4515 let old = build_props_view(&self.props, &self.base)
4516 .get(id, field)
4517 .map(|vr| vr.into_value());
4518 Arc::make_mut(&mut self.props).remove(id, field);
4519 // If the base still supplies the value after the overlay removal,
4520 // record a tombstone so ColumnsView::get does not resurrect it.
4521 // This covers both the base-only case AND the both-resident case:
4522 // base-only (in_overlay=false): old prop was only in base, remove
4523 // is a no-op on overlay, base still visible → tombstone needed.
4524 // both-resident (in_overlay=true): overlay had v2, base has v1;
4525 // removing overlay uncovers v1 → tombstone needed.
4526 // Idempotent on double-replay: second pass sees the tombstone →
4527 // get() returns None → condition is false → no duplicate tombstone.
4528 if build_props_view(&self.props, &self.base)
4529 .get(id, field)
4530 .is_some()
4531 {
4532 Arc::make_mut(&mut self.props).record_prop_tombstone(id, field);
4533 }
4534 let cursor = self.engine.pending_delta_count();
4535 let mut eng = std::mem::take(&mut self.engine);
4536 {
4537 let mut gm = make_graph_mut(
4538 &self.ids,
4539 Arc::make_mut(&mut self.syms),
4540 &self.labels,
4541 build_props_view(&self.props, &self.base),
4542 Arc::make_mut(&mut self.topo),
4543 &self.base,
4544 Arc::make_mut(&mut self.edge_props),
4545 );
4546 eng.on_node_changed(id, Some((field, old)), &mut gm);
4547 }
4548 self.engine = eng;
4549 // Derived-edge deltas → view updates.
4550 if !self.view_store.is_empty() {
4551 #[cfg(test)]
4552 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4553 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4554 for d in &new_deltas {
4555 self.view_store.on_edge_changed(
4556 d.etype_sym,
4557 d.src_id,
4558 d.dst_id,
4559 d.fired,
4560 Arc::make_mut(&mut self.props),
4561 &build_topo_view(&self.topo, &self.base),
4562 &self.ids,
4563 &self.syms,
4564 &self.labels,
4565 base_columns(&self.base),
4566 );
4567 }
4568 }
4569 // Neighbor-aggregate views that read `field` must also update.
4570 self.view_store.on_prop_changed(
4571 id,
4572 field,
4573 Arc::make_mut(&mut self.props),
4574 &build_topo_view(&self.topo, &self.base),
4575 &self.ids,
4576 &self.syms,
4577 &self.labels,
4578 base_columns(&self.base),
4579 );
4580 // Full-text index maintenance: remove tokens for this field.
4581 if self.fulltext.field_indexed(field) {
4582 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4583 }
4584 // Property (equality) index maintenance: drop this node's entry.
4585 if self.prop_index.field_indexed(field) {
4586 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
4587 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
4588 }) {
4589 self.prop_index.remove_node(label, field, id);
4590 }
4591 }
4592 }
4593 WalRecord::DeleteEdge {
4594 edge_type,
4595 src_key,
4596 dst_key,
4597 } => {
4598 // Recovery-safe: unknown keys, unknown etype, or already-
4599 // absent edge is a clean no-op (remove_edge returns false).
4600 let Some(src) = self.ids.get(src_key) else {
4601 return Ok(());
4602 };
4603 let Some(dst) = self.ids.get(dst_key) else {
4604 return Ok(());
4605 };
4606 let Some(etype) = self.syms.get(edge_type) else {
4607 return Ok(());
4608 };
4609 // I3: phantom-tombstone guard. When a V8 base is present, a
4610 // DeleteEdge WAL record for an edge that was already absorbed into
4611 // the new base (i.e. neither in overlay nor in base) must be skipped.
4612 // Without this guard, remove_edge records a tombstone for an edge
4613 // that no longer exists, incorrectly understating edge_count.
4614 if self.base.is_some()
4615 && !self
4616 .topo_view()
4617 .neighbors(etype, core_storage::topology::Direction::Out, src)
4618 .contains(&dst)
4619 {
4620 return Ok(());
4621 }
4622 Arc::make_mut(&mut self.topo).remove_edge(etype, src, dst);
4623 Arc::make_mut(&mut self.edge_props).remove_edge(etype, src, dst);
4624 // View maintenance for manual edge delete (topo already updated above).
4625 self.view_store.on_edge_changed(
4626 etype,
4627 src,
4628 dst,
4629 false,
4630 Arc::make_mut(&mut self.props),
4631 &build_topo_view(&self.topo, &self.base),
4632 &self.ids,
4633 &self.syms,
4634 &self.labels,
4635 base_columns(&self.base),
4636 );
4637 // Rule engine: via-hop rules must retract when user via-edges are deleted.
4638 let cursor = self.engine.pending_delta_count();
4639 let mut eng = std::mem::take(&mut self.engine);
4640 {
4641 let mut gm = make_graph_mut(
4642 &self.ids,
4643 Arc::make_mut(&mut self.syms),
4644 &self.labels,
4645 build_props_view(&self.props, &self.base),
4646 Arc::make_mut(&mut self.topo),
4647 &self.base,
4648 Arc::make_mut(&mut self.edge_props),
4649 );
4650 eng.on_edge_changed(edge_type, src, dst, &mut gm);
4651 }
4652 self.engine = eng;
4653 if !self.view_store.is_empty() {
4654 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4655 for d in &new_deltas {
4656 self.view_store.on_edge_changed(
4657 d.etype_sym,
4658 d.src_id,
4659 d.dst_id,
4660 d.fired,
4661 Arc::make_mut(&mut self.props),
4662 &build_topo_view(&self.topo, &self.base),
4663 &self.ids,
4664 &self.syms,
4665 &self.labels,
4666 base_columns(&self.base),
4667 );
4668 }
4669 }
4670 }
4671 WalRecord::DeleteNode { key } => {
4672 // Recovery-safe: already-tombstoned / unknown key is a clean
4673 // no-op. Crash-window replay over a snapshot that already
4674 // applied this record cannot recover the retired id from the
4675 // key (`IdMap::get` is None), so every subsequent step is
4676 // skipped. Each step is independently idempotent if invoked
4677 // twice on a still-live id: retraction is a no-op on empty
4678 // provenance, `remove_edge` returns false, `remove_all` is a
4679 // no-op, `ids.delete` returns None, label sentinel is sticky.
4680 let Some(n) = self.ids.get(key) else {
4681 return Ok(());
4682 };
4683
4684 // (1) Retract derived edges + de-index while props/labels live.
4685 let cursor = self.engine.pending_delta_count();
4686 let mut eng = std::mem::take(&mut self.engine);
4687 {
4688 let mut gm = make_graph_mut(
4689 &self.ids,
4690 Arc::make_mut(&mut self.syms),
4691 &self.labels,
4692 build_props_view(&self.props, &self.base),
4693 Arc::make_mut(&mut self.topo),
4694 &self.base,
4695 Arc::make_mut(&mut self.edge_props),
4696 );
4697 eng.on_node_removed(n, &mut gm);
4698 }
4699 self.engine = eng;
4700 // Derived-edge retractions → view updates for neighbors.
4701 if !self.view_store.is_empty() {
4702 #[cfg(test)]
4703 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4704 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4705 for d in &new_deltas {
4706 self.view_store.on_edge_changed(
4707 d.etype_sym,
4708 d.src_id,
4709 d.dst_id,
4710 d.fired,
4711 Arc::make_mut(&mut self.props),
4712 &build_topo_view(&self.topo, &self.base),
4713 &self.ids,
4714 &self.syms,
4715 &self.labels,
4716 base_columns(&self.base),
4717 );
4718 }
4719 }
4720
4721 // (2) Sweep ALL remaining edges incident to n, both directions,
4722 // every etype. This cascade is intentionally mask-independent:
4723 // topology integrity requires removing every edge touching the
4724 // deleted node regardless of the caller's visibility scope.
4725 // (The mask limits which nodes a role's read phase can return;
4726 // the WAL delete always executes with full storage authority.)
4727 // Collect then remove so neighbor slices stay valid during
4728 // iteration. Remove from topo first, then call view maintenance
4729 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
4730 //
4731 // Through the view of the snapshot and what was written since,
4732 // not `self.topo` alone: that is only what was written since,
4733 // and an edge the snapshot holds was left behind, pointing at
4734 // a node that was gone (ledger row 95).
4735 let mut doomed = Vec::new();
4736 {
4737 let tv = self.topo_view();
4738 let etypes: Vec<u32> = tv.etypes().collect();
4739 for et in &etypes {
4740 for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
4741 doomed.push((*et, n, dst));
4742 }
4743 for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
4744 // A self-loop is already listed from its Out side.
4745 if src != n {
4746 doomed.push((*et, src, n));
4747 }
4748 }
4749 }
4750 }
4751 for (et, s, d) in doomed {
4752 Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
4753 Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
4754 // View maintenance: n's own view values will be cleared by
4755 // remove_all below; only update surviving neighbors.
4756 self.view_store.on_edge_changed_except(
4757 et,
4758 s,
4759 d,
4760 false,
4761 Arc::make_mut(&mut self.props),
4762 &build_topo_view(&self.topo, &self.base),
4763 &self.ids,
4764 &self.syms,
4765 &self.labels,
4766 base_columns(&self.base),
4767 Some(n),
4768 );
4769 }
4770
4771 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
4772 Arc::make_mut(&mut self.props).remove_all(n);
4773 // Full-text index maintenance: remove all tokens for this node.
4774 Arc::make_mut(&mut self.fulltext).remove_node(n);
4775 // Property (equality) index maintenance: drop all entries for n.
4776 self.prop_index.remove_node_all(n);
4777
4778 // (4) Retire the dense id and stamp the label sentinel.
4779 Arc::make_mut(&mut self.ids).delete(key);
4780 if let Some(slot) = Arc::make_mut(&mut self.labels).get_mut(n as usize) {
4781 *slot = u32::MAX;
4782 }
4783 }
4784 WalRecord::Batch(inner) => {
4785 // Apply each inner record in order through the same apply path.
4786 // Inner records are validated free of nested Batch by encode_record.
4787 for rec in inner {
4788 self.apply(rec)?;
4789 }
4790 }
4791 WalRecord::RebuildRule { name } => {
4792 // Replay-over-snapshot idempotency: the snapshot may already
4793 // reflect a later delete_rule, so the rule is absent; skip.
4794 if !self.engine.rules().any(|r| r.name == *name) {
4795 return Ok(());
4796 }
4797 let cursor = self.engine.pending_delta_count();
4798 let mut eng = std::mem::take(&mut self.engine);
4799 let result = {
4800 let mut gm = make_graph_mut(
4801 &self.ids,
4802 Arc::make_mut(&mut self.syms),
4803 &self.labels,
4804 build_props_view(&self.props, &self.base),
4805 Arc::make_mut(&mut self.topo),
4806 &self.base,
4807 Arc::make_mut(&mut self.edge_props),
4808 );
4809 eng.rebuild(name, &mut gm)
4810 };
4811 self.engine = eng;
4812 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4813 // Derived-edge delta changes → view updates.
4814 if !self.view_store.is_empty() {
4815 #[cfg(test)]
4816 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4817 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4818 for d in &new_deltas {
4819 self.view_store.on_edge_changed(
4820 d.etype_sym,
4821 d.src_id,
4822 d.dst_id,
4823 d.fired,
4824 Arc::make_mut(&mut self.props),
4825 &build_topo_view(&self.topo, &self.base),
4826 &self.ids,
4827 &self.syms,
4828 &self.labels,
4829 base_columns(&self.base),
4830 );
4831 }
4832 }
4833 }
4834 WalRecord::CreateView { def_bytes } => {
4835 let def: ViewDef =
4836 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4837 detail: format!("CreateView def_bytes deserialize failed: {e}"),
4838 })?;
4839 // Replay-over-snapshot idempotency: view already present → skip.
4840 if self.view_store.has_view(&def.name) {
4841 return Ok(());
4842 }
4843 self.view_store
4844 .create_view_over(
4845 def,
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 )
4853 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4854 }
4855 WalRecord::DeleteView { name } => {
4856 // Replay-over-snapshot idempotency: view already absent → skip.
4857 if !self.view_store.has_view(name) {
4858 return Ok(());
4859 }
4860 let doomed_prop = self
4861 .view_store
4862 .views()
4863 .find(|v| v.name == *name)
4864 .map(|v| (v.label.clone(), v.view_prop.clone()));
4865 self.view_store
4866 .delete_view(
4867 name,
4868 Arc::make_mut(&mut self.props),
4869 &self.ids,
4870 &self.labels,
4871 &self.syms,
4872 )
4873 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4874 // `delete_view` clears the view's property from what was
4875 // written since the snapshot. A value the snapshot holds is
4876 // hidden by a tombstone, or it stays readable on every node
4877 // of the label after the view that owned it is gone.
4878 if let (Some((label, prop)), true) = (doomed_prop, self.base.is_some()) {
4879 if let Some(label_sym) = self.syms.get(&label) {
4880 for id in 0..self.ids.len() as u32 {
4881 if self.labels.get(id as usize).copied() == Some(label_sym)
4882 && build_props_view(&self.props, &self.base)
4883 .get(id, &prop)
4884 .is_some()
4885 {
4886 Arc::make_mut(&mut self.props).record_prop_tombstone(id, &prop);
4887 }
4888 }
4889 }
4890 }
4891 }
4892 WalRecord::EnableFulltext { label, field } => {
4893 // Replay-over-snapshot idempotency: already enabled → skip.
4894 if self.fulltext.is_enabled(label, field) {
4895 return Ok(());
4896 }
4897 Arc::make_mut(&mut self.fulltext).enable(label, field);
4898 if self.fulltext_rebuild_follows {
4899 // The open path rebuilds the whole index after replay, which
4900 // clears every posting this scan would write. Doing it twice
4901 // costs a full tokenise-and-stem pass over the corpus per
4902 // enabled pair: measured at 456 ms against 3.8 ms for the
4903 // same 30,000-entity store with no pair enabled.
4904 return Ok(());
4905 }
4906 // Backfill: index all live nodes of this label that have the field.
4907 let n = self.ids.len() as u32;
4908 for id in 0..n {
4909 let Some(&sym) = self.labels.get(id as usize) else {
4910 continue;
4911 };
4912 if sym == u32::MAX {
4913 continue; // tombstoned
4914 }
4915 let Some(lbl) = self.syms.resolve(sym) else {
4916 continue;
4917 };
4918 if lbl != label {
4919 continue;
4920 }
4921 if let Some(value) = build_props_view(&self.props, &self.base)
4922 .get(id, field)
4923 .map(|vr| vr.into_value())
4924 {
4925 Arc::make_mut(&mut self.fulltext).add_tokens(id, field, &value);
4926 }
4927 }
4928 }
4929 WalRecord::DisableFulltext { label, field } => {
4930 // Replay-over-snapshot idempotency: already disabled → skip.
4931 if !self.fulltext.is_enabled(label, field) {
4932 return Ok(());
4933 }
4934 // If another label still indexes this field, the postings column
4935 // is kept — but it must not contain node_ids from the now-disabled
4936 // label. Remove them before calling disable() so the field_indexed
4937 // guard inside disable() sees the correct post-removal state.
4938 if self.fulltext.field_indexed_by_other(label, field) {
4939 if let Some(label_sym) = self.syms.get(label) {
4940 for (node_id, &lsym) in self.labels.iter().enumerate() {
4941 if lsym == label_sym {
4942 Arc::make_mut(&mut self.fulltext)
4943 .remove_node_field(node_id as u32, field);
4944 }
4945 }
4946 }
4947 }
4948 Arc::make_mut(&mut self.fulltext).disable(label, field);
4949 }
4950 WalRecord::EnableIndex { label, field } => {
4951 // Replay-over-snapshot idempotency: already enabled → skip.
4952 if self.prop_index.is_enabled(label, field) {
4953 return Ok(());
4954 }
4955 self.prop_index.enable(label, field);
4956 // Backfill: index all live nodes of this label that have the field.
4957 let n = self.ids.len() as u32;
4958 for id in 0..n {
4959 let Some(&sym) = self.labels.get(id as usize) else {
4960 continue;
4961 };
4962 if sym == u32::MAX {
4963 continue; // tombstoned
4964 }
4965 let Some(lbl) = self.syms.resolve(sym) else {
4966 continue;
4967 };
4968 if lbl != label {
4969 continue;
4970 }
4971 if let Some(value) = build_props_view(&self.props, &self.base)
4972 .get(id, field)
4973 .map(|vr| vr.into_value())
4974 {
4975 self.prop_index.set(label, field, id, &value);
4976 }
4977 }
4978 }
4979 WalRecord::DisableIndex { label, field } => {
4980 self.prop_index.disable(label, field);
4981 }
4982 // ── insert-count multiplicity (§5.13) ────────────────────────────
4983 //
4984 // Two shapes, told apart by `count`: the opt-in declaration, and an
4985 // absolute count for one triple. Absolute is what makes this
4986 // idempotent over a snapshot base — a pre-snapshot frame replayed
4987 // over a base that already folded it in lands on the same number
4988 // rather than adding to it, which is the failure a delta (or a count
4989 // derived from `InsertEdgeId` records) would have.
4990 WalRecord::SetEdgeCount {
4991 etype,
4992 src,
4993 dst,
4994 count,
4995 } => {
4996 if rec.is_multiplicity_decl() {
4997 self.multiplicity = true;
4998 } else {
4999 Arc::make_mut(&mut self.edge_props).set(
5000 *etype,
5001 *src,
5002 *dst,
5003 EDGE_COUNT_PROP,
5004 Value::Int(*count as i64),
5005 );
5006 }
5007 }
5008 // History markers carry no replay state — rules re-derive edges
5009 // deterministically on open/replay. Skip unconditionally.
5010 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
5011 // ── rename_node ──────────────────────────────────────────────────
5012 WalRecord::RenameNode { old_key, new_key } => {
5013 // Recovery-safe: if old_key is already gone (key was renamed
5014 // by a snapshot or a prior replay frame), skip cleanly.
5015 if self.ids.get(old_key).is_none() {
5016 return Ok(());
5017 }
5018 // The rename only updates the key-table; the dense id, all
5019 // topo edges, props, labels, and rule state are id-indexed and
5020 // require no change.
5021 Arc::make_mut(&mut self.ids)
5022 .rename(old_key, new_key)
5023 .map_err(|e| GraphError::Corrupt {
5024 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
5025 })?;
5026 }
5027 }
5028 Ok(())
5029 }
5030
5031 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
5032 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
5033 /// idempotent when the string is already bound. Always emit: after
5034 /// `snapshot()` the WAL is truncated and live intern is not on disk.
5035 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
5036 let id = if let Some(id) = self.syms.get(s) {
5037 id
5038 } else {
5039 Arc::make_mut(&mut self.syms).intern(s)
5040 };
5041 (
5042 id,
5043 WalRecord::Intern {
5044 id,
5045 text: s.to_string(),
5046 },
5047 )
5048 }
5049
5050 /// Rewrite user-facing records into dense-id records. On `Err`, no live
5051 /// state is left mutated: speculative interns made while building the
5052 /// output are rolled back, so a later successful mutation cannot log an
5053 /// `Intern` record whose id replay would never reproduce.
5054 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
5055 self.rewrite_wal_dense_planned(recs.into_iter().map(PlannedRec::Rec).collect())
5056 }
5057
5058 /// [`rewrite_wal_dense`](Self::rewrite_wal_dense) for a frame that still
5059 /// carries [`PlannedRec::DuplicateCount`] entries — the shape a batch
5060 /// produces, where a duplicate's count can only be named once this pass has
5061 /// assigned the frame's own ids.
5062 fn rewrite_wal_dense_planned(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5063 let syms_checkpoint = self.syms.len();
5064 let result = self.rewrite_wal_dense_inner(recs);
5065 if result.is_err() {
5066 Arc::make_mut(&mut self.syms).truncate(syms_checkpoint);
5067 }
5068 result
5069 }
5070
5071 fn rewrite_wal_dense_inner(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5072 let mut out = Vec::with_capacity(recs.len());
5073 // Node ids allocated by later apply(InsertNodeId) in this same batch.
5074 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5075 // Namespace of each node inserted earlier in this same frame, so a SET
5076 // on a node this frame created is measured against the namespace it was
5077 // created in rather than against the store, where it does not exist yet.
5078 let mut pending_ns: std::collections::HashMap<String, String> =
5079 std::collections::HashMap::new();
5080 let mut interned = std::collections::HashSet::<u32>::new();
5081 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
5082 detail: "id space exhausted".into(),
5083 })?;
5084 // Insert counts this frame has already raised. `edge_insert_count`
5085 // reads committed state, which cannot see a count queued earlier in
5086 // this same frame, so N duplicates of one pair would otherwise all
5087 // compute `committed + 1` and the last would win.
5088 let mut pending_counts: HashMap<(u32, u32, u32), u64> = HashMap::new();
5089 // Keys this frame has taken off the node the store holds them on: the
5090 // old key of a rename, and a deleted node's. Until the frame inserts
5091 // under such a key again it names nothing, and after that it names the
5092 // new node — never the stored one, which is what the store alone
5093 // would still answer while the frame is being rewritten.
5094 let mut freed: std::collections::HashSet<String> = std::collections::HashSet::new();
5095 let lookup = |ids: &IdMap,
5096 pending: &std::collections::HashMap<String, u32>,
5097 freed: &std::collections::HashSet<String>,
5098 key: &str|
5099 -> Option<u32> {
5100 match pending.get(key) {
5101 Some(&id) => Some(id),
5102 None if freed.contains(key) => None,
5103 None => ids.get(key),
5104 }
5105 };
5106 for rec in recs {
5107 // A duplicate insert's count, resolved here and nowhere else.
5108 //
5109 // This is the only pass that knows the frame's own ids: a node
5110 // created earlier in the same frame has no dense id until the
5111 // `InsertNodeId` above allocates one, and an edge type first used in
5112 // this frame is not in `syms` until `intern_wal` puts it there.
5113 // Resolving the count in the batch's validate pass instead — where
5114 // it used to live — meant that a duplicate whose endpoints or type
5115 // were created in the same frame silently produced no count at all,
5116 // which is exactly the shape a mirror rebuild writes (defect #24).
5117 let rec = match rec {
5118 PlannedRec::Rec(rec) => rec,
5119 PlannedRec::DuplicateCount {
5120 edge_type,
5121 src_key,
5122 dst_key,
5123 } => {
5124 let (etype, intern) = self.intern_wal(&edge_type);
5125 if interned.insert(etype) {
5126 out.push(intern);
5127 }
5128 let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5129 GraphError::Corrupt {
5130 detail: format!("dense WAL rewrite missing src {src_key}"),
5131 }
5132 })?;
5133 let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5134 GraphError::Corrupt {
5135 detail: format!("dense WAL rewrite missing dst {dst_key}"),
5136 }
5137 })?;
5138 let count = pending_counts
5139 .get(&(etype, src, dst))
5140 .copied()
5141 .unwrap_or_else(|| self.edge_insert_count(etype, src, dst))
5142 .saturating_add(1);
5143 pending_counts.insert((etype, src, dst), count);
5144 out.push(WalRecord::SetEdgeCount {
5145 etype,
5146 src,
5147 dst,
5148 count,
5149 });
5150 continue;
5151 }
5152 };
5153 match rec {
5154 WalRecord::InsertNode { label, key, props } => {
5155 // Namespace validation and normalisation, on the one seam
5156 // every user-visible node insert passes through: insert_node,
5157 // a batch, ingest, Cypher CREATE and MERGE all arrive here
5158 // before the WAL append, and replay never does.
5159 let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
5160 pending_ns.insert(key.clone(), ns_name);
5161 let (label_id, intern) = self.intern_wal(&label);
5162 if interned.insert(label_id) {
5163 out.push(intern);
5164 }
5165 let mut props_id = Vec::with_capacity(props.len());
5166 for (field, value) in props {
5167 let (field_id, intern) = self.intern_wal(&field);
5168 if interned.insert(field_id) {
5169 out.push(intern);
5170 }
5171 props_id.push((field_id, value));
5172 }
5173 if lookup(&self.ids, &pending, &freed, &key).is_none() {
5174 pending.insert(key.clone(), next);
5175 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
5176 detail: "id space exhausted".into(),
5177 })?;
5178 }
5179 out.push(WalRecord::InsertNodeId {
5180 label: label_id,
5181 key,
5182 props: props_id,
5183 });
5184 }
5185 WalRecord::SetProp { key, field, value } => {
5186 // A namespace is set at insert and fixed after: the write is
5187 // refused when it would move the node, and dropped when it
5188 // names the namespace the node is already in. Checked here
5189 // so set_prop, a batch, Cypher SET/MERGE and every upsert
5190 // that merges props get the same answer.
5191 if field == NS_PROP {
5192 let Value::Str(ref to) = value else {
5193 return Err(GraphError::RuleInvalid {
5194 detail: format!(
5195 "node {key}: {NS_PROP} must be a string naming a namespace, \
5196 got {value:?}"
5197 ),
5198 });
5199 };
5200 let from = pending_ns
5201 .get(&key)
5202 .cloned()
5203 .or_else(|| self.namespace_of(&key))
5204 .unwrap_or_else(|| NS_DEFAULT.to_string());
5205 let to = to.clone();
5206 if to != from {
5207 return Err(GraphError::NamespaceImmutable {
5208 key: key.clone(),
5209 from,
5210 to,
5211 });
5212 }
5213 continue;
5214 }
5215 let id = lookup(&self.ids, &pending, &freed, &key).ok_or_else(|| {
5216 GraphError::Corrupt {
5217 detail: format!("dense WAL rewrite missing key {key}"),
5218 }
5219 })?;
5220 let (field_id, intern) = self.intern_wal(&field);
5221 if interned.insert(field_id) {
5222 out.push(intern);
5223 }
5224 out.push(WalRecord::SetPropId {
5225 id,
5226 field: field_id,
5227 value,
5228 });
5229 }
5230 WalRecord::InsertEdge {
5231 edge_type,
5232 src_key,
5233 dst_key,
5234 } => {
5235 let (etype, intern) = self.intern_wal(&edge_type);
5236 if interned.insert(etype) {
5237 out.push(intern);
5238 }
5239 let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5240 GraphError::Corrupt {
5241 detail: format!("dense WAL rewrite missing src {src_key}"),
5242 }
5243 })?;
5244 let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5245 GraphError::Corrupt {
5246 detail: format!("dense WAL rewrite missing dst {dst_key}"),
5247 }
5248 })?;
5249 out.push(WalRecord::InsertEdgeId { etype, src, dst });
5250 }
5251 WalRecord::RenameNode {
5252 ref old_key,
5253 ref new_key,
5254 } => {
5255 // Track the rename in `pending` so subsequent InsertEdge /
5256 // SetProp records in this batch can resolve the new key.
5257 let id = lookup(&self.ids, &pending, &freed, old_key).ok_or_else(|| {
5258 GraphError::Corrupt {
5259 detail: format!(
5260 "dense WAL rewrite: RenameNode old key {old_key} not found"
5261 ),
5262 }
5263 })?;
5264 pending.remove(old_key.as_str());
5265 freed.insert(old_key.clone());
5266 pending.insert(new_key.clone(), id);
5267 out.push(rec);
5268 }
5269 // # Symbol-order invariant (load-bearing)
5270 //
5271 // Write-time and replay-time symbol assignment must agree: every
5272 // symbol in a `Batch` frame has to receive the same dense id when
5273 // the frame's records are replayed in order as it received when
5274 // the frame was written.
5275 //
5276 // A rule's backfill interns its `edge_type` lazily
5277 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
5278 // site), and that backfill runs from `apply` — during the
5279 // `CreateRule` record itself, and again from any later
5280 // `InsertNodeId` in the same frame that makes the rule fire. At
5281 // write time the whole batch is rewritten before any of it is
5282 // applied, so a later `InsertEdge` in the same batch would win the
5283 // lower id for its edge type; on replay the rule's lazy intern
5284 // gets there first and steals it, and the `Intern` record fails at
5285 // the `wal intern assigned …` check in `apply`.
5286 //
5287 // Pre-interning the rule's `edge_type` here, and emitting its
5288 // `Intern` record ahead of the `CreateRule` record, makes both
5289 // orders identical. `weight_prop` needs no pre-intern:
5290 // `EdgeProps::set` keys props by `String`, never through the
5291 // interner. `via_edge` needs none either: via-hop rules resolve it
5292 // with `syms.get` and skip when it is absent.
5293 //
5294 // `RebuildRule` and `DeleteRule` need no such handling here:
5295 // `RebuildRule` has no `BatchOp` variant, so it never appears
5296 // inside a `Batch` today — it is only ever issued as its own
5297 // standalone commit (`rebuild_rule`, or the auto-rebuild path
5298 // that logs it as a second commit after the triggering op).
5299 // `DeleteRule` does have a `BatchOp` variant and can appear
5300 // inside a `Batch`, but it carries only a rule `name` — no
5301 // `edge_type` or other symbol that needs pre-interning — so
5302 // only `CreateRule` needs this arm.
5303 WalRecord::CreateRule { ref def_bytes } => {
5304 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
5305 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
5306 })?;
5307 let (etype, intern) = self.intern_wal(&def.edge_type);
5308 if interned.insert(etype) {
5309 out.push(intern);
5310 }
5311 out.push(rec);
5312 }
5313 WalRecord::DeleteNode { ref key } => {
5314 pending.remove(key.as_str());
5315 freed.insert(key.clone());
5316 out.push(rec);
5317 }
5318 other => out.push(other),
5319 }
5320 }
5321 Ok(out)
5322 }
5323
5324 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
5325 let recs = self.rewrite_wal_dense(recs)?;
5326 match recs.len() {
5327 0 => Ok(()),
5328 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
5329 _ => self.log_then_apply(WalRecord::Batch(recs)),
5330 }
5331 }
5332
5333 /// Durable write, then notify the event sink. Replay (`apply` during
5334 /// `open`) never enters this function, so it is the replay-silent seam.
5335 /// Record that the commit occupying `frame_index` happened now, and append
5336 /// those 16 bytes to the sidecar.
5337 ///
5338 /// `frame_index` is the **global 0-based WAL frame index** of the commit's
5339 /// own record — the space every history surface addresses — taken from
5340 /// [`wal_frames_written`](GraphDb::wal_frames_written) before the append
5341 /// that puts the record there.
5342 ///
5343 /// **It is deliberately not derived from `commit_seq`.** A commit is not a
5344 /// frame: one whose rules fire appends a second frame for the derived-edge
5345 /// history marker, so `commit_seq - 1` falls one frame further behind per
5346 /// rule-firing commit and every date resolves to an ever-earlier graph.
5347 /// That was the shipped behaviour through v0.6.11 and it failed silently,
5348 /// because an older graph is a plausible answer rather than an error.
5349 ///
5350 /// Called from exactly one place — `log_then_apply_with`, immediately after
5351 /// `commit_seq` is incremented. Every write path in the engine funnels
5352 /// through that function, and replay deliberately does not: `apply_frames`
5353 /// re-applies commits that already happened, so stamping there would record
5354 /// replay time as commit time.
5355 ///
5356 /// **This is the engine's only wall-clock read.** Everything else uses
5357 /// `Instant`, which is monotonic and not a date.
5358 ///
5359 /// Failure is swallowed on purpose. The sidecar is not part of the WAL or
5360 /// the snapshot, so a failed append must not fail a commit that is already
5361 /// durable — it costs a date, not data. The map is marked poisoned so the
5362 /// gap is reported rather than resolved across.
5363 fn stamp_commit_time(&mut self, frame_index: u64) {
5364 if self.commit_times_poisoned {
5365 return;
5366 }
5367 let unix_ms = match self.commit_time_override {
5368 Some(ms) => ms,
5369 None => {
5370 let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
5371 else {
5372 // A clock before 1970. Refuse to invent a timestamp.
5373 self.commit_times_poisoned = true;
5374 return;
5375 };
5376 now.as_millis() as i64
5377 }
5378 };
5379 let first = self.commit_times.is_empty();
5380 self.commit_times.push(frame_index, unix_ms);
5381
5382 let wrote = if first {
5383 self.fs.write_atomic(
5384 FileId::CommitTimes,
5385 &core_storage::commit_times::encode(&self.commit_times),
5386 )
5387 } else {
5388 self.fs.append(
5389 FileId::CommitTimes,
5390 &core_storage::commit_times::encode_entry(frame_index, unix_ms),
5391 )
5392 };
5393 if wrote.is_err() {
5394 self.commit_times_poisoned = true;
5395 }
5396 }
5397
5398 /// Read the time sidecar from disk into this handle.
5399 ///
5400 /// Absent is the normal case for any store written before v0.6.11 and is
5401 /// not an error; unreadable is recorded so date queries can say "damaged"
5402 /// rather than "none recorded".
5403 ///
5404 /// Called at open **and** by `refresh` when a peer's frames are absorbed.
5405 /// Both, because the map is a file another process appends to: a handle
5406 /// that raises its frame cursor to include a peer's commits while holding a
5407 /// stale map would answer dates from a prefix of the truth — and, if its
5408 /// own map were still empty, would rewrite the whole file with one entry
5409 /// and destroy the peer's.
5410 fn load_commit_times_from_fs(&mut self) {
5411 match self.fs.read(FileId::CommitTimes) {
5412 Ok(bytes) if bytes.is_empty() => {}
5413 Ok(bytes) => {
5414 // A map from an older format version is discarded, not
5415 // reported as damage and not reinterpreted. Its entries were
5416 // written correctly against a different meaning of the number
5417 // — see `COMMIT_TIMES_VERSION` — and reading them in this
5418 // build's space would resolve dates onto unrelated commits.
5419 // Leaving the map empty makes the store answer
5420 // `NoRecordedTime`, which is the truth: it records no times
5421 // this build can use, and the next commit starts a usable map.
5422 if core_storage::commit_times::superseded_version(&bytes).is_some() {
5423 return;
5424 }
5425 match core_storage::commit_times::decode(&bytes) {
5426 Ok(t) => self.commit_times = t,
5427 Err(_) => self.commit_times_poisoned = true,
5428 }
5429 }
5430 Err(_) => {}
5431 }
5432 }
5433
5434 /// Rewrite the sidecar from memory. Used after truncation, which is the one
5435 /// operation that cannot be expressed as an append.
5436 fn rewrite_commit_times(&mut self) {
5437 if self.commit_times_poisoned {
5438 return;
5439 }
5440 if self
5441 .fs
5442 .write_atomic(
5443 FileId::CommitTimes,
5444 &core_storage::commit_times::encode(&self.commit_times),
5445 )
5446 .is_err()
5447 {
5448 self.commit_times_poisoned = true;
5449 }
5450 }
5451
5452 /// The greatest commit whose recorded time is at or before `unix_ms`.
5453 ///
5454 /// Errors name what they can answer instead of guessing a commit:
5455 /// `Corrupt` when the sidecar would not decode, `NoRecordedTime` when the
5456 /// store records none, `TimeBeforeFloor` when the instant predates the
5457 /// oldest entry, and `CommitOutOfRange` when the answer falls below the WAL
5458 /// horizon and so cannot be replayed.
5459 ///
5460 /// The answer is a **0-based frame index**, ready to hand to `edges_at` or
5461 /// `was_linked` without adjustment.
5462 pub fn resolve_instant(&self, unix_ms: i64) -> Result<u64> {
5463 if self.commit_times_poisoned {
5464 return Err(GraphError::Corrupt {
5465 detail: "commit_times.bin will not decode; date queries are \
5466 unavailable on this store"
5467 .into(),
5468 });
5469 }
5470 let at = self
5471 .commit_times
5472 .resolve_instant(unix_ms, self.wal_horizon_floor)?;
5473 // The map outlives the history it describes. A truncating snapshot folds
5474 // the WAL and discards it, so entries can name commits the engine can no
5475 // longer replay — the floor check above catches pruning, and this catches
5476 // discarding. Returning an index the caller's next call will reject is a
5477 // two-step error where one will do, and `resolve_date` is public: it
5478 // either hands back a usable index or refuses.
5479 let total = self.wal_total_commits()?;
5480 if at >= total {
5481 return Err(GraphError::CommitOutOfRange {
5482 commit: at,
5483 total,
5484 floor: self.wal_horizon_floor,
5485 });
5486 }
5487 Ok(at)
5488 }
5489
5490 /// Record subsequent commits as having happened at `unix_ms`, or pass
5491 /// `None` to go back to the system clock.
5492 ///
5493 /// For **backfilled history**: a mirror importing rows that already carry
5494 /// their own timestamps, or a replay of events that happened months ago.
5495 /// Without this every such commit is stamped "now", so a store holding a
5496 /// year of imported history answers every date question with
5497 /// `TimeBeforeFloor` — the data is there and no date reaches it.
5498 ///
5499 /// Sticky until changed or cleared, because a day of backfilled rows
5500 /// genuinely shares one instant.
5501 ///
5502 /// **Import in chronological order.** A supplied instant earlier than
5503 /// anything already recorded is refused with
5504 /// [`GraphError::CommitTimeNotMonotonic`], because resolution walks commit
5505 /// order: a later commit carrying an earlier time would silently widen every
5506 /// answer after it. Equal is allowed — that is what a shared day means. The
5507 /// live clock is never held to this, so an NTP step backwards still commits.
5508 ///
5509 /// Deliberately **not** exposed over HTTP or MCP: asserting when a commit
5510 /// happened rewrites the store's apparent history, which is not something a
5511 /// role token models. It is an embedding-caller's operation.
5512 pub fn record_commits_at(&mut self, unix_ms: Option<i64>) -> Result<()> {
5513 if self.read_only {
5514 return Err(GraphError::ReadOnly);
5515 }
5516 if let Some(ms) = unix_ms {
5517 if self.commit_times_poisoned {
5518 return Err(GraphError::Corrupt {
5519 detail: "commit_times.bin will not decode; this store cannot \
5520 record an asserted commit time"
5521 .into(),
5522 });
5523 }
5524 if let Some(newest) = self.commit_times.max_ms() {
5525 if ms < newest {
5526 return Err(GraphError::CommitTimeNotMonotonic {
5527 supplied_ms: ms,
5528 newest_ms: newest,
5529 });
5530 }
5531 }
5532 }
5533 self.commit_time_override = unix_ms;
5534 Ok(())
5535 }
5536
5537 /// The instant subsequent commits are being recorded at, when one is set.
5538 pub fn commit_time_override(&self) -> Option<i64> {
5539 self.commit_time_override
5540 }
5541
5542 /// [`Self::edges_at`] addressed by an instant rather than a commit index.
5543 ///
5544 /// Resolves through [`Self::resolve_instant`] — the last commit at or
5545 /// before the instant — then answers exactly as the commit-indexed call
5546 /// does. A store that records no times refuses by name; it never guesses.
5547 pub fn edges_at_instant(&self, key: &str, unix_ms: i64) -> Result<Vec<EdgeAt>> {
5548 let commit = self.resolve_instant(unix_ms)?;
5549 self.edges_at(key, commit)
5550 }
5551
5552 /// [`Self::was_linked`] addressed by an instant rather than a commit index.
5553 pub fn was_linked_at_instant(
5554 &self,
5555 a: &str,
5556 b: &str,
5557 edge_type: &str,
5558 unix_ms: i64,
5559 ) -> Result<bool> {
5560 let commit = self.resolve_instant(unix_ms)?;
5561 self.was_linked(a, b, edge_type, commit)
5562 }
5563
5564 /// Parse an RFC 3339 instant (or a bare `YYYY-MM-DD`) and resolve it.
5565 ///
5566 /// The one place every caller-facing surface converts a date string, so
5567 /// HTTP, MCP, Python and the CLI cannot drift in what they accept.
5568 pub fn resolve_date(&self, s: &str) -> Result<u64> {
5569 // The **end** of what the string denotes. A bare date is a day, so it
5570 // resolves to the last commit at or before that day's end — resolving to
5571 // the midnight that starts it would exclude everything that happened on
5572 // the date the caller asked about.
5573 let ms = core_storage::commit_times::parse_rfc3339_end_ms(s).ok_or_else(|| {
5574 GraphError::QueryError {
5575 detail: format!(
5576 "could not parse {s:?} as a date; expected RFC 3339 \
5577 (2026-06-19, or 2026-06-19T12:00:00Z)"
5578 ),
5579 }
5580 })?;
5581 self.resolve_instant(ms)
5582 }
5583
5584 /// The recorded wall-clock time of `commit`, when the sidecar holds one.
5585 ///
5586 /// `commit` is a **0-based frame index** — the space `edges_at`,
5587 /// `was_linked` and the history events use, not the 1-based `commit_seq`.
5588 pub fn commit_time_ms(&self, commit: u64) -> Option<i64> {
5589 if self.commit_times_poisoned {
5590 return None;
5591 }
5592 self.commit_times.time_of(commit)
5593 }
5594
5595 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
5596 self.log_then_apply_with(rec, None, self.fsync)
5597 }
5598
5599 /// Whether this frame must fsync under `policy`.
5600 ///
5601 /// Batched contract: user-visible batches (>1 mutation) fsync; single
5602 /// mutations do not. The dense rewrite wraps a single mutation in a
5603 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
5604 /// from the count — removing that filter would make every single-op write
5605 /// fsync under Batched (or, if the threshold were raised instead, skip a
5606 /// needed fsync for real two-op batches).
5607 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
5608 match policy {
5609 FsyncPolicy::Relaxed => false,
5610 FsyncPolicy::Strict => true,
5611 FsyncPolicy::Batched => match rec {
5612 // Intern + one mutation is the single-op rewrite, not a user batch.
5613 WalRecord::Batch(inner) => {
5614 inner
5615 .iter()
5616 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
5617 .count()
5618 > 1
5619 }
5620 _ => false,
5621 },
5622 }
5623 }
5624
5625 /// # Apply-infallibility invariant (load-bearing)
5626 ///
5627 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
5628 /// for a `Batch` frame after a successful WAL write, the WAL would contain
5629 /// the full frame while in-memory state would reflect only the ops before
5630 /// the failure. On reopen, WAL replay would then apply the entire batch —
5631 /// diverging permanently from what the pre-crash process had in memory.
5632 ///
5633 /// For `Batch` frames this situation cannot arise because:
5634 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
5635 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
5636 /// use, with no concurrent mutation between validation exit and apply entry.
5637 /// - Every `apply` arm for a validated op is either infallible by construction
5638 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
5639 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
5640 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
5641 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
5642 ///
5643 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
5644 /// `Err` for a `Batch` frame, making any future regression immediately visible
5645 /// in tests rather than silently diverging crash-recovery behaviour.
5646 fn log_then_apply_with(
5647 &mut self,
5648 rec: WalRecord,
5649 ingest: Option<(String, usize)>,
5650 policy: FsyncPolicy,
5651 ) -> Result<()> {
5652 // Read-only guard: as-of instances must never write the WAL.
5653 if self.read_only {
5654 return Err(GraphError::ReadOnly);
5655 }
5656 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
5657 // partway; in-memory state is ahead of (or out of step with) the
5658 // on-disk WAL, so further mutations would deepen the divergence.
5659 // Reopen the database to recover. Checked before the lock guard: this
5660 // is the more serious condition and the more useful error.
5661 if self.degraded {
5662 return Err(GraphError::Io(std::io::Error::other(
5663 "database degraded after group-commit fsync failure; reopen required",
5664 )));
5665 }
5666 // Cross-process guard: this write scope asked for the store's write
5667 // lock and did not get it. Writing anyway would append frames on top of
5668 // a WAL another process is extending, so refuse instead.
5669 if self.lock_denied {
5670 return Err(GraphError::Busy { holder: None });
5671 }
5672 // Ensure retained provenance bytes are decoded into the live mutable
5673 // fields before any mutation touches self.engine.provenance. This is a
5674 // no-op if provenance was never stored (fresh store) or has already been
5675 // consumed (subsequent mutations). WAL replay calls apply() directly
5676 // and is covered by consume_retained_state_eager before replay.
5677 self.ensure_v8_base_sections_loaded();
5678 self.engine.ensure_provenance_loaded_mut();
5679 // Invariant (I-1): no stale deltas may enter from a previous apply.
5680 // If any engine method ever accumulates deltas before erroring, they would
5681 // contaminate the *next* commit's event stream. This assert fires in debug
5682 // builds, making any future regression visible at the earliest point.
5683 debug_assert_eq!(
5684 self.engine.pending_delta_count(),
5685 0,
5686 "stale engine deltas at log_then_apply_with entry — \
5687 a previous apply arm may have accumulated deltas before erroring; \
5688 the caller must drain_deltas() on any error path before returning"
5689 );
5690 if self.intent_unsettled {
5691 self.settle_snapshot_intent()?;
5692 }
5693 let frame = encode_record(&rec);
5694 self.fs.append(FileId::Wal, &frame)?;
5695 // The cursor advances by exactly the bytes appended: these frames are
5696 // ours and already applied, so a later refresh must not replay them.
5697 self.wal_consumed += frame.len() as u64;
5698 self.wal_frames_written += 1;
5699 if Self::wal_needs_sync(policy, &rec) {
5700 self.fs.sync(FileId::Wal)?;
5701 }
5702 // Marker writing always needs the engine deltas, but the engine only
5703 // accumulates them when emit_deltas is true (normally gated on subscribers
5704 // or views being present). Enable emission for this apply if it is
5705 // currently off, then restore the original state unconditionally via an
5706 // RAII guard — this prevents a panic in apply() from leaking the flag.
5707 // The same guard resets the engine's transient chaining state. A panic
5708 // unwinding out of a rule hook would otherwise leave `chain_depth`
5709 // non-zero, which makes every later `begin_chain` decide chaining is
5710 // already running and silently switch it off for good.
5711 struct RestoreEmitDeltas(*mut RuleEngine, bool);
5712 impl Drop for RestoreEmitDeltas {
5713 fn drop(&mut self) {
5714 // SAFETY: pointer into self (GraphDb); guard is dropped within
5715 // this frame before log_then_apply_with returns.
5716 unsafe {
5717 (*self.0).set_emit_deltas(self.1);
5718 (*self.0).reset_chain_state();
5719 }
5720 }
5721 }
5722 let original_emit = self.engine.emit_deltas();
5723 if !original_emit {
5724 self.engine.set_emit_deltas(true);
5725 }
5726 // SAFETY: raw pointer into self; guard dropped within this frame.
5727 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
5728
5729 let apply_result = self.apply(&rec);
5730 // For Batch frames, post-validation apply must be infallible (see above).
5731 // A debug_assert here catches any future change that makes apply fallible
5732 // before the caller notices via silent WAL/memory divergence.
5733 if matches!(&rec, WalRecord::Batch(_)) {
5734 debug_assert!(
5735 apply_result.is_ok(),
5736 "Batch apply returned Err after successful WAL write — \
5737 the validate-then-apply invariant has been violated; \
5738 see log_then_apply_with invariant doc"
5739 );
5740 }
5741 if apply_result.is_err() {
5742 // Discard any partial deltas accumulated by the failed apply.
5743 // They must not ride the next commit's event stream (I-1).
5744 // _emit_guard restores emit_deltas on drop automatically.
5745 let _ = self.engine.drain_deltas();
5746 let _ = self.engine.take_rebuild_needed();
5747 apply_result?;
5748 }
5749 self.commit_seq += 1;
5750 let seq = self.commit_seq;
5751 // Update per-node last-change map for the committed record.
5752 // Must happen after commit_seq is incremented so the seq is correct.
5753 self.update_last_change_from_rec(&rec, seq);
5754 // Drain engine deltas and distribute to subscribers before the existing
5755 // MutationEvent sink fires — both happen post-fsync, post-apply.
5756 // _emit_guard restores emit_deltas after this line when it drops.
5757 let engine_deltas = self.engine.drain_deltas();
5758
5759 // Append history-marker WAL records for any derived-edge changes so
5760 // that `edge_history` and `was_linked` can surface rule-attributed
5761 // events. Markers are STATE NO-OPS during replay; they are written
5762 // without an additional fsync (the triggering commit's sync already
5763 // happened; the next commit's sync covers these lazily).
5764 if !engine_deltas.is_empty() {
5765 let markers: Vec<WalRecord> = engine_deltas
5766 .iter()
5767 .map(|d| {
5768 if d.fired {
5769 WalRecord::DerivedEdgeAdded {
5770 rule: d.rule.clone(),
5771 edge_type: d.edge_type.clone(),
5772 src_key: d.src_key.clone(),
5773 dst_key: d.dst_key.clone(),
5774 }
5775 } else {
5776 WalRecord::DerivedEdgeRetracted {
5777 rule: d.rule.clone(),
5778 edge_type: d.edge_type.clone(),
5779 src_key: d.src_key.clone(),
5780 dst_key: d.dst_key.clone(),
5781 }
5782 }
5783 })
5784 .collect();
5785 let marker_frame = if markers.len() == 1 {
5786 markers.into_iter().next().unwrap()
5787 } else {
5788 WalRecord::Batch(markers)
5789 };
5790 // Ignore append errors: markers are best-effort history
5791 // annotations. Losing them does not affect state correctness.
5792 // The cursor only advances when the bytes actually landed.
5793 let marker_bytes = encode_record(&marker_frame);
5794 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
5795 self.wal_consumed += marker_bytes.len() as u64;
5796 // A marker is a state no-op during replay but it is not an
5797 // index no-op: it occupies a frame that every history surface
5798 // counts. Missing this increment is the whole of defect 1.
5799 self.wal_frames_written += 1;
5800 }
5801 }
5802
5803 // Stamp the commit against the **last** frame it wrote.
5804 //
5805 // `edges_at` and `was_linked` reconstruct derived edges by reading the
5806 // history markers out of the WAL, not by re-running rules over a
5807 // prefix. A commit's complete state — its record *and* the edges its
5808 // rules derived — is therefore only reached at its marker frame, so
5809 // that is the frame a date naming this commit must resolve to. Stamping
5810 // the record's own frame would answer every date with the graph as it
5811 // was one derivation short.
5812 //
5813 // This runs after the marker append for that reason, and it is still
5814 // the single stamping site: one commit, one entry.
5815 self.stamp_commit_time(self.wal_frames_written - 1);
5816
5817 // Record MVCC CommitDelta for the epoch reader. The WAL record is
5818 // stored as-is (including any nested Batch / Intern records); the
5819 // ReaderSnapshot's apply_one function handles all variants.
5820 {
5821 let derived_inserts = engine_deltas
5822 .iter()
5823 .filter(|d| d.fired)
5824 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5825 .collect();
5826 let derived_deletes = engine_deltas
5827 .iter()
5828 .filter(|d| !d.fired)
5829 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5830 .collect();
5831 // `None`: the commit moved more view values than a delta should
5832 // carry, so the fold below takes them instead.
5833 let view_values = self.view_values_of_commit(&rec);
5834 let fold = view_values.is_none();
5835 let delta = Arc::new(crate::reader::CommitDelta {
5836 records: vec![rec.clone()],
5837 derived_inserts,
5838 derived_deletes,
5839 view_values: view_values.unwrap_or_default(),
5840 });
5841 self.delta_tail.push(delta);
5842 self.commits_since_fold += 1;
5843 if fold || self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
5844 self.fold_now();
5845 }
5846 }
5847
5848 if self.defer_events {
5849 // Group-commit drain thread: hold events until after the group
5850 // fsync so subscribers only observe durable data (R2).
5851 self.deferred_events.push(DeferredEvent {
5852 rec: rec.clone(),
5853 engine_deltas,
5854 seq,
5855 ingest,
5856 });
5857 } else {
5858 self.distribute_events(&rec, &engine_deltas, seq);
5859 self.emit_committed(&rec, ingest);
5860 }
5861 // Drift is only known after apply, so auto-rebuild cannot join the
5862 // triggering op's WAL frame. Issue RebuildRule as a second commit.
5863 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
5864 // retrigger loop is impossible if the fit succeeded, but we still
5865 // drain the flag so a leftover cannot re-enter.
5866 // One slice of any outstanding vector-index build rides here too, so a
5867 // store that is being written to finishes its build without anyone
5868 // calling `pump_index_build`. A rule that becomes whole joins the same
5869 // RebuildRule loop below.
5870 let mut rebuilds = self.engine.take_rebuild_needed();
5871 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
5872 // Not after `CreateRule`: that record's own apply already did the
5873 // rule's first slice, and pumping again here would make one
5874 // `create_rule` call do two slices' work under one lock.
5875 // Nothing pending is the overwhelmingly common case and must cost
5876 // a map lookup, not an engine swap: a store being written to has
5877 // long since populated its indexes, so the `pump_index_build`
5878 // entry point owns the not-yet-populated case on its own.
5879 if !is_create_rule_frame(&rec) && !self.engine.builds_in_progress().is_empty() {
5880 rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
5881 }
5882 let mut failed = Vec::new();
5883 for name in rebuilds {
5884 if self.engine.rules().any(|r| r.name == name) {
5885 // User op is already durable. A failed second commit must
5886 // not surface as the caller's error.
5887 if let Err(e) =
5888 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
5889 {
5890 eprintln!(
5891 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
5892 );
5893 failed.push(name);
5894 }
5895 }
5896 }
5897 for name in failed {
5898 self.engine.queue_rebuild_needed(name);
5899 }
5900 }
5901 Ok(())
5902 }
5903
5904 /// Install a post-commit hook. Replaces any previous sink.
5905 ///
5906 /// The sink runs inside `log_then_apply` after a successful
5907 /// durable commit, while the caller still holds `&mut self`. When this
5908 /// database is behind a [`crate::SharedDb`], that means the **write
5909 /// guard is held**. The sink must never call `read` / `write` (or any
5910 /// other method) on the same `SharedDb` — the `RwLock` is not
5911 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
5912 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
5913 /// Intended examples: `std::sync::mpsc::SyncSender`,
5914 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
5915 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
5916 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
5917 self.event_sink = Some(sink);
5918 }
5919
5920 /// Whether a post-commit event sink is currently installed.
5921 pub fn has_event_sink(&self) -> bool {
5922 self.event_sink.is_some()
5923 }
5924
5925 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
5926 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
5927 self.fsync = p;
5928 }
5929
5930 /// Return the current WAL fsync cadence.
5931 pub fn fsync_policy(&self) -> FsyncPolicy {
5932 self.fsync
5933 }
5934
5935 // ── Group-commit event deferral ───────────────────────────────────────────
5936
5937 /// Enable or disable deferred event mode.
5938 ///
5939 /// When `true`, event notifications (subscription `DbEvent`s and legacy
5940 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
5941 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
5942 /// or [`discard_deferred_events`] if the fsync failed and the group must
5943 /// be treated as lost.
5944 pub fn set_deferred_events_mode(&mut self, defer: bool) {
5945 self.defer_events = defer;
5946 }
5947
5948 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
5949 /// was set to true. Clears the buffer.
5950 ///
5951 /// Called by the drain thread AFTER a successful group fsync, so
5952 /// subscribers observe only data that is durably on disk.
5953 pub fn flush_deferred_events(&mut self) {
5954 let events = std::mem::take(&mut self.deferred_events);
5955 for de in events {
5956 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
5957 self.emit_committed(&de.rec, de.ingest);
5958 }
5959 }
5960
5961 /// Discard all buffered events without firing them.
5962 ///
5963 /// Called by the drain thread when a group fsync fails: the WAL has been
5964 /// truncated back to the pre-group offset, so the committed-but-unsynced
5965 /// ops must not be observable to subscribers.
5966 pub fn discard_deferred_events(&mut self) {
5967 self.deferred_events.clear();
5968 }
5969
5970 // ── Degraded state ────────────────────────────────────────────────────────
5971
5972 /// Mark this database as degraded.
5973 ///
5974 /// Called by the group-commit drain thread after a group fsync failure and
5975 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
5976 /// further mutations would deepen the divergence. All subsequent calls to
5977 /// [`log_then_apply_with`] return `Err` until the database is reopened.
5978 pub fn set_degraded(&mut self) {
5979 self.degraded = true;
5980 }
5981
5982 fn emit(&self, ev: MutationEvent) {
5983 if let Some(sink) = &self.event_sink {
5984 sink(ev);
5985 }
5986 }
5987
5988 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
5989 match rec {
5990 WalRecord::Batch(inner) => {
5991 for r in inner {
5992 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
5993 self.emit(ev);
5994 }
5995 }
5996 match ingest {
5997 Some((label, inserted)) => {
5998 self.emit(MutationEvent::Ingested { label, inserted })
5999 }
6000 None => {
6001 let ops = inner
6002 .iter()
6003 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
6004 .count();
6005 if ops > 1 {
6006 self.emit(MutationEvent::BatchApplied { ops });
6007 }
6008 }
6009 }
6010 }
6011 other => {
6012 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
6013 self.emit(ev);
6014 }
6015 }
6016 }
6017 }
6018
6019 // -----------------------------------------------------------------------
6020 // Subscription API
6021 // -----------------------------------------------------------------------
6022
6023 /// Distribute post-commit events to all live subscribers.
6024 ///
6025 /// Build a row-key → row-data map from a [`ResultSet`].
6026 ///
6027 /// Each row is serialized to JSON to form its key; a debug fallback is used
6028 /// if serialization fails. Used by both the initial-seed path in
6029 /// [`Self::subscribe_query`] and the per-commit diff path in
6030 /// [`Self::distribute_events`] to keep the two in sync.
6031 fn result_to_row_map(
6032 result: &core_query::ResultSet,
6033 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
6034 (0..result.len())
6035 .map(|i| {
6036 let row = result.row(i).to_vec();
6037 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
6038 (key, row)
6039 })
6040 .collect()
6041 }
6042
6043 /// Collect the set of label syms touched by a WAL record.
6044 ///
6045 /// Returns `Some(set)` when every record in this commit can be attributed to
6046 /// a known label sym. Returns `None` when the commit must not be skipped:
6047 /// edge records, unresolvable key→label lookups, or any record type not in
6048 /// the explicit handled set.
6049 ///
6050 /// Handled record types and their actions:
6051 /// - `InsertNode` → look up label in interner (fails → None)
6052 /// - `InsertNodeId` → label sym is carried directly
6053 /// - `SetProp` → resolve key→id→label (fails → None)
6054 /// - `DeleteNode` → resolve key→id→label (fails → None)
6055 /// - `Batch` → recurse into every inner record
6056 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
6057 /// - everything else → None (conservative)
6058 fn commit_touched_labels(
6059 rec: &WalRecord,
6060 syms: &Interner,
6061 ids: &IdMap,
6062 labels: &[u32],
6063 ) -> Option<BTreeSet<u32>> {
6064 let mut out = BTreeSet::new();
6065 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
6066 Some(out)
6067 } else {
6068 None
6069 }
6070 }
6071
6072 fn collect_touched_labels(
6073 rec: &WalRecord,
6074 syms: &Interner,
6075 ids: &IdMap,
6076 labels: &[u32],
6077 out: &mut BTreeSet<u32>,
6078 ) -> bool {
6079 match rec {
6080 // String-key insert: the dense rewrite converts this to
6081 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
6082 // records written before the dense path was added.
6083 WalRecord::InsertNode { label, .. } => {
6084 if let Some(sym) = syms.get(label) {
6085 out.insert(sym);
6086 true
6087 } else {
6088 false
6089 }
6090 }
6091 // Dense-id insert (produced by rewrite_wal_dense for every
6092 // insert_node call in the current codebase).
6093 WalRecord::InsertNodeId { label, .. } => {
6094 out.insert(*label);
6095 true
6096 }
6097 // String-key prop set: dense path converts to [Intern, SetPropId].
6098 WalRecord::SetProp { key, .. } => {
6099 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6100 out.insert(sym);
6101 true
6102 } else {
6103 false
6104 }
6105 }
6106 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
6107 WalRecord::SetPropId { id, .. } => {
6108 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
6109 out.insert(sym);
6110 true
6111 } else {
6112 false
6113 }
6114 }
6115 WalRecord::DeleteNode { key } => {
6116 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6117 out.insert(sym);
6118 true
6119 } else {
6120 false
6121 }
6122 }
6123 WalRecord::Batch(inner) => inner
6124 .iter()
6125 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
6126 // Intern is a pure metadata record — it does not touch any node's
6127 // label and is safe to skip for the label-skip predicate.
6128 WalRecord::Intern { .. } => true,
6129 // Edge records: always re-execute (edges can change join results).
6130 WalRecord::InsertEdge { .. }
6131 | WalRecord::DeleteEdge { .. }
6132 | WalRecord::InsertEdgeId { .. } => false,
6133 _ => false,
6134 }
6135 }
6136
6137 /// Resolve a node key to its label sym via the dense id table.
6138 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
6139 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
6140 let id = ids.get(key)?;
6141 let sym = labels.get(id as usize).copied()?;
6142 (sym != u32::MAX).then_some(sym)
6143 }
6144
6145 /// Distribute post-commit events to all live subscribers.
6146 ///
6147 /// Called from `log_then_apply_with` after apply + fsync, before the
6148 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
6149 ///
6150 /// Query subscriptions (subscribe_query) re-execute their plan on every
6151 /// call and diff the result against the previous run. Zero overhead when
6152 /// no query subscriptions are active.
6153 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
6154 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
6155 return;
6156 }
6157
6158 if !self.subscriptions.is_empty() {
6159 // Build write events from the WAL record.
6160 let write_events: Vec<DbEvent> =
6161 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
6162
6163 // Build edge events from engine deltas. Weight is looked up from
6164 // edge_props at distribution time (after apply), so it's always fresh.
6165 let edge_events: Vec<DbEvent> = engine_deltas
6166 .iter()
6167 .map(|d| {
6168 if d.fired {
6169 // The score lives under the rule's declared weight_prop,
6170 // which is not always the literal "weight".
6171 let prop = self
6172 .engine
6173 .rules()
6174 .find(|r| r.name == d.rule)
6175 .and_then(|r| r.weight_prop.as_deref());
6176 let weight = prop.and_then(|p| {
6177 self.edge_props
6178 .get(d.etype_sym, d.src_id, d.dst_id, p)
6179 .and_then(|v| {
6180 if let core_storage::Value::Float(f) = v {
6181 Some(*f)
6182 } else {
6183 None
6184 }
6185 })
6186 });
6187 DbEvent::EdgeFired {
6188 rule: d.rule.clone(),
6189 src_key: d.src_key.clone(),
6190 dst_key: d.dst_key.clone(),
6191 edge_type: d.edge_type.clone(),
6192 weight,
6193 commit_seq: seq,
6194 }
6195 } else {
6196 DbEvent::EdgeRetracted {
6197 rule: d.rule.clone(),
6198 src_key: d.src_key.clone(),
6199 dst_key: d.dst_key.clone(),
6200 edge_type: d.edge_type.clone(),
6201 commit_seq: seq,
6202 }
6203 }
6204 })
6205 .collect();
6206
6207 // Prune dead entries; push matching events to live ones.
6208 self.subscriptions.retain(|entry| {
6209 let Some(inner) = entry.inner.upgrade() else {
6210 return false;
6211 };
6212 for ev in &write_events {
6213 if event_matches(ev, &entry.filter) {
6214 inner.push(ev.clone());
6215 }
6216 }
6217 for ev in &edge_events {
6218 if event_matches(ev, &entry.filter) {
6219 inner.push(ev.clone());
6220 }
6221 }
6222 true
6223 });
6224
6225 // Turn off delta accumulation if all subscribers dropped and no views remain.
6226 if self.subscriptions.is_empty() && self.view_store.is_empty() {
6227 self.engine.set_emit_deltas(false);
6228 }
6229 }
6230
6231 // Query subscriptions: full re-run per commit, then diff rows.
6232 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
6233 // Differential evaluation is roadmap / Phase 5.
6234 if !self.query_subscriptions.is_empty() {
6235 // Take the list out so we can call self.view() without borrow conflict.
6236 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
6237 let empty_params = BTreeMap::new();
6238 query_subs.retain_mut(|entry| {
6239 let Some(inner) = entry.inner.upgrade() else {
6240 return false; // subscriber dropped — prune
6241 };
6242 // Label-skip: if the plan has a known scan label and this commit
6243 // can be proven to touch only different labels (and no rule-derived
6244 // edge deltas fired), the result set cannot have changed — skip.
6245 if let Some(scan_sym) = entry.scan_label {
6246 if engine_deltas.is_empty() {
6247 let touched =
6248 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
6249 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
6250 return true; // safe to skip — result set unchanged
6251 }
6252 }
6253 }
6254 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
6255 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
6256 Ok(r) => r,
6257 Err(e) => {
6258 // Keep the subscription alive; skip the diff for this commit.
6259 // Re-run errors are transient (e.g., planner change) and
6260 // self-heal when the next commit succeeds.
6261 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
6262 return true;
6263 }
6264 };
6265 // Build new row map: serialized-key → row data.
6266 let new_row_map = Self::result_to_row_map(&result);
6267 // Removed rows: in prev but not in new.
6268 for (key, row) in &entry.prev_row_map {
6269 if !new_row_map.contains_key(key) {
6270 inner.push(DbEvent::QueryRowRemoved {
6271 columns: entry.columns.clone(),
6272 row: row.clone(),
6273 });
6274 }
6275 }
6276 // Added rows: in new but not in prev.
6277 for (key, row) in &new_row_map {
6278 if !entry.prev_row_map.contains_key(key) {
6279 inner.push(DbEvent::QueryRowAdded {
6280 columns: entry.columns.clone(),
6281 row: row.clone(),
6282 });
6283 }
6284 }
6285 entry.prev_row_map = new_row_map;
6286 true
6287 });
6288 self.query_subscriptions = query_subs;
6289 }
6290 }
6291
6292 /// Returns `true` if any live subscriber or view definition requires delta
6293 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
6294 fn needs_emit_deltas(&self) -> bool {
6295 !self.view_store.is_empty()
6296 || self
6297 .subscriptions
6298 .iter()
6299 .any(|e| e.inner.upgrade().is_some())
6300 }
6301
6302 /// Convert a WAL record into `DbEvent` write events with the given seq.
6303 fn write_events_from_record(
6304 rec: &WalRecord,
6305 seq: u64,
6306 intern: &Interner,
6307 ids: &IdMap,
6308 ) -> Vec<DbEvent> {
6309 match rec {
6310 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
6311 label: label.clone(),
6312 key: key.clone(),
6313 commit_seq: seq,
6314 }],
6315 // *Id arms run after a successful apply, so resolution can only
6316 // fail on a programming error. Skip the event rather than emit a
6317 // fabricated "" that clients can't tell from a real empty value
6318 // (mirrors event_from_record returning None).
6319 WalRecord::InsertNodeId { label, key, .. } => intern
6320 .resolve(*label)
6321 .map(|label| DbEvent::NodeInserted {
6322 label: label.to_string(),
6323 key: key.clone(),
6324 commit_seq: seq,
6325 })
6326 .into_iter()
6327 .collect(),
6328 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
6329 key: key.clone(),
6330 field: field.clone(),
6331 commit_seq: seq,
6332 }],
6333 WalRecord::SetPropId { id, field, .. } => ids
6334 .key_of(*id)
6335 .zip(intern.resolve(*field))
6336 .map(|(key, field)| DbEvent::PropSet {
6337 key: key.to_string(),
6338 field: field.to_string(),
6339 commit_seq: seq,
6340 })
6341 .into_iter()
6342 .collect(),
6343 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
6344 key: key.clone(),
6345 field: field.clone(),
6346 commit_seq: seq,
6347 }],
6348 WalRecord::InsertEdge {
6349 edge_type,
6350 src_key,
6351 dst_key,
6352 } => vec![DbEvent::EdgeInserted {
6353 edge_type: edge_type.clone(),
6354 src: src_key.clone(),
6355 dst: dst_key.clone(),
6356 commit_seq: seq,
6357 }],
6358 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
6359 Some(DbEvent::EdgeInserted {
6360 edge_type: intern.resolve(*etype)?.to_string(),
6361 src: ids.key_of(*src)?.to_string(),
6362 dst: ids.key_of(*dst)?.to_string(),
6363 commit_seq: seq,
6364 })
6365 })()
6366 .into_iter()
6367 .collect(),
6368 WalRecord::DeleteEdge {
6369 edge_type,
6370 src_key,
6371 dst_key,
6372 } => vec![DbEvent::EdgeDeleted {
6373 edge_type: edge_type.clone(),
6374 src: src_key.clone(),
6375 dst: dst_key.clone(),
6376 commit_seq: seq,
6377 }],
6378 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
6379 key: key.clone(),
6380 commit_seq: seq,
6381 }],
6382 WalRecord::Batch(inner) => inner
6383 .iter()
6384 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
6385 .collect(),
6386 WalRecord::CreateRule { .. }
6387 | WalRecord::DeleteRule { .. }
6388 | WalRecord::RebuildRule { .. }
6389 | WalRecord::CreateView { .. }
6390 | WalRecord::DeleteView { .. }
6391 | WalRecord::EnableFulltext { .. }
6392 | WalRecord::DisableFulltext { .. }
6393 | WalRecord::EnableIndex { .. }
6394 | WalRecord::DisableIndex { .. }
6395 | WalRecord::Intern { .. }
6396 // History markers produce no DbEvent — the engine delta already
6397 // fired the EdgeFired/EdgeRetracted subscription events.
6398 | WalRecord::DerivedEdgeAdded { .. }
6399 | WalRecord::DerivedEdgeRetracted { .. }
6400 // A count is not an edge event: the pair it counts already fired one
6401 // when it was first inserted.
6402 | WalRecord::SetEdgeCount { .. }
6403 | WalRecord::RenameNode { .. } => vec![],
6404 }
6405 }
6406
6407 /// Subscribe to edge-fire and edge-retract events for one named rule.
6408 ///
6409 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
6410 /// currently registered. Dropping the returned [`Subscription`] handle
6411 /// unregisters the subscriber — no further events are queued, no
6412 /// resources leak.
6413 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
6414 if self.read_only {
6415 return Err(core_storage::GraphError::ReadOnly);
6416 }
6417 if !self.engine.rules().any(|r| r.name == rule_name) {
6418 return Err(core_storage::GraphError::RuleNotFound {
6419 name: rule_name.to_string(),
6420 });
6421 }
6422 let inner = SubInner::new(self.sub_capacity());
6423 self.subscriptions.push(SubEntry {
6424 filter: SubFilter::Rule(rule_name.to_string()),
6425 inner: std::sync::Arc::downgrade(&inner),
6426 });
6427 self.engine.set_emit_deltas(true);
6428 Ok(Subscription(inner))
6429 }
6430
6431 /// Subscribe to edge-fire and edge-retract events for **all** rules.
6432 ///
6433 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6434 /// as-of instances never commit, so `distribute_events` never runs and the
6435 /// subscription would never deliver events.
6436 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
6437 if self.read_only {
6438 return Err(core_storage::GraphError::ReadOnly);
6439 }
6440 let inner = SubInner::new(self.sub_capacity());
6441 self.subscriptions.push(SubEntry {
6442 filter: SubFilter::AllRules,
6443 inner: std::sync::Arc::downgrade(&inner),
6444 });
6445 self.engine.set_emit_deltas(true);
6446 Ok(Subscription(inner))
6447 }
6448
6449 /// Subscribe to write events: node insert/delete, prop set/remove.
6450 ///
6451 /// Does not include edge-fire / edge-retract (rule-derived edge events).
6452 ///
6453 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6454 /// as-of instances never commit, so `distribute_events` never runs and the
6455 /// subscription would never deliver events.
6456 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
6457 if self.read_only {
6458 return Err(core_storage::GraphError::ReadOnly);
6459 }
6460 let inner = SubInner::new(self.sub_capacity());
6461 self.subscriptions.push(SubEntry {
6462 filter: SubFilter::Writes,
6463 inner: std::sync::Arc::downgrade(&inner),
6464 });
6465 self.engine.set_emit_deltas(true);
6466 Ok(Subscription(inner))
6467 }
6468
6469 /// Subscribe to incremental Cypher query results.
6470 ///
6471 /// Parses and plans `cypher`; rejects the query if the plan is not in the
6472 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
6473 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
6474 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
6475 ///
6476 /// SKIP is not supported — it shifts the result window on every commit,
6477 /// causing spurious Added/Removed churn for rows whose data never changed.
6478 /// Multi-hop Expand chains are not supported; each additional MATCH clause
6479 /// widens scope beyond the documented single-scan / single-hop subset.
6480 ///
6481 /// After each successful commit, the plan is **fully re-executed** and the
6482 /// result is diffed against the previous run. Added rows produce
6483 /// [`DbEvent::QueryRowAdded`]; removed rows produce
6484 /// [`DbEvent::QueryRowRemoved`].
6485 ///
6486 /// **Full re-run per commit; use LIMIT to bound execution cost.**
6487 /// The existing 1 M intermediate-row cap applies. Differential evaluation
6488 /// is roadmap / Phase 5.
6489 ///
6490 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6491 /// as-of instances never commit, so `distribute_events` never runs and the
6492 /// subscription would never deliver events.
6493 ///
6494 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
6495 /// or if the plan shape is not in the allowlist.
6496 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
6497 if self.read_only {
6498 return Err(GraphError::ReadOnly);
6499 }
6500 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6501 detail: format!("lex: {e}"),
6502 })?;
6503 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6504 detail: format!("parse: {e}"),
6505 })?;
6506 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6507 detail: format!("plan: {e}"),
6508 })?;
6509 if !is_subscribable(&ops) {
6510 return Err(GraphError::QueryError {
6511 detail: "subscribe_query only supports allowlisted plan shapes: \
6512 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
6513 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
6514 Not supported: multi-hop Expand chains, SKIP (creates \
6515 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
6516 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
6517 Use LIMIT to bound re-execution cost."
6518 .to_string(),
6519 });
6520 }
6521 // Execute once to capture initial state (initial rows are not emitted as
6522 // events — the subscriber learns the baseline via the first query call).
6523 let empty_params = BTreeMap::new();
6524 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
6525 GraphError::QueryError {
6526 detail: format!("execute: {e}"),
6527 }
6528 })?;
6529 let columns = initial.columns().to_vec();
6530 let prev_row_map = Self::result_to_row_map(&initial);
6531 let inner = SubInner::new(self.sub_capacity());
6532 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
6533 // or unrecognized leading scan → None (always re-execute).
6534 let scan_label = extract_scan_label(&ops, Arc::make_mut(&mut self.syms));
6535 self.query_subscriptions.push(QuerySubEntry {
6536 ops,
6537 columns,
6538 prev_row_map,
6539 inner: std::sync::Arc::downgrade(&inner),
6540 scan_label,
6541 });
6542 Ok(Subscription(inner))
6543 }
6544
6545 /// Queue capacity used for new subscriptions.
6546 fn sub_capacity(&self) -> usize {
6547 self.sub_capacity
6548 }
6549
6550 /// Override per-subscriber queue capacity for subsequently created
6551 /// subscriptions on this db instance.
6552 ///
6553 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
6554 /// value in tests to exercise the [`DbEvent::Lagged`] path without
6555 /// generating tens of thousands of events.
6556 ///
6557 /// This is a test-support escape hatch. Calling it in production reduces
6558 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
6559 /// to discourage accidental production use.
6560 #[doc(hidden)]
6561 pub fn set_sub_capacity(&mut self, capacity: usize) {
6562 self.sub_capacity = capacity;
6563 }
6564
6565 // -----------------------------------------------------------------------
6566
6567 /// Start an atomic batch.
6568 ///
6569 /// The returned [`BatchBuilder`] borrows `self` mutably until
6570 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
6571 /// validation, no WAL I/O. `commit` validates every queued op against
6572 /// live state plus preceding ops in this batch (duplicate key inside
6573 /// the batch is `Err`; an edge between two nodes created earlier in
6574 /// the batch is valid; `delete_node` then insert of the same key is a
6575 /// fresh identity). Validation never mutates the database. Any failure
6576 /// leaves WAL bytes and in-memory state identical to before `commit`.
6577 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
6578 /// and each inner record is applied in order so rules fire per record.
6579 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
6580 ///
6581 /// **Rule-window limitation:** batch validation cannot see edges that a
6582 /// rule created earlier in the *same* batch will derive at apply time, so
6583 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
6584 /// where sequential calls would return `Err(RuleOwned)`. State integrity
6585 /// is unaffected (idempotent apply, provenance intact). Create rules in
6586 /// their own batch, or sequentially, when later ops may touch derived
6587 /// edges.
6588 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
6589 BatchBuilder {
6590 db: self,
6591 ops: Vec::new(),
6592 }
6593 }
6594
6595 /// Closure-style atomic write batch.
6596 ///
6597 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
6598 /// then committing. All ops queued inside `build` are validated in order and
6599 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
6600 /// once per inner record, in order, after commit — semantically identical to
6601 /// sequential single-op writes.
6602 ///
6603 /// **Error semantics — validate-then-apply.** `build` queues ops without
6604 /// touching the database. [`BatchBuilder::commit`] validates every op against
6605 /// live state plus earlier ops in this batch before writing anything. If op N
6606 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
6607 /// entire batch is rejected: no WAL bytes are written and no in-memory state
6608 /// changes. The database is identical to its state before `write_batch` was
6609 /// called.
6610 ///
6611 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
6612 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
6613 /// either fully applied or not at all. However, while applying a committed
6614 /// batch, concurrent readers may observe intermediate states as ops are applied
6615 /// sequentially in memory. There is no interactive transaction isolation in v1.
6616 /// This is documented as "crash-atomic write batches; no interactive
6617 /// transactions or read isolation."
6618 ///
6619 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
6620 /// writes zero WAL bytes and returns `(0, 0)`.
6621 ///
6622 /// # Example
6623 ///
6624 /// ```rust,ignore
6625 /// let (nodes, edges) = db.write_batch(|b| {
6626 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
6627 /// b.insert_node("Person", "bob", vec![]);
6628 /// b.insert_edge("KNOWS", "alice", "bob");
6629 /// b.set_prop("alice", "role", Value::Str("admin".into()));
6630 /// b.delete_node("old_key");
6631 /// })?;
6632 /// // One fsync; on crash replay: all five ops land or none do.
6633 /// ```
6634 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
6635 where
6636 C: FnOnce(&mut BatchBuilder<'_, F>),
6637 {
6638 let mut b = self.batch();
6639 build(&mut b);
6640 b.commit()
6641 }
6642
6643 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
6644 /// auto-declared KeyMatch rules (if any) first, then the accepted node
6645 /// inserts, so incremental fire sees the new rules. Per-row key problems
6646 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
6647 /// `Err` means nothing was applied.
6648 ///
6649 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
6650 /// distinct source labels sharing an FK field each get their own rule.
6651 pub fn ingest(
6652 &mut self,
6653 label: &str,
6654 rows: Vec<BTreeMap<String, Value>>,
6655 opts: &IngestOptions,
6656 ) -> Result<IngestReport> {
6657 self.ingest_with_edges(label, rows, opts, &[])
6658 }
6659
6660 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
6661 /// A failing edge rejects the whole request; nothing is applied.
6662 pub fn ingest_with_edges(
6663 &mut self,
6664 label: &str,
6665 rows: Vec<BTreeMap<String, Value>>,
6666 opts: &IngestOptions,
6667 edges: &[(String, String, String)],
6668 ) -> Result<IngestReport> {
6669 crate::ingest::run(self, label, rows, opts, edges)
6670 }
6671
6672 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
6673 ///
6674 /// JSON `null` fields are silently omitted (not stored, not a row error).
6675 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
6676 /// Parse failures and a top-level value that is not an array of objects
6677 /// return [`GraphError::IngestError`].
6678 pub fn ingest_json(
6679 &mut self,
6680 label: &str,
6681 json: &str,
6682 opts: &IngestOptions,
6683 ) -> Result<IngestReport> {
6684 crate::ingest::run_json(self, label, json, opts)
6685 }
6686
6687 fn commit_logged_batch(
6688 &mut self,
6689 ops: Vec<BatchOp>,
6690 ingest: Option<(String, usize)>,
6691 // Two-source rule: write_batch_authz threads authz here directly (never
6692 // touches pending_write_authz); query_write_authz sets the field instead
6693 // and passes None. Only one source is non-None per call.
6694 param_authz: Option<WriteAuthz>,
6695 ) -> Result<BatchOutcome> {
6696 // Read-only guard: catches empty-batch calls before the early-return
6697 // that skips log_then_apply_with, ensuring all mutation entry points fail.
6698 if self.read_only {
6699 return Err(GraphError::ReadOnly);
6700 }
6701 // Ensure provenance is decoded before MutPreview accesses it
6702 // (note_delete_rule / is_rule_owned may call engine.provenance()).
6703 self.engine.ensure_provenance_loaded_mut();
6704
6705 // ── Authz pre-check ──────────────────────────────────────────────────
6706 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
6707 // produces no WAL frame (all-or-nothing at the authz boundary extends
6708 // the existing validate-then-apply contract to role-scope checks).
6709 //
6710 // `batch_created` tracks key→label for nodes created by earlier ops in
6711 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
6712 // as visible without needing to call `self.ids.get` on not-yet-committed
6713 // keys (they won't be there yet).
6714 //
6715 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
6716 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
6717 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
6718 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
6719 if let Some(ref authz) = authz_opt {
6720 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
6721 for op in &ops {
6722 self.check_single_op_authz(authz, op, &batch_created)?;
6723 // Update batch_created after a passing authz check so that
6724 // subsequent ops in this batch see the nodes as "about to exist".
6725 match op {
6726 BatchOp::InsertNode { label, key, .. } => {
6727 // Only track genuinely new nodes (absent from the
6728 // snapshot at authz-check time). A pre-existing visible
6729 // key would be a DuplicateKey — not a real creation —
6730 // so MutPreview handles it. Letting it into batch_created
6731 // would allow a later SetProp to bypass update_labels
6732 // via the "batch-created → always updatable" ruling
6733 // (delete+recreate exploit, fix for I1 review round 2).
6734 //
6735 // Accepted edge: for a delete+recreate-with-different-
6736 // label batch, node_status resolves the pre-delete
6737 // (store) label for any subsequent update checks. This
6738 // grants no net-new capability — a role that can delete+
6739 // create can already place arbitrary props via
6740 // InsertNode's own props field.
6741 if self.ids.get(key.as_str()).is_none() {
6742 batch_created.insert(key.clone(), label.clone());
6743 }
6744 }
6745 BatchOp::InsertEdgeUpsert {
6746 placeholder_label,
6747 src_key,
6748 dst_key,
6749 ..
6750 } => {
6751 // Both endpoints will be created if not already in store.
6752 for ep_key in [src_key, dst_key] {
6753 if self.ids.get(ep_key.as_str()).is_none()
6754 && !batch_created.contains_key(ep_key.as_str())
6755 {
6756 batch_created.insert(ep_key.clone(), placeholder_label.clone());
6757 }
6758 }
6759 }
6760 _ => {}
6761 }
6762 }
6763 }
6764
6765 let mut outcome = BatchOutcome::default();
6766 let recs = {
6767 let mut preview = MutPreview::new(self);
6768 let mut recs = Vec::with_capacity(ops.len());
6769 // Which node row we are on, counted over the node-insert ops only.
6770 // A caller that queues its rows in order reads this straight back
6771 // as the index into its own list.
6772 let mut node_row = 0usize;
6773 // Every field name the store knows, which a `Replace` needs to work
6774 // out what it removes. Resolved on the first `Replace` in the frame
6775 // and reused, so N replaces read the field list once, not N times.
6776 let mut store_fields: Option<Vec<String>> = None;
6777 // Duplicate inserts this frame has to count, each paired with the
6778 // position in `recs` it belongs at. The count itself is named in the
6779 // dense rewrite and not here: a duplicate's endpoints and edge type
6780 // may all be created by earlier ops in this same frame, and nothing
6781 // in the frame has a dense id yet. See [`PlannedRec`].
6782 let mut deferred_counts: Vec<(usize, String, String, String)> = Vec::new();
6783 for op in ops {
6784 match op {
6785 BatchOp::InsertNode { label, key, props } => {
6786 node_row += 1;
6787 preview.check_insert_node(&key, &props)?;
6788 preview.note_insert_node(&label, &key, &props);
6789 recs.push(WalRecord::InsertNode { label, key, props });
6790 }
6791 BatchOp::InsertNodeOnConflict {
6792 label,
6793 key,
6794 props,
6795 on_conflict,
6796 } => {
6797 let row = node_row;
6798 node_row += 1;
6799 if !preview.has_key(&key) {
6800 // No conflict: an ordinary insert on any policy —
6801 // except that a supplied view-owned field is the
6802 // same mistake here as on a taken key, and gets the
6803 // same row error rather than a frame error. Without
6804 // this, one op answered one request two ways
6805 // depending on whether the store already had the
6806 // key (defect #19).
6807 if let Some(why) = preview.supplied_view_owned_prop(&key, &props) {
6808 outcome.row_errors.push((row, why));
6809 continue;
6810 }
6811 preview.note_insert_node(&label, &key, &props);
6812 recs.push(WalRecord::InsertNode { label, key, props });
6813 continue;
6814 }
6815 match on_conflict {
6816 OnConflict::Error => {
6817 return Err(GraphError::DuplicateKey { key });
6818 }
6819 OnConflict::Skip => outcome.skipped += 1,
6820 OnConflict::Replace => {
6821 if store_fields.is_none() {
6822 store_fields = Some(preview.db.props_view().field_names());
6823 }
6824 let fields = store_fields.as_deref().unwrap_or_default();
6825 match preview.plan_replace(&label, &key, &props, fields) {
6826 Ok((writes, kept_view_owned)) => {
6827 outcome.kept_view_owned += kept_view_owned;
6828 for (field, value) in writes {
6829 match value {
6830 Some(value) => {
6831 preview.note_set_prop(&key, &field, &value);
6832 recs.push(WalRecord::SetProp {
6833 key: key.clone(),
6834 field,
6835 value,
6836 });
6837 }
6838 None => {
6839 preview.note_remove_prop(&key, &field);
6840 recs.push(WalRecord::RemoveProp {
6841 key: key.clone(),
6842 field,
6843 });
6844 }
6845 }
6846 }
6847 outcome.replaced += 1;
6848 }
6849 Err(why) => outcome.row_errors.push((row, why)),
6850 }
6851 }
6852 }
6853 }
6854 BatchOp::InsertEdge {
6855 edge_type,
6856 src_key,
6857 dst_key,
6858 } => {
6859 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6860 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6861 recs.push(WalRecord::InsertEdge {
6862 edge_type,
6863 src_key,
6864 dst_key,
6865 });
6866 } else if preview.db.multiplicity {
6867 // A duplicate inside a batch counts the way a
6868 // duplicate through `insert_edge` does: `ingest` and
6869 // Cypher `CREATE` reach this choke-point and not
6870 // that one, and a count only one entry point keeps
6871 // would be worse than no count at all.
6872 //
6873 // This is the one gate on discriminant 23 from the
6874 // batch path: a store that never opted in queues
6875 // nothing here and so writes no such record.
6876 deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6877 }
6878 }
6879 BatchOp::SetProp { key, field, value } => {
6880 if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
6881 return Err(GraphError::ViewPropReadOnly {
6882 view_name: view_name.to_string(),
6883 });
6884 }
6885 preview.check_live_key(&key)?;
6886 preview.note_set_prop(&key, &field, &value);
6887 recs.push(WalRecord::SetProp { key, field, value });
6888 }
6889 BatchOp::RemoveProp { key, field } => {
6890 if preview.prepare_remove_prop(&key, &field)? {
6891 preview.note_remove_prop(&key, &field);
6892 recs.push(WalRecord::RemoveProp { key, field });
6893 }
6894 }
6895 BatchOp::DeleteEdge {
6896 edge_type,
6897 src_key,
6898 dst_key,
6899 } => {
6900 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
6901 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
6902 recs.push(WalRecord::DeleteEdge {
6903 edge_type,
6904 src_key,
6905 dst_key,
6906 });
6907 }
6908 }
6909 BatchOp::DeleteNode { key } => {
6910 preview.check_live_key(&key)?;
6911 preview.note_delete_node(&key);
6912 recs.push(WalRecord::DeleteNode { key });
6913 }
6914 BatchOp::CreateRule(def) => {
6915 preview.check_create_rule(&def)?;
6916 let def_bytes =
6917 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6918 detail: format!("serialize rule: {e}"),
6919 })?;
6920 preview.note_create_rule(&def);
6921 recs.push(WalRecord::CreateRule { def_bytes });
6922 }
6923 BatchOp::DeleteRule { name } => {
6924 preview.check_delete_rule(&name)?;
6925 preview.note_delete_rule(&name);
6926 recs.push(WalRecord::DeleteRule { name });
6927 }
6928 BatchOp::RenameNode { old_key, new_key } => {
6929 preview.check_rename_node(&old_key, &new_key)?;
6930 // A stored `id` that equals the key is the key, written
6931 // as a property by `upsert_entity`'s create path and by
6932 // Cypher `CREATE {id: …}`. It moves with the key, in
6933 // this frame; left behind, `WHERE n.id = '<new key>'`
6934 // finds nothing (ledger row 72). An `id` holding
6935 // anything else is the caller's and stays.
6936 let id_follows = preview.prop_value(&old_key, "id")
6937 == Some(Value::Str(old_key.clone()))
6938 && preview.db.view_store.view_for_prop("id").is_none();
6939 preview.note_rename_node(&old_key, &new_key);
6940 recs.push(WalRecord::RenameNode {
6941 old_key,
6942 new_key: new_key.clone(),
6943 });
6944 if id_follows {
6945 let value = Value::Str(new_key.clone());
6946 preview.note_set_prop(&new_key, "id", &value);
6947 recs.push(WalRecord::SetProp {
6948 key: new_key,
6949 field: "id".into(),
6950 value,
6951 });
6952 }
6953 }
6954 BatchOp::InsertEdgeUpsert {
6955 edge_type,
6956 src_key,
6957 dst_key,
6958 placeholder_label,
6959 } => {
6960 // Auto-create any missing endpoints as plain InsertNode ops.
6961 // Rules fire and last-change is updated for each created node.
6962 for key in [&src_key, &dst_key] {
6963 if !preview.has_key(key) {
6964 // A placeholder endpoint carries no props, so
6965 // the view-owned check has nothing to refuse.
6966 preview.check_insert_node(key, &[])?;
6967 preview.note_insert_node(&placeholder_label, key, &[]);
6968 recs.push(WalRecord::InsertNode {
6969 label: placeholder_label.clone(),
6970 key: key.clone(),
6971 props: vec![],
6972 });
6973 }
6974 }
6975 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6976 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6977 recs.push(WalRecord::InsertEdge {
6978 edge_type,
6979 src_key,
6980 dst_key,
6981 });
6982 } else if preview.db.multiplicity {
6983 // Same choke-point, same gate as `BatchOp::InsertEdge`
6984 // above: an upsert that finds the pair already there
6985 // is a duplicate insert and counts as one.
6986 deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6987 }
6988 }
6989 }
6990 }
6991 // Splice the deferred counts back into the positions they were
6992 // raised at, so a count still sits exactly where the duplicate did
6993 // — before any later op in the frame that deletes the pair.
6994 let mut planned: Vec<PlannedRec> =
6995 Vec::with_capacity(recs.len() + deferred_counts.len());
6996 let mut deferred = deferred_counts.into_iter().peekable();
6997 for (i, rec) in recs.into_iter().enumerate() {
6998 while deferred.peek().is_some_and(|(at, ..)| *at == i) {
6999 let (_, edge_type, src_key, dst_key) = deferred.next().expect("just peeked");
7000 planned.push(PlannedRec::DuplicateCount {
7001 edge_type,
7002 src_key,
7003 dst_key,
7004 });
7005 }
7006 planned.push(PlannedRec::Rec(rec));
7007 }
7008 for (_, edge_type, src_key, dst_key) in deferred {
7009 planned.push(PlannedRec::DuplicateCount {
7010 edge_type,
7011 src_key,
7012 dst_key,
7013 });
7014 }
7015 planned
7016 };
7017 // A frame that is nothing but skips or refused rows writes no WAL, but
7018 // it still has counts to report, so the early returns carry `outcome`
7019 // rather than zeros.
7020 if recs.is_empty() {
7021 return Ok(outcome);
7022 }
7023 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
7024 // *Id form, so only the dense variants can appear in `recs` here.
7025 let recs = self.rewrite_wal_dense_planned(recs)?;
7026 // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
7027 // namespace the node is already in is a no-op and is dropped there. An
7028 // empty `Batch` frame would still take a commit sequence and a WAL
7029 // record, so a batch that turns out to be nothing writes nothing.
7030 if recs.is_empty() {
7031 return Ok(outcome);
7032 }
7033 outcome.nodes_inserted = recs
7034 .iter()
7035 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
7036 .count();
7037 outcome.edges_inserted = recs
7038 .iter()
7039 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
7040 .count();
7041 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
7042 // under Strict. Pass self.fsync directly so Strict stays Strict —
7043 // wal_needs_sync(Strict, _) always returns true regardless of op count.
7044 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
7045 // short-circuit on single-op batches and silently skip the fsync.
7046 // Batched fsyncs only for multi-op batches; Relaxed always skips.
7047 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
7048 Ok(outcome)
7049 }
7050
7051 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7052 self.commit_logged_batch(ops, None, None).map(inserted_pair)
7053 }
7054
7055 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
7056 /// and the group-commit drain thread, which do a single group fsync later.
7057 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7058 // Restore fsync policy even on panic via a raw-pointer drop guard.
7059 // A panic here would poison the RwLock anyway, but the correct policy
7060 // must be in place if the guard is ever unwrapped.
7061 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7062 impl Drop for RestoreFsync {
7063 fn drop(&mut self) {
7064 // SAFETY: the pointer is valid for the full duration of
7065 // commit_batch_nosync; the guard is dropped before the frame
7066 // returns, and GraphDb outlives this frame.
7067 unsafe {
7068 *self.0 = self.1;
7069 }
7070 }
7071 }
7072 let saved = self.fsync;
7073 // SAFETY: raw pointer into self; guard dropped within this frame.
7074 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7075 self.fsync = FsyncPolicy::Relaxed;
7076 self.commit_logged_batch(ops, None, None).map(inserted_pair)
7077 }
7078
7079 /// Commit multiple op-batches as a **group**: each submission gets its own
7080 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
7081 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
7082 ///
7083 /// # Durability semantics
7084 ///
7085 /// A crash before the group fsync may lose **all** submissions in the group.
7086 /// A crash after the group fsync preserves all of them. No submission is
7087 /// ever torn: each WAL frame is either fully applied on replay or dropped
7088 /// in its entirety (CRC-protected frame boundaries).
7089 ///
7090 /// Events and subscription notifications fire per-submission immediately
7091 /// after apply, which may be before the group fsync. From a subscriber's
7092 /// perspective this is equivalent to the `Relaxed` durability window.
7093 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
7094 /// fsync, so from their perspective durability is fully guaranteed.
7095 ///
7096 /// # MVCC interplay
7097 ///
7098 /// Each submission records its own `CommitDelta`; the fold-every-K counter
7099 /// increments per submission (not per group), preserving existing reader
7100 /// snapshot semantics.
7101 ///
7102 /// # Returns
7103 ///
7104 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
7105 /// in order. Failures are per-submission (validation errors); the group
7106 /// fsync error (if any) is returned as the second tuple element.
7107 pub fn commit_group(
7108 &mut self,
7109 groups: Vec<Vec<BatchOp>>,
7110 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
7111 let mut results = Vec::with_capacity(groups.len());
7112 for ops in groups {
7113 results.push(self.commit_batch_nosync(ops));
7114 }
7115 let any_ok = results.iter().any(|r| r.is_ok());
7116 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
7117 self.fs
7118 .sync(core_storage::fs::FileId::Wal)
7119 .map_err(GraphError::Io)
7120 .err()
7121 } else {
7122 None
7123 };
7124 (results, sync_err)
7125 }
7126
7127 /// Like [`commit_group`] but skips the group fsync entirely.
7128 ///
7129 /// Used by the drain thread to apply submissions under the write lock and
7130 /// then perform the single fsync OUTSIDE the lock (via
7131 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
7132 /// to concurrent readers.
7133 pub fn commit_group_nosync(
7134 &mut self,
7135 groups: Vec<Vec<BatchOp>>,
7136 ) -> Vec<Result<(usize, usize)>> {
7137 let mut results = Vec::with_capacity(groups.len());
7138 for ops in groups {
7139 results.push(self.commit_batch_nosync(ops));
7140 }
7141 results
7142 }
7143
7144 pub fn insert_node(
7145 &mut self,
7146 label: &str,
7147 key: &str,
7148 props: Vec<(String, Value)>,
7149 ) -> Result<()> {
7150 if self.read_only {
7151 return Err(GraphError::ReadOnly);
7152 }
7153 MutPreview::new(self).check_insert_node(key, &props)?;
7154 self.log_dense(vec![WalRecord::InsertNode {
7155 label: label.into(),
7156 key: key.into(),
7157 props,
7158 }])
7159 }
7160
7161 /// Insert a user edge. `Ok(true)` when the pair was new, `Ok(false)` when it
7162 /// was already there — the question is "was this pair new", and a duplicate
7163 /// does not make it so.
7164 ///
7165 /// On a store that called [`enable_multiplicity`](Self::enable_multiplicity)
7166 /// a duplicate is no longer a total no-op: it raises the pair's insert count
7167 /// (§5.13). Adjacency is still a set, so [`degree`](Self::degree) is
7168 /// unchanged and the return value is still `Ok(false)`; the count is visible
7169 /// only through [`degree_multiplicity`](Self::degree_multiplicity) and the
7170 /// reserved [`EDGE_COUNT_PROP`]. On every other store a duplicate writes
7171 /// nothing at all, as it always has.
7172 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7173 if self.read_only {
7174 return Err(GraphError::ReadOnly);
7175 }
7176 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
7177 // The pair exists. The only thing left to record is that it was
7178 // asked for again, and only where the store asked to be told.
7179 if let Some(rec) = self.edge_count_record(edge_type, src_key, dst_key) {
7180 self.log_then_apply(rec)?;
7181 }
7182 return Ok(false);
7183 }
7184 self.log_dense(vec![WalRecord::InsertEdge {
7185 edge_type: edge_type.into(),
7186 src_key: src_key.into(),
7187 dst_key: dst_key.into(),
7188 }])?;
7189 Ok(true)
7190 }
7191
7192 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
7193 if self.read_only {
7194 return Err(GraphError::ReadOnly);
7195 }
7196 if let Some(view_name) = self.view_store.view_for_prop(field) {
7197 return Err(GraphError::ViewPropReadOnly {
7198 view_name: view_name.to_string(),
7199 });
7200 }
7201 MutPreview::new(self).check_live_key(key)?;
7202 self.log_dense(vec![WalRecord::SetProp {
7203 key: key.into(),
7204 field: field.into(),
7205 value,
7206 }])
7207 }
7208
7209 /// Set several properties on one live node in a single WAL commit.
7210 ///
7211 /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
7212 /// names, live key, the `ns` immutability rule and its type — is evaluated
7213 /// for the whole list before any record is logged. The first refusal
7214 /// returns and the node is unchanged. An empty list writes nothing.
7215 pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
7216 if self.read_only {
7217 return Err(GraphError::ReadOnly);
7218 }
7219 MutPreview::new(self).check_live_key(key)?;
7220 for (field, _) in &props {
7221 if let Some(view_name) = self.view_store.view_for_prop(field) {
7222 return Err(GraphError::ViewPropReadOnly {
7223 view_name: view_name.to_string(),
7224 });
7225 }
7226 }
7227 if props.is_empty() {
7228 return Ok(());
7229 }
7230 self.write_batch(|b| {
7231 for (field, value) in props {
7232 b.set_prop(key, &field, value);
7233 }
7234 })
7235 .map(|_| ())
7236 }
7237
7238 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
7239 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
7240 /// A field a view owns is `Err(ViewPropReadOnly)` — stated once, in
7241 /// [`MutPreview::prepare_remove_prop`], so that the batch ops reaching that
7242 /// same choke-point cannot miss it.
7243 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
7244 if self.read_only {
7245 return Err(GraphError::ReadOnly);
7246 }
7247 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
7248 return Ok(false);
7249 }
7250 self.log_then_apply(WalRecord::RemoveProp {
7251 key: key.into(),
7252 field: field.into(),
7253 })?;
7254 Ok(true)
7255 }
7256
7257 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
7258 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
7259 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
7260 /// (the rule would just put the edge back; delete or change the rule).
7261 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7262 if self.read_only {
7263 return Err(GraphError::ReadOnly);
7264 }
7265 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
7266 return Ok(false);
7267 }
7268 self.log_then_apply(WalRecord::DeleteEdge {
7269 edge_type: edge_type.into(),
7270 src_key: src_key.into(),
7271 dst_key: dst_key.into(),
7272 })?;
7273 Ok(true)
7274 }
7275
7276 /// Delete a live node. Unknown or already-tombstoned keys are
7277 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
7278 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
7279 /// (crash window) is a clean no-op.
7280 ///
7281 /// Returns a [`DeleteReport`] with counts of manual and derived edges
7282 /// removed (computed from live state before the deletion is applied).
7283 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
7284 if self.read_only {
7285 return Err(GraphError::ReadOnly);
7286 }
7287 // Provenance must be loaded before we query provenance_touching.
7288 self.engine.ensure_provenance_loaded_mut();
7289 let id = self
7290 .ids
7291 .get(key)
7292 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7293
7294 // Count edges before the delete is applied so we can report counts.
7295 let derived_set: BTreeSet<(u32, u32, u32)> = self
7296 .engine
7297 .provenance_touching(id)
7298 .map(|(_, etype, src, dst)| (etype, src, dst))
7299 .collect();
7300 let derived_edges = derived_set.len() as u64;
7301
7302 let mut total_topo = 0u64;
7303 let tv = self.topo_view();
7304 for et in tv.etypes() {
7305 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
7306 + tv.neighbors(et, Direction::In, id).len() as u64;
7307 }
7308 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
7309 // triples in both the topo scan (Out and In from id) and in provenance_touching.
7310 // The subtraction remains correct because both counts include both directions.
7311 let manual_edges = total_topo.saturating_sub(derived_edges);
7312
7313 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
7314 Ok(DeleteReport {
7315 manual_edges,
7316 derived_edges,
7317 })
7318 }
7319
7320 /// Rename a live node's key. The dense id (and therefore all edges,
7321 /// props, history, and last-change tracking) is unaffected.
7322 ///
7323 /// A stored `id` property that equals `old` is rewritten to `new` in the
7324 /// same commit, so `n.id` and `key(n)` keep agreeing; an `id` holding any
7325 /// other value, and a node that stores none, are left as they are.
7326 ///
7327 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
7328 /// Returns `Err(DuplicateKey)` if `new` is already live.
7329 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
7330 if self.read_only {
7331 return Err(GraphError::ReadOnly);
7332 }
7333 // The `id` rewrite lives in the batch arm, which HTTP reaches without
7334 // this function. A node with no such `id` keeps the bare frame.
7335 if self.get_prop(old, "id") == Some(Value::Str(old.to_string())) {
7336 return self
7337 .commit_batch(vec![BatchOp::RenameNode {
7338 old_key: old.into(),
7339 new_key: new.into(),
7340 }])
7341 .map(|_| ());
7342 }
7343 MutPreview::new(self).check_rename_node(old, new)?;
7344 self.log_then_apply(WalRecord::RenameNode {
7345 old_key: old.into(),
7346 new_key: new.into(),
7347 })
7348 }
7349
7350 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
7351 /// `None` if the rule does not exist or is not approximate.
7352 ///
7353 /// The drift counter increments on IVF insert/remove after the last fit.
7354 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
7355 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
7356 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
7357 // SideIvfExport = (centroids, node→cluster, drift)
7358 self.engine
7359 .export_ivf_state()
7360 .remove(rule)
7361 .map(|(_src, dst)| dst.2)
7362 }
7363
7364 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
7365 /// Validation and duplicate-name check run before logging so invalid rules
7366 /// never enter the WAL.
7367 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
7368 if self.read_only {
7369 return Err(GraphError::ReadOnly);
7370 }
7371 MutPreview::new(self).check_create_rule(&def)?;
7372 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7373 detail: format!("serialize rule: {e}"),
7374 })?;
7375 self.log_dense(vec![WalRecord::CreateRule { def_bytes }])
7376 }
7377
7378 /// Override this handle's HNSW build-slice size, or `None` to restore
7379 /// [`core_rules::HNSW_BUILD_BATCH`].
7380 ///
7381 /// Exposed for tests that need a small slice without a large corpus; not
7382 /// part of the stable surface.
7383 #[doc(hidden)]
7384 pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
7385 self.engine.set_hnsw_build_batch(batch);
7386 }
7387
7388 /// Rules whose vector index is still being built, in name order.
7389 ///
7390 /// The same list [`GraphDb::stats`] reports per rule in `building`.
7391 /// After a clean open this includes a build a snapshot cut short, so
7392 /// `serve`'s ticker can pump it without a write.
7393 pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
7394 self.engine.builds_in_progress()
7395 }
7396
7397 /// Advance any vector index still building and backfill each rule that
7398 /// finishes. Returns what is still outstanding.
7399 ///
7400 /// A map lookup when nothing is pending, so it is cheap to call on a timer.
7401 /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
7402 /// inserts per pending rule per call, so a caller can drive a large build
7403 /// to completion without ever holding the lock for more than a slice.
7404 ///
7405 /// A rule that finishes here is backfilled through the same
7406 /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
7407 /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
7408 /// and appear all at once.
7409 ///
7410 /// Every ordinary write pumps one slice on its own (see the post-commit
7411 /// hook in `log_then_apply_with`), so this is for quiescent stores and for
7412 /// operators who want the build finished before traffic arrives.
7413 pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
7414 Ok(self.pump_index_build_reporting()?.1)
7415 }
7416
7417 /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
7418 /// call finished, so a progress display can say so.
7419 ///
7420 /// A build can be registered and completed inside a single call — that is
7421 /// what a mid-build snapshot looks like on reopen, where the index scan
7422 /// finishes the graph and only the backfill is outstanding — and the
7423 /// outstanding list alone cannot show that anything happened.
7424 pub fn pump_index_build_reporting(
7425 &mut self,
7426 ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
7427 // A read-only handle cannot issue the `RebuildRule` a finished build
7428 // needs, so it would advance the index and then silently fail to
7429 // produce the edges. Refusing is the honest answer.
7430 if self.read_only {
7431 return Err(GraphError::ReadOnly);
7432 }
7433 let finished = self.pump_one_slice();
7434 for done in &finished {
7435 // The index is whole but the rule still owns no edges. A failed
7436 // second commit must leave the rule re-pumpable rather than
7437 // silently edge-less, so the error is surfaced here — unlike the
7438 // post-commit hook, this call is not riding someone else's commit.
7439 self.log_then_apply(WalRecord::RebuildRule {
7440 name: done.rule.clone(),
7441 })?;
7442 }
7443 Ok((finished, self.engine.builds_in_progress()))
7444 }
7445
7446 /// Run the deferred candidate-index build, if it is still owed, against the
7447 /// graph as it stands *now* — before the caller applies anything.
7448 ///
7449 /// A no-op bool test once the indexes are populated, which is after the
7450 /// first write of the handle's life, and for a store with no rules at all.
7451 fn populate_indexes_before_write(&mut self) {
7452 if !self.engine.needs_index_population() {
7453 return;
7454 }
7455 // The retained snapshot blobs arrive with the V8 base sections; without
7456 // them the scan would rebuild every graph the snapshot already holds.
7457 self.ensure_v8_base_sections_loaded();
7458 if !self.engine.needs_index_population() {
7459 return;
7460 }
7461 let mut eng = std::mem::take(&mut self.engine);
7462 {
7463 let gm = make_graph_mut(
7464 &self.ids,
7465 Arc::make_mut(&mut self.syms),
7466 &self.labels,
7467 build_props_view(&self.props, &self.base),
7468 Arc::make_mut(&mut self.topo),
7469 &self.base,
7470 Arc::make_mut(&mut self.edge_props),
7471 );
7472 eng.populate_indexes(&gm);
7473 }
7474 self.engine = eng;
7475 }
7476
7477 /// One slice of build work for every pending rule. Returns the rules whose
7478 /// index just became whole, which the caller must `RebuildRule`.
7479 ///
7480 /// Goes through the engine even with nothing pending when the indexes have
7481 /// not been populated yet: that call adopts the persisted graphs and, for
7482 /// an incomplete blob already registered at open, leaves the remainder to
7483 /// this slice rather than inserting it inline.
7484 fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
7485 // The retained snapshot blobs — and the id count an interrupted build
7486 // is recognised against — arrive with the V8 base sections, which a
7487 // clean open reads lazily. Without this a freshly opened handle pumps
7488 // against empty retained state and concludes there is nothing to do,
7489 // which is precisely the store `build-index` exists for.
7490 self.ensure_v8_base_sections_loaded();
7491 let mut eng = std::mem::take(&mut self.engine);
7492 let finished = {
7493 let mut gm = make_graph_mut(
7494 &self.ids,
7495 Arc::make_mut(&mut self.syms),
7496 &self.labels,
7497 build_props_view(&self.props, &self.base),
7498 Arc::make_mut(&mut self.topo),
7499 &self.base,
7500 Arc::make_mut(&mut self.edge_props),
7501 );
7502 eng.pump_index_build(&mut gm)
7503 };
7504 self.engine = eng;
7505 finished
7506 }
7507
7508 /// Register a sliced build a snapshot cut short, from blobs with
7509 /// `complete == false`.
7510 ///
7511 /// Peeks the V8 mmap for incomplete entries without copying complete
7512 /// graphs. V5–V7 already hold the blobs in the engine from restore.
7513 fn register_outstanding_index_builds(&mut self) {
7514 if self.engine.indexes_populated() {
7515 return;
7516 }
7517 let extra = self.collect_incomplete_hnsw_blobs();
7518 let mut eng = std::mem::take(&mut self.engine);
7519 {
7520 let gm = make_graph_mut(
7521 &self.ids,
7522 Arc::make_mut(&mut self.syms),
7523 &self.labels,
7524 build_props_view(&self.props, &self.base),
7525 Arc::make_mut(&mut self.topo),
7526 &self.base,
7527 Arc::make_mut(&mut self.edge_props),
7528 );
7529 eng.register_incomplete_hnsw_builds(&extra, &gm);
7530 }
7531 self.engine = eng;
7532 }
7533
7534 /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
7535 /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
7536 /// engine's retained map instead).
7537 fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
7538 let Some(base) = &self.base else {
7539 return BTreeMap::new();
7540 };
7541 let Ok(archived) = base.hnsw_section() else {
7542 return BTreeMap::new();
7543 };
7544 archived
7545 .rules
7546 .iter()
7547 .filter_map(|e| {
7548 let src = e.src_blob.as_slice();
7549 let dst = e.dst_blob.as_slice();
7550 if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
7551 || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
7552 {
7553 Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
7554 } else {
7555 None
7556 }
7557 })
7558 .collect()
7559 }
7560
7561 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
7562 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
7563 if self.read_only {
7564 return Err(GraphError::ReadOnly);
7565 }
7566 MutPreview::new(self).check_delete_rule(name)?;
7567 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
7568 }
7569
7570 /// Return a snapshot of all registered rules.
7571 pub fn rules(&self) -> Vec<RuleDef> {
7572 self.engine.rules().cloned().collect()
7573 }
7574
7575 // -----------------------------------------------------------------------
7576 // Rule suggestion API
7577 // -----------------------------------------------------------------------
7578
7579 /// Profile the database and suggest linking rules with previewed edge counts.
7580 ///
7581 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
7582 /// sampling. Suggestions are sorted by estimated edge count (descending).
7583 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
7584 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
7585 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
7586 }
7587
7588 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
7589 /// reproducibility. Same seed + same data = identical output.
7590 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
7591 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
7592 .suggestions
7593 }
7594
7595 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
7596 ///
7597 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
7598 /// and a `truncated` flag indicating whether the global budget fired before all
7599 /// candidates were evaluated.
7600 pub fn suggest_rules_with_config(
7601 &self,
7602 config: &core_rules::suggest::SuggestConfig,
7603 seed: u64,
7604 ) -> core_rules::SuggestReport {
7605 use std::collections::BTreeMap;
7606
7607 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
7608 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
7609 for id in 0..self.ids.len() as u32 {
7610 let Some(key) = self.ids.key_of(id) else {
7611 continue;
7612 };
7613 let Some(&sym) = self.labels.get(id as usize) else {
7614 continue;
7615 };
7616 if sym == u32::MAX {
7617 continue; // tombstoned
7618 }
7619 let Some(label) = self.syms.resolve(sym) else {
7620 continue;
7621 };
7622 label_nodes
7623 .entry(label.to_string())
7624 .or_default()
7625 .push((id, key.to_string()));
7626 }
7627
7628 let existing = self.rules();
7629 let pv = build_props_view(&self.props, &self.base);
7630 let all_fields: Vec<String> = pv.field_names();
7631
7632 core_rules::suggest::suggest_rules(
7633 &label_nodes,
7634 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
7635 &all_fields,
7636 &existing,
7637 config,
7638 seed,
7639 )
7640 }
7641
7642 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
7643 /// plus later mutations replay identically (rebuild is a pure function
7644 /// of state).
7645 ///
7646 /// Only exit from the tripped latch: if the full desired set fits the
7647 /// budget, it is applied completely and `tripped` clears; if it still
7648 /// exceeds the budget, provenance is left untouched and `tripped` stays
7649 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
7650 /// Unknown rule → `RuleNotFound`, nothing logged.
7651 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
7652 if self.read_only {
7653 return Err(GraphError::ReadOnly);
7654 }
7655 if !self.engine.rules().any(|r| r.name == name) {
7656 return Err(GraphError::RuleNotFound { name: name.into() });
7657 }
7658 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
7659 }
7660
7661 // -----------------------------------------------------------------------
7662 // Materialized view API
7663 // -----------------------------------------------------------------------
7664
7665 /// Register a new materialized property view, backfill its values for all
7666 /// existing nodes, and WAL-log the definition.
7667 ///
7668 /// # Errors
7669 /// - `ReadOnly`: called on an as-of instance.
7670 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
7671 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
7672 if self.read_only {
7673 return Err(GraphError::ReadOnly);
7674 }
7675 // Pre-validate before WAL write.
7676 def.validate()
7677 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
7678 if self.view_store.has_view(&def.name) {
7679 return Err(GraphError::RuleInvalid {
7680 detail: format!("view {:?} already exists", def.name),
7681 });
7682 }
7683 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
7684 return Err(GraphError::RuleInvalid {
7685 detail: format!(
7686 "view_prop {:?} is already used by view {:?}",
7687 def.view_prop, existing
7688 ),
7689 });
7690 }
7691 // A view cannot take the name of a property nodes already carry —
7692 // in the snapshot or written since. Refused here, before the WAL
7693 // write: `apply` refuses some of it too, but by then the record is
7694 // logged, and the next open replays it and fails.
7695 //
7696 // "Carry" is a node that reads a value under the name. The column
7697 // alone does not count: a snapshot keeps the column of a view that
7698 // has since been deleted, with every value in it hidden, and that
7699 // name is free.
7700 let taken = {
7701 let pv = build_props_view(&self.props, &self.base);
7702 pv.field_names().contains(&def.view_prop)
7703 && (0..self.ids.len() as u32)
7704 .any(|id| !self.ids.is_tombstoned(id) && pv.get(id, &def.view_prop).is_some())
7705 };
7706 if taken {
7707 return Err(GraphError::RuleInvalid {
7708 detail: format!(
7709 "view_prop {:?} conflicts with an existing node property",
7710 def.view_prop
7711 ),
7712 });
7713 }
7714 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7715 detail: format!("serialize view: {e}"),
7716 })?;
7717 // Enable delta accumulation before the view is registered so subsequent
7718 // incremental edge events reach view maintenance from this point onward.
7719 // (The backfill inside create_view reads topo directly; it does not rely
7720 // on pending deltas.)
7721 self.engine.set_emit_deltas(true);
7722 self.log_then_apply(WalRecord::CreateView { def_bytes })
7723 }
7724
7725 /// Remove a named view and delete its values from every node.
7726 ///
7727 /// # Errors
7728 /// - `ReadOnly`: called on an as-of instance.
7729 /// - `RuleNotFound`: view does not exist.
7730 pub fn delete_view(&mut self, name: &str) -> Result<()> {
7731 if self.read_only {
7732 return Err(GraphError::ReadOnly);
7733 }
7734 if !self.view_store.has_view(name) {
7735 return Err(GraphError::RuleNotFound { name: name.into() });
7736 }
7737 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
7738 // After deletion, disable accumulation if no listeners remain.
7739 if !self.needs_emit_deltas() {
7740 self.engine.set_emit_deltas(false);
7741 }
7742 result
7743 }
7744
7745 /// Snapshot of all registered view definitions.
7746 pub fn views(&self) -> Vec<ViewDef> {
7747 self.view_store.views().cloned().collect()
7748 }
7749
7750 // -----------------------------------------------------------------------
7751 // Full-text-lite API
7752 // -----------------------------------------------------------------------
7753
7754 /// Enable full-text indexing for all nodes of `label` on property `field`.
7755 ///
7756 /// After this call, every subsequent write to `(label, field)` is reflected
7757 /// in the index incrementally. Existing nodes are backfilled immediately.
7758 /// The declaration is persisted as a WAL record. The index is carried by
7759 /// the next snapshot and read from it at open; until then it is rebuilt
7760 /// at open from the stored values.
7761 ///
7762 /// # Errors
7763 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7764 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
7765 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7766 if self.read_only {
7767 return Err(GraphError::ReadOnly);
7768 }
7769 if self.fulltext.is_enabled(label, field) {
7770 return Err(GraphError::RuleInvalid {
7771 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
7772 });
7773 }
7774 self.log_then_apply(WalRecord::EnableFulltext {
7775 label: label.into(),
7776 field: field.into(),
7777 })
7778 }
7779
7780 /// Disable full-text indexing for `(label, field)` and drop its postings.
7781 ///
7782 /// # Errors
7783 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7784 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
7785 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7786 if self.read_only {
7787 return Err(GraphError::ReadOnly);
7788 }
7789 if !self.fulltext.is_enabled(label, field) {
7790 return Err(GraphError::RuleNotFound {
7791 name: format!("fulltext({label},{field})"),
7792 });
7793 }
7794 self.log_then_apply(WalRecord::DisableFulltext {
7795 label: label.into(),
7796 field: field.into(),
7797 })
7798 }
7799
7800 /// Whether this handle read its full-text index from the snapshot it
7801 /// opened, and kept it, where it would otherwise have built it from the
7802 /// stored values. False for a store with no snapshot or no declared pair,
7803 /// for a snapshot written before the index was carried, and for an open
7804 /// that had to fall back. The answers are the same either way; this is
7805 /// how a test or an operator tells which open they got.
7806 pub fn fulltext_loaded_from_snapshot(&self) -> bool {
7807 self.fulltext_loaded
7808 }
7809
7810 /// Called by the insert arms of `apply` before they index `field` on
7811 /// `id`. `add_tokens` appends, so a node already indexed under the field
7812 /// has to be cleared first or its old words stay searchable.
7813 ///
7814 /// Live, that is one insert naming a property twice: the last value is
7815 /// what is stored, and it is what is indexed. At open it means the WAL
7816 /// holds a write the loaded index already has; the index is rebuilt after
7817 /// the replay, and clearing one node at a time here would only add a walk
7818 /// of the vocabulary for every such insert, so it is counted and left.
7819 fn unindex_before_reindexing(&mut self, id: u32, field: &str) {
7820 if !self.fulltext.has_doc(id, field) {
7821 return;
7822 }
7823 if self.replaying_at_open {
7824 self.fulltext_reindexed_a_node = true;
7825 self.fulltext_reindexed_at_open += 1;
7826 } else {
7827 Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
7828 }
7829 }
7830
7831 /// How many times the replay at open indexed a node that was already
7832 /// indexed under that field. Non-zero means the WAL held writes the
7833 /// loaded index already had, and the open rebuilt it. Exposed for tests;
7834 /// not part of the stable surface.
7835 #[doc(hidden)]
7836 pub fn fulltext_reindexed_during_open(&self) -> u64 {
7837 self.fulltext_reindexed_at_open
7838 }
7839
7840 /// Read the full-text index out of the snapshot this handle just mapped,
7841 /// if it carries one that reads back and was built over this id space.
7842 /// Anything else leaves the index empty for the rebuild that follows.
7843 fn load_fulltext_from_base(&mut self) {
7844 let Some(base) = self.base.as_ref() else {
7845 return;
7846 };
7847 // A snapshot that kept its WAL and carries no mark of how much of it
7848 // it holds is replayed over in full. Every insert in that WAL is
7849 // already in the blob, so reading it would end in a rebuild anyway —
7850 // after making each replayed insert tokenize first. With a mark, the
7851 // frames the snapshot holds are not applied again and the index is as
7852 // good as any other snapshot's.
7853 if !self.base_wal_truncated && base.wal_mark().is_none() {
7854 return;
7855 }
7856 let bytes = match base.fulltext_bytes() {
7857 Ok(bytes) if !bytes.is_empty() => bytes,
7858 _ => return,
7859 };
7860 match core_storage::fulltext::FulltextIndex::from_blob(bytes) {
7861 Some((index, node_count)) if node_count as usize == self.ids.len() => {
7862 self.fulltext = Arc::new(index);
7863 self.fulltext_loaded = true;
7864 }
7865 _ => eprintln!(
7866 "mushroomdb: the snapshot's full-text index could not be read; rebuilding it"
7867 ),
7868 }
7869 }
7870
7871 /// The full-text index as the bytes a snapshot carries, or nothing when
7872 /// no pair is declared.
7873 fn fulltext_blob(&self) -> Vec<u8> {
7874 if self.fulltext.enabled_pairs().next().is_none() {
7875 return Vec::new();
7876 }
7877 self.fulltext.to_blob(self.ids.len() as u32)
7878 }
7879
7880 /// Remove every edge the snapshot holds to or from a node that has been
7881 /// deleted.
7882 ///
7883 /// There should be none. Through 0.7.1 a node deleted after a snapshot
7884 /// kept the edges that snapshot held (ledger row 95), and the next
7885 /// snapshot wrote them into its own base, where they answered a
7886 /// neighbour read with `topology id N has no key`. This is what repairs
7887 /// such a store. It asks for the neighbours of every retired id under
7888 /// every edge type, which on a store with half a million deleted nodes is
7889 /// over a second, so it runs only over a snapshot that does not say its
7890 /// writer sweeps them (`MappedBase::edges_swept`). The removal is in
7891 /// memory until the next snapshot, which no longer carries them and says
7892 /// so.
7893 fn drop_edges_of_retired_nodes(&mut self) {
7894 let mut doomed = Vec::new();
7895 {
7896 let tv = self.topo_view();
7897 let etypes: Vec<u32> = tv.etypes().collect();
7898 for n in self.ids.tombstoned_ids() {
7899 for et in &etypes {
7900 for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
7901 doomed.push((*et, n, dst));
7902 }
7903 for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
7904 if src != n {
7905 doomed.push((*et, src, n));
7906 }
7907 }
7908 }
7909 }
7910 }
7911 for (et, s, d) in doomed {
7912 Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
7913 Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
7914 self.view_store.on_edge_changed(
7915 et,
7916 s,
7917 d,
7918 false,
7919 Arc::make_mut(&mut self.props),
7920 &build_topo_view(&self.topo, &self.base),
7921 &self.ids,
7922 &self.syms,
7923 &self.labels,
7924 base_columns(&self.base),
7925 );
7926 }
7927 }
7928
7929 /// What `snapshot.intent` says about the snapshot and the WAL on disk.
7930 ///
7931 /// The first value is the WAL that the snapshot already holds, when the
7932 /// intent names this snapshot and `wal` begins with exactly the WAL it
7933 /// names: a snapshot that was to replace its WAL and did not get that
7934 /// far. The second is `true` when the file holds anything else — an
7935 /// intent left over, which must not outlive this handle's next append.
7936 fn interrupted_snapshot(
7937 &self,
7938 wal: &[u8],
7939 ) -> Result<(Option<core_storage::v8::WalMark>, bool)> {
7940 let (standing, left_over) = self.standing_intent(wal)?;
7941 Ok((standing.map(|i| i.wal), left_over))
7942 }
7943
7944 /// The intent in `snapshot.intent` that is true of the snapshot and the
7945 /// WAL on disk, if one is, and whether the file holds anything that is
7946 /// not.
7947 ///
7948 /// A file that cannot be read is an error, not an absent intent: taking
7949 /// it for absent would replay the WAL over the very snapshot the intent
7950 /// is there to protect.
7951 fn standing_intent(
7952 &self,
7953 wal: &[u8],
7954 ) -> Result<(Option<core_storage::v8::SnapshotIntent>, bool)> {
7955 let bytes = self.fs.read(FileId::SnapshotIntent)?;
7956 if bytes.is_empty() {
7957 return Ok((None, false));
7958 }
7959 // The WAL is in hand, so it is asked first. Only a record whose mark
7960 // it matches is worth reading the whole snapshot for — which leaves a
7961 // left-over intent, the usual kind, costing nothing at each open.
7962 let candidates: Vec<_> = core_storage::v8::SnapshotIntent::decode_all(&bytes)
7963 .into_iter()
7964 .filter(|i| {
7965 i.wal.matches(wal)
7966 // The mark was taken at the end of a whole WAL.
7967 && decode_all(&wal[..i.wal.len as usize]).1 as u64 == i.wal.len
7968 })
7969 .collect();
7970 let standing = if candidates.is_empty() {
7971 None
7972 } else {
7973 let snapshot = self.fs.read(FileId::Snapshot)?;
7974 candidates.into_iter().find(|i| i.names(&snapshot))
7975 };
7976 // Anything in the file beyond the one standing intent is left over:
7977 // a record of a snapshot that finished or never landed.
7978 let left_over = bytes.len() != if standing.is_some() { 32 } else { 0 };
7979 Ok((standing, left_over))
7980 }
7981
7982 /// Empty `snapshot.intent` unless it is still true. Called with the write
7983 /// lock held, before an append.
7984 fn settle_snapshot_intent(&mut self) -> Result<()> {
7985 let wal = self.fs.read(FileId::Wal)?;
7986 let (standing, left_over) = self.standing_intent(&wal)?;
7987 if left_over {
7988 // Keep the one that is still true, drop the rest.
7989 let keep = standing.map(|i| i.encode()).unwrap_or_default();
7990 self.fs.write_atomic(FileId::SnapshotIntent, &keep)?;
7991 }
7992 self.intent_unsettled = false;
7993 Ok(())
7994 }
7995
7996 /// Make the next snapshot on this handle fail at `at`, leaving the files
7997 /// as a crash there would. For tests; not part of the stable surface.
7998 #[doc(hidden)]
7999 pub fn stop_next_snapshot(&mut self, at: SnapshotStop) {
8000 self.snapshot_stop = Some(at);
8001 }
8002
8003 /// Whether `(label, field)` is currently indexed for full-text search.
8004 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
8005 self.fulltext.is_enabled(label, field)
8006 }
8007
8008 /// Every `(label, field)` pair with a live full-text index, sorted.
8009 ///
8010 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
8011 /// declares which nodes are *indexed*, so callers that want to search
8012 /// everything indexed should query each distinct field once.
8013 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
8014 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
8015 v.sort();
8016 v
8017 }
8018
8019 /// Enable an equality index for all nodes of `label` on scalar property
8020 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
8021 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
8022 /// declaration persists via WAL and the postings rebuild on re-open.
8023 ///
8024 /// # Errors
8025 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8026 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
8027 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
8028 if self.read_only {
8029 return Err(GraphError::ReadOnly);
8030 }
8031 if self.prop_index.is_enabled(label, field) {
8032 return Err(GraphError::RuleInvalid {
8033 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
8034 });
8035 }
8036 self.log_then_apply(WalRecord::EnableIndex {
8037 label: label.into(),
8038 field: field.into(),
8039 })
8040 }
8041
8042 /// Disable the equality index for `(label, field)` and drop its postings.
8043 ///
8044 /// # Errors
8045 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8046 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
8047 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
8048 if self.read_only {
8049 return Err(GraphError::ReadOnly);
8050 }
8051 if !self.prop_index.is_enabled(label, field) {
8052 return Err(GraphError::RuleNotFound {
8053 name: format!("index({label},{field})"),
8054 });
8055 }
8056 self.log_then_apply(WalRecord::DisableIndex {
8057 label: label.into(),
8058 field: field.into(),
8059 })
8060 }
8061
8062 /// Whether `(label, field)` currently has an equality index.
8063 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
8064 self.prop_index.is_enabled(label, field)
8065 }
8066
8067 /// Every `(label, field)` pair with a live equality index, sorted.
8068 ///
8069 /// The enumerator [`is_index_enabled`](Self::is_index_enabled) never had:
8070 /// the MCP `schema` tool lists a store's indexes rather than probing them
8071 /// one guess at a time.
8072 pub fn index_pairs(&self) -> Vec<(String, String)> {
8073 self.prop_index.enabled_pairs().cloned().collect()
8074 }
8075
8076 /// The dense id of a live key, `None` for an unknown or deleted one.
8077 ///
8078 /// Ids are allocated in insertion order and never reused, so the lowest
8079 /// live id among a set of keys is the oldest node — which is how identity
8080 /// resolution picks a canonical node without a timestamp field
8081 /// (`memory::identity`). Crate-private: an id is an engine detail, not
8082 /// something a caller should hold.
8083 pub(crate) fn dense_id(&self, key: &str) -> Option<u32> {
8084 self.ids.get(key)
8085 }
8086
8087 /// Start recording insert-count multiplicity on this store (§5.13).
8088 ///
8089 /// Adjacency stays a set and nothing about an existing read changes: a
8090 /// duplicate [`insert_edge`](Self::insert_edge) still returns `Ok(false)`
8091 /// and still leaves [`degree`](Self::degree) alone. What it gains is that
8092 /// the duplicate is *counted*, as the reserved edge property
8093 /// [`EDGE_COUNT_PROP`], readable through
8094 /// [`degree_multiplicity`](Self::degree_multiplicity).
8095 ///
8096 /// # This is a one-way step, and that is why it is a call
8097 ///
8098 /// The count is durable, so it is written to the WAL — as discriminant 23,
8099 /// which no release before v0.6.10 knows. A reader meeting an unknown WAL
8100 /// discriminant cannot know what the record would have changed, so it
8101 /// cannot degrade the way an unreadable index blob can. **After this call
8102 /// the store can no longer be read by an older binary, and there is no call
8103 /// that undoes it.** Gating the record behind this method is what keeps
8104 /// that step a decision an operator makes when they want the feature,
8105 /// rather than one everybody takes by upgrading.
8106 ///
8107 /// # It fails loudly, and that costs a snapshot
8108 ///
8109 /// An older binary does not refuse discriminant 23 — it truncates the WAL
8110 /// at it and, with `repair_wal`, persists the truncation. So this call also
8111 /// writes a **V10 snapshot**, a version no earlier release knows, and it
8112 /// writes it *first*: the snapshot is read before the WAL, so an older
8113 /// binary stops at `snapshot: unsupported version 10` with the WAL
8114 /// untouched. Taking the snapshot before appending the record is what makes
8115 /// the guard unconditional — the store is never, at any interruption point,
8116 /// carrying the record without the stamp that announces it.
8117 ///
8118 /// The snapshot keeps the WAL (`keep_wal: true`): opting in is not a
8119 /// compaction, and history reachable by [`open_at`](Self::open_at) stays
8120 /// reachable. On a large store the call therefore costs one full snapshot
8121 /// write.
8122 ///
8123 /// # What it costs a store that archives
8124 ///
8125 /// Writing `snapshot.bin` is also how the archive path decides whether the
8126 /// store may have a *genesis chain* — whether `open_at` can replay
8127 /// archive-resident commits from empty state. The rule is conservative: a
8128 /// snapshot that was already on disk might have been a truncating one, and
8129 /// once the handle that took it is gone this binary cannot tell. A
8130 /// `keep_wal` snapshot taken by **this** handle is the case where it can, so
8131 /// opting in and then archiving **in the same session** keeps the chain.
8132 ///
8133 /// Opting in, closing the store, and archiving in a *later* session does
8134 /// not — but that is the answer any store with a prior snapshot gets, not
8135 /// something this call causes. A store that wants the chain should take its
8136 /// first archive in the session that opted in.
8137 ///
8138 /// Calling it on a store that has already opted in writes nothing and
8139 /// returns `Ok(())`: an operator should not have to ask first.
8140 ///
8141 /// # This call is not atomic, and an `Err` does not undo it
8142 ///
8143 /// There is no rollback here, and there never was one. An `Err` means this
8144 /// handle stopped believing the store is opted in — `self.multiplicity` is
8145 /// reset, so this handle reports `false` from then on — and nothing more. It
8146 /// says nothing about what reached disk. Two reachable failures leave the
8147 /// opt-in standing:
8148 ///
8149 /// * **The declaration landed and only its fsync failed.** `log_then_apply`
8150 /// appends, then syncs; a failed barrier leaves `MULTIPLICITY_ENABLED`
8151 /// already in `wal.bin`. The next open replays it and the store is opted
8152 /// in. No archive is involved — this one predates the recovery below.
8153 /// * **The declaration never landed, but the V10 snapshot did, on a store
8154 /// that already had an archive.** The open-time recovery in
8155 /// `load_from_disk` reads V10-beside-an-archive as an interrupted archive
8156 /// sequence and opts the store in.
8157 ///
8158 /// So a failed call may leave the opt-in on disk immediately (the first
8159 /// case) or conjure it at the next open (the second), and nothing puts the
8160 /// store back out. Treat `Err` as "the outcome is unknown", not as "nothing
8161 /// happened".
8162 ///
8163 /// **This is safe, and the ordering is the reason.** The V10 stamp is
8164 /// written *before* the declaration, so every one of these intermediate
8165 /// states is one an older binary refuses by name rather than truncates at.
8166 /// The failure direction costs a refusal, never a commit. That ordering is
8167 /// the property worth protecting, not the atomicity this call never had.
8168 ///
8169 /// **To know where the store stands, ask the store.** Reopen it and call
8170 /// [`is_multiplicity_enabled`](Self::is_multiplicity_enabled); that is the
8171 /// only answer that accounts for what reached disk.
8172 ///
8173 /// The one case that really does leave the store opted out is a failure with
8174 /// no archive present and no record written: a stray V10 snapshot remains,
8175 /// costing an older reader a refusal it did not strictly need, and *that*
8176 /// store's next snapshot rewrites at V9.
8177 ///
8178 /// # Errors
8179 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8180 /// - Anything [`snapshot_with`](Self::snapshot_with) can return, and
8181 /// anything the WAL append or its fsync can return. See the atomicity
8182 /// section above for what the store is left holding.
8183 pub fn enable_multiplicity(&mut self) -> Result<()> {
8184 if self.read_only {
8185 return Err(GraphError::ReadOnly);
8186 }
8187 if self.multiplicity {
8188 return Ok(());
8189 }
8190 // The snapshot goes first, and the order is the guard.
8191 //
8192 // An older reader refuses a V10 snapshot by name and stops; it does not
8193 // refuse discriminant 23, it truncates the WAL at it. So the store must
8194 // never hold the record without the snapshot that announces it — not
8195 // even for the width of one fsync. Writing the snapshot before the
8196 // record makes the only reachable intermediate state "V10 snapshot, no
8197 // record", which is merely conservative: this binary reads it as a
8198 // store that has not opted in, and an older one refuses it.
8199 //
8200 // `keep_wal: true` because opting in is not a compaction: an operator
8201 // asking for multiplicity has not asked to lose the history `open_at`
8202 // can reach.
8203 self.multiplicity = true;
8204 let forced = self
8205 .snapshot_with(SnapshotOptions {
8206 keep_wal: true,
8207 ..SnapshotOptions::default()
8208 })
8209 .and_then(|()| self.log_then_apply(core_storage::wal::MULTIPLICITY_ENABLED));
8210 if forced.is_err() {
8211 // This handle stops believing it is opted in. That is all this line
8212 // does — it is not a rollback, and cannot be one: the declaration
8213 // may already be in `wal.bin` (the append succeeded and only the
8214 // fsync failed), and even when it is not, the V10 snapshot beside an
8215 // existing archive is enough for the open-time recovery to opt the
8216 // store in. See the "not atomic" section on this method.
8217 //
8218 // It fails in the safe direction either way: the V10 stamp reached
8219 // disk before anything a v0.6.9 reader would truncate at, so the
8220 // worst an interruption costs that reader is a refusal by name.
8221 self.multiplicity = false;
8222 }
8223 forced
8224 }
8225
8226 /// Whether this store records insert-count multiplicity.
8227 ///
8228 /// `false` on every store that has not called
8229 /// [`enable_multiplicity`](Self::enable_multiplicity) — which is every
8230 /// store that did not ask for it, including one upgraded from an earlier
8231 /// release.
8232 pub fn is_multiplicity_enabled(&self) -> bool {
8233 self.multiplicity
8234 }
8235
8236 /// How many times `(etype, src, dst)` has been inserted: the reserved
8237 /// `count` edge property, or 1 when it is absent.
8238 ///
8239 /// Answers 1 for a pair on a store that never opted in, which is the truth
8240 /// available there — the pair was inserted at least once, and the store
8241 /// kept no record of any second insert.
8242 fn edge_insert_count(&self, etype: u32, src: u32, dst: u32) -> u64 {
8243 match self.edge_props_view().get(etype, src, dst, EDGE_COUNT_PROP) {
8244 Some(Value::Int(n)) if n > 0 => n as u64,
8245 _ => 1,
8246 }
8247 }
8248
8249 /// The `SetEdgeCount` record a duplicate insert of `(edge_type, src_key,
8250 /// dst_key)` should log, or `None` when nothing should be written.
8251 ///
8252 /// `None` when the store has not opted in, so **no discriminant-23 record
8253 /// is written at all** — the gate the whole feature rests on.
8254 ///
8255 /// The other two `None`s are unreachable from the one caller. This is the
8256 /// single-mutation path, where `prepare_insert_edge` has already refused a
8257 /// missing endpoint and an existing pair's edge type is necessarily
8258 /// interned. A batch is the case where a pair's endpoints and type can all
8259 /// be created by the same frame, and it does not come through here: it
8260 /// queues a [`PlannedRec::DuplicateCount`] and names the count in the dense
8261 /// rewrite, which is the only pass that knows the frame's own ids.
8262 fn edge_count_record(
8263 &self,
8264 edge_type: &str,
8265 src_key: &str,
8266 dst_key: &str,
8267 ) -> Option<WalRecord> {
8268 if !self.multiplicity {
8269 return None;
8270 }
8271 let etype = self.syms.get(edge_type)?;
8272 let src = self.ids.get(src_key)?;
8273 let dst = self.ids.get(dst_key)?;
8274 Some(WalRecord::SetEdgeCount {
8275 etype,
8276 src,
8277 dst,
8278 count: self.edge_insert_count(etype, src, dst).saturating_add(1),
8279 })
8280 }
8281
8282 /// Search a full-text-indexed field.
8283 ///
8284 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
8285 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
8286 ///
8287 /// **Query syntax:**
8288 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
8289 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
8290 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
8291 /// - `AND` keyword is accepted explicitly and is the default.
8292 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
8293 ///
8294 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
8295 /// Pin: this is the documented, tested, stable behavior for v1.
8296 ///
8297 /// **Memory / performance:** O(postings) lookup; no scan. The index is
8298 /// in-memory and proportional to total indexed text across all enabled fields.
8299 ///
8300 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
8301 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
8302 /// key ascending for deterministic tiebreaking.
8303 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8304 // Resolve node_ids to keys (excluding tombstones) then re-sort by
8305 // (score DESC, key ASC) to give a deterministic, key-lexicographic
8306 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
8307 // which diverges from key order when nodes were not inserted in key-lex order.
8308 let mut results: Vec<(String, f64)> = self
8309 .fulltext
8310 .search(field, query, 0)
8311 .into_iter()
8312 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8313 .collect();
8314 results.sort_by(|a, b| {
8315 b.1.partial_cmp(&a.1)
8316 .unwrap_or(std::cmp::Ordering::Equal)
8317 .then(a.0.cmp(&b.0))
8318 });
8319 results
8320 }
8321
8322 /// [`search`](Self::search), stopping at the `k` best hits.
8323 ///
8324 /// Same ranking and the same deterministic tiebreak, but the index drops
8325 /// everything past `k` before any key is resolved, so a caller that wants
8326 /// the top few out of a field that matched thousands does not pay to
8327 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
8328 /// [`search`](Self::search) behaves.
8329 ///
8330 /// The BM25 scoring itself is not bounded by `k` — every candidate is
8331 /// scored either way — so this trims the resolve and the sort, not the
8332 /// search.
8333 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
8334 // A tombstoned id resolves to nothing, so asking the index for exactly
8335 // `k` could return fewer. Over-fetching a little and truncating after
8336 // the filter keeps the count right without unbounding the call.
8337 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
8338 let mut results: Vec<(String, f64)> = self
8339 .fulltext
8340 .search(field, query, want)
8341 .into_iter()
8342 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8343 .collect();
8344 results.sort_by(|a, b| {
8345 b.1.partial_cmp(&a.1)
8346 .unwrap_or(std::cmp::Ordering::Equal)
8347 .then(a.0.cmp(&b.0))
8348 });
8349 if k > 0 {
8350 results.truncate(k);
8351 }
8352 results
8353 }
8354
8355 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
8356 ///
8357 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
8358 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
8359 /// them with RRF using a fixed constant of 60.
8360 ///
8361 /// ```text
8362 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
8363 /// ```
8364 ///
8365 /// Returns the top `k` nodes by fused score, ties broken by node key
8366 /// ascending (deterministic).
8367 ///
8368 /// # Vector leg fallback
8369 ///
8370 /// When `query_vec` is empty the vector leg is skipped entirely and
8371 /// results are ranked by the text list alone through the same RRF path
8372 /// (each text result scores `1/(60 + rank)` from that single list).
8373 ///
8374 /// When `label` is `None`, the vector leg **always** returns empty results.
8375 /// Internally `label` is mapped to `""`, which does not match any rule-created
8376 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
8377 /// the brute-force fallback finds no nodes with an empty label. The fused
8378 /// ranking is therefore text-only in this case.
8379 pub fn search_hybrid(
8380 &self,
8381 text_field: &str,
8382 query_text: &str,
8383 vector_field: &str,
8384 query_vec: &[f64],
8385 label: Option<&str>,
8386 k: usize,
8387 ) -> Vec<(String, f64)> {
8388 self.search_hybrid_inner(
8389 text_field,
8390 query_text,
8391 vector_field,
8392 query_vec,
8393 label,
8394 k,
8395 None,
8396 )
8397 }
8398
8399 /// [`search_hybrid`](Self::search_hybrid) with **each leg** filtered to the
8400 /// mask before the fusion.
8401 ///
8402 /// Filtering the fused list afterwards would quietly return fewer than `k`.
8403 /// Each leg over-fetches `4*k` candidates, so when the visible nodes rank
8404 /// below `4*k` hidden ones neither leg carries them into the fusion at all
8405 /// and the post-filter has nothing left to keep. Filtering first spends the
8406 /// `4*k` on **visible** hits, so a scoped call is as long as the corpus it
8407 /// can see allows.
8408 ///
8409 /// The ranks that enter RRF are therefore the ranks of the visible corpus,
8410 /// not the visible entries of the store-wide ranking. The constant stays 60
8411 /// and the tiebreak stays key-ascending.
8412 #[allow(clippy::too_many_arguments)]
8413 pub fn search_hybrid_scoped(
8414 &self,
8415 text_field: &str,
8416 query_text: &str,
8417 vector_field: &str,
8418 query_vec: &[f64],
8419 label: Option<&str>,
8420 k: usize,
8421 mask: &crate::mask::NodeMask,
8422 ) -> Vec<(String, f64)> {
8423 self.search_hybrid_inner(
8424 text_field,
8425 query_text,
8426 vector_field,
8427 query_vec,
8428 label,
8429 k,
8430 Some(mask),
8431 )
8432 }
8433
8434 /// The body shared by [`search_hybrid`](Self::search_hybrid) and
8435 /// [`search_hybrid_scoped`](Self::search_hybrid_scoped). `mask = None` is
8436 /// the unscoped contract unchanged: the filter below is then a no-op and
8437 /// the vector leg is the same unmasked call it has always been.
8438 #[allow(clippy::too_many_arguments)]
8439 fn search_hybrid_inner(
8440 &self,
8441 text_field: &str,
8442 query_text: &str,
8443 vector_field: &str,
8444 query_vec: &[f64],
8445 label: Option<&str>,
8446 k: usize,
8447 mask: Option<&crate::mask::NodeMask>,
8448 ) -> Vec<(String, f64)> {
8449 use std::collections::HashMap;
8450
8451 const RRF_K: f64 = 60.0;
8452 let pool = 4 * k;
8453
8454 // Accumulate per-node RRF scores.
8455 let mut scores: HashMap<String, f64> = HashMap::new();
8456
8457 // Text leg. The mask bites on the candidates, before `take(pool)`, so
8458 // the over-fetch is a budget of visible hits rather than one a hidden
8459 // prefix can exhaust.
8460 let text_hits = self.search(text_field, query_text);
8461 let visible_text = text_hits
8462 .into_iter()
8463 .filter(|(key, _count)| mask.is_none_or(|m| m.contains_node(self, key)));
8464 for (rank0, (key, _count)) in visible_text.take(pool).enumerate() {
8465 let rank = (rank0 + 1) as f64;
8466 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8467 }
8468
8469 // Vector leg (skipped when query_vec is empty). The masked variant
8470 // applies the mask before its own k-truncation, for the same reason.
8471 if !query_vec.is_empty() {
8472 // `ExactnessCaller::Hybrid`: the leg is the same one
8473 // `find_similar_vector_masked` runs, but the advice its warning
8474 // gives has to fit *this* signature, which has no `exact`.
8475 let vec_hits = self
8476 .find_similar_vector_as(
8477 vector_field,
8478 label,
8479 query_vec,
8480 pool,
8481 0.0,
8482 mask,
8483 None,
8484 false,
8485 ExactnessCaller::Hybrid,
8486 )
8487 .expect("find_similar_vector_as is infallible without where_");
8488 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
8489 let rank = (rank0 + 1) as f64;
8490 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8491 }
8492 }
8493
8494 // Sort: score DESC, then key ASC for deterministic tie-breaking.
8495 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
8496 ranked.sort_by(|a, b| {
8497 b.1.partial_cmp(&a.1)
8498 .unwrap_or(std::cmp::Ordering::Equal)
8499 .then(a.0.cmp(&b.0))
8500 });
8501 ranked.truncate(k);
8502 ranked
8503 }
8504
8505 /// For DST/testing: scratch BM25 search over live nodes without the index.
8506 /// Walks every live node, re-stems field tokens, computes corpus stats, and
8507 /// returns BM25-ranked results.
8508 ///
8509 /// The oracle: the ordered key list of `search(field, q)` must equal that of
8510 /// `scratch_search(field, q)` at every quiescent state.
8511 #[doc(hidden)]
8512 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8513 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
8514 use std::collections::BTreeMap;
8515
8516 let groups = parse_query(query);
8517 if groups.is_empty() {
8518 return vec![];
8519 }
8520
8521 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
8522 struct NodeData {
8523 key: String,
8524 /// stemmed_token → positions (sorted)
8525 tokens: BTreeMap<String, Vec<u32>>,
8526 dl: u32,
8527 }
8528
8529 let mut nodes: Vec<NodeData> = Vec::new();
8530 for id in 0..self.ids.len() as u32 {
8531 let Some(key) = self.ids.key_of(id) else {
8532 continue;
8533 };
8534 let Some(&sym) = self.labels.get(id as usize) else {
8535 continue;
8536 };
8537 if sym == u32::MAX {
8538 continue;
8539 }
8540 let label = match self.syms.resolve(sym) {
8541 Some(l) => l,
8542 None => continue,
8543 };
8544 if !self.fulltext.is_enabled(label, field) {
8545 continue;
8546 }
8547 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
8548 continue;
8549 };
8550 // Use value_tokens_stemmed_with_positions so list elements are
8551 // separated by POSITION_GAP — identical to the index path, which
8552 // prevents phrase queries from matching across element boundaries.
8553 let stemmed_with_pos = match &value {
8554 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
8555 _ => continue,
8556 };
8557 let dl = stemmed_with_pos.len() as u32;
8558 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
8559 for (tok, pos) in stemmed_with_pos {
8560 tok_map.entry(tok).or_default().push(pos);
8561 }
8562 nodes.push(NodeData {
8563 key: key.to_string(),
8564 tokens: tok_map,
8565 dl,
8566 });
8567 }
8568
8569 if nodes.is_empty() {
8570 return vec![];
8571 }
8572
8573 // --- BM25 corpus stats ---
8574 let n = nodes.len() as f64;
8575 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
8576 // df per stemmed token across all live indexed nodes.
8577 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
8578 for nd in &nodes {
8579 for tok in nd.tokens.keys() {
8580 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
8581 }
8582 }
8583
8584 const K1: f64 = 1.2;
8585 const B: f64 = 0.75;
8586
8587 // --- Pass 2: score each node against each OR-group ---
8588 let mut results: Vec<(String, f64)> = Vec::new();
8589 for nd in &nodes {
8590 let dl = nd.dl as f64;
8591 let mut total_score = 0.0f64;
8592
8593 'group: for group in &groups {
8594 let mut group_score = 0.0f64;
8595
8596 for term in group {
8597 if term.negated {
8598 // Negated: if doc has this stemmed token → group fails.
8599 let present = if term.prefix {
8600 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
8601 } else {
8602 nd.tokens.contains_key(term.token.as_str())
8603 };
8604 if present {
8605 continue 'group;
8606 }
8607 continue;
8608 }
8609 if term.prefix {
8610 // Prefix: sum BM25 for all matching stemmed tokens.
8611 let mut prefix_matched = false;
8612 for (tok, positions) in &nd.tokens {
8613 if tok.starts_with(term.token.as_str()) {
8614 let tf = positions.len() as f64;
8615 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
8616 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8617 let tf_norm =
8618 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8619 group_score += idf * tf_norm;
8620 prefix_matched = true;
8621 }
8622 }
8623 if !prefix_matched {
8624 continue 'group;
8625 }
8626 } else {
8627 // term.token is already stemmed by parse_query; use directly.
8628 match nd.tokens.get(term.token.as_str()) {
8629 None => continue 'group,
8630 Some(positions) => {
8631 let tf = positions.len() as f64;
8632 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
8633 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8634 let tf_norm =
8635 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8636 group_score += idf * tf_norm;
8637 }
8638 }
8639 }
8640 }
8641
8642 if group_score > 0.0 {
8643 total_score += group_score;
8644 }
8645 }
8646
8647 if total_score > 0.0 {
8648 results.push((nd.key.clone(), total_score));
8649 }
8650 }
8651
8652 results.sort_by(|a, b| {
8653 b.1.partial_cmp(&a.1)
8654 .unwrap_or(std::cmp::Ordering::Equal)
8655 .then(a.0.cmp(&b.0))
8656 });
8657 results
8658 }
8659
8660 /// Return the current view-maintained value of `view_prop` for node `key`.
8661 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
8662 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
8663 let id = self.ids.get(key)?;
8664 self.props_view()
8665 .get(id, view_prop)
8666 .map(|vr| vr.into_value())
8667 }
8668
8669 /// For testing / DST oracle: scratch recompute of a view value for one node.
8670 ///
8671 /// Returns `None` if the node does not exist, the view does not exist, or
8672 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
8673 #[doc(hidden)]
8674 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
8675 let node = self.ids.get(key)?;
8676 let def = self.view_store.views().find(|v| v.name == view_name)?;
8677 // Use TopologyView so that NeighborAgg sees base + overlay edges
8678 // without materialising a temporary Topology (I1).
8679 let topo_view = self.topo_view();
8680 core_rules::views::compute_view_value(
8681 def,
8682 node,
8683 self.props_view(),
8684 &topo_view,
8685 &self.ids,
8686 &self.syms,
8687 &self.labels,
8688 )
8689 }
8690
8691 // -----------------------------------------------------------------------
8692 // Graph algorithm API
8693 // -----------------------------------------------------------------------
8694
8695 /// Run PageRank over the unified topology (manual + derived edges).
8696 ///
8697 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
8698 /// ascending). Set `config.edge_type` to restrict to one edge type.
8699 /// `config.converged` is `true` only when the power iteration converged
8700 /// within `config.max_iters` and within any time budget.
8701 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
8702 let topo = build_topo_view(&self.topo, &self.base);
8703 let edge_props = self.edge_props_view();
8704 crate::algo::pagerank(
8705 &topo,
8706 &self.ids,
8707 &self.syms,
8708 &self.labels,
8709 &edge_props,
8710 config,
8711 )
8712 }
8713
8714 /// Weakly-connected components over the unified topology (treated as
8715 /// undirected regardless of how edges were inserted).
8716 ///
8717 /// Component IDs are the key of the smallest member in the component
8718 /// (deterministic). Result sorted by (component_id, key).
8719 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
8720 let topo = build_topo_view(&self.topo, &self.base);
8721 let edge_props = self.edge_props_view();
8722 crate::algo::wcc(
8723 &topo,
8724 &self.ids,
8725 &self.syms,
8726 &self.labels,
8727 &edge_props,
8728 config,
8729 )
8730 }
8731
8732 /// Degree centrality for every live node.
8733 ///
8734 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
8735 /// `AlgoDir::Both` = out + in (total directed degree).
8736 ///
8737 /// For one-shot ranking use this; for a live property updated on every
8738 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
8739 pub fn degree_centrality(
8740 &self,
8741 config: &crate::algo::DegreeConfig,
8742 ) -> crate::algo::DegreeReport {
8743 let topo = build_topo_view(&self.topo, &self.base);
8744 let edge_props = self.edge_props_view();
8745 crate::algo::degree_centrality(
8746 &topo,
8747 &self.ids,
8748 &self.syms,
8749 &self.labels,
8750 &edge_props,
8751 config,
8752 )
8753 }
8754
8755 /// Louvain community detection over the unified topology (undirected).
8756 ///
8757 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
8758 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
8759 /// result (communities sorted size-desc, then smallest member key asc).
8760 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
8761 let topo = build_topo_view(&self.topo, &self.base);
8762 let edge_props = self.edge_props_view();
8763 crate::algo::louvain(
8764 &topo,
8765 &self.ids,
8766 &self.syms,
8767 &self.labels,
8768 &edge_props,
8769 config,
8770 )
8771 }
8772
8773 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
8774 /// atomically via a single write-batch (one WAL frame, one fsync).
8775 ///
8776 /// # Errors
8777 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8778 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
8779 /// (collision check mirrors `create_view`).
8780 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
8781 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
8782 if self.read_only {
8783 return Err(GraphError::ReadOnly);
8784 }
8785 // Collision check: refuse if prop_name is view-managed.
8786 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
8787 return Err(GraphError::RuleInvalid {
8788 detail: format!(
8789 "prop {:?} is managed by view {:?} and cannot be written as scores",
8790 prop_name, view_name
8791 ),
8792 });
8793 }
8794 // Refuse if prop_name is a view name itself (confusing namespace collision).
8795 if self.view_store.has_view(prop_name) {
8796 return Err(GraphError::RuleInvalid {
8797 detail: format!(
8798 "prop_name {:?} collides with an existing view name",
8799 prop_name
8800 ),
8801 });
8802 }
8803 // Write all scores in a single crash-atomic batch.
8804 self.write_batch(|b| {
8805 for (key, score) in scores {
8806 b.set_prop(key, prop_name, Value::Float(*score));
8807 }
8808 })?;
8809 Ok(())
8810 }
8811
8812 /// Return the value of `field` for the node with key `key`, or `None` if
8813 /// the node or field is absent. Reads through the overlay-over-base
8814 /// `ColumnsView`, materialising base values on demand (zero heap cost for
8815 /// overlay hits; one clone per base hit).
8816 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
8817 let id = self.ids.get(key)?;
8818 self.props_view().get(id, field).map(|vr| vr.into_value())
8819 }
8820
8821 pub fn has_node(&self, key: &str) -> bool {
8822 self.ids.get(key).is_some()
8823 }
8824
8825 /// One human line for a node: the first of `text`, `summary` or `name` it
8826 /// carries, truncated. Used by the recall digest.
8827 pub fn node_summary_line(&self, key: &str) -> Option<String> {
8828 for field in ["text", "summary", "name"] {
8829 if let Some(Value::Str(s)) = self.get_prop(key, field) {
8830 let s = s.trim();
8831 if !s.is_empty() {
8832 return Some(s.chars().take(120).collect());
8833 }
8834 }
8835 }
8836 None
8837 }
8838
8839 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
8840 pub(crate) fn ids(&self) -> &IdMap {
8841 &self.ids
8842 }
8843
8844 // -----------------------------------------------------------------------
8845 // Namespaces
8846 // -----------------------------------------------------------------------
8847
8848 /// The index `name` already has in `ns_names`, if any.
8849 fn ns_index_of(&self, name: &str) -> Option<u32> {
8850 self.ns_names
8851 .iter()
8852 .position(|n| n == name)
8853 .map(|i| i as u32)
8854 }
8855
8856 /// The index for `name`, appending it to `ns_names` when it is new.
8857 ///
8858 /// The table holds one entry per distinct namespace in the store — a
8859 /// tenant count, not a node count — so the linear scan is cheaper than a
8860 /// map and keeps `namespaces()` allocation-free of a second index.
8861 fn ns_index_for(&mut self, name: &str) -> u32 {
8862 match self.ns_index_of(name) {
8863 Some(i) => i,
8864 None => {
8865 self.ns_names.push(name.to_string());
8866 (self.ns_names.len() - 1) as u32
8867 }
8868 }
8869 }
8870
8871 /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
8872 /// does not know (unreachable; the default is the narrowing answer).
8873 fn ns_name(&self, idx: u32) -> &str {
8874 self.ns_names
8875 .get(idx as usize)
8876 .map(String::as_str)
8877 .unwrap_or(NS_DEFAULT)
8878 }
8879
8880 /// The namespace index of dense node `id`, defaulting for an id with no
8881 /// entry (a node inserted before this handle rebuilt the array cannot
8882 /// exist: every insert path maintains it).
8883 fn node_ns_idx(&self, id: u32) -> u32 {
8884 self.node_ns
8885 .get(id as usize)
8886 .copied()
8887 .unwrap_or(NS_DEFAULT_IDX)
8888 }
8889
8890 /// File node `id` under namespace `name`, growing `node_ns` as `labels`
8891 /// grows. Called from `apply` for every node insert, live and replayed.
8892 fn set_node_ns(&mut self, id: u32, name: &str) {
8893 let idx = if name == NS_DEFAULT {
8894 NS_DEFAULT_IDX
8895 } else {
8896 self.ns_index_for(name)
8897 };
8898 if self.node_ns.len() <= id as usize {
8899 self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
8900 }
8901 self.node_ns[id as usize] = idx;
8902 }
8903
8904 /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
8905 /// open or a reload, after the snapshot is restored and the WAL replayed.
8906 ///
8907 /// A store with no `ns` column reads nothing: the column-name check fails
8908 /// and the vector is filled with one constant.
8909 fn rebuild_node_ns(&mut self) {
8910 let total = self.ids.len();
8911 self.ns_names.truncate(1);
8912 self.node_ns.clear();
8913 self.node_ns.resize(total, NS_DEFAULT_IDX);
8914 let has_ns_column = {
8915 let cv = self.props_view();
8916 cv.field_names().iter().any(|f| f == NS_PROP)
8917 };
8918 if !has_ns_column {
8919 return;
8920 }
8921 // Collected first so the props view is released before `ns_index_for`
8922 // takes `&mut self`.
8923 let named: Vec<(u32, String)> = {
8924 let cv = self.props_view();
8925 (0..total as u32)
8926 .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
8927 Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
8928 _ => None,
8929 })
8930 .collect()
8931 };
8932 for (id, name) in named {
8933 let idx = self.ns_index_for(&name);
8934 self.node_ns[id as usize] = idx;
8935 }
8936 }
8937
8938 /// Every namespace with at least one live node, in name order.
8939 ///
8940 /// `["default"]` on any store that has never named a namespace, including
8941 /// an empty one: a store is always at least its default namespace.
8942 pub fn namespaces(&self) -> Vec<String> {
8943 let mut out: BTreeSet<&str> = BTreeSet::new();
8944 out.insert(NS_DEFAULT);
8945 for (id, &idx) in self.node_ns.iter().enumerate() {
8946 if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
8947 continue;
8948 }
8949 out.insert(self.ns_name(idx));
8950 }
8951 out.into_iter().map(str::to_string).collect()
8952 }
8953
8954 /// The namespace of `key`, or `None` when the key names no live node.
8955 pub fn namespace_of(&self, key: &str) -> Option<String> {
8956 let id = self.ids.get(key)?;
8957 if !self.is_live_node(id) {
8958 return None;
8959 }
8960 Some(self.ns_name(self.node_ns_idx(id)).to_string())
8961 }
8962
8963 /// Every live node in `namespace`, as a visibility mask.
8964 ///
8965 /// Built off `node_ns` on whichever handle this is, so on a temporal handle
8966 /// it is the namespace's membership at that commit. A name no node uses
8967 /// gives an empty mask — a namespace scope never widens.
8968 pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
8969 let Some(idx) = self.ns_index_of(namespace) else {
8970 return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
8971 };
8972 let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
8973 .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
8974 .collect();
8975 crate::mask::NodeMask::from_ids(visible)
8976 }
8977
8978 /// Live-node test used by the namespace accessors: a deleted node keeps its
8979 /// dense id and its `node_ns` slot, and the label sentinel is what marks it
8980 /// gone — the same test `mask_for_role`'s label leg applies implicitly.
8981 fn is_live_node(&self, id: u32) -> bool {
8982 self.labels
8983 .get(id as usize)
8984 .is_some_and(|&sym| sym != u32::MAX)
8985 && self.ids.key_of(id).is_some()
8986 }
8987
8988 /// Per-namespace live node counts for [`Stats`], in name order.
8989 fn namespace_stats(&self) -> Vec<NamespaceStats> {
8990 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
8991 counts.insert(NS_DEFAULT, 0);
8992 for id in 0..self.ids.len() as u32 {
8993 if !self.is_live_node(id) {
8994 continue;
8995 }
8996 *counts
8997 .entry(self.ns_name(self.node_ns_idx(id)))
8998 .or_insert(0) += 1;
8999 }
9000 counts
9001 .into_iter()
9002 .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
9003 .map(|(name, nodes_live)| NamespaceStats {
9004 name: name.to_string(),
9005 nodes_live,
9006 })
9007 .collect()
9008 }
9009
9010 /// The namespace a create-class op would put its node in: the `ns` entry of
9011 /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
9012 fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
9013 Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
9014 }
9015
9016 /// The one `ns` entry in a node's props, or `None` when it carries none.
9017 ///
9018 /// A props list naming `ns` twice is refused. Without that refusal the
9019 /// write path and the authorisation path can read the same list
9020 /// differently — one taking the first entry, the other the last — and
9021 /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
9022 /// the role was checked against the other of. One entry is the only shape
9023 /// where "the node's namespace" is a single fact, so it is the only shape
9024 /// accepted, and every reader of it agrees by construction.
9025 fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
9026 let mut found: Option<&'a Value> = None;
9027 for (field, value) in props {
9028 if field != NS_PROP {
9029 continue;
9030 }
9031 if found.is_some() {
9032 return Err(GraphError::RuleInvalid {
9033 detail: format!(
9034 "node {key}: {NS_PROP} is given more than once; a node has exactly \
9035 one namespace"
9036 ),
9037 });
9038 }
9039 found = Some(value);
9040 }
9041 Ok(found)
9042 }
9043
9044 /// The definition of the role a write authorisation names.
9045 ///
9046 /// `None` when `roles.json` was corrupt at open or the role has since been
9047 /// removed — neither can reach a write, because the authorisation carries a
9048 /// mask `mask_for_role` already resolved for that name.
9049 fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
9050 self.roles.as_ref()?.iter().find(|r| r.name == role)
9051 }
9052
9053 /// Validate the `ns` entry of a node's props and drop an explicit default.
9054 ///
9055 /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
9056 /// a record that reached the WAL was already accepted here.
9057 fn normalise_insert_ns(
9058 key: &str,
9059 props: Vec<(String, Value)>,
9060 ) -> Result<(Vec<(String, Value)>, String)> {
9061 // One `ns` or none: this is where that is enforced, so every later
9062 // reader of the list — the authorisation gate, the two `apply` arms,
9063 // `node_ns` — is looking at a single entry and cannot disagree about
9064 // which one counts.
9065 Self::sole_ns_entry(key, &props)?;
9066 let mut name = NS_DEFAULT.to_string();
9067 let mut out = Vec::with_capacity(props.len());
9068 for (field, value) in props {
9069 if field != NS_PROP {
9070 out.push((field, value));
9071 continue;
9072 }
9073 let Value::Str(ref s) = value else {
9074 return Err(GraphError::RuleInvalid {
9075 detail: format!(
9076 "node {key}: {NS_PROP} must be a string naming a namespace, \
9077 got {value:?}"
9078 ),
9079 });
9080 };
9081 if !valid_namespace(s) {
9082 return Err(GraphError::RuleInvalid {
9083 detail: format!(
9084 "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
9085 characters of [A-Za-z0-9_.-]"
9086 ),
9087 });
9088 }
9089 name = s.clone();
9090 // An explicit default stores nothing, so a single-tenant store
9091 // never grows an `ns` column.
9092 if name != NS_DEFAULT {
9093 out.push((field, value));
9094 }
9095 }
9096 Ok((out, name))
9097 }
9098
9099 // -----------------------------------------------------------------------
9100 // RBAC role resolution
9101 // -----------------------------------------------------------------------
9102
9103 /// Parse `roles.json` bytes from `fs`.
9104 ///
9105 /// Return values:
9106 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
9107 /// and valid; in both cases `mask_for_role` uses the
9108 /// list normally (an absent file means no roles defined).
9109 /// `Ok(None)` — file present but corrupt or unrecognised version
9110 /// → poisoned state; `mask_for_role` returns `Err` for
9111 /// any role name until the file is fixed and the DB
9112 /// re-opened (or `apply_schema` is called to repair it).
9113 ///
9114 /// Note: `None` signals corruption, not absence — the opposite of what an
9115 /// optional "file missing" convention would suggest. The open path stores
9116 /// this result on `db.roles` directly.
9117 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
9118 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
9119 if bytes.is_empty() {
9120 // Empty bytes means either the file is absent or zero-byte — both
9121 // are treated identically as "no roles defined". A zero-byte
9122 // roles.json does NOT widen access: an absent file and a zero-byte
9123 // file both resolve to an empty role list (sees nothing by default).
9124 return Ok(Some(vec![]));
9125 }
9126 match serde_json::from_slice::<RolesFile>(&bytes) {
9127 Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
9128 // Corrupt or unrecognised version (>4): poison the roles state.
9129 // Never widen: a version this binary does not know may carry a
9130 // narrowing this binary would not apply.
9131 _ => Ok(None),
9132 }
9133 }
9134
9135 /// Resolve a role to a node-visibility mask against the current graph state.
9136 ///
9137 /// Returns `Err` when:
9138 /// - `roles.json` was present but corrupt at open (poisoned state), or
9139 /// - `role` does not match any defined role name.
9140 ///
9141 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
9142 /// all live nodes carrying any label in `labels` that also pass the role's
9143 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
9144 /// has one. Label resolution is live — new nodes of an allowed label are
9145 /// visible without re-applying the schema, and a property edited out of the
9146 /// predicate takes its node out of the mask on the next read. An empty
9147 /// union = empty mask = sees nothing.
9148 ///
9149 /// This is the one resolver every read path calls, live and as-of alike, so
9150 /// the predicate applies everywhere at once. On an as-of handle the role
9151 /// *definition* is the current one and the graph is the historical one: the
9152 /// predicate is evaluated against the property values at the commit being
9153 /// read.
9154 ///
9155 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
9156 /// between two writes resolves the role once. See
9157 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
9158 /// stale.
9159 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9160 self.role_masks
9161 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
9162 .map(|m| (*m).clone())
9163 }
9164
9165 /// The mask an [`AsOfScope`] names, resolved against this handle.
9166 ///
9167 /// Shared by [`GraphDb::query_at_scoped`] and
9168 /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
9169 /// however the namespace leg is added.
9170 fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
9171 // One resolver answers "what may this role see" — `mask_for_role` — and
9172 // it runs against this handle, so on a temporal one the answer is the
9173 // as-of one.
9174 Ok(match scope {
9175 AsOfScope::Role(role) => self.mask_for_role(role)?,
9176 AsOfScope::Keys(keys) => {
9177 crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
9178 }
9179 AsOfScope::RoleAndKeys(role, keys) => {
9180 self.mask_for_role(role)?
9181 .intersect(&crate::mask::NodeMask::from_keys(
9182 self,
9183 keys.iter().map(String::as_str),
9184 ))
9185 }
9186 AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
9187 })
9188 }
9189
9190 /// Resolve `role` against the current graph, ignoring the memo.
9191 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9192 let roles = self.roles.as_ref().ok_or_else(roles_poisoned)?;
9193 let def = roles
9194 .iter()
9195 .find(|r| r.name == role)
9196 .ok_or_else(|| GraphError::KeyNotFound {
9197 key: format!("role:{role}"),
9198 })?;
9199
9200 let mut visible = std::collections::HashSet::new();
9201
9202 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
9203 // An administrative grant, never narrowed by the predicate.
9204 for key in &def.keys {
9205 if let Some(id) = self.ids.get(key) {
9206 visible.insert(id);
9207 }
9208 }
9209
9210 // Label leg: live scan — iterate labels vec for matching symbol, and
9211 // when the role carries a predicate, test the property as well. The
9212 // property comes from the store's own merged view (overlay over the
9213 // mmap'd base), so an as-of handle reads the values of its own commit.
9214 let props = def.visible_where.as_ref().map(|_| self.props_view());
9215 for label_name in &def.labels {
9216 if let Some(sym) = self.syms.get(label_name) {
9217 for (i, &s) in self.labels.iter().enumerate() {
9218 if s != sym {
9219 continue;
9220 }
9221 let id = i as u32;
9222 match (&def.visible_where, &props) {
9223 (Some(pred), Some(view)) => {
9224 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
9225 if pred.holds(value.as_ref()) {
9226 visible.insert(id);
9227 }
9228 }
9229 _ => {
9230 visible.insert(id);
9231 }
9232 }
9233 }
9234 }
9235 }
9236
9237 // Namespace leg: an intersection over the whole union, the key leg
9238 // included. A namespace is a tenancy boundary, so a key naming a node in
9239 // another tenant's namespace is not an administrative grant — and
9240 // `apply_schema` has already refused that role, so this only has to be
9241 // right about the node that moved into existence afterwards.
9242 if def.namespaces.is_some() {
9243 visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
9244 }
9245
9246 Ok(crate::mask::NodeMask::from_ids(visible))
9247 }
9248
9249 /// Return the current list of role definitions.
9250 ///
9251 /// Returns an empty list when no roles are defined or when `roles.json`
9252 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
9253 /// the fail-loud error in that case, or call
9254 /// [`roles_checked`](Self::roles_checked), which is this readout with that
9255 /// error in it).
9256 pub fn roles(&self) -> Vec<RoleDef> {
9257 self.roles.as_deref().unwrap_or(&[]).to_vec()
9258 }
9259
9260 /// The role definitions, or the poison error when `roles.json` was corrupt
9261 /// at open.
9262 ///
9263 /// [`roles`](Self::roles) answers `[]` both for a store that defines no
9264 /// roles and for one whose sidecar did not parse, and a caller validating a
9265 /// role name at boot cannot tell those apart. The wrong reading of the pair
9266 /// is the dangerous one: a store with no roles at all is an unrestricted
9267 /// store, so a poisoned file would read as "nothing is restricted here".
9268 ///
9269 /// This is the same answer, for the same cause, that
9270 /// [`mask_for_role`](Self::mask_for_role) gives on the first read.
9271 pub fn roles_checked(&self) -> Result<Vec<RoleDef>> {
9272 match self.roles.as_deref() {
9273 Some(roles) => Ok(roles.to_vec()),
9274 None => Err(roles_poisoned()),
9275 }
9276 }
9277
9278 // ── Role-scoped write authz ───────────────────────────────────────────────
9279
9280 /// Execute `ops` with optional role-scoped write authorization.
9281 ///
9282 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
9283 /// (zero-cost bypass of all authz checks).
9284 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
9285 /// record is built. A denial returns an error with no WAL frame written
9286 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
9287 ///
9288 /// See the plan's "authz decision table" section for the full semantics.
9289 pub fn write_batch_authz(
9290 &mut self,
9291 authz: Option<&WriteAuthz>,
9292 ops: Vec<BatchOp>,
9293 ) -> Result<(usize, usize)> {
9294 // Thread authz as a direct parameter — never touches pending_write_authz.
9295 self.commit_logged_batch(ops, None, authz.cloned())
9296 .map(inserted_pair)
9297 }
9298
9299 /// Execute a Cypher write statement with role-scoped write authorization.
9300 ///
9301 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
9302 /// lifetime as execution, satisfying §5 lock discipline). The resolved
9303 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
9304 /// call so that all inner `batch.commit()` calls are authz-checked.
9305 ///
9306 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
9307 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
9308 /// timing-oracle item (hidden ≡ absent for unscoped roles).
9309 ///
9310 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
9311 /// "this endpoint is not permitted".
9312 pub fn query_write_authz(
9313 &mut self,
9314 role: &str,
9315 cypher: &str,
9316 params: &BTreeMap<String, Value>,
9317 ) -> Result<ResultSet> {
9318 // Resolve scope (fails fast if role has no write scope).
9319 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9320 let scope =
9321 {
9322 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9323 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9324 })?;
9325 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9326 GraphError::KeyNotFound {
9327 key: format!("role:{role}"),
9328 }
9329 })?;
9330 def.write
9331 .clone()
9332 .ok_or_else(|| GraphError::RoleWriteDenied {
9333 reason: "role-bound token: writes are not permitted".into(),
9334 })?
9335 };
9336 // Resolve mask inside the call (same guard, §5 coherence).
9337 let mask = self.mask_for_role(role)?;
9338 self.pending_write_authz = Some(WriteAuthz {
9339 role: role.into(),
9340 scope,
9341 mask,
9342 });
9343 // RAII guard: always clears pending_write_authz on scope exit, including
9344 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9345 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9346 impl Drop for ClearPendingAuthzOnDrop {
9347 fn drop(&mut self) {
9348 // SAFETY: pointer into the owning GraphDb; guard is dropped
9349 // within this function's frame before it returns.
9350 unsafe { *self.0 = None };
9351 }
9352 }
9353 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9354 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9355 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9356 detail: format!("lex: {e}"),
9357 })?;
9358 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9359 detail: format!("parse: {e}"),
9360 })?;
9361 self.exec_write_stmt(stmt, params)
9362 }
9363
9364 /// Execute `ops` with optional role-scoped write authorization, suppressing
9365 /// fsync (for use inside the group-commit drain thread, which performs one
9366 /// group fsync after releasing the write lock).
9367 ///
9368 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
9369 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
9370 /// contract established by [`commit_batch_nosync`].
9371 pub(crate) fn write_batch_authz_nosync(
9372 &mut self,
9373 authz: Option<&WriteAuthz>,
9374 ops: Vec<BatchOp>,
9375 ) -> Result<(usize, usize)> {
9376 let saved = self.fsync;
9377 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
9378 impl Drop for RestoreFsync {
9379 fn drop(&mut self) {
9380 // SAFETY: pointer into the owning GraphDb; guard is dropped
9381 // within the enclosing function's frame before it returns.
9382 unsafe { *self.0 = self.1 };
9383 }
9384 }
9385 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9386 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
9387 self.fsync = FsyncPolicy::Relaxed;
9388 self.commit_logged_batch(ops, None, authz.cloned())
9389 .map(inserted_pair)
9390 }
9391
9392 /// Execute a `/ingest` request with role-scoped write authorization.
9393 ///
9394 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
9395 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
9396 /// Sets `pending_write_authz` for the duration of the call so that the
9397 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
9398 /// and evaluates the decision table per-op before any WAL write.
9399 ///
9400 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
9401 /// denied by the decision table with the appropriate §4.3 scope reason;
9402 /// no special HTTP-layer check is needed.
9403 ///
9404 /// Roles with `write: None` return `RoleWriteDenied` with
9405 /// "writes are not permitted" (byte-identical to v1 blanket 403).
9406 pub fn ingest_with_edges_authz(
9407 &mut self,
9408 role: &str,
9409 label: &str,
9410 rows: Vec<std::collections::BTreeMap<String, Value>>,
9411 opts: &crate::ingest::IngestOptions,
9412 edges: &[(String, String, String)],
9413 ) -> Result<crate::ingest::IngestReport> {
9414 // Resolve scope (fails fast if role has no write scope).
9415 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9416 let scope =
9417 {
9418 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9419 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9420 })?;
9421 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9422 GraphError::KeyNotFound {
9423 key: format!("role:{role}"),
9424 }
9425 })?;
9426 def.write
9427 .clone()
9428 .ok_or_else(|| GraphError::RoleWriteDenied {
9429 reason: "role-bound token: writes are not permitted".into(),
9430 })?
9431 };
9432 let mask = self.mask_for_role(role)?;
9433 self.pending_write_authz = Some(WriteAuthz {
9434 role: role.into(),
9435 scope,
9436 mask,
9437 });
9438 // RAII guard: always clears pending_write_authz on scope exit, including
9439 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9440 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9441 impl Drop for ClearPendingAuthzOnDrop {
9442 fn drop(&mut self) {
9443 // SAFETY: pointer into the owning GraphDb; guard is dropped
9444 // within this function's frame before it returns.
9445 unsafe { *self.0 = None };
9446 }
9447 }
9448 // SAFETY: raw pointer into self; guard dropped before this fn returns.
9449 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9450 self.ingest_with_edges(label, rows, opts, edges)
9451 }
9452
9453 /// Evaluate the write-authz decision table for one `BatchOp`.
9454 ///
9455 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
9456 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
9457 /// the remaining ops are not evaluated and no WAL frame is written.
9458 ///
9459 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
9460 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
9461 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
9462 /// batch creates counts as visible if its label passed the create-class gate").
9463 fn check_single_op_authz(
9464 &self,
9465 authz: &WriteAuthz,
9466 op: &BatchOp,
9467 batch_created: &BTreeMap<String, String>,
9468 ) -> Result<()> {
9469 // Helper: 3-way node status under the authz mask.
9470 //
9471 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
9472 // as Visible with their recorded label — their create gate already passed
9473 // and they are not yet in self.ids (not committed). This fixes the
9474 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
9475 // the SetProp must not see the node as Absent.
9476 let node_status = |key: &str| -> NodeAuthzStatus {
9477 if let Some(label) = batch_created.get(key) {
9478 return NodeAuthzStatus::Visible(label.clone());
9479 }
9480 match self.ids.get(key) {
9481 None => NodeAuthzStatus::Absent,
9482 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
9483 Some(id) => {
9484 let label = self
9485 .labels
9486 .get(id as usize)
9487 .and_then(|&sym| {
9488 if sym == u32::MAX {
9489 None
9490 } else {
9491 self.syms.resolve(sym).map(str::to_string)
9492 }
9493 })
9494 .unwrap_or_default();
9495 NodeAuthzStatus::Visible(label)
9496 }
9497 }
9498 };
9499
9500 // Helper: is an InsertEdgeUpsert endpoint visible?
9501 // A same-batch placeholder counts as visible if its label passed
9502 // the create-class gate (spec "upsert placeholder-counts-as-visible").
9503 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
9504 // In store and visible?
9505 if let Some(id) = self.ids.get(ep_key) {
9506 return authz.mask.contains_id(id);
9507 }
9508 // Created by an earlier op in this batch?
9509 if let Some(created_label) = batch_created.get(ep_key) {
9510 return authz.scope.create_labels.contains(created_label);
9511 }
9512 // Will be created by THIS InsertEdgeUpsert: placeholder_label
9513 // must pass the create-class gate.
9514 authz
9515 .scope
9516 .create_labels
9517 .contains(&placeholder_label.to_string())
9518 };
9519
9520 match op {
9521 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
9522 // These ops are never routed to role-scoped paths by the HTTP layer,
9523 // but we 403 them here to close any future bypass route.
9524 //
9525 // InsertNodeOnConflict joins them: it is reachable only from the
9526 // embedded Python binding, which has no role token, and `Replace`
9527 // is a create and an update at once. Rather than split the decision
9528 // table for an op no role-scoped path constructs, refuse it — a
9529 // role-scoped caller writes through the ops that are already in the
9530 // table.
9531 BatchOp::RenameNode { .. }
9532 | BatchOp::CreateRule(_)
9533 | BatchOp::DeleteRule { .. }
9534 | BatchOp::InsertNodeOnConflict { .. } => {
9535 return Err(GraphError::RoleWriteDenied {
9536 reason: "role-bound token: this endpoint is not permitted".into(),
9537 });
9538 }
9539
9540 // ── CREATE-class: InsertNode ─────────────────────────────────────
9541 //
9542 // Decision table row 1 (scope-before-lookup): check label in
9543 // create_labels BEFORE any key lookup. This is the structural
9544 // closure of the §6.2 timing-oracle item — the denial fires even
9545 // when the store is EMPTY (see test_create_scope_denied_empty_store).
9546 BatchOp::InsertNode { label, key, props } => {
9547 if !authz.scope.create_labels.contains(label) {
9548 return Err(GraphError::RoleWriteDenied {
9549 reason: format!(
9550 "role-bound token: label '{}' not in write scope (create_labels)",
9551 label
9552 ),
9553 });
9554 }
9555 // A role bound to namespaces may only create inside them. The
9556 // never-widen rule is about what a write makes visible to *any*
9557 // party, not only to the writer: a node this role could never
9558 // read back is a write into somebody else's tenancy. Also a
9559 // scope check, so it runs before the key lookup — it discloses
9560 // nothing about the store. Covers Cypher `CREATE` and the node
9561 // `MERGE` creates, both of which arrive as this op.
9562 // Resolved before the role lookup so a props list naming `ns`
9563 // twice is refused for every role, scoped or not: it is the same
9564 // malformed write the seam refuses, and leaving it to the seam
9565 // would mean the gate had already read one of the two.
9566 let target = Self::created_namespace(key, props)?;
9567 if let Some(def) = self.role_def_for(&authz.role) {
9568 if !def.sees_namespace(target) {
9569 return Err(GraphError::RoleWriteDenied {
9570 reason: format!(
9571 "role-bound token: namespace '{target}' not in the role's \
9572 namespaces"
9573 ),
9574 });
9575 }
9576 }
9577 // Row 2/3: key lookup.
9578 match self.ids.get(key.as_str()) {
9579 Some(id) if authz.mask.contains_id(id) => {
9580 // Visible: DuplicateKey — let MutPreview handle this.
9581 }
9582 Some(_) => {
9583 // Hidden: indistinguishable from absent to the role.
9584 return Err(GraphError::RoleWriteDenied {
9585 reason: "role-bound token: target node not visible".into(),
9586 });
9587 }
9588 None => {
9589 // Absent: proceed (create).
9590 }
9591 }
9592 }
9593
9594 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
9595 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
9596 if batch_created.contains_key(key.as_str()) {
9597 // Batch-created node: create gate already passed this batch.
9598 // Updating it in the same batch is always allowed, regardless
9599 // of update_labels (ruling §3.5: "writer just created it").
9600 } else {
9601 let label = match node_status(key) {
9602 NodeAuthzStatus::Visible(lbl) => lbl,
9603 _ => {
9604 return Err(GraphError::RoleWriteDenied {
9605 reason: "role-bound token: target node not visible".into(),
9606 });
9607 }
9608 };
9609 if !authz.scope.update_labels.contains(&label) {
9610 return Err(GraphError::RoleWriteDenied {
9611 reason: format!(
9612 "role-bound token: label '{}' not in write scope (update_labels)",
9613 label
9614 ),
9615 });
9616 }
9617 }
9618 }
9619
9620 // ── DELETE-class: DeleteNode ─────────────────────────────────────
9621 BatchOp::DeleteNode { key } => {
9622 let label = match node_status(key) {
9623 NodeAuthzStatus::Visible(lbl) => lbl,
9624 _ => {
9625 return Err(GraphError::RoleWriteDenied {
9626 reason: "role-bound token: target node not visible".into(),
9627 });
9628 }
9629 };
9630 if !authz.scope.delete_labels.contains(&label) {
9631 return Err(GraphError::RoleWriteDenied {
9632 reason: format!(
9633 "role-bound token: label '{}' not in write scope (delete_labels)",
9634 label
9635 ),
9636 });
9637 }
9638 }
9639
9640 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
9641 //
9642 // Derived-edge rejection runs BEFORE the delete_edge_types scope
9643 // check (spec §3.5: "existing derived-edge rejection precedes
9644 // delete_edge_types check").
9645 BatchOp::DeleteEdge {
9646 edge_type,
9647 src_key,
9648 dst_key,
9649 } => {
9650 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
9651 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
9652 self.ids.get(src_key.as_str()),
9653 self.ids.get(dst_key.as_str()),
9654 self.syms.get(edge_type.as_str()),
9655 ) {
9656 if self.engine.is_owned(et_sym, src_id, dst_id) {
9657 return Err(GraphError::RuleOwned {
9658 detail: format!(
9659 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9660 delete or change the owning rule"
9661 ),
9662 });
9663 }
9664 // Also check would_derive via MutPreview (empty overlay, pre-batch).
9665 let preview = MutPreview::new(self);
9666 if preview.would_derive(edge_type, src_key, dst_key) {
9667 return Err(GraphError::RuleOwned {
9668 detail: format!(
9669 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9670 delete or change the owning rule, or a live rule would \
9671 re-derive it"
9672 ),
9673 });
9674 }
9675 }
9676 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
9677 if !authz.scope.delete_edge_types.contains(edge_type) {
9678 return Err(GraphError::RoleWriteDenied {
9679 reason: format!(
9680 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
9681 edge_type
9682 ),
9683 });
9684 }
9685 // Both endpoints must be visible.
9686 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9687 match self.ids.get(ep_key) {
9688 None => {
9689 return Err(GraphError::RoleWriteDenied {
9690 reason: "role-bound token: edge endpoint not visible".into(),
9691 });
9692 }
9693 Some(id) if !authz.mask.contains_id(id) => {
9694 return Err(GraphError::RoleWriteDenied {
9695 reason: "role-bound token: edge endpoint not visible".into(),
9696 });
9697 }
9698 _ => {}
9699 }
9700 }
9701 }
9702
9703 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
9704 //
9705 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
9706 BatchOp::InsertEdge {
9707 edge_type,
9708 src_key,
9709 dst_key,
9710 } => {
9711 if !authz.scope.create_edge_types.contains(edge_type) {
9712 return Err(GraphError::RoleWriteDenied {
9713 reason: format!(
9714 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9715 edge_type
9716 ),
9717 });
9718 }
9719 // Both endpoints must be visible. A node created by an earlier
9720 // InsertNode in the same batch (tracked in batch_created) counts
9721 // as visible if its label passed the create-class gate.
9722 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9723 if batch_created.contains_key(ep_key) {
9724 // Created earlier this batch — already scope-checked.
9725 continue;
9726 }
9727 match self.ids.get(ep_key) {
9728 None => {
9729 return Err(GraphError::RoleWriteDenied {
9730 reason: "role-bound token: edge endpoint not visible".into(),
9731 });
9732 }
9733 Some(id) if !authz.mask.contains_id(id) => {
9734 return Err(GraphError::RoleWriteDenied {
9735 reason: "role-bound token: edge endpoint not visible".into(),
9736 });
9737 }
9738 _ => {}
9739 }
9740 }
9741 }
9742
9743 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
9744 //
9745 // Scope check first; then endpoint visibility using same-batch
9746 // placeholder awareness (spec: "a placeholder endpoint the SAME
9747 // batch creates counts as visible if its label passed the
9748 // create-class gate").
9749 BatchOp::InsertEdgeUpsert {
9750 edge_type,
9751 src_key,
9752 dst_key,
9753 placeholder_label,
9754 } => {
9755 if !authz.scope.create_edge_types.contains(edge_type) {
9756 return Err(GraphError::RoleWriteDenied {
9757 reason: format!(
9758 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9759 edge_type
9760 ),
9761 });
9762 }
9763 // Check placeholder label against create_labels (create-class gate).
9764 // This ensures the auto-created endpoints are scope-allowed.
9765 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9766 if !upsert_ep_visible(ep_key, placeholder_label) {
9767 return Err(GraphError::RoleWriteDenied {
9768 reason: "role-bound token: edge endpoint not visible".into(),
9769 });
9770 }
9771 }
9772 // A placeholder is created with no props, so it lands in the
9773 // default namespace. A role that cannot read `default` must not
9774 // create one there, for the same reason it may not create a node
9775 // there outright.
9776 //
9777 // The refusal is byte-identical to the hidden-endpoint one above,
9778 // and deliberately so: this arm fires only for an endpoint that
9779 // does **not** exist, and the one above only for an endpoint that
9780 // does. Two different strings would make the pair an existence
9781 // oracle — ask for an upsert and read off whether the key is
9782 // taken. Hidden ≡ absent is the rule everywhere else in this
9783 // table and it holds here too.
9784 if let Some(def) = self.role_def_for(&authz.role) {
9785 if !def.sees_namespace(NS_DEFAULT) {
9786 for ep_key in [src_key.as_str(), dst_key.as_str()] {
9787 if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
9788 {
9789 return Err(GraphError::RoleWriteDenied {
9790 reason: "role-bound token: edge endpoint not visible".into(),
9791 });
9792 }
9793 }
9794 }
9795 }
9796 }
9797 }
9798 Ok(())
9799 }
9800
9801 /// Write `roles` to `roles.json` atomically and update the in-memory list.
9802 ///
9803 /// Called by `apply_schema` when roles change. Never called on unchanged
9804 /// re-apply — this preserves byte-identical idempotency.
9805 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
9806 let file = RolesFile::new_versioned(roles.clone());
9807 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
9808 detail: format!("roles serialization: {e}"),
9809 })?;
9810 self.fs
9811 .write_atomic(FileId::Roles, &bytes)
9812 .map_err(GraphError::Io)?;
9813 self.roles = Some(roles);
9814 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
9815 // and a memoised mask would still match its version. Install a fresh
9816 // cache instead of clearing the shared one: a reader snapshot frozen
9817 // against the old definitions keeps the old `Arc` to itself and can
9818 // never publish an answer this handle would read back.
9819 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
9820 // Refresh the MVCC frozen overlay so that reader() immediately sees the
9821 // updated role definitions without waiting for the next K-commit fold.
9822 self.fold_now();
9823 Ok(())
9824 }
9825
9826 fn view(&self) -> GraphView<'_> {
9827 GraphView {
9828 ids: &self.ids,
9829 syms: &self.syms,
9830 labels: &self.labels,
9831 props: self.props_view(),
9832 topo: self.topo_view(),
9833 edge_props: self.edge_props_view(),
9834 mask: None,
9835 prop_index: Some(&self.prop_index),
9836 }
9837 }
9838
9839 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
9840 GraphView {
9841 ids: &self.ids,
9842 syms: &self.syms,
9843 labels: &self.labels,
9844 props: self.props_view(),
9845 topo: self.topo_view(),
9846 edge_props: self.edge_props_view(),
9847 mask: Some(&mask.visible),
9848 prop_index: Some(&self.prop_index),
9849 }
9850 }
9851
9852 /// Execute a read-only Cypher query with a node visibility mask.
9853 ///
9854 /// Only nodes whose key is in `mask` are accessible: label scans, key
9855 /// lookups, and neighbor expansions all respect the mask. Edges where
9856 /// either endpoint is hidden are silently dropped.
9857 ///
9858 /// Returns `Err` with a "masked queries are read-only" message when
9859 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
9860 pub fn query_masked(
9861 &self,
9862 cypher: &str,
9863 params: &std::collections::BTreeMap<String, Value>,
9864 mask: &crate::mask::NodeMask,
9865 ) -> Result<ResultSet> {
9866 // Reject write statements up front.
9867 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9868 detail: format!("lex: {e}"),
9869 })?;
9870 if is_write_tokens(&tokens) {
9871 return Err(GraphError::MaskedReadOnly);
9872 }
9873 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9874 detail: format!("parse: {e}"),
9875 })?;
9876 // Each UNION part executes against the same masked view, so the mask
9877 // applies uniformly across the chain.
9878 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
9879 GraphError::QueryError {
9880 detail: format!("execute: {e}"),
9881 }
9882 })
9883 }
9884
9885 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
9886 let id = self.ids.get(key)?;
9887 Some(NodeRef { db: self, id })
9888 }
9889
9890 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
9891 ///
9892 /// Hidden nodes are never used as traversal intermediaries in either
9893 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
9894 /// only through a hidden node will not appear in results.
9895 ///
9896 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
9897 /// a visited visible node are appended to the result as stub rows
9898 /// (`label` column is `null`, same key+depth columns as visible rows).
9899 /// They are NOT added to the BFS frontier.
9900 ///
9901 /// Returns `None` when `key` does not exist (caller should 404).
9902 ///
9903 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
9904 /// stub rows are never produced on the role path.
9905 pub fn neighborhood_masked(
9906 &self,
9907 key: &str,
9908 depth: u32,
9909 edge_types: Option<&[&str]>,
9910 dir: Dir,
9911 mask: &crate::mask::NodeMask,
9912 ) -> Option<ResultSet> {
9913 let start_id = self.ids.get(key)?;
9914 let view = self.view_masked(mask);
9915 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9916 names
9917 .iter()
9918 .filter_map(|name| view.syms.get(name))
9919 .collect()
9920 });
9921 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
9922 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9923 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
9924 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
9925 visited.push((start_id, 0));
9926 for (nid, d) in &nb.nodes {
9927 let k = view.key_of(*nid);
9928 let label = view
9929 .label_of(*nid)
9930 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9931 rs.push_row(vec![
9932 Some(Value::Str(k.to_string())),
9933 Some(Value::Str(label.to_string())),
9934 Some(Value::Int(*d as i64)),
9935 ]);
9936 visited.push((*nid, *d));
9937 }
9938 // Stub mode: add hidden direct neighbours of each visited node as stubs.
9939 // Hidden nodes are edge-endpoints only — they are not added to the BFS
9940 // frontier, so the BFS never expands through them.
9941 if mask.mode() == crate::mask::MaskMode::Stub {
9942 let raw_view = self.view();
9943 let mut seen: std::collections::HashSet<u32> =
9944 visited.iter().map(|(id, _)| *id).collect();
9945 for (node_id, node_depth) in &visited {
9946 if *node_depth >= depth {
9947 continue;
9948 }
9949 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
9950 let nbr = if e.src == *node_id { e.dst } else { e.src };
9951 if !mask.contains_id(nbr) && seen.insert(nbr) {
9952 if let Some(k) = self.ids.key_of(nbr) {
9953 rs.push_row(vec![
9954 Some(Value::Str(k.to_string())),
9955 None,
9956 Some(Value::Int((*node_depth + 1) as i64)),
9957 ]);
9958 }
9959 }
9960 }
9961 }
9962 }
9963 Some(rs)
9964 }
9965
9966 /// [`neighborhood_masked`](Self::neighborhood_masked) with the **subject
9967 /// check** a scoped caller needs: a start key the mask hides answers exactly
9968 /// as an absent one does.
9969 ///
9970 /// `neighborhood_masked` expands from any existing key, hidden or not,
9971 /// because a full-token caller supplying a client mask already knows which
9972 /// keys exist. A scoped caller does not, so telling it apart a hidden key
9973 /// from an absent one would be an existence oracle.
9974 ///
9975 /// Expansion itself is unchanged: hidden nodes are neither returned nor used
9976 /// as traversal intermediaries, so a visible node reachable only through a
9977 /// hidden one stays out of the result.
9978 ///
9979 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
9980 pub fn neighborhood_scoped(
9981 &self,
9982 key: &str,
9983 depth: u32,
9984 edge_types: Option<&[&str]>,
9985 dir: Dir,
9986 mask: &crate::mask::NodeMask,
9987 ) -> Result<ResultSet> {
9988 if !mask.contains_node(self, key) {
9989 return Err(GraphError::KeyNotFound { key: key.into() });
9990 }
9991 self.neighborhood_masked(key, depth, edge_types, dir, mask)
9992 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
9993 }
9994
9995 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
9996 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
9997 let n = self.node_ref(key)?;
9998 Some(NodeInfo {
9999 key: n.key().to_string(),
10000 label: n.label().to_string(),
10001 props: n.props(),
10002 })
10003 }
10004
10005 /// Look up a node with mask awareness.
10006 ///
10007 /// | Key state | Omit mode | Stub mode |
10008 /// |-------------------|-----------------|------------------------|
10009 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
10010 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
10011 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
10012 ///
10013 /// **SECURITY**: only call from client-mask (full-token) paths.
10014 /// Role-token paths must use [`node_info`] after an explicit visibility check.
10015 pub fn node_info_masked(
10016 &self,
10017 key: &str,
10018 mask: &crate::mask::NodeMask,
10019 ) -> Option<MaskedNodeResult> {
10020 let id = self.ids.get(key)?;
10021 if mask.contains_id(id) {
10022 Some(MaskedNodeResult::Visible(self.node_info(key)?))
10023 } else {
10024 match mask.mode() {
10025 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
10026 crate::mask::MaskMode::Omit => None,
10027 }
10028 }
10029 }
10030
10031 /// Get edges for `key` with mask-aware hidden-endpoint handling.
10032 ///
10033 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
10034 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
10035 /// is `true` for each hidden endpoint.
10036 ///
10037 /// Unknown key → [`GraphError::KeyNotFound`].
10038 ///
10039 /// **SECURITY**: only call from client-mask (full-token) paths.
10040 pub fn node_edges_masked(
10041 &self,
10042 key: &str,
10043 mask: &crate::mask::NodeMask,
10044 ) -> Result<Vec<MaskedEdge>> {
10045 self.ensure_v8_base_sections_loaded();
10046 let id = self
10047 .ids
10048 .get(key)
10049 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10050 let derived: BTreeSet<(u32, u32, u32)> = self
10051 .engine
10052 .provenance_touching(id)
10053 .map(|(_rule, etype, src, dst)| (etype, src, dst))
10054 .collect();
10055 let mut edges = Vec::new();
10056 let tv = self.topo_view();
10057 for etype in tv.etypes() {
10058 // etype comes from the archived CSR (access_unchecked, no eager CRC).
10059 // A bit-flip in the large TOPOLOGY section can produce an etype id
10060 // that is not in the interner. Return Corrupt rather than panic.
10061 let edge_type = self
10062 .syms
10063 .resolve(etype)
10064 .ok_or_else(|| GraphError::Corrupt {
10065 detail: format!("v8: topology etype {etype} not in interner"),
10066 })?
10067 .to_string();
10068 for dir in [Direction::Out, Direction::In] {
10069 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10070 let nbr_restricted = !mask.contains_id(nbr);
10071 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
10072 continue;
10073 }
10074 let nbr_key = self
10075 .ids
10076 .key_of(nbr)
10077 .ok_or_else(|| GraphError::Corrupt {
10078 detail: format!("topology id {nbr} has no key"),
10079 })?
10080 .to_string();
10081 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
10082 match dir {
10083 Direction::Out => {
10084 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
10085 }
10086 Direction::In => {
10087 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
10088 }
10089 };
10090 edges.push(MaskedEdge {
10091 edge_type: edge_type.clone(),
10092 src_key,
10093 src_restricted,
10094 dst_key,
10095 dst_restricted,
10096 derived: derived.contains(&(etype, src_id, dst_id)),
10097 });
10098 }
10099 }
10100 }
10101 edges.sort_by(|a, b| {
10102 a.edge_type
10103 .cmp(&b.edge_type)
10104 .then(a.src_key.cmp(&b.src_key))
10105 .then(a.dst_key.cmp(&b.dst_key))
10106 });
10107 edges.dedup_by(|a, b| {
10108 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
10109 });
10110 Ok(edges)
10111 }
10112
10113 /// [`node_edges_masked`](Self::node_edges_masked) with the **subject check**
10114 /// a scoped caller needs, and a plain [`EdgeInfo`] list.
10115 ///
10116 /// `node_edges_masked` raises [`GraphError::KeyNotFound`] only when `key` is
10117 /// unknown; a key that exists but is hidden still yields its (filtered) edge
10118 /// list, which is correct for a full-token client mask and an existence
10119 /// oracle for a scoped one. Here a hidden subject answers exactly as an
10120 /// absent one does.
10121 ///
10122 /// Every edge naming a hidden endpoint is dropped, whatever the mask's
10123 /// [`MaskMode`](crate::mask::MaskMode): a scoped caller never sees a
10124 /// restricted stub, so there is nothing for it to render.
10125 ///
10126 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
10127 pub fn node_edges_scoped(
10128 &self,
10129 key: &str,
10130 mask: &crate::mask::NodeMask,
10131 ) -> Result<Vec<EdgeInfo>> {
10132 if !mask.contains_node(self, key) {
10133 return Err(GraphError::KeyNotFound { key: key.into() });
10134 }
10135 Ok(self
10136 .node_edges_masked(key, mask)?
10137 .into_iter()
10138 .filter(|e| !e.src_restricted && !e.dst_restricted)
10139 .map(|e| EdgeInfo {
10140 edge_type: e.edge_type,
10141 src_key: e.src_key,
10142 dst_key: e.dst_key,
10143 derived: e.derived,
10144 })
10145 .collect())
10146 }
10147
10148 /// Every directed edge incident on `key`, both directions, every etype.
10149 ///
10150 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
10151 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
10152 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
10153 /// Unknown key → [`GraphError::KeyNotFound`].
10154 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
10155 self.ensure_v8_base_sections_loaded();
10156 let id = self
10157 .ids
10158 .get(key)
10159 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10160 let derived: BTreeSet<(u32, u32, u32)> = self
10161 .engine
10162 .provenance_touching(id)
10163 .map(|(_rule, etype, src, dst)| (etype, src, dst))
10164 .collect();
10165 let mut edges = Vec::new();
10166 let tv = self.topo_view();
10167 for etype in tv.etypes() {
10168 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
10169 let edge_type = self
10170 .syms
10171 .resolve(etype)
10172 .ok_or_else(|| GraphError::Corrupt {
10173 detail: format!("v8: topology etype {etype} not in interner"),
10174 })?
10175 .to_string();
10176 for dir in [Direction::Out, Direction::In] {
10177 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10178 let (src, dst, src_key, dst_key) = match dir {
10179 Direction::Out => (
10180 id,
10181 nbr,
10182 key.to_string(),
10183 self.ids
10184 .key_of(nbr)
10185 .ok_or_else(|| GraphError::Corrupt {
10186 detail: format!("topology id {nbr} has no key"),
10187 })?
10188 .to_string(),
10189 ),
10190 Direction::In => (
10191 nbr,
10192 id,
10193 self.ids
10194 .key_of(nbr)
10195 .ok_or_else(|| GraphError::Corrupt {
10196 detail: format!("topology id {nbr} has no key"),
10197 })?
10198 .to_string(),
10199 key.to_string(),
10200 ),
10201 };
10202 edges.push(EdgeInfo {
10203 edge_type: edge_type.clone(),
10204 src_key,
10205 dst_key,
10206 derived: derived.contains(&(etype, src, dst)),
10207 });
10208 }
10209 }
10210 }
10211 edges.sort_by(|a, b| {
10212 a.edge_type
10213 .cmp(&b.edge_type)
10214 .then(a.src_key.cmp(&b.src_key))
10215 .then(a.dst_key.cmp(&b.dst_key))
10216 });
10217 // Self-loops appear in both Out and In; sort makes the pair adjacent
10218 // (sort key matches PartialEq for this case) so one pass drops the dup.
10219 edges.dedup();
10220 Ok(edges)
10221 }
10222
10223 // ── Backup ────────────────────────────────────────────────────────────────
10224
10225 /// Copy this store to `dest` as a consistent, verified snapshot.
10226 ///
10227 /// Copies every durable file in the database directory — `snapshot.bin`,
10228 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
10229 /// `roles.json` — into a freshly created `dest` directory using OS-level
10230 /// `copy` calls (no large in-process buffers).
10231 ///
10232 /// # Consistency guarantee
10233 ///
10234 /// The guarantee is **process-local**: the caller holds `&self`, which
10235 /// prevents any concurrent writer in the **same process** from modifying
10236 /// the files during the copy. Running `mushroomdb backup` against a
10237 /// directory that is **concurrently being written by another process** (e.g.
10238 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
10239 /// `verified: true` result reduces but does not eliminate the risk of a
10240 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
10241 /// consistent mid-write snapshot).
10242 ///
10243 /// **The safe path for a live-served store is `POST /backup` on the HTTP
10244 /// server.** That handler acquires the read lock on the shared database
10245 /// before calling this method, which is the correct cross-process
10246 /// synchronisation point because the server is the single process writing
10247 /// the files.
10248 ///
10249 /// After copying, opens the destination read-only and runs the CRC section
10250 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
10251 /// `BackupReport::verified` reflects whether both checks passed.
10252 ///
10253 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
10254 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
10255 // Derive source directory from snapshot_path (RealFs only).
10256 let src_dir = match self.fs.snapshot_path() {
10257 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
10258 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
10259 })?,
10260 None => {
10261 return Err(GraphError::Io(std::io::Error::other(
10262 "backup_to requires a real filesystem (RealFs)",
10263 )))
10264 }
10265 };
10266
10267 std::fs::create_dir_all(dest)?;
10268
10269 let mut files: Vec<String> = Vec::new();
10270 let mut bytes: u64 = 0;
10271
10272 // Helper: copy src_dir/name → dest/name if the file exists.
10273 let mut try_copy = |name: &str| -> std::io::Result<()> {
10274 let src_path = src_dir.join(name);
10275 if src_path.exists() {
10276 let n = std::fs::copy(&src_path, dest.join(name))?;
10277 bytes += n;
10278 files.push(name.to_string());
10279 }
10280 Ok(())
10281 };
10282
10283 try_copy("snapshot.bin")?;
10284 try_copy("snapshot.bin.bak")?;
10285 try_copy("wal.bin")?;
10286 try_copy("wal.floor")?;
10287 try_copy("wal.genesis")?;
10288 try_copy("roles.json")?;
10289 // The map from commit to wall-clock time. Without it the copy cannot
10290 // resolve a date to a commit, and answers `NoRecordedTime` for history
10291 // it holds in full (ledger row 86).
10292 try_copy("commit_times.bin")?;
10293 // Empty unless the store's last WAL-replacing snapshot was
10294 // interrupted. Then, without it, the copy is that snapshot beside the
10295 // WAL it was made from, with nothing to say so.
10296 try_copy("snapshot.intent")?;
10297
10298 // Copy WAL archives.
10299 let archives = self.fs.list_archives()?;
10300 for n in &archives {
10301 let name = format!("wal.{n}.archive");
10302 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
10303 bytes += n_bytes;
10304 files.push(name);
10305 }
10306
10307 files.sort();
10308
10309 // Post-copy verification: open dest and run CRC checks.
10310 let snap_in_dest = dest.join("snapshot.bin").exists();
10311 let crc_ok = if snap_in_dest {
10312 crate::verify_snapshot(dest)
10313 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
10314 .unwrap_or(false)
10315 } else {
10316 true // WAL-only store: nothing to CRC-check in snapshot
10317 };
10318 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
10319 let verified = crc_ok && opens_ok;
10320
10321 Ok(BackupReport {
10322 files,
10323 bytes,
10324 verified,
10325 })
10326 }
10327
10328 // ── Export helpers ────────────────────────────────────────────────────────
10329
10330 /// All live nodes, sorted by key (deterministic).
10331 ///
10332 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
10333 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
10334 self.ensure_v8_base_sections_loaded();
10335 let pv = self.props_view();
10336 let mut nodes = Vec::new();
10337 for id in 0..self.ids.len() as u32 {
10338 let Some(key) = self.ids.key_of(id) else {
10339 continue;
10340 };
10341 let Some(&sym) = self.labels.get(id as usize) else {
10342 continue;
10343 };
10344 if sym == u32::MAX {
10345 continue; // tombstoned
10346 }
10347 let Some(label) = self.syms.resolve(sym) else {
10348 continue;
10349 };
10350 let mut props = BTreeMap::new();
10351 for field in pv.field_names() {
10352 if let Some(vr) = pv.get(id, &field) {
10353 props.insert(field, vr.into_value());
10354 }
10355 }
10356 nodes.push(NodeInfo {
10357 key: key.to_string(),
10358 label: label.to_string(),
10359 props,
10360 });
10361 }
10362 nodes.sort_by(|a, b| a.key.cmp(&b.key));
10363 nodes
10364 }
10365
10366 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
10367 ///
10368 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
10369 /// Manual edges carry `derived: false` and `rule: None`.
10370 /// `weight` is the creating rule's `weight_prop` value read off the edge
10371 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
10372 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
10373 /// store state.
10374 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
10375 self.ensure_v8_base_sections_loaded();
10376
10377 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
10378 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
10379 for (rule_name, triples) in self.engine.provenance() {
10380 for &(etype, src, dst) in triples {
10381 prov.insert((etype, src, dst), rule_name.clone());
10382 }
10383 }
10384
10385 // rule_name → weight_prop, for O(1) lookup per derived edge.
10386 let weight_props: HashMap<&str, Option<&str>> = self
10387 .engine
10388 .rules()
10389 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
10390 .collect();
10391
10392 let tv = self.topo_view();
10393 let ep = self.edge_props_view();
10394 let mut edges = Vec::new();
10395
10396 for id in 0..self.ids.len() as u32 {
10397 let Some(key) = self.ids.key_of(id) else {
10398 continue;
10399 };
10400 let Some(&lsym) = self.labels.get(id as usize) else {
10401 continue;
10402 };
10403 if lsym == u32::MAX {
10404 continue; // tombstoned
10405 }
10406
10407 for etype_sym in tv.etypes() {
10408 // etype from archived CSR (access_unchecked, no eager CRC).
10409 // Skip edges whose etype is not in the interner; this can only
10410 // occur with a corrupt large TOPOLOGY section (bit-flip on an
10411 // etype field in the archived data). The function returns Vec,
10412 // not Result, so we continue rather than propagate.
10413 let Some(edge_type) = self.syms.resolve(etype_sym) else {
10414 continue;
10415 };
10416 let edge_type = edge_type.to_string();
10417 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10418 let Some(dst_key) = self.ids.key_of(nbr) else {
10419 continue; // skip corrupt entries
10420 };
10421 let prov_key = (etype_sym, id, nbr);
10422 let rule = prov.get(&prov_key).cloned();
10423 let derived = rule.is_some();
10424 let weight = rule
10425 .as_deref()
10426 .and_then(|rn| weight_props.get(rn).copied().flatten())
10427 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10428 Some(Value::Float(f)) => Some(f),
10429 Some(Value::Int(i)) => Some(i as f64),
10430 _ => None,
10431 });
10432 edges.push(ExportEdge {
10433 edge_type: edge_type.clone(),
10434 src: key.to_string(),
10435 dst: dst_key.to_string(),
10436 derived,
10437 rule,
10438 weight,
10439 });
10440 }
10441 }
10442 }
10443
10444 edges.sort_by(|a, b| {
10445 a.edge_type
10446 .cmp(&b.edge_type)
10447 .then(a.src.cmp(&b.src))
10448 .then(a.dst.cmp(&b.dst))
10449 });
10450 edges
10451 }
10452
10453 /// What each edge type *is*, without building one record per edge.
10454 ///
10455 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
10456 /// question by materialising every edge — three `String`s apiece, a
10457 /// provenance `HashMap` over every derived edge, and a final sort. That is
10458 /// the right shape for an export, and the wrong one for a summary: on a
10459 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
10460 /// produce nine lines. This walks the topology instead, summing neighbour
10461 /// slice lengths and collecting *label symbols* rather than label strings,
10462 /// so the per-edge cost is an integer add and a set insert on a set with
10463 /// as many members as the store has labels.
10464 ///
10465 /// The rule names come off the rule *definitions*, which each declare the
10466 /// `edge_type` they derive, so naming them costs one pass over the rules
10467 /// rather than one provenance lookup per edge. That is also why `rules`
10468 /// is a list: two rules may derive the same type — the association store
10469 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
10470 /// talent→job rule — and naming only one of them would be a half-truth.
10471 /// A type with no rules is one written by hand.
10472 ///
10473 /// `sample` is the first edge of the type in the store's own id order,
10474 /// which is insertion order: deterministic for a given store, and not the
10475 /// same as key order, which cannot be had without resolving a key per
10476 /// edge. Sorted by `edge_type`.
10477 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
10478 self.ensure_v8_base_sections_loaded();
10479
10480 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10481 for r in self.engine.rules() {
10482 rules_by_type
10483 .entry(r.edge_type.as_str())
10484 .or_default()
10485 .insert(r.name.as_str());
10486 }
10487
10488 let tv = self.topo_view();
10489 let node_count = self.ids.len() as u32;
10490 let mut out = Vec::new();
10491 for etype_sym in tv.etypes() {
10492 // An etype the interner cannot resolve means a corrupt TOPOLOGY
10493 // section; skip it rather than name it, as `all_edges_for_export`
10494 // does for the same reason.
10495 let Some(edge_type) = self.syms.resolve(etype_sym) else {
10496 continue;
10497 };
10498 let mut edges: u64 = 0;
10499 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
10500 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
10501 let mut sample: Option<(u32, u32)> = None;
10502 for id in 0..node_count {
10503 let Some(&lsym) = self.labels.get(id as usize) else {
10504 continue;
10505 };
10506 if lsym == u32::MAX {
10507 continue; // tombstoned
10508 }
10509 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
10510 let nbrs = nbrs.as_ref();
10511 if nbrs.is_empty() {
10512 continue;
10513 }
10514 edges += nbrs.len() as u64;
10515 src_syms.insert(lsym);
10516 for &nbr in nbrs {
10517 if let Some(&dsym) = self.labels.get(nbr as usize) {
10518 if dsym != u32::MAX {
10519 dst_syms.insert(dsym);
10520 }
10521 }
10522 }
10523 if sample.is_none() {
10524 sample = Some((id, nbrs[0]));
10525 }
10526 }
10527 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
10528 syms.iter()
10529 .filter_map(|&s| self.syms.resolve(s))
10530 .map(ToString::to_string)
10531 .collect()
10532 };
10533 out.push(EdgeTypeCensus {
10534 edge_type: edge_type.to_string(),
10535 edges,
10536 src_labels: resolve(&src_syms),
10537 dst_labels: resolve(&dst_syms),
10538 rules: rules_by_type
10539 .get(edge_type)
10540 .map(|rs| rs.iter().map(ToString::to_string).collect())
10541 .unwrap_or_default(),
10542 sample: sample.and_then(|(s, d)| {
10543 Some((
10544 self.ids.key_of(s)?.to_string(),
10545 self.ids.key_of(d)?.to_string(),
10546 ))
10547 }),
10548 });
10549 }
10550 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
10551 out
10552 }
10553
10554 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
10555 /// on each edge when given.
10556 ///
10557 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
10558 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
10559 /// `None` — callers that want a default weight (e.g. `1.0` for missing
10560 /// props) apply it themselves, matching the convention used internally
10561 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
10562 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
10563 ///
10564 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
10565 /// (manual + rule-derived edges). An unknown `edge_type` returns an
10566 /// empty vec.
10567 pub fn weighted_edges(
10568 &self,
10569 edge_type: &str,
10570 weight_prop: Option<&str>,
10571 ) -> Vec<(String, String, Option<f64>)> {
10572 let Some(etype_sym) = self.syms.get(edge_type) else {
10573 return Vec::new();
10574 };
10575 let tv = self.topo_view();
10576 let ep = self.edge_props_view();
10577 let mut out = Vec::new();
10578 for id in 0..self.ids.len() as u32 {
10579 let Some(key) = self.ids.key_of(id) else {
10580 continue;
10581 };
10582 let Some(&sym) = self.labels.get(id as usize) else {
10583 continue;
10584 };
10585 if sym == u32::MAX {
10586 continue; // tombstoned
10587 }
10588 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10589 let Some(dst_key) = self.ids.key_of(nbr) else {
10590 continue;
10591 };
10592 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10593 Some(Value::Float(f)) => Some(f),
10594 Some(Value::Int(i)) => Some(i as f64),
10595 _ => None,
10596 });
10597 out.push((key.to_string(), dst_key.to_string(), weight));
10598 }
10599 }
10600 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
10601 out
10602 }
10603
10604 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
10605 self.view()
10606 .nodes_with_label(label)
10607 .into_iter()
10608 .map(|id| NodeRef { db: self, id })
10609 .collect()
10610 }
10611
10612 /// Whether any of the first `limit` live nodes under `label`, in id
10613 /// order, carries `field`.
10614 ///
10615 /// Bounded for a caller that asks on every request: one props view, at
10616 /// most `limit` property reads, no value cloned and no `NodeRef` built,
10617 /// and it stops at the first carrier. The label column itself has no
10618 /// per-label index, so on a label with fewer than `limit` nodes the walk
10619 /// of that column runs to its end — integer compares, no property read.
10620 pub(crate) fn first_nodes_carry_prop(&self, label: &str, field: &str, limit: usize) -> bool {
10621 let Some(sym) = self.syms.get(label) else {
10622 return false;
10623 };
10624 let props = self.props_view();
10625 self.labels
10626 .iter()
10627 .enumerate()
10628 .filter(|&(_, &s)| s == sym)
10629 .take(limit)
10630 .any(|(id, _)| props.get(id as u32, field).is_some())
10631 }
10632
10633 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
10634 let view = self.view();
10635 view.nodes_with_label(label)
10636 .into_iter()
10637 .filter(|&id| {
10638 eval_filter(filter, &|field| {
10639 view.prop(id, field).map(|vr| vr.into_value())
10640 })
10641 })
10642 .map(|id| NodeRef { db: self, id })
10643 .collect()
10644 }
10645
10646 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
10647 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
10648 /// with `label = None` will use the native ANN path rather than the O(n)
10649 /// brute-force scan.
10650 pub fn has_vector_rule(&self, field: &str) -> bool {
10651 self.engine.hnsw_has_rule(field)
10652 }
10653
10654 /// How many HNSW graphs this handle has built from scratch since it was
10655 /// opened (one per side of an approximate rule).
10656 ///
10657 /// An open that restored every graph from the snapshot reports `0`.
10658 /// Exposed for tests that assert the open path reuses the persisted index
10659 /// rather than rebuilding it; not part of the stable surface.
10660 #[doc(hidden)]
10661 pub fn hnsw_build_count(&self) -> u64 {
10662 self.engine.hnsw_build_count()
10663 }
10664
10665 /// Whether the rule engine has built its candidate indexes on this handle.
10666 ///
10667 /// A store opened from a snapshot defers that full node scan until the
10668 /// first write or the first read that needs it. Exposed for tests that
10669 /// assert an open did not pay for it; not part of the stable surface.
10670 #[doc(hidden)]
10671 pub fn rule_indexes_populated(&self) -> bool {
10672 self.engine.indexes_populated()
10673 }
10674
10675 /// How many rules this handle still holds a lazily-decoded HNSW graph for.
10676 ///
10677 /// Zero before the first ANN query on a clean open, and again once the
10678 /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
10679 /// Exposed for tests that assert the lazy copies are released; not part of
10680 /// the stable surface.
10681 #[doc(hidden)]
10682 pub fn lazy_hnsw_len(&self) -> usize {
10683 self.engine.lazy_hnsw_len()
10684 }
10685
10686 /// Find nodes whose `field` vector is most similar to `q` (cosine
10687 /// similarity), returning up to `k` results with similarity ≥ `min`,
10688 /// sorted descending.
10689 ///
10690 /// When `label` is `None` the search spans all labels (via
10691 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
10692 /// `Some(lbl)` it restricts to nodes with that label.
10693 ///
10694 /// Uses the HNSW index when one is available (fast path); otherwise falls
10695 /// back to an O(n) brute-force scan.
10696 ///
10697 /// **The index supplies candidates, never scores.** Its own distances are
10698 /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
10699 /// every candidate is re-scored from the `f64` property vectors by
10700 /// [`exact_vector_similarity`] before `min`, the ordering and the reported
10701 /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
10702 /// the re-ordering cannot drop a true top-`k` member; see that constant for
10703 /// the rule. The score a caller receives is therefore the same number the
10704 /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
10705 /// finds an exact duplicate.
10706 pub fn find_similar_vector(
10707 &self,
10708 field: &str,
10709 label: Option<&str>,
10710 q: &[f64],
10711 k: usize,
10712 min: f64,
10713 ) -> Vec<(String, f64)> {
10714 self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
10715 .expect("find_similar_vector_filtered is infallible without where_")
10716 }
10717
10718 /// Like [`find_similar_vector`] but restricts results to nodes visible in
10719 /// `mask`. Hidden nodes never appear in results; the mask is applied
10720 /// **before** k-truncation so a caller still receives up to `k` visible
10721 /// hits.
10722 ///
10723 /// # HNSW path (widening beam)
10724 ///
10725 /// When an HNSW index covers the request, the beam starts at an over-fetch
10726 /// of `k × n / |visible|` (plus the rescore margin) when the mask's
10727 /// selectivity is known from the index length, otherwise at `k` plus that
10728 /// margin. If fewer than `k` visible candidates remain after the mask and
10729 /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
10730 /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
10731 /// or a beam that comes back short of its own width, falls through to the
10732 /// exhaustive masked scan rather than returning a short result.
10733 ///
10734 /// Every surviving candidate is re-scored from the `f64` property vectors,
10735 /// exactly as [`find_similar_vector`] does and for the same reason.
10736 ///
10737 /// # Brute-force path
10738 ///
10739 /// When no HNSW index covers the request, or the beam cannot admit `k`
10740 /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
10741 /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
10742 /// results (or all visible nodes if fewer than `k` exist).
10743 pub fn find_similar_vector_masked(
10744 &self,
10745 field: &str,
10746 label: Option<&str>,
10747 q: &[f64],
10748 k: usize,
10749 min: f64,
10750 mask: &crate::mask::NodeMask,
10751 ) -> Vec<(String, f64)> {
10752 self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
10753 .expect("find_similar_vector_filtered is infallible without where_")
10754 }
10755
10756 /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
10757 ///
10758 /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
10759 /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
10760 /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
10761 /// uses HNSW when an index covers the field.
10762 #[allow(clippy::too_many_arguments)]
10763 pub fn find_similar_vector_filtered(
10764 &self,
10765 field: &str,
10766 label: Option<&str>,
10767 q: &[f64],
10768 k: usize,
10769 min: f64,
10770 mask: Option<&crate::mask::NodeMask>,
10771 where_: Option<&PropPredicate>,
10772 exact: bool,
10773 ) -> Result<Vec<(String, f64)>> {
10774 self.find_similar_vector_as(
10775 field,
10776 label,
10777 q,
10778 k,
10779 min,
10780 mask,
10781 where_,
10782 exact,
10783 ExactnessCaller::Vector,
10784 )
10785 }
10786
10787 /// [`find_similar_vector_filtered`](Self::find_similar_vector_filtered)
10788 /// with the caller shape named, so the exactness warning can advise the
10789 /// signature that actually reached it. Everything else is identical.
10790 #[allow(clippy::too_many_arguments)]
10791 fn find_similar_vector_as(
10792 &self,
10793 field: &str,
10794 label: Option<&str>,
10795 q: &[f64],
10796 k: usize,
10797 min: f64,
10798 mask: Option<&crate::mask::NodeMask>,
10799 where_: Option<&PropPredicate>,
10800 exact: bool,
10801 caller: ExactnessCaller,
10802 ) -> Result<Vec<(String, f64)>> {
10803 if let Some(pred) = where_ {
10804 pred.validate_named("where")
10805 .map_err(|detail| GraphError::QueryError { detail })?;
10806 }
10807
10808 // Ensure any HNSW blobs retained from the snapshot are deserialized
10809 // before the first ANN query on a clean-open (no-WAL) path. The
10810 // section read has to come first: on a clean open nothing else has
10811 // called it, so without it `retained_hnsw_blobs` is empty,
10812 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
10813 // approximate query on the handle runs brute force — correct results,
10814 // silently off the index. Both calls are idempotent and cheap once hot.
10815 self.ensure_v8_base_sections_loaded();
10816 self.engine.ensure_hnsw_loaded();
10817 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
10818 if norm == 0.0 {
10819 return Ok(vec![]);
10820 }
10821 if let Some(m) = mask {
10822 if k == 0 || m.is_empty() {
10823 return Ok(vec![]);
10824 }
10825 }
10826 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
10827
10828 // `where` implies exact: a predicate must not ride a silent ANN.
10829 let skip_hnsw = exact || where_.is_some();
10830 if !skip_hnsw {
10831 if let Some(mask) = mask {
10832 if let Some(out) =
10833 self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask, caller)
10834 {
10835 return Ok(out);
10836 }
10837 } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
10838 return Ok(out);
10839 }
10840 }
10841
10842 let view = match mask {
10843 Some(m) => self.view_masked(m),
10844 None => self.view(),
10845 };
10846 let candidate_ids = Self::vector_candidates(&view, label, where_);
10847 Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
10848 }
10849
10850 /// Unmasked HNSW path. `None` when no populated index covers the request.
10851 fn find_similar_hnsw(
10852 &self,
10853 field: &str,
10854 label: Option<&str>,
10855 q_unit: &[f64],
10856 k: usize,
10857 min: f64,
10858 ) -> Option<Vec<(String, f64)>> {
10859 // Try HNSW fast path.
10860 // `None` label searches across all VectorSimilar rules covering `field`
10861 // (merging their results); `Some(lbl)` restricts to rules whose
10862 // dst_label matches. Returns `None` when no populated HNSW index
10863 // covers the request — the O(n) brute-force fallback handles that case.
10864 let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
10865 let hits = match label {
10866 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
10867 None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
10868 };
10869 // Candidates only: the index's `f32` similarity is discarded and
10870 // each hit is re-scored against the `f64` vectors.
10871 let view = self.view();
10872 let mut out: Vec<(String, f64)> = hits
10873 .into_iter()
10874 .filter_map(|(id, _)| {
10875 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
10876 if sim < min {
10877 return None;
10878 }
10879 Some((self.ids.key_of(id)?.to_string(), sim))
10880 })
10881 .collect();
10882 out.sort_by(|a, b| {
10883 b.1.partial_cmp(&a.1)
10884 .unwrap_or(std::cmp::Ordering::Equal)
10885 .then_with(|| a.0.cmp(&b.0))
10886 });
10887 out.truncate(k);
10888 Some(out)
10889 }
10890
10891 /// Masked HNSW widening beam. `None` when no index covers the request or
10892 /// the beam cannot admit `k` visible hits (caller falls through to brute).
10893 #[allow(clippy::too_many_arguments)]
10894 fn find_similar_hnsw_masked(
10895 &self,
10896 field: &str,
10897 label: Option<&str>,
10898 q_unit: &[f64],
10899 k: usize,
10900 min: f64,
10901 mask: &crate::mask::NodeMask,
10902 caller: ExactnessCaller,
10903 ) -> Option<Vec<(String, f64)>> {
10904 let index_len = match label {
10905 Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
10906 None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
10907 };
10908 let n = index_len?;
10909 // The `?` above is the coverage test: past it, an index exists and this
10910 // masked, non-exact call is about to ride it.
10911 self.note_ambiguous_exactness(field, label, caller);
10912 // Same ceiling the exact-rule widening loop in `hnsw_candidates`
10913 // consults — including the `with_ef_max` test hook.
10914 let cap = ef_max();
10915 let visible = mask.len();
10916 let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
10917 if visible > 0 && n > 0 {
10918 let over = k
10919 .saturating_mul(n)
10920 .div_ceil(visible)
10921 .saturating_add(VECTOR_RESCORE_MARGIN);
10922 ef = ef.max(over);
10923 }
10924 loop {
10925 let hits = match label {
10926 Some(lbl) => self
10927 .engine
10928 .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
10929 None => self
10930 .engine
10931 .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
10932 };
10933 let hits = hits?;
10934 let full = hits.len() == ef;
10935 let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
10936 if out.len() >= k {
10937 out.truncate(k);
10938 return Some(out);
10939 }
10940 // Short of its width (frontier exhausted) or at the ceiling:
10941 // a wider beam reaches nothing new, so the scan answers.
10942 if !full || ef >= cap {
10943 return None;
10944 }
10945 ef = ef.saturating_mul(2);
10946 }
10947 }
10948
10949 /// Say once, per `(field, label)` index and caller shape, that a masked
10950 /// search is answering approximately.
10951 ///
10952 /// A mask narrows *which nodes may be returned*. It does not choose a
10953 /// kernel — `exact=true` and a `where=` predicate do, and nothing else
10954 /// does. A caller who needed exact answers, passed `mask=` alone, and read
10955 /// the mask as a promise of exhaustiveness gets a correct-looking
10956 /// approximate answer and no signal at all; that is a silent wrong answer,
10957 /// and it has cost an integration team real time.
10958 ///
10959 /// The fix is a question, not a behaviour change. Making a mask imply
10960 /// `exact` would turn every existing masked caller's ANN into an O(n) GEMM
10961 /// without asking them, which is a worse trade than the ambiguity.
10962 ///
10963 /// Printed once per index for the reason the dimension-mismatch skip in
10964 /// `core_rules::hnsw` is: a line on every call is a line callers learn to
10965 /// scroll past.
10966 ///
10967 /// `caller` decides the advice. The same leg is reached from two signatures
10968 /// and only one of them has an `exact` argument to pass; see
10969 /// [`ExactnessCaller`].
10970 fn note_ambiguous_exactness(&self, field: &str, label: Option<&str>, caller: ExactnessCaller) {
10971 let entry = (field.to_string(), label.unwrap_or("").to_string(), caller);
10972 let first = match self.warned_ambiguous_exactness.lock() {
10973 Ok(mut seen) => seen.insert(entry),
10974 Err(poisoned) => poisoned.into_inner().insert(entry),
10975 };
10976 if !first {
10977 return;
10978 }
10979 AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(c.get().saturating_add(1)));
10980 let which = match label {
10981 Some(lbl) => format!(" (label `{lbl}`)"),
10982 None => String::new(),
10983 };
10984 let subject = caller.subject();
10985 let advice = caller.advice();
10986 let line = format!(
10987 "mushroomdb: {subject} on field `{field}`{which} is answering \
10988 approximately. A mask narrows which nodes may be returned; it does not \
10989 change which kernel runs, and an index covers this field. For an exact \
10990 answer over the same visible candidate set, {advice} Further masked \
10991 searches of this shape on this index are silent."
10992 );
10993 AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = Some(line.clone()));
10994 eprintln!("{line}");
10995 }
10996
10997 /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
10998 /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
10999 fn vector_candidates(
11000 view: &GraphView<'_>,
11001 label: Option<&str>,
11002 where_: Option<&PropPredicate>,
11003 ) -> Vec<u32> {
11004 if let (Some(lbl), Some(pred)) = (label, where_) {
11005 let indexed = view
11006 .prop_index
11007 .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
11008 if indexed {
11009 match (&pred.eq, &pred.in_) {
11010 (Some(eq), None) => {
11011 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
11012 return ids;
11013 }
11014 }
11015 (None, Some(allowed)) => {
11016 let mut seen = HashSet::new();
11017 let mut out = Vec::new();
11018 for v in allowed {
11019 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
11020 for id in ids {
11021 if seen.insert(id) {
11022 out.push(id);
11023 }
11024 }
11025 }
11026 }
11027 return out;
11028 }
11029 _ => {}
11030 }
11031 }
11032 }
11033
11034 let mut ids: Vec<u32> = match label {
11035 Some(lbl) => view
11036 .nodes_with_label(lbl)
11037 .into_iter()
11038 .filter(|&id| view.visible(id))
11039 .collect(),
11040 None => view.nodes_all(),
11041 };
11042 if let Some(pred) = where_ {
11043 ids.retain(|&id| match view.prop(id, &pred.field) {
11044 None => pred.holds(None),
11045 Some(vr) => pred.holds(Some(vr.as_value())),
11046 });
11047 }
11048 ids
11049 }
11050
11051 /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
11052 /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
11053 fn brute_vector_hits(
11054 &self,
11055 view: &GraphView<'_>,
11056 candidate_ids: impl IntoIterator<Item = u32>,
11057 field: &str,
11058 q_unit: &[f64],
11059 k: usize,
11060 min: f64,
11061 ) -> Vec<(String, f64)> {
11062 let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
11063 .into_iter()
11064 .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
11065 .collect();
11066 let packed =
11067 crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
11068 let scores = crate::exact_knn::gemv(&packed, q_unit);
11069 let mut scored: Vec<(String, f64)> = packed
11070 .ids
11071 .iter()
11072 .zip(scores.iter())
11073 .filter_map(|(&id, &sim)| {
11074 if sim < min {
11075 return None;
11076 }
11077 let key = self.ids.key_of(id)?.to_string();
11078 Some((key, sim))
11079 })
11080 .collect();
11081 scored.sort_by(|a, b| {
11082 b.1.partial_cmp(&a.1)
11083 .unwrap_or(std::cmp::Ordering::Equal)
11084 .then_with(|| a.0.cmp(&b.0))
11085 });
11086 scored.truncate(k);
11087 scored
11088 }
11089
11090 /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
11091 ///
11092 /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
11093 /// same unit and inequality as `find_similar_vector`. Self-matches are
11094 /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
11095 /// embeddings are omitted as both query and candidate. Duplicate keys are
11096 /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
11097 /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
11098 #[allow(clippy::type_complexity)]
11099 pub fn pairwise_similar(
11100 &self,
11101 keys: &[&str],
11102 field: &str,
11103 k: usize,
11104 min: f64,
11105 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11106 let mut seen = HashSet::new();
11107 let mut unique_ids = Vec::new();
11108 for key in keys {
11109 let Some(id) = self.ids.get(key) else {
11110 continue;
11111 };
11112 if seen.insert(id) {
11113 unique_ids.push(id);
11114 }
11115 }
11116 let max_n = crate::exact_knn::pairwise_max_n();
11117 if unique_ids.len() > max_n {
11118 return Err(GraphError::QueryError {
11119 detail: format!(
11120 "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
11121 unique_ids.len()
11122 ),
11123 });
11124 }
11125 if unique_ids.is_empty() {
11126 return Ok(Vec::new());
11127 }
11128
11129 let view = self.view();
11130 let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
11131 let mut counts: HashMap<usize, usize> = HashMap::new();
11132 for id in unique_ids {
11133 let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
11134 continue;
11135 };
11136 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
11137 if norm == 0.0 {
11138 continue;
11139 }
11140 *counts.entry(v.len()).or_default() += 1;
11141 rows.push((id, v));
11142 }
11143 if rows.is_empty() {
11144 return Ok(Vec::new());
11145 }
11146 let dim = counts
11147 .into_iter()
11148 .max_by_key(|&(d, c)| (c, d))
11149 .map(|(d, _)| d)
11150 .expect("rows non-empty");
11151 let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
11152 let n = packed.ids.len();
11153 if n == 0 {
11154 return Ok(Vec::new());
11155 }
11156 let src_keys: Vec<String> = packed
11157 .ids
11158 .iter()
11159 .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
11160 .collect();
11161
11162 let mut out = Vec::with_capacity(n);
11163 if n <= crate::exact_knn::pairwise_gram_max() {
11164 let sims = crate::exact_knn::gram(&packed);
11165 for i in 0..n {
11166 out.push(Self::topk_from_row(
11167 &src_keys,
11168 i,
11169 &sims[i * n..(i + 1) * n],
11170 k,
11171 min,
11172 ));
11173 }
11174 } else {
11175 for i in 0..n {
11176 let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
11177 let scores = crate::exact_knn::gemv(&packed, row);
11178 out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
11179 }
11180 }
11181 Ok(out)
11182 }
11183
11184 /// [`pairwise_similar`](Self::pairwise_similar) over the keys the mask
11185 /// admits — intersected **before** the matmul, never filtered after it.
11186 ///
11187 /// A hidden vector packed into the Gram is a row every visible key is
11188 /// scored against. It can take a visible neighbour's place in the top-`k`,
11189 /// and because the packed dimension is a majority vote over the candidate
11190 /// rows it can decide whether a visible pair is scored at all. Dropping
11191 /// hidden names from the finished answer leaves both effects standing, so
11192 /// the intersection happens first and the answer is byte-for-byte the one
11193 /// `pairwise_similar` gives for the visible keys alone.
11194 ///
11195 /// The caps therefore measure the **post-filter** count: a key set over
11196 /// [`PAIRWISE_MAX_N`](crate::PAIRWISE_MAX_N) unscoped can come under it
11197 /// scoped and succeed, because the work the cap refuses is work this call
11198 /// no longer does. A filtered count still over the cap is still refused.
11199 #[allow(clippy::type_complexity)]
11200 pub fn pairwise_similar_scoped(
11201 &self,
11202 keys: &[&str],
11203 field: &str,
11204 k: usize,
11205 min: f64,
11206 mask: &crate::mask::NodeMask,
11207 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11208 let visible: Vec<&str> = keys
11209 .iter()
11210 .copied()
11211 .filter(|key| mask.contains_node(self, key))
11212 .collect();
11213 self.pairwise_similar(&visible, field, k, min)
11214 }
11215
11216 /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
11217 /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
11218 /// still appear as `(src, [])`.
11219 fn topk_from_row(
11220 src_keys: &[String],
11221 i: usize,
11222 scores: &[f64],
11223 k: usize,
11224 min: f64,
11225 ) -> (String, Vec<(String, f64)>) {
11226 let mut neigh: Vec<(String, f64)> = scores
11227 .iter()
11228 .enumerate()
11229 .filter_map(|(j, &sim)| {
11230 if i == j || sim < min {
11231 return None;
11232 }
11233 Some((src_keys[j].clone(), sim))
11234 })
11235 .collect();
11236 neigh.sort_by(|a, b| {
11237 b.1.partial_cmp(&a.1)
11238 .unwrap_or(std::cmp::Ordering::Equal)
11239 .then_with(|| a.0.cmp(&b.0))
11240 });
11241 neigh.truncate(k);
11242 (src_keys[i].clone(), neigh)
11243 }
11244
11245 /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
11246 /// hits, order by score then key. The index's own `f32` similarity is discarded.
11247 fn score_masked_hnsw_hits(
11248 &self,
11249 hits: &[(u32, f64)],
11250 field: &str,
11251 q_unit: &[f64],
11252 min: f64,
11253 mask: &crate::mask::NodeMask,
11254 ) -> Vec<(String, f64)> {
11255 let view = self.view_masked(mask);
11256 let mut out: Vec<(String, f64)> = hits
11257 .iter()
11258 .copied()
11259 .filter(|&(id, _)| mask.contains_id(id))
11260 .filter_map(|(id, _)| {
11261 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
11262 if sim < min {
11263 return None;
11264 }
11265 Some((self.ids.key_of(id)?.to_string(), sim))
11266 })
11267 .collect();
11268 out.sort_by(|a, b| {
11269 b.1.partial_cmp(&a.1)
11270 .unwrap_or(std::cmp::Ordering::Equal)
11271 .then_with(|| a.0.cmp(&b.0))
11272 });
11273 out
11274 }
11275
11276 /// Read a single property from an edge.
11277 ///
11278 /// Returns `None` when the edge does not exist, the field is absent, or any
11279 /// of the string keys cannot be resolved to interned ids. Only edge props
11280 /// written by rules (weight fields) are accessible without a `set_edge_prop`
11281 /// binding; topology-only edges (no props set) return `None` for every field.
11282 pub fn get_edge_prop(
11283 &self,
11284 edge_type: &str,
11285 src_key: &str,
11286 dst_key: &str,
11287 field: &str,
11288 ) -> Option<Value> {
11289 let etype = self.syms.get(edge_type)?;
11290 let src = self.ids.get(src_key)?;
11291 let dst = self.ids.get(dst_key)?;
11292 self.edge_props_view().get(etype, src, dst, field)
11293 }
11294
11295 /// Lex → parse → plan → execute `cypher` over a read-only view.
11296 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
11297 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
11298 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
11299 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11300 detail: format!("lex: {e}"),
11301 })?;
11302 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
11303 detail: format!("parse: {e}"),
11304 })?;
11305 let t0 = std::time::Instant::now();
11306 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
11307 GraphError::QueryError {
11308 detail: format!("execute: {e}"),
11309 }
11310 });
11311 let elapsed_ms = t0.elapsed().as_millis() as u64;
11312 let threshold = self.slow_query_threshold_ms;
11313 if threshold > 0 && elapsed_ms >= threshold {
11314 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
11315 let entry = SlowQueryEntry {
11316 ms: elapsed_ms,
11317 query: cypher.to_string(),
11318 at_commit: self.commit_seq,
11319 };
11320 if let Ok(mut log) = self.slow_queries.lock() {
11321 if log.entries.len() == SLOW_QUERY_RING_CAP {
11322 log.entries.pop_front();
11323 }
11324 log.entries.push_back(entry);
11325 log.total += 1;
11326 }
11327 }
11328 result
11329 }
11330
11331 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
11332 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
11333 /// calling [`GraphDb::query`].
11334 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
11335 let map: BTreeMap<String, Value> = params
11336 .iter()
11337 .map(|(k, v)| (k.to_string(), v.clone()))
11338 .collect();
11339 self.query(cypher, &map)
11340 }
11341
11342 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
11343 ///
11344 /// All mutations flow through the same `insert_node` / `set_prop` /
11345 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
11346 /// fires and the WAL captures everything with one fsync per statement.
11347 ///
11348 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
11349 /// and `deleted` matching the write-result contract.
11350 ///
11351 /// **Mutation routing**: mutations are collected into a single
11352 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
11353 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
11354 /// over `self.view()` — the borrow is dropped before the batch is opened.
11355 ///
11356 /// **Limitations (v1)**:
11357 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
11358 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
11359 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
11360 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
11361 /// - Deleting a derived edge → named error "cannot delete derived edge".
11362 pub fn query_write(
11363 &mut self,
11364 cypher: &str,
11365 params: &BTreeMap<String, Value>,
11366 ) -> Result<ResultSet> {
11367 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11368 detail: format!("lex: {e}"),
11369 })?;
11370 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
11371 detail: format!("parse: {e}"),
11372 })?;
11373 self.exec_write_stmt(stmt, params)
11374 }
11375
11376 fn exec_write_stmt(
11377 &mut self,
11378 stmt: WriteStatement,
11379 params: &BTreeMap<String, Value>,
11380 ) -> Result<ResultSet> {
11381 match stmt {
11382 WriteStatement::Create(s) => self.exec_create(s, params),
11383 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
11384 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
11385 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
11386 WriteStatement::Merge(s) => self.exec_merge(s, params),
11387 }
11388 }
11389
11390 fn exec_create(
11391 &mut self,
11392 stmt: core_query::cypher::CreateStmt,
11393 params: &BTreeMap<String, Value>,
11394 ) -> Result<ResultSet> {
11395 // Extract the node key from props: require a string-valued `id` field.
11396 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
11397 for node in &stmt.nodes {
11398 let var = node.var.as_deref().unwrap_or("_cn0");
11399 let key = node
11400 .props
11401 .iter()
11402 .find(|(f, _)| f == "id")
11403 .and_then(|(_, v)| {
11404 if let Value::Str(s) = v {
11405 Some(s.clone())
11406 } else {
11407 None
11408 }
11409 })
11410 .ok_or_else(|| GraphError::QueryError {
11411 detail: format!(
11412 "CREATE node ({}:{}) requires a string 'id' property",
11413 var, node.label
11414 ),
11415 })?;
11416 var_to_key.insert(var.to_string(), key);
11417 }
11418
11419 let mut batch = self.batch();
11420 let mut created: usize = 0;
11421 for node in &stmt.nodes {
11422 let var = node.var.as_deref().unwrap_or("_cn0");
11423 let key = &var_to_key[var];
11424 batch.insert_node(&node.label, key, node.props.clone());
11425 created += 1;
11426 }
11427 for edge in &stmt.edges {
11428 let src_key = var_to_key
11429 .get(&edge.src_var)
11430 .ok_or_else(|| GraphError::QueryError {
11431 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
11432 })?;
11433 let dst_key = var_to_key
11434 .get(&edge.dst_var)
11435 .ok_or_else(|| GraphError::QueryError {
11436 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
11437 })?;
11438 batch.insert_edge(&edge.etype, src_key, dst_key);
11439 }
11440 batch.commit()?;
11441
11442 // Optional RETURN clause: project created bindings as a read result.
11443 if let Some(returns) = stmt.returns {
11444 // Each created node is looked up by its key via a separate MATCH pattern.
11445 // Multiple single-node patterns cross-join to produce 1 output row with
11446 // all variables bound (each pattern returns exactly 1 row).
11447 let patterns: Vec<Pattern> = stmt
11448 .nodes
11449 .iter()
11450 .map(|node| {
11451 let var = node.var.as_deref().unwrap_or("_cn0");
11452 let key = var_to_key[var].clone();
11453 Pattern {
11454 start: NodePat {
11455 var: Some(var.to_string()),
11456 label: Some(node.label.clone()),
11457 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
11458 },
11459 chain: vec![],
11460 shortest: false,
11461 }
11462 })
11463 .collect();
11464 let q = Query {
11465 matches: patterns,
11466 optional_clauses: vec![],
11467 where_expr: None,
11468 unwinds: vec![],
11469 post_unwind_where: None,
11470 stages: vec![],
11471 returns,
11472 distinct: false,
11473 order_by: vec![],
11474 skip: None,
11475 limit: None,
11476 };
11477 let ops = plan(&q).map_err(|e| GraphError::QueryError {
11478 detail: format!("plan: {e}"),
11479 })?;
11480 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
11481 GraphError::QueryError {
11482 detail: format!("execute: {e}"),
11483 }
11484 });
11485 }
11486
11487 let mut rs = write_result_set();
11488 rs.push_row(vec![
11489 Some(Value::Int(created as i64)),
11490 Some(Value::Int(0)),
11491 Some(Value::Int(0)),
11492 ]);
11493 Ok(rs)
11494 }
11495
11496 fn exec_match_set(
11497 &mut self,
11498 stmt: core_query::cypher::MatchSetStmt,
11499 params: &BTreeMap<String, Value>,
11500 ) -> Result<ResultSet> {
11501 let project_returns = stmt.returns.clone();
11502 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
11503 // so the post-write projection can look them up by key.
11504 let mut set_vars: Vec<String> = Vec::new();
11505 for s in &stmt.sets {
11506 if !set_vars.contains(&s.var) {
11507 set_vars.push(s.var.clone());
11508 }
11509 }
11510 let rel_vars = pattern_rel_vars(&stmt.matches);
11511 // `count` is the engine's, on an edge: it is the insert-count §5.13
11512 // maintains, and a `SET` that overwrote it would make the number mean
11513 // whatever the last writer said rather than how many times the pair was
11514 // inserted. Refused by name here, before the match runs, so the caller
11515 // is told what is actually wrong instead of meeting the executor's
11516 // generic "did not resolve to a node key" — and so the answer does not
11517 // depend on whether the pattern happened to match a row. The same name
11518 // on a *node* is an ordinary property and is untouched.
11519 for s in &stmt.sets {
11520 if s.field == EDGE_COUNT_PROP && rel_vars.iter().any(|r| r == &s.var) {
11521 return Err(GraphError::QueryError {
11522 detail: format!(
11523 "cannot SET {}.{EDGE_COUNT_PROP}: `{EDGE_COUNT_PROP}` is a reserved edge \
11524 property holding the pair's insert count",
11525 s.var
11526 ),
11527 });
11528 }
11529 }
11530 let mut lookup_vars = set_vars.clone();
11531 for v in pattern_node_vars(&stmt.matches) {
11532 add_var(&mut lookup_vars, &v);
11533 }
11534 if let Some(ref returns) = project_returns {
11535 for v in ret_node_vars(returns) {
11536 if !rel_vars.iter().any(|r| r == &v) {
11537 add_var(&mut lookup_vars, &v);
11538 }
11539 }
11540 }
11541
11542 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
11543 // SET values are projected as ScalarExpr items so that arithmetic expressions
11544 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
11545 let mut set_returns: Vec<RetItem> = lookup_vars
11546 .iter()
11547 .map(|v| RetItem {
11548 value: RetVal::Var(v.clone()),
11549 alias: None,
11550 })
11551 .collect();
11552 // One computed column per SET clause; alias is `__sv_<i>`.
11553 let set_val_cols: Vec<String> = stmt
11554 .sets
11555 .iter()
11556 .enumerate()
11557 .map(|(i, _)| format!("__sv_{i}"))
11558 .collect();
11559 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11560 set_returns.push(RetItem {
11561 value: RetVal::ScalarExpr(sc.value.clone()),
11562 alias: Some(col.clone()),
11563 });
11564 }
11565 // Capture relationship types while r is bound; SET does not change them.
11566 for r in &rel_vars {
11567 set_returns.push(RetItem {
11568 value: RetVal::FuncCall {
11569 name: "type".into(),
11570 args: vec![Operand::Var(r.clone())],
11571 },
11572 alias: Some(rel_type_alias(r)),
11573 });
11574 }
11575
11576 let read_q = Query {
11577 matches: stmt.matches.clone(),
11578 optional_clauses: vec![],
11579 where_expr: stmt.where_expr.clone(),
11580 unwinds: vec![],
11581 post_unwind_where: None,
11582 stages: vec![],
11583 returns: set_returns,
11584 distinct: false,
11585 order_by: vec![],
11586 skip: None,
11587 limit: None,
11588 };
11589 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11590 detail: format!("plan: {e}"),
11591 })?;
11592 // MATCH phase is read-only; borrow ends before batch opens.
11593 //
11594 // When a role-scoped write is in flight, run the MATCH read through
11595 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
11596 // zero-rows (no SetProp ops generated, no existence-oracle 403).
11597 // Full-authority writes (pending_write_authz=None) keep view().
11598 let match_rs = {
11599 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11600 if let Some(ref mask) = mask_opt {
11601 execute(&self.view_masked(mask), &ops, &Params(params))
11602 } else {
11603 execute(&self.view(), &ops, &Params(params))
11604 }
11605 }
11606 .map_err(|e| GraphError::QueryError {
11607 detail: format!("execute: {e}"),
11608 })?;
11609
11610 // Collect (key, field, value) for each matched row × each SET clause.
11611 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
11612 for row_i in 0..match_rs.len() {
11613 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11614 let key = match match_rs.get(row_i, &sc.var) {
11615 Some(Value::Str(k)) => k.clone(),
11616 _ => {
11617 return Err(GraphError::QueryError {
11618 detail: format!(
11619 "SET variable '{}' did not resolve to a node key",
11620 sc.var
11621 ),
11622 })
11623 }
11624 };
11625 // The SET value was already evaluated by the executor.
11626 let value = match match_rs.get(row_i, col) {
11627 Some(v) => v.clone(),
11628 None => {
11629 return Err(GraphError::QueryError {
11630 detail: format!(
11631 "SET value for {}.{} evaluated to null",
11632 sc.var, sc.field
11633 ),
11634 })
11635 }
11636 };
11637 set_ops.push((key, sc.field.clone(), value));
11638 }
11639 }
11640
11641 // Apply as one atomic batch.
11642 let props_set = set_ops.len();
11643 let mut batch = self.batch();
11644 for (key, field, value) in set_ops {
11645 batch.set_prop(&key, &field, value);
11646 }
11647 batch.commit()?;
11648
11649 if let Some(returns) = project_returns {
11650 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
11651 }
11652
11653 let mut rs = write_result_set();
11654 rs.push_row(vec![
11655 Some(Value::Int(0)),
11656 Some(Value::Int(props_set as i64)),
11657 Some(Value::Int(0)),
11658 ]);
11659 Ok(rs)
11660 }
11661
11662 fn exec_match_delete(
11663 &mut self,
11664 stmt: core_query::cypher::MatchDeleteStmt,
11665 params: &BTreeMap<String, Value>,
11666 ) -> Result<ResultSet> {
11667 // Collect unique node vars needed to identify edge endpoints.
11668 let mut node_vars: Vec<String> = Vec::new();
11669 for ed in &stmt.deletes {
11670 if !node_vars.contains(&ed.src_var) {
11671 node_vars.push(ed.src_var.clone());
11672 }
11673 if !node_vars.contains(&ed.dst_var) {
11674 node_vars.push(ed.dst_var.clone());
11675 }
11676 }
11677
11678 // Synthesize read query.
11679 let returns: Vec<RetItem> = node_vars
11680 .iter()
11681 .map(|v| RetItem {
11682 value: RetVal::Var(v.clone()),
11683 alias: None,
11684 })
11685 .collect();
11686 let read_q = Query {
11687 matches: stmt.matches,
11688 optional_clauses: vec![],
11689 where_expr: stmt.where_expr,
11690 unwinds: vec![],
11691 post_unwind_where: None,
11692 stages: vec![],
11693 returns,
11694 distinct: false,
11695 order_by: vec![],
11696 skip: None,
11697 limit: None,
11698 };
11699 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11700 detail: format!("plan: {e}"),
11701 })?;
11702 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11703 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11704 let match_rs = {
11705 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11706 if let Some(ref mask) = mask_opt {
11707 execute(&self.view_masked(mask), &ops, &Params(params))
11708 } else {
11709 execute(&self.view(), &ops, &Params(params))
11710 }
11711 }
11712 .map_err(|e| GraphError::QueryError {
11713 detail: format!("execute: {e}"),
11714 })?;
11715
11716 // Collect (etype, src_key, dst_key) for each row × each delete target.
11717 let mut del_ops: Vec<(String, String, String)> = Vec::new();
11718 for row_i in 0..match_rs.len() {
11719 for ed in &stmt.deletes {
11720 let src_key = match match_rs.get(row_i, &ed.src_var) {
11721 Some(Value::Str(k)) => k.clone(),
11722 _ => {
11723 return Err(GraphError::QueryError {
11724 detail: format!(
11725 "DELETE src variable '{}' did not resolve to a node key",
11726 ed.src_var
11727 ),
11728 })
11729 }
11730 };
11731 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
11732 Some(Value::Str(k)) => k.clone(),
11733 _ => {
11734 return Err(GraphError::QueryError {
11735 detail: format!(
11736 "DELETE dst variable '{}' did not resolve to a node key",
11737 ed.dst_var
11738 ),
11739 })
11740 }
11741 };
11742 del_ops.push((ed.etype.clone(), src_key, dst_key));
11743 }
11744 }
11745
11746 // Apply as one atomic batch.
11747 let deleted = del_ops.len();
11748 let mut batch = self.batch();
11749 for (etype, src_key, dst_key) in del_ops {
11750 batch.delete_edge(&etype, &src_key, &dst_key);
11751 }
11752 batch.commit().map_err(|e| match e {
11753 GraphError::RuleOwned { .. } => GraphError::QueryError {
11754 detail: "cannot delete derived edge; retract via the rule or change the property"
11755 .to_string(),
11756 },
11757 other => other,
11758 })?;
11759
11760 let mut rs = write_result_set();
11761 rs.push_row(vec![
11762 Some(Value::Int(0)),
11763 Some(Value::Int(0)),
11764 Some(Value::Int(deleted as i64)),
11765 ]);
11766 Ok(rs)
11767 }
11768
11769 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
11770 ///
11771 /// Collects the matching node keys via an ephemeral read query, then calls
11772 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
11773 /// the executor first checks that the node has no incident edges; if any
11774 /// remain it returns a named error matching openCypher semantics.
11775 fn exec_match_delete_node(
11776 &mut self,
11777 stmt: MatchDeleteNodeStmt,
11778 params: &BTreeMap<String, Value>,
11779 ) -> Result<ResultSet> {
11780 // Build a read query returning only the node keys we need.
11781 let returns: Vec<RetItem> = stmt
11782 .node_vars
11783 .iter()
11784 .map(|v| RetItem {
11785 value: RetVal::Var(v.clone()),
11786 alias: None,
11787 })
11788 .collect();
11789 let read_q = Query {
11790 matches: stmt.matches,
11791 optional_clauses: vec![],
11792 where_expr: stmt.where_expr,
11793 unwinds: vec![],
11794 post_unwind_where: None,
11795 stages: vec![],
11796 returns,
11797 distinct: false,
11798 order_by: vec![],
11799 skip: None,
11800 limit: None,
11801 };
11802 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11803 detail: format!("plan: {e}"),
11804 })?;
11805 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11806 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11807 let match_rs = {
11808 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11809 if let Some(ref mask) = mask_opt {
11810 execute(&self.view_masked(mask), &ops, &Params(params))
11811 } else {
11812 execute(&self.view(), &ops, &Params(params))
11813 }
11814 }
11815 .map_err(|e| GraphError::QueryError {
11816 detail: format!("execute: {e}"),
11817 })?;
11818
11819 // Collect unique node keys to delete (deduplicate across rows × vars).
11820 let mut keys: Vec<String> = Vec::new();
11821 for row_i in 0..match_rs.len() {
11822 for var in &stmt.node_vars {
11823 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
11824 if !keys.contains(k) {
11825 keys.push(k.clone());
11826 }
11827 }
11828 }
11829 }
11830
11831 if !stmt.detach {
11832 // openCypher bare DELETE: error if any matched node has incident edges.
11833 for key in &keys {
11834 if let Some(id) = self.ids.get(key) {
11835 let tv = self.topo_view();
11836 let has_edges = tv.etypes().any(|et| {
11837 !tv.neighbors(et, Direction::Out, id).is_empty()
11838 || !tv.neighbors(et, Direction::In, id).is_empty()
11839 });
11840 if has_edges {
11841 return Err(GraphError::QueryError {
11842 detail: format!(
11843 "Cannot delete node `{key}` because it still has incident edges. \
11844 Use DETACH DELETE to remove the node and all its edges."
11845 ),
11846 });
11847 }
11848 }
11849 }
11850 }
11851
11852 let mut nodes_deleted = 0i64;
11853 let mut edges_deleted = 0i64;
11854 for key in keys {
11855 match self.delete_node(&key) {
11856 Ok(report) => {
11857 nodes_deleted += 1;
11858 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
11859 }
11860 Err(GraphError::KeyNotFound { .. }) => {
11861 // Node may have been deleted by an earlier iteration (e.g., via
11862 // multiple MATCH rows for the same node). Safe to skip.
11863 }
11864 Err(e) => return Err(e),
11865 }
11866 }
11867
11868 let mut rs = write_result_set();
11869 rs.push_row(vec![
11870 Some(Value::Int(0)),
11871 Some(Value::Int(0)),
11872 Some(Value::Int(nodes_deleted + edges_deleted)),
11873 ]);
11874 Ok(rs)
11875 }
11876
11877 /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
11878 /// the pattern named one, or the executing role's sole namespace when it
11879 /// did not. A role bound to two or more namespaces cannot choose, and is
11880 /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
11881 /// refuses a named `ns` the role cannot write.
11882 fn merge_create_props(
11883 &self,
11884 key_field: &str,
11885 key_value: &Value,
11886 named_ns: Option<&Value>,
11887 ) -> Result<Vec<(String, Value)>> {
11888 let mut props = vec![(key_field.to_string(), key_value.clone())];
11889 if let Some(ns) = named_ns {
11890 props.push((NS_PROP.to_string(), ns.clone()));
11891 return Ok(props);
11892 }
11893 if let Some(ns) = self.merge_create_stamp_ns()? {
11894 props.push((NS_PROP.to_string(), Value::Str(ns)));
11895 }
11896 Ok(props)
11897 }
11898
11899 /// The namespace a role-scoped MERGE create stamps when the pattern does
11900 /// not name `ns`. `None` = unscoped / full authority, so the node lands in
11901 /// `default`.
11902 fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
11903 let Some(authz) = self.pending_write_authz.as_ref() else {
11904 return Ok(None);
11905 };
11906 let Some(def) = self.role_def_for(&authz.role) else {
11907 return Ok(None);
11908 };
11909 match def.namespaces.as_deref() {
11910 Some([only]) => Ok(Some(only.clone())),
11911 Some(_) => Err(GraphError::RoleWriteDenied {
11912 reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
11913 }),
11914 None => Ok(None),
11915 }
11916 }
11917
11918 fn exec_merge(
11919 &mut self,
11920 stmt: core_query::cypher::MergeStmt,
11921 params: &BTreeMap<String, Value>,
11922 ) -> Result<ResultSet> {
11923 // MERGE: check if a node with the given key already exists.
11924 let key = match &stmt.key_value {
11925 Value::Str(s) => s.clone(),
11926 _ => {
11927 return Err(GraphError::QueryError {
11928 detail: format!(
11929 "MERGE key value must be a string (got {:?})",
11930 stmt.key_value
11931 ),
11932 })
11933 }
11934 };
11935
11936 if let Some(var) = stmt.var.as_deref() {
11937 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
11938 if sc.var != var {
11939 return Err(GraphError::QueryError {
11940 detail: format!(
11941 "SET variable '{}' does not match MERGE variable '{var}'",
11942 sc.var
11943 ),
11944 });
11945 }
11946 }
11947 }
11948
11949 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
11950 //
11951 // MERGE scope precondition: check create OR update scope for the
11952 // declared label BEFORE calling `has_node` (timing-oracle closure,
11953 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
11954 // unscoped roles — the scope denial fires without touching the key store).
11955 //
11956 // Clone to avoid holding a borrow on `self.pending_write_authz` while
11957 // also calling `self.ids.get(key)`.
11958 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
11959 let has_create = authz.scope.create_labels.contains(&stmt.label);
11960 let has_update = authz.scope.update_labels.contains(&stmt.label);
11961 if !has_create && !has_update {
11962 // Scope-before-lookup: 403 without has_node call (timing oracle
11963 // closure — see test_merge_unscoped_no_key_lookup).
11964 return Err(GraphError::RoleWriteDenied {
11965 reason: format!(
11966 "role-bound token: label '{}' not in write scope (create_labels)",
11967 stmt.label
11968 ),
11969 });
11970 }
11971 // Key lookup under mask.
11972 match self.ids.get(key.as_str()) {
11973 Some(id) if authz.mask.contains_id(id) => {
11974 // Visible: must have update scope to proceed to match arm.
11975 if !has_update {
11976 return Err(GraphError::RoleWriteDenied {
11977 reason: format!(
11978 "role-bound token: label '{}' not in write scope (update_labels)",
11979 stmt.label
11980 ),
11981 });
11982 }
11983 true // existed = true → match arm
11984 }
11985 Some(_) => {
11986 // Hidden: same error as absent to the role (spec §3.1/§3.3).
11987 return Err(GraphError::RoleWriteDenied {
11988 reason: "role-bound token: target node not visible".into(),
11989 });
11990 }
11991 None => {
11992 // Absent: must have create scope to proceed to the create arm.
11993 //
11994 // Update-only roles (create_labels empty, update_labels set):
11995 // return the SAME "not visible" error as the hidden-key branch
11996 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
11997 // "confirm existence of hidden nodes: No").
11998 //
11999 // Create-scoped roles (has_create=true): absent → create arm
12000 // as before. The accepted structural key-existence disclosure
12001 // (§THREAT-MODEL) applies only when the role holds create scope.
12002 if !has_create {
12003 return Err(GraphError::RoleWriteDenied {
12004 reason: "role-bound token: target node not visible".into(),
12005 });
12006 }
12007 false // existed = false → create arm
12008 }
12009 }
12010 } else {
12011 // Full authority: use the existing non-masked has_node check.
12012 self.has_node(&key)
12013 };
12014
12015 let existed = merge_existed;
12016 let create_props = if existed {
12017 None
12018 } else {
12019 Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
12020 };
12021 let mut created = 0i64;
12022 if create_props.is_some() || !stmt.on_match.is_empty() {
12023 let mut batch = self.batch();
12024 if let Some(props) = create_props {
12025 batch.insert_node(&stmt.label, &key, props);
12026 for sc in &stmt.on_create {
12027 let value = resolve_merge_set_value(&sc.value, params)?;
12028 batch.set_prop(&key, &sc.field, value);
12029 }
12030 created = 1;
12031 } else {
12032 for sc in &stmt.on_match {
12033 let value = resolve_merge_set_value(&sc.value, params)?;
12034 batch.set_prop(&key, &sc.field, value);
12035 }
12036 }
12037 batch.commit()?;
12038 }
12039
12040 // Refresh the role mask so the just-created node is visible to this
12041 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
12042 // (apply_schema subset rule), so the new node's label is already in the
12043 // role's read scope — this never widens beyond the role's declared labels.
12044 if !existed {
12045 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
12046 let new_mask = self.mask_for_role(&role)?;
12047 if let Some(a) = self.pending_write_authz.as_mut() {
12048 a.mask = new_mask;
12049 }
12050 }
12051 }
12052
12053 // Optional RETURN clause: project the node (created or matched) as a read result.
12054 if let Some(returns) = stmt.returns {
12055 let var = stmt.var.as_deref().unwrap_or("_mn0");
12056 let q = Query {
12057 matches: vec![Pattern {
12058 start: NodePat {
12059 var: Some(var.to_string()),
12060 label: Some(stmt.label.clone()),
12061 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
12062 },
12063 chain: vec![],
12064 shortest: false,
12065 }],
12066 optional_clauses: vec![],
12067 where_expr: None,
12068 unwinds: vec![],
12069 post_unwind_where: None,
12070 stages: vec![],
12071 returns,
12072 distinct: false,
12073 order_by: vec![],
12074 skip: None,
12075 limit: None,
12076 };
12077 let ops = plan(&q).map_err(|e| GraphError::QueryError {
12078 detail: format!("plan: {e}"),
12079 })?;
12080 // Use view_masked when a role-scoped write is in flight so the
12081 // post-merge projection is consistent with the masked read phase.
12082 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
12083 return (if let Some(ref mask) = mask_opt {
12084 execute(&self.view_masked(mask), &ops, &Params(params))
12085 } else {
12086 execute(&self.view(), &ops, &Params(params))
12087 })
12088 .map_err(|e| GraphError::QueryError {
12089 detail: format!("execute: {e}"),
12090 });
12091 }
12092
12093 let mut rs = write_result_set();
12094 rs.push_row(vec![
12095 Some(Value::Int(created)),
12096 Some(Value::Int(0)),
12097 Some(Value::Int(0)),
12098 ]);
12099 Ok(rs)
12100 }
12101
12102 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
12103 /// annotated with rule name, edge type, direction, and weight.
12104 /// Results are sorted by (rule, edge_type).
12105 /// Returns `Err(KeyNotFound)` if either key is unknown.
12106 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
12107 self.ensure_v8_base_sections_loaded();
12108 let id_a = self
12109 .ids
12110 .get(key_a)
12111 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
12112 let id_b = self
12113 .ids
12114 .get(key_b)
12115 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
12116
12117 let mut results = Vec::new();
12118
12119 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
12120 // rather than O(total provenance).
12121 let scan = if self.engine.provenance_touching_len(id_a)
12122 <= self.engine.provenance_touching_len(id_b)
12123 {
12124 id_a
12125 } else {
12126 id_b
12127 };
12128 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
12129 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
12130 continue;
12131 }
12132 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
12133 continue;
12134 };
12135 let edge_type = match self.syms.resolve(etype) {
12136 Some(s) => s.to_string(),
12137 None => continue,
12138 };
12139 // Provenance (src, dst) ids come from the archived PROVENANCE section
12140 // (large, no eager CRC). A corrupt section can produce ids that are
12141 // out of range; return Corrupt rather than panic.
12142 let src_key = self
12143 .ids
12144 .key_of(src)
12145 .ok_or_else(|| GraphError::Corrupt {
12146 detail: format!("v8: provenance src id {src} not in id table"),
12147 })?
12148 .to_string();
12149 let dst_key = self
12150 .ids
12151 .key_of(dst)
12152 .ok_or_else(|| GraphError::Corrupt {
12153 detail: format!("v8: provenance dst id {dst} not in id table"),
12154 })?
12155 .to_string();
12156 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
12157 self.edge_props_view()
12158 .get(etype, src, dst, prop)
12159 .and_then(|v| {
12160 if let Value::Float(f) = v {
12161 Some(f)
12162 } else {
12163 None
12164 }
12165 })
12166 });
12167 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
12168 // still have a score: recompute it from the predicate so explain
12169 // never reports "no score" for an edge the engine scored. Via-hop
12170 // rules score over their via set, not over (src, dst), so leave
12171 // those None rather than report a number the rule did not produce.
12172 let weight = stored.or_else(|| {
12173 if rule_def.via_edge.is_some() {
12174 return None;
12175 }
12176 let props_view = build_props_view(&self.props, &self.base);
12177 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
12178 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12179 let src_view = NodeView {
12180 key: &src_key,
12181 props: &src_get,
12182 };
12183 let dst_view = NodeView {
12184 key: &dst_key,
12185 props: &dst_get,
12186 };
12187 evaluate(&rule_def.predicate, &src_view, &dst_view)
12188 });
12189 results.push(Explanation {
12190 rule: rule_name.to_string(),
12191 edge_type,
12192 src_key,
12193 dst_key,
12194 weight,
12195 predicate: PredicateSummary {
12196 approximate: rule_def.approximate,
12197 ..PredicateSummary::from(&rule_def.predicate)
12198 },
12199 via_edge: rule_def.via_edge.clone(),
12200 });
12201 }
12202
12203 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
12204 Ok(results)
12205 }
12206
12207 /// [`explain`](Self::explain) with the scoped read contract (§5.3): both
12208 /// endpoints are subject-checked, and any explanation whose evidence runs
12209 /// through a hidden node is **dropped entirely, not redacted**.
12210 ///
12211 /// A plain two-node rule's evidence is the pair itself, so once both
12212 /// subjects are visible there is nothing left to hide. A **via-hop** rule is
12213 /// different: it fires `src → dst` because some node carrying `via_label`
12214 /// sits between them, and [`Explanation`] carries the hop's edge *type*
12215 /// (`via_edge`) and never the hop's key. There is no field to blank, so a
12216 /// redacted explanation would still say "these two are linked through
12217 /// something you cannot see" — which discloses that the something exists.
12218 /// The explanation is therefore kept only when at least one **visible** via
12219 /// node satisfies the rule on its own.
12220 ///
12221 /// The weight is the **visible corpus's** number, not the store's: a via-hop
12222 /// rule stores the max over every via it hopped through, so the stored value
12223 /// can be a score only a hidden via produced. It is recomputed over the
12224 /// visible vias alone.
12225 ///
12226 /// Hidden or unknown `key_a` or `key_b` → [`GraphError::KeyNotFound`].
12227 pub fn explain_scoped(
12228 &self,
12229 key_a: &str,
12230 key_b: &str,
12231 mask: &crate::mask::NodeMask,
12232 ) -> Result<Vec<Explanation>> {
12233 for key in [key_a, key_b] {
12234 if !mask.contains_node(self, key) {
12235 return Err(GraphError::KeyNotFound { key: key.into() });
12236 }
12237 }
12238 Ok(self
12239 .explain(key_a, key_b)?
12240 .into_iter()
12241 .filter_map(|e| self.scoped_explanation(e, mask))
12242 .collect())
12243 }
12244
12245 /// `e` as a caller limited to `mask` may have it, or `None` when it must be
12246 /// dropped entirely.
12247 ///
12248 /// Every non-via-hop explanation passes through untouched: its only nodes
12249 /// are the two subjects, which [`explain_scoped`](Self::explain_scoped) has
12250 /// already checked, and its weight is scored over that pair alone.
12251 ///
12252 /// A via-hop explanation is kept only when some via node the caller may see
12253 /// satisfies the rule on its own — and then its weight is recomputed as the
12254 /// max over exactly those vias. The engine writes the max over **all** of
12255 /// them (`core-rules::engine`, `best = prev.max(score)`), so passing the
12256 /// stored number through would let a hidden node set a figure the caller
12257 /// reads: the same disclosure dropping the explanation exists to prevent.
12258 ///
12259 /// A rule that stores no weight still reports none. The recomputed score is
12260 /// a sanitised version of a number `explain` already returned, never a new
12261 /// one — a scoped read must not say more than the unscoped read it narrows.
12262 fn scoped_explanation(
12263 &self,
12264 e: Explanation,
12265 mask: &crate::mask::NodeMask,
12266 ) -> Option<Explanation> {
12267 let Some(via_edge) = e.via_edge.clone() else {
12268 return Some(e);
12269 };
12270 let Some(rule_def) = self.engine.rules().find(|r| r.name == e.rule) else {
12271 // The rule is gone but its provenance is not; nothing can vouch for
12272 // the hop, so nothing is shown.
12273 return None;
12274 };
12275 let Some(via_label) = rule_def.via_label.as_deref() else {
12276 return Some(e);
12277 };
12278 let (Some(src), Some(dst)) = (self.ids.get(&e.src_key), self.ids.get(&e.dst_key)) else {
12279 return None;
12280 };
12281 let (Some(via_etype), Some(via_sym)) = (self.syms.get(&via_edge), self.syms.get(via_label))
12282 else {
12283 return None;
12284 };
12285 let via_dir = rule_def.via_dir.unwrap_or(Direction::Out);
12286 let props_view = build_props_view(&self.props, &self.base);
12287 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12288 let dst_view = NodeView {
12289 key: &e.dst_key,
12290 props: &dst_get,
12291 };
12292 // The rule's own namespace test, the one the engine applies to each via
12293 // candidate (`core-rules::engine::rule_sees_node`). Without it a
12294 // visible, out-of-namespace via — right label, satisfying predicate —
12295 // vouches for a hop the engine never made, and an explanation whose real
12296 // evidence is a hidden in-namespace node is kept.
12297 let rule_sees = |id: u32| match rule_def.namespace.as_deref() {
12298 None => true,
12299 Some(ns) => {
12300 let value = props_view.get(id, NS_PROP).map(|vr| vr.into_value());
12301 namespace_of_value(value.as_ref()) == ns
12302 }
12303 };
12304 let best = self
12305 .topo_view()
12306 .neighbors(via_etype, via_dir, src)
12307 .iter()
12308 .copied()
12309 .filter_map(|via| {
12310 if !mask.contains_id(via) {
12311 return None;
12312 }
12313 if self.labels.get(via as usize).copied() != Some(via_sym) {
12314 return None;
12315 }
12316 if !rule_sees(via) {
12317 return None;
12318 }
12319 let via_key = self.ids.key_of(via)?;
12320 let via_get = |field: &str| props_view.get(via, field).map(|vr| vr.into_value());
12321 let via_view = NodeView {
12322 key: via_key,
12323 props: &via_get,
12324 };
12325 evaluate(&rule_def.predicate, &via_view, &dst_view)
12326 })
12327 .fold(None::<f64>, |best, score| {
12328 Some(match best {
12329 None => score,
12330 Some(prev) => prev.max(score),
12331 })
12332 })?;
12333 let weight = e.weight.map(|_| best);
12334 Some(Explanation { weight, ..e })
12335 }
12336
12337 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
12338 let id = self
12339 .ids
12340 .get(key)
12341 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12342 let Some(sym) = self.syms.get(edge_type) else {
12343 return Ok(Vec::new());
12344 };
12345 self.topo_view()
12346 .neighbors(sym, dir, id)
12347 .iter()
12348 .map(|&n| {
12349 self.ids
12350 .key_of(n)
12351 .map(|k| k.to_string())
12352 .ok_or_else(|| GraphError::Corrupt {
12353 detail: format!("topology id {n} has no key"),
12354 })
12355 })
12356 .collect::<Result<Vec<_>>>()
12357 }
12358
12359 /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
12360 /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
12361 pub fn degree(
12362 &self,
12363 key: &str,
12364 edge_type: Option<&str>,
12365 direction: crate::algo::AlgoDir,
12366 ) -> Result<u64> {
12367 let id = self
12368 .ids
12369 .get(key)
12370 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12371 let topo = self.topo_view();
12372 Ok(Self::unique_directed_degree(
12373 &topo, &self.syms, id, edge_type, direction,
12374 ))
12375 }
12376
12377 /// [`degree`](Self::degree) summing each pair's **insert count** instead of
12378 /// counting each pair once (§5.13).
12379 ///
12380 /// The unique degree asks how many neighbours there are; this asks how many
12381 /// times they were inserted. A pair with no recorded count contributes 1,
12382 /// so on a store that never called
12383 /// [`enable_multiplicity`](Self::enable_multiplicity) this returns exactly
12384 /// what [`degree`](Self::degree) returns rather than erroring — the
12385 /// distinction is a readout preference, not a demand the store cannot meet.
12386 ///
12387 /// `AlgoDir::Both` still sums out + in, so a pair visible on both sides
12388 /// still contributes twice: multiplicity changes what a pair is worth, never
12389 /// how a direction is counted.
12390 pub fn degree_multiplicity(
12391 &self,
12392 key: &str,
12393 edge_type: Option<&str>,
12394 direction: crate::algo::AlgoDir,
12395 ) -> Result<u64> {
12396 let id = self
12397 .ids
12398 .get(key)
12399 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12400 Ok(Self::multiplicity_directed_degree(
12401 &self.topo_view(),
12402 &self.edge_props_view(),
12403 &self.syms,
12404 id,
12405 edge_type,
12406 direction,
12407 None,
12408 ))
12409 }
12410
12411 /// [`degree_multiplicity`](Self::degree_multiplicity) under a scope.
12412 ///
12413 /// The sum covers **visible pairs only**. A hidden neighbour's inserts stay
12414 /// out of it for the reason
12415 /// [`degree_scoped`](Self::degree_scoped) documents, and more sharply: an
12416 /// unscoped multiplicity count discloses not only that a hidden neighbour
12417 /// exists but how often it was written.
12418 ///
12419 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
12420 pub fn degree_scoped_multiplicity(
12421 &self,
12422 key: &str,
12423 edge_type: Option<&str>,
12424 direction: crate::algo::AlgoDir,
12425 mask: &crate::mask::NodeMask,
12426 ) -> Result<u64> {
12427 let id = self
12428 .ids
12429 .get(key)
12430 .filter(|&id| mask.contains_id(id))
12431 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12432 Ok(Self::multiplicity_directed_degree(
12433 &self.topo_view(),
12434 &self.edge_props_view(),
12435 &self.syms,
12436 id,
12437 edge_type,
12438 direction,
12439 Some(mask),
12440 ))
12441 }
12442
12443 /// [`degree`](Self::degree) counting **only neighbours the mask admits**.
12444 ///
12445 /// The filter is a correctness requirement, not an optimisation: an
12446 /// unfiltered count discloses the existence of a hidden neighbour to a
12447 /// caller who cannot see it, which is the same leak
12448 /// [`node_edges_scoped`](Self::node_edges_scoped) exists to prevent —
12449 /// reached by arithmetic instead of by name.
12450 ///
12451 /// Hidden or unknown `key` → [`GraphError::KeyNotFound`]. Unknown
12452 /// `edge_type` is still 0, as it is unscoped.
12453 pub fn degree_scoped(
12454 &self,
12455 key: &str,
12456 edge_type: Option<&str>,
12457 direction: crate::algo::AlgoDir,
12458 mask: &crate::mask::NodeMask,
12459 ) -> Result<u64> {
12460 let id = self
12461 .ids
12462 .get(key)
12463 .filter(|&id| mask.contains_id(id))
12464 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12465 let topo = self.topo_view();
12466 Ok(Self::visible_directed_degree(
12467 &topo, &self.syms, id, edge_type, direction, mask,
12468 ))
12469 }
12470
12471 /// Unique directed degree for a subset or a label scan.
12472 ///
12473 /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
12474 /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
12475 /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
12476 #[allow(clippy::too_many_arguments)]
12477 pub fn degrees(
12478 &self,
12479 keys: Option<&[String]>,
12480 label: Option<&str>,
12481 where_: Option<&PropPredicate>,
12482 edge_type: Option<&str>,
12483 direction: crate::algo::AlgoDir,
12484 limit: Option<usize>,
12485 ) -> Result<Vec<(String, u64)>> {
12486 self.degrees_inner(
12487 keys, label, where_, edge_type, direction, limit, None, false,
12488 )
12489 }
12490
12491 /// [`degrees`](Self::degrees) reporting each row's **insert-count** sum
12492 /// instead of its unique neighbour count, as
12493 /// [`degree_multiplicity`](Self::degree_multiplicity) does for one key.
12494 ///
12495 /// The sort is still degree descending, key ascending — over the counts this
12496 /// reading produces — and `limit` still applies after it.
12497 #[allow(clippy::too_many_arguments)]
12498 pub fn degrees_multiplicity(
12499 &self,
12500 keys: Option<&[String]>,
12501 label: Option<&str>,
12502 where_: Option<&PropPredicate>,
12503 edge_type: Option<&str>,
12504 direction: crate::algo::AlgoDir,
12505 limit: Option<usize>,
12506 ) -> Result<Vec<(String, u64)>> {
12507 self.degrees_inner(keys, label, where_, edge_type, direction, limit, None, true)
12508 }
12509
12510 /// [`degrees_scoped`](Self::degrees_scoped) reporting insert counts.
12511 ///
12512 /// Both filters apply: a hidden key stays out of the result, and every
12513 /// row's sum covers its **visible** pairs only.
12514 #[allow(clippy::too_many_arguments)]
12515 pub fn degrees_scoped_multiplicity(
12516 &self,
12517 keys: Option<&[String]>,
12518 label: Option<&str>,
12519 where_: Option<&PropPredicate>,
12520 edge_type: Option<&str>,
12521 direction: crate::algo::AlgoDir,
12522 limit: Option<usize>,
12523 mask: &crate::mask::NodeMask,
12524 ) -> Result<Vec<(String, u64)>> {
12525 self.degrees_inner(
12526 keys,
12527 label,
12528 where_,
12529 edge_type,
12530 direction,
12531 limit,
12532 Some(mask),
12533 true,
12534 )
12535 }
12536
12537 /// [`degrees`](Self::degrees) with the scope applied on both sides: a hidden
12538 /// key is omitted from the input — whether it arrived in `keys` or came out
12539 /// of the `label`/`where_` scan — and every row's count is the count of its
12540 /// **visible** neighbours, for the reason
12541 /// [`degree_scoped`](Self::degree_scoped) documents.
12542 ///
12543 /// Unlike `degree_scoped`, a hidden key here is not
12544 /// [`GraphError::KeyNotFound`]: `degrees` already drops unknown keys
12545 /// silently, so hidden and absent stay one answer by staying out of the
12546 /// result. `limit` still applies after the sort, and so counts visible rows.
12547 #[allow(clippy::too_many_arguments)]
12548 pub fn degrees_scoped(
12549 &self,
12550 keys: Option<&[String]>,
12551 label: Option<&str>,
12552 where_: Option<&PropPredicate>,
12553 edge_type: Option<&str>,
12554 direction: crate::algo::AlgoDir,
12555 limit: Option<usize>,
12556 mask: &crate::mask::NodeMask,
12557 ) -> Result<Vec<(String, u64)>> {
12558 self.degrees_inner(
12559 keys,
12560 label,
12561 where_,
12562 edge_type,
12563 direction,
12564 limit,
12565 Some(mask),
12566 false,
12567 )
12568 }
12569
12570 /// The body shared by [`degrees`](Self::degrees) and
12571 /// [`degrees_scoped`](Self::degrees_scoped). `mask = None` is the unscoped
12572 /// contract unchanged.
12573 #[allow(clippy::too_many_arguments)]
12574 fn degrees_inner(
12575 &self,
12576 keys: Option<&[String]>,
12577 label: Option<&str>,
12578 where_: Option<&PropPredicate>,
12579 edge_type: Option<&str>,
12580 direction: crate::algo::AlgoDir,
12581 limit: Option<usize>,
12582 mask: Option<&crate::mask::NodeMask>,
12583 multiplicity: bool,
12584 ) -> Result<Vec<(String, u64)>> {
12585 if let Some(pred) = where_ {
12586 pred.validate_named("where")
12587 .map_err(|detail| GraphError::QueryError { detail })?;
12588 }
12589 if matches!(keys, Some(ks) if ks.is_empty()) {
12590 return Ok(Vec::new());
12591 }
12592 let view = self.view();
12593 let ids: Vec<u32> = match keys {
12594 Some(ks) => {
12595 let mut seen = HashSet::new();
12596 let mut out = Vec::new();
12597 for k in ks {
12598 let Some(id) = view.ids.get(k) else {
12599 continue;
12600 };
12601 if !seen.insert(id) {
12602 continue;
12603 }
12604 if let Some(pred) = where_ {
12605 let holds = match view.prop(id, &pred.field) {
12606 None => pred.holds(None),
12607 Some(vr) => pred.holds(Some(vr.as_value())),
12608 };
12609 if !holds {
12610 continue;
12611 }
12612 }
12613 out.push(id);
12614 }
12615 out
12616 }
12617 None => Self::vector_candidates(&view, label, where_),
12618 };
12619 let mut out: Vec<(String, u64)> = ids
12620 .into_iter()
12621 // A hidden candidate leaves as quietly as an unknown key does.
12622 .filter(|&id| mask.is_none_or(|m| m.contains_id(id)))
12623 .filter_map(|id| {
12624 let key = self.ids.key_of(id)?.to_string();
12625 let deg = match (multiplicity, mask) {
12626 // The same `view` the unique arms read, so the per-row
12627 // rebuild F9 measured is gone and all three arms agree on
12628 // the state they are reading.
12629 (true, m) => Self::multiplicity_directed_degree(
12630 &view.topo,
12631 &view.edge_props,
12632 view.syms,
12633 id,
12634 edge_type,
12635 direction,
12636 m,
12637 ),
12638 (false, Some(m)) => Self::visible_directed_degree(
12639 &view.topo, view.syms, id, edge_type, direction, m,
12640 ),
12641 (false, None) => Self::unique_directed_degree(
12642 &view.topo, view.syms, id, edge_type, direction,
12643 ),
12644 };
12645 Some((key, deg))
12646 })
12647 .collect();
12648 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
12649 if let Some(lim) = limit {
12650 out.truncate(lim);
12651 }
12652 Ok(out)
12653 }
12654
12655 /// Unique neighbour count for `id` across `edge_type` (or all types) and
12656 /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
12657 fn unique_directed_degree(
12658 topo: &TopologyView<'_>,
12659 syms: &Interner,
12660 id: u32,
12661 edge_type: Option<&str>,
12662 direction: crate::algo::AlgoDir,
12663 ) -> u64 {
12664 let dirs: &[Direction] = match direction {
12665 crate::algo::AlgoDir::Out => &[Direction::Out],
12666 crate::algo::AlgoDir::In => &[Direction::In],
12667 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12668 };
12669 match edge_type {
12670 Some(name) => {
12671 let Some(et) = syms.get(name) else {
12672 return 0;
12673 };
12674 dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
12675 }
12676 None => topo
12677 .etypes()
12678 .map(|et| {
12679 dirs.iter()
12680 .map(|&d| topo.degree(et, d, id) as u64)
12681 .sum::<u64>()
12682 })
12683 .sum(),
12684 }
12685 }
12686
12687 /// [`unique_directed_degree`](Self::unique_directed_degree) counting only
12688 /// neighbours `mask` admits.
12689 ///
12690 /// Same shape, one substitution: `topo.degree` is a length, so it cannot be
12691 /// filtered; the neighbour list it measures can. `Both` still sums out + in,
12692 /// so a node visible on both sides still counts twice — the filter changes
12693 /// which neighbours are counted, never how a degree is defined.
12694 fn visible_directed_degree(
12695 topo: &TopologyView<'_>,
12696 syms: &Interner,
12697 id: u32,
12698 edge_type: Option<&str>,
12699 direction: crate::algo::AlgoDir,
12700 mask: &crate::mask::NodeMask,
12701 ) -> u64 {
12702 let dirs: &[Direction] = match direction {
12703 crate::algo::AlgoDir::Out => &[Direction::Out],
12704 crate::algo::AlgoDir::In => &[Direction::In],
12705 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12706 };
12707 let visible = |et: u32| -> u64 {
12708 dirs.iter()
12709 .map(|&d| {
12710 topo.neighbors(et, d, id)
12711 .iter()
12712 .filter(|&&n| mask.contains_id(n))
12713 .count() as u64
12714 })
12715 .sum()
12716 };
12717 match edge_type {
12718 Some(name) => syms.get(name).map_or(0, visible),
12719 None => topo.etypes().map(visible).sum(),
12720 }
12721 }
12722
12723 /// Sum of the insert counts of `id`'s pairs (§5.13), over `edge_type` (or
12724 /// all types) and `direction`, restricted to what `mask` admits when one is
12725 /// given.
12726 ///
12727 /// The same neighbour lists the unique reading measures, with each entry
12728 /// worth its pair's count rather than worth 1 — so the filter decides which
12729 /// pairs are in the sum and the count decides what each contributes. A
12730 /// direction decides which way round the pair is addressed: an `In`
12731 /// neighbour `n` of `id` is the pair `(et, n, id)`.
12732 ///
12733 /// Takes its views as parameters, exactly as the unique helpers do, because
12734 /// it is called once per row from a label scan. `edge_props_view()` reaches
12735 /// into the mmap'd base's rkyv section on every call, so building the two
12736 /// views inside made an N-row `degrees(multiplicity=True)` do N section
12737 /// accesses where the unique reading does one: worth 2.57 ms of 16.68 ms
12738 /// over 20 000 rows, about 0.13 us per row (defect #29,
12739 /// `tests/f9_bench.rs`). Most of that call's cost is the per-neighbour
12740 /// count lookup and is inherent, so this is a hoist, not a rescue.
12741 ///
12742 /// The views are exactly `self.view()`'s own `topo` and `edge_props`, so a
12743 /// caller that already has a view passes its halves and reads the same
12744 /// state it reads everything else from.
12745 fn multiplicity_directed_degree(
12746 topo: &TopologyView<'_>,
12747 edge_props: &EdgePropsView<'_>,
12748 syms: &Interner,
12749 id: u32,
12750 edge_type: Option<&str>,
12751 direction: crate::algo::AlgoDir,
12752 mask: Option<&crate::mask::NodeMask>,
12753 ) -> u64 {
12754 let dirs: &[Direction] = match direction {
12755 crate::algo::AlgoDir::Out => &[Direction::Out],
12756 crate::algo::AlgoDir::In => &[Direction::In],
12757 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12758 };
12759 let count_of = |et: u32, src: u32, dst: u32| -> u64 {
12760 match edge_props.get(et, src, dst, EDGE_COUNT_PROP) {
12761 Some(Value::Int(n)) if n > 0 => n as u64,
12762 _ => 1,
12763 }
12764 };
12765 let per_etype = |et: u32| -> u64 {
12766 dirs.iter()
12767 .map(|&d| {
12768 topo.neighbors(et, d, id)
12769 .iter()
12770 .filter(|&&n| mask.is_none_or(|m| m.contains_id(n)))
12771 .map(|&n| match d {
12772 Direction::Out => count_of(et, id, n),
12773 Direction::In => count_of(et, n, id),
12774 })
12775 .sum::<u64>()
12776 })
12777 .sum()
12778 };
12779 match edge_type {
12780 Some(name) => syms.get(name).map_or(0, per_etype),
12781 None => topo.etypes().map(per_etype).sum(),
12782 }
12783 }
12784
12785 /// Return the last-change commit sequence for `key`, or `None` if the node
12786 /// does not exist or has never been mutated since the last V5-V7 snapshot
12787 /// (horizon-bounded for legacy stores).
12788 ///
12789 /// The returned sequence is a monotonically increasing counter that starts
12790 /// at 1 for the first commit after `open` and increments with every
12791 /// successful write. WAL replay at open also assigns sequences (1..N for N
12792 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
12793 ///
12794 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
12795 /// in the snapshot but not touched by any WAL frame will return `None`
12796 /// (horizon-bounded: CAS against such nodes is only safe after the first
12797 /// V8 snapshot or after the node is next mutated).
12798 pub fn last_changed(&self, key: &str) -> Option<u64> {
12799 let id = self.ids.get(key)?;
12800 self.last_change.get(&id).copied()
12801 }
12802
12803 /// Which loaded store this handle is.
12804 ///
12805 /// Paired with [`commit_seq`](GraphDb::commit_seq) it identifies a graph
12806 /// state outright, which `commit_seq` alone does not: two stores of the same
12807 /// age share a sequence, and a reload can return to one. Memos of dense
12808 /// node ids that the handle does not own are stamped with both; see
12809 /// [`StoreStamp`](crate::mask::StoreStamp).
12810 pub(crate) fn store_id(&self) -> crate::mask::StoreId {
12811 self.store_id
12812 }
12813
12814 /// The current commit sequence (number of successful commits since open,
12815 /// including WAL replay frames). Useful for recording a baseline before
12816 /// a read-modify-write cycle.
12817 pub fn commit_seq(&self) -> u64 {
12818 self.commit_seq
12819 }
12820
12821 /// Check that all `preconds` are satisfied against the current db state.
12822 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
12823 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
12824 for precond in preconds {
12825 match precond {
12826 Precondition::NodeUnchangedSince { key, expected } => {
12827 // Missing entry means the node predates the WAL window or
12828 // does not exist; treat as 0 (before any commit).
12829 let actual = self.last_changed(key).unwrap_or_default();
12830 if actual != *expected {
12831 return Err(GraphError::CasConflict {
12832 key: key.clone(),
12833 expected: *expected,
12834 actual,
12835 });
12836 }
12837 }
12838 Precondition::NodeAbsent { key } => {
12839 // Node must not exist (not live).
12840 if self.ids.get(key).is_some() {
12841 let actual = self.last_changed(key).unwrap_or(0);
12842 return Err(GraphError::CasConflict {
12843 key: key.clone(),
12844 expected: u64::MAX,
12845 actual,
12846 });
12847 }
12848 }
12849 }
12850 }
12851 Ok(())
12852 }
12853
12854 /// Apply a batch of mutations with compare-and-set preconditions.
12855 ///
12856 /// All preconditions are checked atomically before any operation is applied.
12857 /// If any precondition fails, the entire batch is rejected with
12858 /// [`GraphError::CasConflict`] and no WAL frame is written.
12859 ///
12860 /// # Returns
12861 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
12862 ///
12863 /// # Errors
12864 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
12865 /// - Any error that [`write_batch`] would return for the ops themselves.
12866 pub fn write_batch_cas(
12867 &mut self,
12868 preconds: Vec<Precondition>,
12869 ops: Vec<BatchOp>,
12870 ) -> Result<(usize, usize)> {
12871 self.check_preconditions(&preconds)?;
12872 self.commit_logged_batch(ops, None, None).map(inserted_pair)
12873 }
12874
12875 /// Update the per-node last-change map for a WAL record at commit `seq`.
12876 ///
12877 /// Called after a successful apply to record which nodes were touched.
12878 /// For replay, called with the WAL-frame's replayed seq.
12879 ///
12880 /// Touch definition (see [`Precondition`] doc):
12881 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
12882 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
12883 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
12884 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
12885 /// - Batch → recurse into inner records.
12886 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
12887 match rec {
12888 WalRecord::InsertNode { key, .. }
12889 | WalRecord::SetProp { key, .. }
12890 | WalRecord::RemoveProp { key, .. } => {
12891 if let Some(id) = self.ids.get(key) {
12892 self.last_change.insert(id, seq);
12893 }
12894 }
12895 WalRecord::InsertNodeId { key, .. } => {
12896 if let Some(id) = self.ids.get(key) {
12897 self.last_change.insert(id, seq);
12898 }
12899 }
12900 WalRecord::SetPropId { id, .. } => {
12901 self.last_change.insert(*id, seq);
12902 }
12903 WalRecord::InsertEdge {
12904 src_key, dst_key, ..
12905 }
12906 | WalRecord::DeleteEdge {
12907 src_key, dst_key, ..
12908 } => {
12909 if let Some(src_id) = self.ids.get(src_key) {
12910 self.last_change.insert(src_id, seq);
12911 }
12912 if let Some(dst_id) = self.ids.get(dst_key) {
12913 self.last_change.insert(dst_id, seq);
12914 }
12915 }
12916 WalRecord::InsertEdgeId { src, dst, .. } => {
12917 self.last_change.insert(*src, seq);
12918 self.last_change.insert(*dst, seq);
12919 }
12920 // A count record touches the pair, so it touches both endpoints —
12921 // the same reading `InsertEdgeId` gets, because a duplicate insert
12922 // that raises the count *is* a mutation of that pair. The opt-in
12923 // declaration touches nothing.
12924 WalRecord::SetEdgeCount { src, dst, .. } if !rec.is_multiplicity_decl() => {
12925 self.last_change.insert(*src, seq);
12926 self.last_change.insert(*dst, seq);
12927 }
12928 WalRecord::SetEdgeCount { .. } => {}
12929 // DeleteNode: node is tombstoned; last_changed(key) returns None for
12930 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
12931 // History markers: state no-ops; the underlying mutation already
12932 // touched the relevant nodes' last_change entries.
12933 WalRecord::DeleteNode { .. }
12934 | WalRecord::DerivedEdgeAdded { .. }
12935 | WalRecord::DerivedEdgeRetracted { .. }
12936 | WalRecord::Intern { .. }
12937 | WalRecord::CreateRule { .. }
12938 | WalRecord::DeleteRule { .. }
12939 | WalRecord::RebuildRule { .. }
12940 | WalRecord::CreateView { .. }
12941 | WalRecord::DeleteView { .. }
12942 | WalRecord::EnableFulltext { .. }
12943 | WalRecord::DisableFulltext { .. }
12944 | WalRecord::EnableIndex { .. }
12945 | WalRecord::DisableIndex { .. } => {}
12946 // RenameNode: node id is stable; update last_change via the new key.
12947 // Called after apply(), so ids already reflects new_key.
12948 WalRecord::RenameNode { new_key, .. } => {
12949 if let Some(id) = self.ids.get(new_key) {
12950 self.last_change.insert(id, seq);
12951 }
12952 }
12953 WalRecord::Batch(inner) => {
12954 for inner_rec in inner {
12955 self.update_last_change_from_rec(inner_rec, seq);
12956 }
12957 }
12958 }
12959 }
12960
12961 pub fn node_count(&self) -> usize {
12962 self.ids.len()
12963 }
12964
12965 /// Configure archive retention: keep the `N` newest WAL archives at each
12966 /// [`snapshot_with`] call when `archive_wal: true`.
12967 ///
12968 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
12969 /// `Some(0)` or `None` → unlimited (no pruning).
12970 ///
12971 /// Pruning only ever happens inside [`snapshot_with`]; this method only
12972 /// stores the policy. Archives below the retention limit are deleted
12973 /// oldest-first. The horizon floor is updated so that
12974 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
12975 /// in pruned archives rather than silently returning wrong data.
12976 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
12977 self.wal_archive_retention = keep;
12978 }
12979
12980 /// Delete any WAL archives that are fully below the current horizon floor.
12981 ///
12982 /// Orphaned archives arise when the floor is written first during retention
12983 /// pruning and then a crash interrupts the archive-delete sequence. The
12984 /// opening cleanup ensures no subsequent read path sees stale data.
12985 ///
12986 /// Under the monotonic naming scheme, the archive name N equals the
12987 /// cumulative end-frame index of the archive in global commit space (i.e.
12988 /// the archive covers global frames `[prev_n, N)`). An archive is
12989 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
12990 /// below the floor and have already been counted in it.
12991 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
12992 if self.wal_horizon_floor == 0 {
12993 // Floor at 0 means no pruning has ever occurred; nothing to clean.
12994 return Ok(());
12995 }
12996 let archive_ns = self.fs.list_archives()?;
12997 for n in archive_ns {
12998 if n <= self.wal_horizon_floor {
12999 // Archive N ends at global frame N; all its frames are below
13000 // the floor (floor already accounts for them) → orphaned.
13001 self.fs.delete_archive(n).map_err(GraphError::Io)?;
13002 } else {
13003 // Archives are sorted ascending; first one above floor stops scan.
13004 break;
13005 }
13006 }
13007 Ok(())
13008 }
13009
13010 /// Collect all WAL frames from surviving archives (oldest-first) then the
13011 /// live WAL into one flat list, and return the total along with the number
13012 /// of archive frames at the front of the list.
13013 ///
13014 /// Commit indices into the returned list are LOCAL (0 = first frame of
13015 /// oldest surviving archive). To obtain the GLOBAL index add
13016 /// `self.wal_horizon_floor`.
13017 /// How many frames the surviving archives hold, without materialising them.
13018 ///
13019 /// The same count `all_frames` puts at the front of its list. Used to seed
13020 /// [`wal_frames_written`](GraphDb::wal_frames_written) at open without
13021 /// decoding the live WAL a second time; free on a store with no archives,
13022 /// which is most of them.
13023 fn archive_frame_count(&self) -> Result<u64> {
13024 let mut n = 0u64;
13025 for a in self.fs.list_archives()? {
13026 let bytes = self.fs.read_archive(a)?;
13027 let (frames, _) = decode_all(&bytes);
13028 n += frames.len() as u64;
13029 }
13030 Ok(n)
13031 }
13032
13033 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
13034 let archive_ns = self.fs.list_archives()?;
13035 let mut all: Vec<WalRecord> = Vec::new();
13036 for n in archive_ns {
13037 let bytes = self.fs.read_archive(n)?;
13038 let (frames, _) = decode_all(&bytes);
13039 all.extend(frames);
13040 }
13041 let archive_count = all.len() as u64;
13042 let live_bytes = self.fs.read(FileId::Wal)?;
13043 let (live_frames, _) = decode_all(&live_bytes);
13044 all.extend(live_frames);
13045 Ok((all, archive_count))
13046 }
13047
13048 /// Return the total number of committed WAL frames visible in the current
13049 /// horizon window, including frames in surviving WAL archives.
13050 ///
13051 /// This is the exclusive upper bound for valid `at_commit` indices in
13052 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
13053 ///
13054 /// Returns the horizon floor when all surviving history is empty.
13055 pub fn wal_total_commits(&self) -> Result<u64> {
13056 // The floor that belongs with these frames: both are read from disk
13057 // here. A handle that has not refreshed since another process moved
13058 // the floor would otherwise number new frames from its old one (row 81).
13059 let floor = self.fs.read_horizon_floor()?;
13060 let (frames, _) = self.all_frames()?;
13061 Ok(floor + frames.len() as u64)
13062 }
13063
13064 /// The global frame index of the first commit reachable through surviving
13065 /// archives (0 when no archives have been pruned).
13066 pub fn wal_horizon_floor(&self) -> u64 {
13067 self.wal_horizon_floor
13068 }
13069
13070 /// Return the per-node change history for `key` by scanning the on-disk WAL.
13071 ///
13072 /// ## Horizon
13073 ///
13074 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
13075 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
13076 /// zero-cost contract; a durable history log is out of scope.
13077 ///
13078 /// ## Derived edges
13079 ///
13080 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
13081 /// history. Only edges written directly by the application are recorded.
13082 ///
13083 /// ## Deleted nodes
13084 ///
13085 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
13086 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
13087 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
13088 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
13089 ///
13090 /// ## Dense-id edge entries and tombstoned partners
13091 ///
13092 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
13093 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
13094 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
13095 /// Build commit-bounded alias intervals for `queried_key`.
13096 ///
13097 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
13098 /// A record written under `key` at commit `c` matches the queried identity iff
13099 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
13100 ///
13101 /// Each alias entry carries both a lower and an upper bound so that key-reuse
13102 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
13103 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
13104 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
13105 /// only identity-2's events (commits 7–9 under "a") are in scope.
13106 ///
13107 /// Only **forward aliasing**: querying the *new* key surfaces events written
13108 /// under the *old* key. The reverse direction is not supported.
13109 fn build_key_alias_intervals(
13110 &self,
13111 frames: &[core_storage::wal::WalRecord],
13112 queried_key: &str,
13113 floor: u64,
13114 ) -> Vec<(String, u64, Option<u64>)> {
13115 self.key_alias_intervals(frames, queried_key, floor, false)
13116 }
13117
13118 /// [`Self::build_key_alias_intervals`], and with `current_holder_only`
13119 /// the queried key itself is bounded too: it counts from the commit the
13120 /// node that has it now acquired it. An earlier node that held the same
13121 /// key and was deleted is then no part of the answer — neither its edges
13122 /// nor its deletion.
13123 fn key_alias_intervals(
13124 &self,
13125 frames: &[core_storage::wal::WalRecord],
13126 queried_key: &str,
13127 floor: u64,
13128 current_holder_only: bool,
13129 ) -> Vec<(String, u64, Option<u64>)> {
13130 use core_storage::wal::WalRecord;
13131
13132 // Pre-pass: build reverse_rename and key_starts maps.
13133 // Every rename into a key, in commit order. A key can be renamed into
13134 // more than once — `a → b`, `b → a`, `a → b` — and which rename led
13135 // to the occupancy being followed depends on when.
13136 let mut reverse_rename: HashMap<String, Vec<(String, u64)>> = HashMap::new();
13137 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
13138
13139 for (local_i, frame) in frames.iter().enumerate() {
13140 let commit = floor + local_i as u64;
13141 let records: &[WalRecord] = match frame {
13142 WalRecord::Batch(inner) => inner.as_slice(),
13143 single => std::slice::from_ref(single),
13144 };
13145 for rec in records {
13146 match rec {
13147 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
13148 key_starts.entry(key.clone()).or_default().push(commit);
13149 }
13150 WalRecord::RenameNode { old_key, new_key } => {
13151 // new_key came into existence at this commit.
13152 key_starts.entry(new_key.clone()).or_default().push(commit);
13153 // Record the reverse rename: new_key was introduced by renaming old_key.
13154 reverse_rename
13155 .entry(new_key.clone())
13156 .or_default()
13157 .push((old_key.clone(), commit));
13158 }
13159 _ => {}
13160 }
13161 }
13162 }
13163
13164 // Build alias intervals by following the reverse rename chain.
13165 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
13166 let mut current_key = queried_key.to_string();
13167 let mut current_valid_until: Option<u64> = None;
13168
13169 loop {
13170 // valid_from: the most recent commit where current_key was assigned to this
13171 // identity. For aliases (valid_until = Some(vu)), find the last start event
13172 // for the key strictly before vu — this is where the alias's occupancy by
13173 // this identity began, correctly excluding prior identities that reused the key.
13174 let valid_from = if let Some(vu) = current_valid_until {
13175 key_starts
13176 .get(¤t_key)
13177 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
13178 .unwrap_or(floor)
13179 } else if current_holder_only {
13180 key_starts
13181 .get(¤t_key)
13182 .and_then(|starts| starts.last().copied())
13183 .unwrap_or(floor)
13184 } else {
13185 // Queried key — no upper bound; may have been introduced at any commit.
13186 floor
13187 };
13188
13189 result.push((current_key.clone(), valid_from, current_valid_until));
13190
13191 // The rename that led here: the latest into this key, and for an
13192 // alias the latest before the alias stopped being this node's.
13193 // Each step goes strictly back in time, so the walk ends — taking
13194 // the last rename whatever its commit sent `a → b → a` round for
13195 // ever.
13196 let led_here = reverse_rename
13197 .get(¤t_key)
13198 .and_then(|renames| {
13199 renames
13200 .iter()
13201 .rev()
13202 .find(|(_, at)| current_valid_until.is_none_or(|until| *at < until))
13203 })
13204 // And only if that rename is how this occupancy of the key
13205 // began. A key renamed into, deleted and inserted fresh
13206 // starts a new node at the insert; the older rename led to
13207 // the node that was deleted.
13208 .filter(|(_, at)| {
13209 (current_valid_until.is_none() && !current_holder_only) || *at == valid_from
13210 });
13211 match led_here {
13212 Some((old_key, rename_commit)) => {
13213 current_valid_until = Some(*rename_commit);
13214 current_key = old_key.clone();
13215 }
13216 None => break,
13217 }
13218 }
13219
13220 result
13221 }
13222
13223 /// Returns true if `record_key` matches any alias interval that covers `commit`.
13224 fn aliases_match(
13225 intervals: &[(String, u64, Option<u64>)],
13226 record_key: &str,
13227 commit: u64,
13228 ) -> bool {
13229 intervals
13230 .iter()
13231 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
13232 }
13233
13234 /// Return the change history of node `key` by scanning the on-disk WAL.
13235 ///
13236 /// ## Horizon
13237 ///
13238 /// History reaches back only as far as the retained WAL. The returned
13239 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
13240 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
13241 /// oldest commit still reachable). When `horizon > 0`, older events were
13242 /// pruned and are not in `items`.
13243 pub fn node_history(
13244 &self,
13245 key: &str,
13246 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
13247 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
13248 use core_storage::wal::WalRecord;
13249
13250 // The floor that belongs with these frames: both are read from disk
13251 // here. A handle that has not refreshed since another process moved
13252 // the floor would otherwise number new frames from its old one (row 81).
13253 let floor = self.fs.read_horizon_floor()?;
13254 let (frames, _) = self.all_frames()?;
13255 let total_commits = floor + frames.len() as u64;
13256
13257 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
13258 let alias_intervals = self.build_key_alias_intervals(&frames, key, floor);
13259
13260 let mut out: Vec<HistoryEntry> = Vec::new();
13261
13262 for (local_i, frame) in frames.iter().enumerate() {
13263 let commit = floor + local_i as u64;
13264 // Collect the inner records to process — Batch is one commit, single records are one commit.
13265 let records: &[WalRecord] = match frame {
13266 WalRecord::Batch(inner) => inner.as_slice(),
13267 single => std::slice::from_ref(single),
13268 };
13269
13270 for rec in records {
13271 let change = match rec {
13272 WalRecord::InsertNode { label, key: k, .. }
13273 if Self::aliases_match(&alias_intervals, k, commit) =>
13274 {
13275 Some(HistoryChange::NodeInserted {
13276 label: label.clone(),
13277 })
13278 }
13279 WalRecord::InsertNodeId { label, key: k, .. }
13280 if Self::aliases_match(&alias_intervals, k, commit) =>
13281 {
13282 let label_str = match self.syms.resolve(*label) {
13283 Some(s) => s.to_string(),
13284 None => continue,
13285 };
13286 Some(HistoryChange::NodeInserted { label: label_str })
13287 }
13288 WalRecord::SetProp {
13289 key: k,
13290 field,
13291 value,
13292 } if Self::aliases_match(&alias_intervals, k, commit) => {
13293 Some(HistoryChange::PropSet {
13294 field: field.clone(),
13295 value: value.clone(),
13296 })
13297 }
13298 WalRecord::SetPropId { id, field, value } => {
13299 // Use key_of_historical (not key_of) so a node's prop_set
13300 // events remain visible after the node is later deleted:
13301 // key_of returns None for a tombstoned id, which would
13302 // silently drop every PropSet between insert and delete.
13303 // Mirrors the InsertEdgeId arm below and edge_history's
13304 // own id-keyed arms.
13305 match self.ids.key_of_historical(*id) {
13306 // key_of_historical returns the last-known (possibly
13307 // post-rename, possibly post-delete) key; compare to queried key.
13308 Some(resolved) if resolved == key => {
13309 let field_str = match self.syms.resolve(*field) {
13310 Some(s) => s.to_string(),
13311 None => continue,
13312 };
13313 Some(HistoryChange::PropSet {
13314 field: field_str,
13315 value: value.clone(),
13316 })
13317 }
13318 _ => None,
13319 }
13320 }
13321 WalRecord::RemoveProp { key: k, field }
13322 if Self::aliases_match(&alias_intervals, k, commit) =>
13323 {
13324 Some(HistoryChange::PropRemoved {
13325 field: field.clone(),
13326 })
13327 }
13328 WalRecord::InsertEdge {
13329 edge_type,
13330 src_key,
13331 dst_key,
13332 } => {
13333 if Self::aliases_match(&alias_intervals, src_key, commit) {
13334 Some(HistoryChange::EdgeAdded {
13335 edge_type: edge_type.clone(),
13336 other: dst_key.clone(),
13337 outgoing: true,
13338 })
13339 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13340 Some(HistoryChange::EdgeAdded {
13341 edge_type: edge_type.clone(),
13342 other: src_key.clone(),
13343 outgoing: false,
13344 })
13345 } else {
13346 None
13347 }
13348 }
13349 WalRecord::InsertEdgeId { etype, src, dst } => {
13350 let etype_str = match self.syms.resolve(*etype) {
13351 Some(s) => s.to_string(),
13352 None => continue,
13353 };
13354 // key_of_historical (not key_of): an edge added before
13355 // either endpoint was later deleted must still resolve —
13356 // see the SetPropId arm above and edge_history's
13357 // InsertEdgeId arm, which use the same lookup for the
13358 // same reason.
13359 let src_key = self.ids.key_of_historical(*src);
13360 let dst_key = self.ids.key_of_historical(*dst);
13361 if src_key == Some(key) {
13362 let other = match dst_key {
13363 Some(s) => s.to_string(),
13364 None => continue,
13365 };
13366 Some(HistoryChange::EdgeAdded {
13367 edge_type: etype_str,
13368 other,
13369 outgoing: true,
13370 })
13371 } else if dst_key == Some(key) {
13372 let other = match src_key {
13373 Some(s) => s.to_string(),
13374 None => continue,
13375 };
13376 Some(HistoryChange::EdgeAdded {
13377 edge_type: etype_str,
13378 other,
13379 outgoing: false,
13380 })
13381 } else {
13382 None
13383 }
13384 }
13385 WalRecord::DeleteEdge {
13386 edge_type,
13387 src_key,
13388 dst_key,
13389 } => {
13390 if Self::aliases_match(&alias_intervals, src_key, commit) {
13391 Some(HistoryChange::EdgeRemoved {
13392 edge_type: edge_type.clone(),
13393 other: dst_key.clone(),
13394 outgoing: true,
13395 })
13396 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13397 Some(HistoryChange::EdgeRemoved {
13398 edge_type: edge_type.clone(),
13399 other: src_key.clone(),
13400 outgoing: false,
13401 })
13402 } else {
13403 None
13404 }
13405 }
13406 WalRecord::DeleteNode { key: k }
13407 if Self::aliases_match(&alias_intervals, k, commit) =>
13408 {
13409 Some(HistoryChange::NodeDeleted)
13410 }
13411 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
13412 _ => None,
13413 };
13414
13415 if let Some(change) = change {
13416 out.push(HistoryEntry { commit, change });
13417 }
13418 }
13419 }
13420
13421 Ok(HistoryResult {
13422 items: out,
13423 total_commits,
13424 horizon: floor,
13425 })
13426 }
13427
13428 /// Return the per-edge change history between nodes `a` and `b` by scanning
13429 /// the on-disk WAL.
13430 ///
13431 /// ## Horizon
13432 ///
13433 /// History reaches back only to the last WAL-truncating snapshot, exactly
13434 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
13435 /// `total_commits` (= number of WAL frames), which is the exclusive upper
13436 /// bound for valid commit indices.
13437 ///
13438 /// ## Derived edges
13439 ///
13440 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13441 /// WAL markers written by `log_then_apply_with` after each rule-firing
13442 /// mutation. The `rule` field of those events carries the rule name.
13443 ///
13444 /// ## DeleteNode
13445 ///
13446 /// When a node is deleted, its manual incident edges are swept inline without
13447 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
13448 /// events for either endpoint and synthesises `Retracted(rule:None)` events
13449 /// for each manual edge that was active at that point. Derived edges active at
13450 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
13451 /// the engine appends immediately after the `DeleteNode` record; those events
13452 /// carry correct rule attribution and are emitted by the marker arm, not the
13453 /// synthetic sweep.
13454 ///
13455 /// ## Masks
13456 ///
13457 /// Like `node_history`, this method has no mask parameter and returns WAL
13458 /// history regardless of any role mask. For masked history semantics, apply
13459 /// the mask at the caller level.
13460 pub fn edge_history(
13461 &self,
13462 a: &str,
13463 b: &str,
13464 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
13465 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
13466 use core_storage::wal::WalRecord;
13467
13468 // The floor that belongs with these frames: both are read from disk
13469 // here. A handle that has not refreshed since another process moved
13470 // the floor would otherwise number new frames from its old one (row 81).
13471 let floor = self.fs.read_horizon_floor()?;
13472 let (frames, _) = self.all_frames()?;
13473 let total_commits = floor + frames.len() as u64;
13474
13475 // Resolve all historical names for a and b (handles RenameNode in the WAL).
13476 // Intervals are commit-bounded so recycled keys don't contaminate histories.
13477 let alias_a = self.build_key_alias_intervals(&frames, a, floor);
13478 let alias_b = self.build_key_alias_intervals(&frames, b, floor);
13479
13480 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
13481 // The is_derived flag is used by the DeleteNode sweep: manual edges are
13482 // swept with a synthetic Retracted(rule:None); derived edges are skipped
13483 // because the engine writes a DerivedEdgeRetracted marker immediately after
13484 // the DeleteNode record, which carries the correct rule attribution.
13485 let mut active: Vec<(String, String, String, bool)> = Vec::new();
13486 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
13487
13488 for (local_i, frame) in frames.iter().enumerate() {
13489 let commit = floor + local_i as u64;
13490 let records: &[WalRecord] = match frame {
13491 WalRecord::Batch(inner) => inner.as_slice(),
13492 single => std::slice::from_ref(single),
13493 };
13494
13495 for rec in records {
13496 match rec {
13497 WalRecord::InsertEdge {
13498 edge_type,
13499 src_key,
13500 dst_key,
13501 } => {
13502 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13503 && Self::aliases_match(&alias_b, dst_key, commit);
13504 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13505 && Self::aliases_match(&alias_a, dst_key, commit);
13506 if is_ab || is_ba {
13507 active.push((
13508 edge_type.clone(),
13509 src_key.clone(),
13510 dst_key.clone(),
13511 false,
13512 ));
13513 out.push(EdgeHistoryEvent {
13514 edge_type: edge_type.clone(),
13515 commit,
13516 event: EdgeEvent::Added,
13517 rule: None,
13518 });
13519 }
13520 }
13521 WalRecord::InsertEdgeId { etype, src, dst } => {
13522 let etype_str = match self.syms.resolve(*etype) {
13523 Some(s) => s.to_string(),
13524 None => continue,
13525 };
13526 // Use key_of_historical so tombstoned nodes (deleted
13527 // later in the WAL) still resolve during the scan.
13528 let src_key = self.ids.key_of_historical(*src);
13529 let dst_key = self.ids.key_of_historical(*dst);
13530 let is_ab = src_key == Some(a) && dst_key == Some(b);
13531 let is_ba = src_key == Some(b) && dst_key == Some(a);
13532 if is_ab || is_ba {
13533 let src_str = src_key.unwrap().to_string();
13534 let dst_str = dst_key.unwrap().to_string();
13535 active.push((etype_str.clone(), src_str, dst_str, false));
13536 out.push(EdgeHistoryEvent {
13537 edge_type: etype_str,
13538 commit,
13539 event: EdgeEvent::Added,
13540 rule: None,
13541 });
13542 }
13543 }
13544 WalRecord::DeleteEdge {
13545 edge_type,
13546 src_key,
13547 dst_key,
13548 } => {
13549 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13550 && Self::aliases_match(&alias_b, dst_key, commit);
13551 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13552 && Self::aliases_match(&alias_a, dst_key, commit);
13553 if is_ab || is_ba {
13554 // Remove the first matching active entry (flag ignored).
13555 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
13556 et == edge_type && s == src_key && d == dst_key
13557 }) {
13558 active.remove(pos);
13559 }
13560 out.push(EdgeHistoryEvent {
13561 edge_type: edge_type.clone(),
13562 commit,
13563 event: EdgeEvent::Retracted,
13564 rule: None,
13565 });
13566 }
13567 }
13568 WalRecord::DeleteNode { key: k }
13569 if Self::aliases_match(&alias_a, k, commit)
13570 || Self::aliases_match(&alias_b, k, commit) =>
13571 {
13572 // Sweep: implicitly retract only MANUAL active edges.
13573 // Derived active edges are skipped here because the rule
13574 // engine appends a DerivedEdgeRetracted marker immediately
13575 // after this DeleteNode record; that marker produces the
13576 // single correctly-attributed Retracted event. Derived
13577 // entries are dropped from `active` (the marker arm's
13578 // idempotent retain finds nothing to remove).
13579 for (et, _, _, is_derived) in active.drain(..) {
13580 if !is_derived {
13581 out.push(EdgeHistoryEvent {
13582 edge_type: et,
13583 commit,
13584 event: EdgeEvent::Retracted,
13585 rule: None,
13586 });
13587 }
13588 // Derived: drop silently; marker carries the Retracted event.
13589 }
13590 }
13591 WalRecord::DerivedEdgeAdded {
13592 rule,
13593 edge_type: et,
13594 src_key,
13595 dst_key,
13596 } => {
13597 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13598 && Self::aliases_match(&alias_b, dst_key, commit);
13599 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13600 && Self::aliases_match(&alias_a, dst_key, commit);
13601 if is_ab || is_ba {
13602 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
13603 out.push(EdgeHistoryEvent {
13604 edge_type: et.clone(),
13605 commit,
13606 event: EdgeEvent::Added,
13607 rule: Some(rule.clone()),
13608 });
13609 }
13610 }
13611 WalRecord::DerivedEdgeRetracted {
13612 rule,
13613 edge_type: et,
13614 src_key,
13615 dst_key,
13616 } => {
13617 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13618 && Self::aliases_match(&alias_b, dst_key, commit);
13619 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13620 && Self::aliases_match(&alias_a, dst_key, commit);
13621 if is_ab || is_ba {
13622 // Push unconditionally: a derived edge whose Added marker
13623 // predates the history horizon has no `active` entry, but
13624 // the retraction is still a real in-window event.
13625 // Remove from active idempotently if present.
13626 active.retain(|(aet, s, d, _)| {
13627 !(aet == et && s == src_key && d == dst_key)
13628 });
13629 out.push(EdgeHistoryEvent {
13630 edge_type: et.clone(),
13631 commit,
13632 event: EdgeEvent::Retracted,
13633 rule: Some(rule.clone()),
13634 });
13635 }
13636 }
13637 // All other records (InsertNode, SetProp, CreateRule, etc.)
13638 // do not affect edges between a and b.
13639 _ => {}
13640 }
13641 }
13642 }
13643
13644 Ok(HistoryResult {
13645 items: out,
13646 total_commits,
13647 horizon: floor,
13648 })
13649 }
13650
13651 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
13652 /// (in either direction) at the WAL commit `at_commit`.
13653 ///
13654 /// ## Horizon
13655 ///
13656 /// Valid commit indices are `0..total_commits` where `total_commits` is the
13657 /// number of WAL frames. An `at_commit >= total_commits` is outside the
13658 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
13659 ///
13660 /// ## Derived edges
13661 ///
13662 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13663 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
13664 /// and therefore includes derived edges in its point-in-time evaluation,
13665 /// matching `edge_history`'s fidelity.
13666 ///
13667 /// ## Which nodes
13668 ///
13669 /// `a` and `b` name the nodes that hold those keys now, and the answer
13670 /// follows each of them back through its renames. A node that held one of
13671 /// the keys earlier and was deleted is another node: for a commit at
13672 /// which that earlier node was linked, the answer is `false`.
13673 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
13674 use core_storage::wal::WalRecord;
13675
13676 // The floor that belongs with these frames: both are read from disk
13677 // here. A handle that has not refreshed since another process moved
13678 // the floor would otherwise number new frames from its old one (row 81).
13679 let floor = self.fs.read_horizon_floor()?;
13680 let (frames, _) = self.all_frames()?;
13681 let total_commits = floor + frames.len() as u64;
13682
13683 // Horizon floor: commits in pruned archives are unreachable.
13684 if at_commit < floor {
13685 return Err(GraphError::CommitOutOfRange {
13686 commit: at_commit,
13687 total: total_commits,
13688 floor,
13689 });
13690 }
13691 if at_commit >= total_commits {
13692 return Err(GraphError::CommitOutOfRange {
13693 commit: at_commit,
13694 total: total_commits,
13695 floor,
13696 });
13697 }
13698
13699 // Resolve all historical names for a and b (handles RenameNode in the WAL).
13700 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
13701 let alias_a = self.key_alias_intervals(&frames, a, floor, true);
13702 let alias_b = self.key_alias_intervals(&frames, b, floor, true);
13703 // The two nodes themselves, when both are live. A record that names
13704 // its endpoints by dense id is then matched by id: a retired id keeps
13705 // the key it last had, and matching on that string took an earlier,
13706 // deleted holder of the key for the node that has it now.
13707 let live_ids = self.ids.get(a).zip(self.ids.get(b));
13708
13709 // Local index into surviving frames (0 = first frame of oldest archive).
13710 let local_commit = at_commit - floor;
13711
13712 // Replay local frames 0..=local_commit, tracking active edges between
13713 // the two nodes: the edge type and whether it runs from `a` to `b`.
13714 // Tracked by which node, not by the key each record names it with: an
13715 // edge written while a node was called one thing and deleted while it
13716 // was called another is the same edge.
13717 let mut active: BTreeSet<(String, bool)> = BTreeSet::new();
13718
13719 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13720 let commit = floor + local_i as u64;
13721 let records: &[WalRecord] = match frame {
13722 WalRecord::Batch(inner) => inner.as_slice(),
13723 single => std::slice::from_ref(single),
13724 };
13725
13726 for rec in records {
13727 match rec {
13728 WalRecord::InsertEdge {
13729 edge_type: et,
13730 src_key,
13731 dst_key,
13732 } => {
13733 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13734 && Self::aliases_match(&alias_b, dst_key, commit);
13735 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13736 && Self::aliases_match(&alias_a, dst_key, commit);
13737 if is_ab {
13738 active.insert((et.clone(), true));
13739 }
13740 if is_ba {
13741 active.insert((et.clone(), false));
13742 }
13743 }
13744 WalRecord::InsertEdgeId { etype, src, dst } => {
13745 let etype_str = match self.syms.resolve(*etype) {
13746 Some(s) => s.to_string(),
13747 None => continue,
13748 };
13749 // Use key_of_historical so tombstoned nodes resolve.
13750 let src_key = self.ids.key_of_historical(*src);
13751 let dst_key = self.ids.key_of_historical(*dst);
13752 let (is_ab, is_ba) = match live_ids {
13753 Some((id_a, id_b)) => {
13754 (*src == id_a && *dst == id_b, *src == id_b && *dst == id_a)
13755 }
13756 None => (
13757 src_key == Some(a) && dst_key == Some(b),
13758 src_key == Some(b) && dst_key == Some(a),
13759 ),
13760 };
13761 if is_ab {
13762 active.insert((etype_str.clone(), true));
13763 }
13764 if is_ba {
13765 active.insert((etype_str, false));
13766 }
13767 }
13768 WalRecord::DeleteEdge {
13769 edge_type: et,
13770 src_key,
13771 dst_key,
13772 } => {
13773 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13774 && Self::aliases_match(&alias_b, dst_key, commit);
13775 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13776 && Self::aliases_match(&alias_a, dst_key, commit);
13777 if is_ab {
13778 active.remove(&(et.clone(), true));
13779 }
13780 if is_ba {
13781 active.remove(&(et.clone(), false));
13782 }
13783 }
13784 WalRecord::DeleteNode { key: k }
13785 if Self::aliases_match(&alias_a, k, commit)
13786 || Self::aliases_match(&alias_b, k, commit) =>
13787 {
13788 // One of the two nodes is gone, and every edge
13789 // between them with it.
13790 active.clear();
13791 }
13792 WalRecord::DerivedEdgeAdded {
13793 edge_type: et,
13794 src_key,
13795 dst_key,
13796 ..
13797 } => {
13798 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13799 && Self::aliases_match(&alias_b, dst_key, commit);
13800 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13801 && Self::aliases_match(&alias_a, dst_key, commit);
13802 if is_ab {
13803 active.insert((et.clone(), true));
13804 }
13805 if is_ba {
13806 active.insert((et.clone(), false));
13807 }
13808 }
13809 WalRecord::DerivedEdgeRetracted {
13810 edge_type: et,
13811 src_key,
13812 dst_key,
13813 ..
13814 } => {
13815 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13816 && Self::aliases_match(&alias_b, dst_key, commit);
13817 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13818 && Self::aliases_match(&alias_a, dst_key, commit);
13819 if is_ab {
13820 active.remove(&(et.clone(), true));
13821 }
13822 if is_ba {
13823 active.remove(&(et.clone(), false));
13824 }
13825 }
13826 _ => {}
13827 }
13828 }
13829 }
13830
13831 Ok(active.iter().any(|(et, _)| et == edge_type))
13832 }
13833
13834 /// Every edge incident to `key` — either endpoint — that existed at WAL
13835 /// commit `commit`, from ONE scan of the WAL.
13836 ///
13837 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
13838 /// "what did K's relationships look like at commit C" with one call instead
13839 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
13840 /// The two agree edge for edge.
13841 ///
13842 /// Results are sorted by `(edge_type, src_key, dst_key)`.
13843 ///
13844 /// ## Horizon
13845 ///
13846 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
13847 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
13848 /// `was_linked`. An unknown key is not an error — it simply had no edges.
13849 ///
13850 /// ## Derived edges
13851 ///
13852 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
13853 /// attribution, so a rule-owned edge comes back with `derived: true` and
13854 /// `rule: Some(name)`.
13855 ///
13856 /// ## Renames
13857 ///
13858 /// `key` is matched through the same commit-bounded alias intervals
13859 /// `edge_history` uses, so querying a node's *current* key surfaces edges
13860 /// written under an earlier name. Endpoint keys in the result are reported
13861 /// under the name the node carries today, so they can be fed straight back
13862 /// into `node_info`, `explain` or another `edges_at`.
13863 ///
13864 /// ## Masks
13865 ///
13866 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
13867 /// any role mask. Apply masking at the caller level.
13868 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
13869 use core_storage::wal::WalRecord;
13870
13871 // Floor and frames from the same read of the disk (row 81).
13872 let floor = self.fs.read_horizon_floor()?;
13873 let (frames, _) = self.all_frames()?;
13874 let total_commits = floor + frames.len() as u64;
13875
13876 // Horizon floor: commits in pruned archives are unreachable.
13877 if commit < floor || commit >= total_commits {
13878 return Err(GraphError::CommitOutOfRange {
13879 commit,
13880 total: total_commits,
13881 floor,
13882 });
13883 }
13884
13885 // Commit-bounded historical names of `key` (handles RenameNode).
13886 let alias = self.build_key_alias_intervals(&frames, key, floor);
13887
13888 // Forward rename chain, for reporting endpoints under their current
13889 // names: old key → [(commit, new key)] in ascending commit order.
13890 // Built over the whole WAL, not just the prefix up to `commit`, because
13891 // a rename after `commit` still changes what the node is called today.
13892 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
13893 for (local_i, frame) in frames.iter().enumerate() {
13894 let c = floor + local_i as u64;
13895 let records: &[WalRecord] = match frame {
13896 WalRecord::Batch(inner) => inner.as_slice(),
13897 single => std::slice::from_ref(single),
13898 };
13899 for rec in records {
13900 if let WalRecord::RenameNode { old_key, new_key } = rec {
13901 renames
13902 .entry(old_key.clone())
13903 .or_default()
13904 .push((c, new_key.clone()));
13905 }
13906 }
13907 }
13908
13909 // The name a node written as `k` at commit `from` carries today.
13910 // Follows the first rename at or after `from`, then keeps going. The
13911 // iteration cap bounds a rename cycle inside a single batch.
13912 let canon = |k: &str, from: u64| -> String {
13913 if renames.is_empty() {
13914 return k.to_string();
13915 }
13916 let mut cur = k.to_string();
13917 let mut at = from;
13918 for _ in 0..64 {
13919 match renames
13920 .get(&cur)
13921 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
13922 {
13923 Some((c, new)) => {
13924 at = *c;
13925 cur = new.clone();
13926 }
13927 None => break,
13928 }
13929 }
13930 cur
13931 };
13932
13933 let local_commit = commit - floor;
13934 // (edge_type, src_key, dst_key) → (derived, rule)
13935 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
13936 BTreeMap::new();
13937
13938 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13939 let c = floor + local_i as u64;
13940 let records: &[WalRecord] = match frame {
13941 WalRecord::Batch(inner) => inner.as_slice(),
13942 single => std::slice::from_ref(single),
13943 };
13944
13945 for rec in records {
13946 match rec {
13947 WalRecord::InsertEdge {
13948 edge_type,
13949 src_key,
13950 dst_key,
13951 } => {
13952 if Self::aliases_match(&alias, src_key, c)
13953 || Self::aliases_match(&alias, dst_key, c)
13954 {
13955 active.insert(
13956 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
13957 (false, None),
13958 );
13959 }
13960 }
13961 WalRecord::InsertEdgeId { etype, src, dst } => {
13962 let Some(etype_str) = self.syms.resolve(*etype) else {
13963 continue;
13964 };
13965 // `key_of_historical` resolves tombstoned ids too, and
13966 // already returns the node's current key — no rename
13967 // canonicalisation needed on this arm.
13968 let (Some(src_key), Some(dst_key)) = (
13969 self.ids.key_of_historical(*src),
13970 self.ids.key_of_historical(*dst),
13971 ) else {
13972 continue;
13973 };
13974 if src_key == key || dst_key == key {
13975 active.insert(
13976 (
13977 etype_str.to_string(),
13978 src_key.to_string(),
13979 dst_key.to_string(),
13980 ),
13981 (false, None),
13982 );
13983 }
13984 }
13985 WalRecord::DeleteEdge {
13986 edge_type,
13987 src_key,
13988 dst_key,
13989 } => {
13990 if Self::aliases_match(&alias, src_key, c)
13991 || Self::aliases_match(&alias, dst_key, c)
13992 {
13993 active.remove(&(
13994 edge_type.clone(),
13995 canon(src_key, c),
13996 canon(dst_key, c),
13997 ));
13998 }
13999 }
14000 WalRecord::DeleteNode { key: k } => {
14001 if active.is_empty() {
14002 continue;
14003 }
14004 if Self::aliases_match(&alias, k, c) {
14005 // Our node is gone; every incident edge goes with it.
14006 active.clear();
14007 } else {
14008 // A partner is gone; its edges to us go with it.
14009 let ck = canon(k, c);
14010 active.retain(|(_, s, d), _| *s != ck && *d != ck);
14011 }
14012 }
14013 WalRecord::DerivedEdgeAdded {
14014 rule,
14015 edge_type,
14016 src_key,
14017 dst_key,
14018 } => {
14019 if Self::aliases_match(&alias, src_key, c)
14020 || Self::aliases_match(&alias, dst_key, c)
14021 {
14022 active.insert(
14023 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
14024 (true, Some(rule.clone())),
14025 );
14026 }
14027 }
14028 WalRecord::DerivedEdgeRetracted {
14029 edge_type,
14030 src_key,
14031 dst_key,
14032 ..
14033 } => {
14034 if Self::aliases_match(&alias, src_key, c)
14035 || Self::aliases_match(&alias, dst_key, c)
14036 {
14037 active.remove(&(
14038 edge_type.clone(),
14039 canon(src_key, c),
14040 canon(dst_key, c),
14041 ));
14042 }
14043 }
14044 // InsertNode, SetProp, CreateRule, … do not move edges.
14045 _ => {}
14046 }
14047 }
14048 }
14049
14050 // BTreeMap iteration is already (edge_type, src, dst) order.
14051 Ok(active
14052 .into_iter()
14053 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
14054 edge_type,
14055 src_key,
14056 dst_key,
14057 derived,
14058 rule,
14059 })
14060 .collect())
14061 }
14062
14063 /// The derived edges that would be retracted and derived if `key.field`
14064 /// were set to `value` — computed WITHOUT writing anything.
14065 ///
14066 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
14067 /// provenance, its candidate indexes, the topology and the property columns
14068 /// are all cloned first, the change is applied to the clone, and the real
14069 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
14070 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
14071 /// it emits are the answer, so rule semantics — predicates, top-k,
14072 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
14073 ///
14074 /// Works on a read-only handle.
14075 ///
14076 /// **While a rule's vector index is still building** (`RuleStats::building`)
14077 /// the clone carries no pending-build state, so this reports the edges that
14078 /// rule would derive — which the live store will not derive until its
14079 /// backfill runs. Right about the end state, early about the timing.
14080 ///
14081 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
14082 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
14083 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
14084 /// (the node already holds `value`, or no rule watches `field`) returns
14085 /// empty lists.
14086 ///
14087 /// ## Cost
14088 ///
14089 /// One clone of the property columns, the topology overlay, the symbol
14090 /// interner, the edge properties and the provenance map, plus one candidate
14091 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
14092 /// directory, but it is not free — this is an interactive "what if", not a
14093 /// hot path.
14094 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
14095 // The engine's provenance, HNSW and IVF state live in the mmap'd base
14096 // until something asks for them. On a store opened cold from a snapshot
14097 // this is the first ask, and without it the clone below starts from an
14098 // empty provenance map: nothing to retract, so `lost` comes back empty.
14099 self.ensure_v8_base_sections_loaded();
14100
14101 let empty = WhatIf {
14102 lost: Vec::new(),
14103 gained: Vec::new(),
14104 };
14105
14106 if let Some(view_name) = self.view_store.view_for_prop(field) {
14107 return Err(GraphError::ViewPropReadOnly {
14108 view_name: view_name.to_string(),
14109 });
14110 }
14111 MutPreview::new(self).check_live_key(key)?;
14112 let id = self
14113 .ids
14114 .get(key)
14115 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
14116
14117 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
14118 if rules.is_empty() {
14119 return Ok(empty);
14120 }
14121
14122 // No rule watches this field → no derivation can change.
14123 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
14124 return Ok(empty);
14125 }
14126
14127 let old_value = build_props_view(&self.props, &self.base)
14128 .get(id, field)
14129 .map(|vr| vr.into_value());
14130 if old_value.as_ref() == Some(&value) {
14131 return Ok(empty);
14132 }
14133
14134 // --- Clone every piece of state the re-derivation writes to. ---
14135 // `what_if` mutates its own throwaway copies and writes nothing, so it
14136 // takes owned clones rather than sharing the `Arc`s. Deref-clone, not
14137 // `Arc::clone`: sharing here would make `make_mut` copy on first touch
14138 // anyway, and an owned local keeps the rest of this function unchanged.
14139 let mut props = (*self.props).clone();
14140 let mut topo = (*self.topo).clone();
14141 let mut syms = (*self.syms).clone();
14142 let mut edge_props = (*self.edge_props).clone();
14143
14144 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
14145 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
14146 for r in &rules {
14147 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
14148 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
14149 }
14150 // `provenance()` decodes retained snapshot bytes on first use; the
14151 // engine clone needs the real map, not an empty one.
14152 let provenance = self.engine.provenance().clone();
14153 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
14154
14155 // Build the candidate indexes from the state BEFORE the change, exactly
14156 // as apply() sees them: `on_node_changed` withdraws the node under its
14157 // old value and refiles it under the new one, so the index must not
14158 // already reflect the change.
14159 engine.reindex_all_load_state(
14160 &self.ids,
14161 &syms,
14162 &self.labels,
14163 build_props_view(&self.props, &self.base),
14164 self.engine.export_ivf_state(),
14165 self.engine.export_hnsw_state_passthrough(),
14166 );
14167 engine.set_emit_deltas(true);
14168
14169 // --- Apply the hypothetical change and re-derive. ---
14170 props.set(id, field, value);
14171 {
14172 let mut gm = make_graph_mut(
14173 &self.ids,
14174 &mut syms,
14175 &self.labels,
14176 build_props_view(&props, &self.base),
14177 &mut topo,
14178 &self.base,
14179 &mut edge_props,
14180 );
14181 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
14182 }
14183
14184 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
14185 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
14186 for d in engine.drain_deltas() {
14187 let edge = EdgeAt {
14188 edge_type: d.edge_type,
14189 src_key: d.src_key,
14190 dst_key: d.dst_key,
14191 derived: true,
14192 rule: Some(d.rule),
14193 };
14194 if d.fired {
14195 gained.insert(edge);
14196 } else {
14197 lost.insert(edge);
14198 }
14199 }
14200 // An edge retracted and re-derived within the same re-derivation (top-k
14201 // churn) is not a change the caller would see.
14202 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
14203 for e in churn {
14204 lost.remove(&e);
14205 gained.remove(&e);
14206 }
14207
14208 Ok(WhatIf {
14209 lost: lost.into_iter().collect(),
14210 gained: gained.into_iter().collect(),
14211 })
14212 }
14213
14214 pub fn edge_count(&self) -> u64 {
14215 self.topo_view().edge_count()
14216 }
14217
14218 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
14219 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
14220 pub fn stats(&self) -> Stats {
14221 self.ensure_v8_base_sections_loaded();
14222 let building = self.engine.builds_in_progress();
14223 let rules: Vec<RuleStats> = self
14224 .engine
14225 .rules()
14226 .map(|r| RuleStats {
14227 name: r.name.clone(),
14228 edges: self
14229 .engine
14230 .provenance()
14231 .get(&r.name)
14232 .map(|s| s.len() as u64)
14233 .unwrap_or(0),
14234 tripped: self.engine.is_tripped(&r.name),
14235 fires: self.engine.fire_count(&r.name),
14236 approximate: r.approximate,
14237 building: building.iter().find(|b| b.rule == r.name).cloned(),
14238 })
14239 .collect();
14240 Stats {
14241 nodes_live: self.ids.live_len(),
14242 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
14243 edges: self.topo_view().edge_count(),
14244 rules,
14245 chain_truncations: self.engine.chain_truncations(),
14246 history_floor: self.wal_horizon_floor,
14247 namespaces: self.namespace_stats(),
14248 }
14249 }
14250
14251 /// On-disk size of the WAL file in bytes.
14252 ///
14253 /// Reads file metadata without loading WAL contents. Returns `Err` for
14254 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
14255 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
14256 let path = self.fs.wal_path().ok_or_else(|| {
14257 std::io::Error::new(
14258 std::io::ErrorKind::Unsupported,
14259 "wal_path not available for this Fs implementation",
14260 )
14261 })?;
14262 Ok(std::fs::metadata(path)?.len())
14263 }
14264
14265 /// Set the slow-query threshold. Queries whose execution time equals or
14266 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
14267 ///
14268 /// Use this setter in tests — the environment variable
14269 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
14270 /// threads.
14271 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
14272 self.slow_query_threshold_ms = ms;
14273 }
14274
14275 /// Snapshot of the slow-query ring buffer and lifetime counter.
14276 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
14277 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
14278 SlowQuerySnapshot {
14279 threshold_ms: self.slow_query_threshold_ms,
14280 count: log.total,
14281 last: log.entries.iter().cloned().collect(),
14282 }
14283 }
14284
14285 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
14286 /// to compute uptime.
14287 pub fn started_at(&self) -> std::time::Instant {
14288 self.started_at
14289 }
14290
14291 /// The on-disk snapshot version a store that has opted in to nothing
14292 /// writes — the **floor**, not the whole answer.
14293 ///
14294 /// It is not "the version this binary writes", and it is not "the version
14295 /// this binary reads". Since v0.6.10 this binary writes 9 **or** 10
14296 /// depending on the store — [`snapshot::version_for`] decides, and a store
14297 /// that has called [`enable_multiplicity`](Self::enable_multiplicity)
14298 /// writes 10 — and it reads 5 through 10. A caller comparing a store's
14299 /// stamp against this value must use `>=`, not `==`, or it will report an
14300 /// opted-in store as needing a migration *down*; `cli::run_migrate` is the
14301 /// worked example.
14302 ///
14303 /// The name is kept for compatibility: it is public API reachable from the
14304 /// CLI and from any embedder, and respelling it would break them for a
14305 /// doc-level clarification.
14306 ///
14307 /// [`snapshot::version_for`]: core_storage::snapshot::version_for
14308 pub fn format_version() -> u16 {
14309 core_storage::snapshot::VERSION
14310 }
14311
14312 /// Test-support: total bytes appended (SimFs only usage).
14313 pub fn fs_total_appended(&self) -> usize
14314 where
14315 F: FsIntrospect,
14316 {
14317 self.fs.total_appended()
14318 }
14319
14320 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
14321 pub fn fs_sync_count(&self) -> usize
14322 where
14323 F: FsIntrospect,
14324 {
14325 self.fs.sync_count()
14326 }
14327
14328 /// Consume the db, returning its fs (for crash simulation).
14329 pub fn into_fs(self) -> F {
14330 self.fs
14331 }
14332
14333 pub fn snapshot(&mut self) -> Result<()> {
14334 self.snapshot_with(SnapshotOptions::default())
14335 }
14336
14337 /// Snapshot with explicit options.
14338 ///
14339 /// # `keep_wal`
14340 ///
14341 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
14342 /// - The WAL is replaced with a minimal baseline containing one
14343 /// `EnableFulltext` record per active declaration. All pre-snapshot
14344 /// history is discarded; `open_at` can only reach post-snapshot commits.
14345 ///
14346 /// When `keep_wal` is `true`:
14347 /// - The WAL is left intact. All pre-snapshot commits remain reachable
14348 /// via `open_at`. The existing WAL already contains the original
14349 /// `EnableFulltext` records, so no baseline re-write is needed; the
14350 /// recovery guards in `apply()` silently skip any duplicate records on
14351 /// replay.
14352 /// - Crash window: a crash after the snapshot write but before the next
14353 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
14354 /// snapshot is loaded and the WAL replayed idempotently over it — safe
14355 /// because every `apply()` arm is idempotent when replayed over an
14356 /// already-current snapshot.
14357 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
14358 if self.read_only {
14359 return Err(GraphError::ReadOnly);
14360 }
14361 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
14362 // appending ends up holding a descriptor on an unlinked inode and loses
14363 // commits it believes durable. Snapshotting therefore requires the
14364 // cross-process write lock, exactly as appending does. Unlike the WAL
14365 // append path this does not go through `log_then_apply_with`, so both
14366 // guards are repeated here.
14367 if self.degraded {
14368 return Err(GraphError::Io(std::io::Error::other(
14369 "database degraded after group-commit fsync failure; reopen required",
14370 )));
14371 }
14372 if self.lock_denied {
14373 return Err(GraphError::Busy { holder: None });
14374 }
14375 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
14376 // Used by the archive path's conservative genesis-chain check: if a prior
14377 // snapshot exists but wal.truncated does not, we cannot distinguish a
14378 // legacy store (may have been truncated in an older code version) from a
14379 // new store that only used keep_wal=true. Conservative: refuse genesis in
14380 // both cases. Must be sampled here, before the snapshot write below.
14381 //
14382 // `snapshot_preserved_history` is the one case where the answer is not a
14383 // guess: a snapshot *this handle* took, on a store that had none when it
14384 // opened, and that kept the WAL. The proxy defers to it, because
14385 // otherwise `enable_multiplicity` — whose forced snapshot is exactly
14386 // that — would permanently disqualify the store from a genesis chain it
14387 // is fully entitled to (defect #23).
14388 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false)
14389 && !self.snapshot_preserved_history;
14390 // The genesis marker, for the first archive of a store whose history
14391 // has never been cut — written now, before anything is replaced.
14392 //
14393 // It says that this store's archives and WAL together reach back to
14394 // its first write, and that is as true now, with no archive yet, as
14395 // it will be when this snapshot has made one. It used to be written
14396 // last, after the snapshot, the rename and the new WAL, and a
14397 // snapshot interrupted anywhere before that never got another
14398 // chance: the next one found the interrupted one's `snapshot.bin`,
14399 // could not tell it from a truncating snapshot's, and declined. Every
14400 // commit in an archive was `out of range` from then on (row 98).
14401 //
14402 // Both conditions as before. No archive exists, and no snapshot did
14403 // before this one: a truncating snapshot always leaves `snapshot.bin`,
14404 // so one found here means the WAL may not start at the beginning, and
14405 // the chain is not claimed (never silently wrong data). The exception
14406 // is a snapshot this handle took itself with the WAL kept, which
14407 // `snapshot_preserved_history` records (defect #23). On SimFs
14408 // (`snapshot_path() == None`) there is never a prior snapshot.
14409 //
14410 // A marker left by a snapshot that then fails is still true. What
14411 // makes it false removes it: a truncating snapshot, and a retention
14412 // sweep that prunes an archive.
14413 if opts.archive_wal
14414 && !had_prior_snapshot
14415 && !self.archive_genesis_chain
14416 && self.fs.list_archives()?.is_empty()
14417 {
14418 self.fs.write_genesis_marker()?;
14419 self.archive_genesis_chain = true;
14420 }
14421 // Which version this store writes. V9 unless it has opted in to
14422 // multiplicity, in which case V10 — the stamp that makes a reader which
14423 // does not know WAL discriminant 23 refuse the open instead of
14424 // truncating the WAL at the first such frame. The container is
14425 // identical either way; only these two header bytes move.
14426 let snapshot_version = core_storage::snapshot::version_for(self.multiplicity);
14427 // A snapshot that leaves the WAL where it is records how much of it
14428 // it holds, so the next open does not apply those writes again on top
14429 // of the state they produced. This handle holds the write lock, so
14430 // every byte in the WAL now is a write it has applied.
14431 let replaces_wal = opts.archive_wal || !opts.keep_wal;
14432 let wal_now = self.fs.read(FileId::Wal)?;
14433 let wal_mark = if replaces_wal {
14434 Vec::new()
14435 } else {
14436 core_storage::v8::WalMark::of(&wal_now).encode()
14437 };
14438 let standing_intent = if replaces_wal {
14439 self.standing_intent(&wal_now)?.0
14440 } else {
14441 None
14442 };
14443 let stop = self.snapshot_stop.take();
14444 let stopped = |at: SnapshotStop| -> Result<()> {
14445 if stop == Some(at) {
14446 return Err(GraphError::Io(std::io::Error::other(format!(
14447 "snapshot stopped {at:?} (test hook)"
14448 ))));
14449 }
14450 Ok(())
14451 };
14452 self.ensure_v8_base_sections_loaded();
14453 // Ensure provenance is decoded before to_persist() clones it.
14454 self.engine.ensure_provenance_loaded_mut();
14455 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
14456 let rule_defs = rule_defs_typed
14457 .iter()
14458 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
14459 .collect();
14460 // Collect HNSW state and IVF state. When indexes are not yet
14461 // populated (clean open, no mutation since open), pass the retained
14462 // raw bytes through directly so that migrate/snapshot does not
14463 // silently discard fitted approximate-rule indexes.
14464 let hnsw_state = self.engine.export_hnsw_state_passthrough();
14465 let ivf_bytes = if !self.engine.indexes_populated() {
14466 // Pass retained IVF bytes through unchanged (no re-encode).
14467 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
14468 } else {
14469 // Indexes live: encode from current state.
14470 let raw_ivf = self.engine.export_ivf_state();
14471 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
14472 .into_iter()
14473 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
14474 (
14475 name,
14476 core_storage::snapshot::PerRuleIvfState {
14477 src: core_storage::snapshot::SideIvfState {
14478 centroids: sc,
14479 clusters: sa,
14480 drift: sd,
14481 },
14482 dst: core_storage::snapshot::SideIvfState {
14483 centroids: dc,
14484 clusters: da,
14485 drift: dd,
14486 },
14487 },
14488 )
14489 })
14490 .collect();
14491 if ivf_state_map.is_empty() {
14492 Vec::new()
14493 } else {
14494 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
14495 }
14496 };
14497 let view_defs: Vec<Vec<u8>> = self
14498 .view_store
14499 .views()
14500 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
14501 .collect();
14502 if self.base.is_some() {
14503 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
14504 // write it atomically, remap it as the new base, then clear the overlay.
14505 let meta = V8Meta {
14506 labels: (*self.labels).clone(),
14507 edge_props: (*self.edge_props).clone(),
14508 rule_defs,
14509 provenance,
14510 rule_tripped,
14511 rule_fires,
14512 ivf_bytes,
14513 view_defs,
14514 wal_truncated: !opts.keep_wal,
14515 hnsw: hnsw_state,
14516 last_change: self.last_change.clone(),
14517 fulltext_blob: self.fulltext_blob(),
14518 wal_mark: wal_mark.clone(),
14519 };
14520 let mut buf: Vec<u8> = Vec::new();
14521 {
14522 // Clone the Arc so the old base stays alive while we encode.
14523 // The borrow of archived_csr (into old_base's mmap) is released
14524 // at the end of this block, before we replace self.base.
14525 let old_base = self.base.clone().expect("is_some checked above");
14526 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
14527 detail: format!("v8 snapshot: topology section: {e:?}"),
14528 })?;
14529 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
14530 detail: format!("v8 snapshot: columns section: {e:?}"),
14531 })?;
14532 // `None` when the base predates V9 — the migration path: its
14533 // string columns still carry their own tables and this snapshot
14534 // is the rewrite that collapses them into section 12.
14535 let archived_strings =
14536 old_base
14537 .string_table()
14538 .transpose()
14539 .map_err(|e| GraphError::Corrupt {
14540 detail: format!("v8 snapshot: strings section: {e:?}"),
14541 })?;
14542 let archived_edge_props =
14543 old_base
14544 .edge_props_section()
14545 .map_err(|e| GraphError::Corrupt {
14546 detail: format!("v8 snapshot: edge_props section: {e:?}"),
14547 })?;
14548 let edge_props_raw =
14549 old_base
14550 .edge_props_raw_bytes()
14551 .map_err(|e| GraphError::Corrupt {
14552 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
14553 })?;
14554 encode_v8(
14555 Some(archived_csr),
14556 Some(archived_cols),
14557 archived_strings,
14558 Some((archived_edge_props, edge_props_raw)),
14559 // Never the old snapshot's provenance bytes. The encoder
14560 // passes them through when the live map is empty, taking
14561 // empty for "never loaded" — but it is loaded above, so
14562 // empty means every rule's edges are gone, and passing
14563 // the old section through brought them back as owned by
14564 // rules that no longer derive them (ledger row 95).
14565 None,
14566 &self.topo,
14567 &self.props,
14568 &self.ids,
14569 &self.syms,
14570 &meta,
14571 &mut buf,
14572 )?;
14573 }
14574 core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14575 if replaces_wal {
14576 // Before the snapshot: what is about to be written, and the
14577 // WAL it holds. From here until the WAL is replaced a crash
14578 // leaves that WAL beside the new snapshot, and this is how
14579 // the next open knows not to apply it again (ledger row 92).
14580 //
14581 // An intent already standing — the last snapshot was
14582 // interrupted too, and this store opens because of it — is
14583 // kept beside the new one until the new snapshot is down. If
14584 // this attempt fails before then, it is still what names the
14585 // snapshot on disk.
14586 let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14587 if let Some(standing) = &standing_intent {
14588 intent.extend_from_slice(&standing.encode());
14589 }
14590 self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14591 // Until this snapshot empties it. If it does not get that far
14592 // and this handle goes on writing, the next append looks at
14593 // whether the intent is still true.
14594 self.intent_unsettled = true;
14595 }
14596 stopped(SnapshotStop::BeforeSnapshotWrite)?;
14597 self.fs.write_atomic(FileId::Snapshot, &buf)?;
14598 // Remap the freshly-written snapshot as the new base.
14599 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
14600 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14601 core_storage::v8::MappedBase::map(&snap_path)
14602 } else {
14603 core_storage::v8::MappedBase::from_bytes(buf)
14604 }
14605 .map_err(|e| GraphError::Corrupt {
14606 detail: format!("v8 snapshot: remap new base: {e:?}"),
14607 })?;
14608 self.base = Some(Arc::new(new_base));
14609 // Clear the overlay and prop tombstones — all data is now in the new base.
14610 self.topo = Arc::new(Topology::new());
14611 self.props = Arc::new(core_storage::columns::ColumnStore::new());
14612 } else {
14613 // Legacy path (V5–V7 stores without a V8 base).
14614 //
14615 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
14616 // clone and no encode_v8_from_state intermediate clones. The big
14617 // structures (self.topo, self.props) are borrowed, not cloned.
14618 // self.edge_props is moved (not cloned) because we immediately clear it
14619 // when we remap the new V8 snapshot as self.base (see below).
14620 //
14621 // Eliminates from peak RSS vs. the old SnapshotState path:
14622 // • self.topo.clone() (~topology HashMap footprint)
14623 // • self.props.clone() (~column-store footprint)
14624 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
14625 let mut meta = V8Meta {
14626 labels: (*self.labels).clone(),
14627 wal_truncated: !opts.keep_wal,
14628 // Moved, not cloned, for the encode — and moved straight back
14629 // below, before anything that can fail. They are released
14630 // only once the new snapshot is mapped and reads go through
14631 // it. Left out of the handle across the write, a snapshot
14632 // that failed there went on with no edge properties at all,
14633 // and the next one that worked wrote that down.
14634 edge_props: std::mem::take(Arc::make_mut(&mut self.edge_props)),
14635 rule_defs,
14636 provenance,
14637 rule_tripped,
14638 rule_fires,
14639 ivf_bytes,
14640 view_defs,
14641 hnsw: hnsw_state,
14642 last_change: self.last_change.clone(),
14643 fulltext_blob: self.fulltext_blob(),
14644 wal_mark: wal_mark.clone(),
14645 };
14646 let mut buf = Vec::new();
14647 let encoded = encode_v8(
14648 None,
14649 None,
14650 None,
14651 None,
14652 None,
14653 &self.topo,
14654 &self.props,
14655 &self.ids,
14656 &self.syms,
14657 &meta,
14658 &mut buf,
14659 );
14660 self.edge_props = Arc::new(std::mem::take(&mut meta.edge_props));
14661 drop(meta);
14662 encoded?;
14663 core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14664 if replaces_wal {
14665 // Before the snapshot: what is about to be written, and the
14666 // WAL it holds. From here until the WAL is replaced a crash
14667 // leaves that WAL beside the new snapshot, and this is how
14668 // the next open knows not to apply it again (ledger row 92).
14669 //
14670 // An intent already standing — the last snapshot was
14671 // interrupted too, and this store opens because of it — is
14672 // kept beside the new one until the new snapshot is down. If
14673 // this attempt fails before then, it is still what names the
14674 // snapshot on disk.
14675 let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14676 if let Some(standing) = &standing_intent {
14677 intent.extend_from_slice(&standing.encode());
14678 }
14679 self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14680 // Until this snapshot empties it. If it does not get that far
14681 // and this handle goes on writing, the next append looks at
14682 // whether the intent is still true.
14683 self.intent_unsettled = true;
14684 }
14685 stopped(SnapshotStop::BeforeSnapshotWrite)?;
14686 self.fs.write_atomic(FileId::Snapshot, &buf)?;
14687 // Remap the freshly-written V8 snapshot as self.base.
14688 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
14689 // On SimFs (tests): pass buf to from_bytes.
14690 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14691 drop(buf);
14692 core_storage::v8::MappedBase::map(&snap_path)
14693 } else {
14694 core_storage::v8::MappedBase::from_bytes(buf)
14695 }
14696 .map_err(|e| GraphError::Corrupt {
14697 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
14698 })?;
14699 self.base = Some(Arc::new(new_base));
14700 // Free the large heap-allocated decoded state — all data is now in the
14701 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
14702 self.edge_props = Arc::new(Default::default());
14703 self.topo = Arc::new(Topology::new());
14704 self.props = Arc::new(core_storage::columns::ColumnStore::new());
14705 }
14706
14707 stopped(SnapshotStop::AfterSnapshotWrite)?;
14708 if opts.archive_wal {
14709 // History-preserving snapshot (Task 4):
14710 // 1. Snapshot already written above (write_atomic → fsynced).
14711 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
14712 // Crash window B: crash here leaves archive present, WAL
14713 // absent. Reopen: snapshot loaded (full state), no WAL
14714 // replay. Archive is NOT replayed into live state — it is
14715 // pre-snapshot by construction. Safe.
14716 // 3. Write new minimal baseline WAL (write_atomic), with no
14717 // fallible call between it and the rename.
14718 // Crash window C: a crash between the two leaves the new
14719 // archive and no live WAL. Same as window B — handled above.
14720 // 4. Prune old archives (retention), update horizon floor.
14721 // Pruning invalidates the genesis chain; delete marker.
14722 // (The genesis marker is written before step 1; see above.
14723 // Steps 2 and 3 are skipped when the WAL holds nothing new.)
14724 //
14725 // Sample existing archives BEFORE the rename: the last one names
14726 // where this one starts.
14727 let existing_archives = self.fs.list_archives()?;
14728
14729 // Compute a globally-monotonic archive name: the name equals the
14730 // cumulative end-frame index of the archive in global commit space.
14731 //
14732 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
14733 // commit_seq is seeded from max(last_change), which underestimates
14734 // the WAL depth when trailing commits (e.g. insert_edge) do not
14735 // update last_change. A session-2 archive could then receive a name
14736 // ≤ the session-1 archive, causing incorrect sort order or collision.
14737 //
14738 // Instead: read and decode the live WAL here (before the rename) to
14739 // get its exact frame count, then add it to the last known global
14740 // end-frame index (the name of the most recent existing archive, or
14741 // wal_horizon_floor if no archives exist). This is O(WAL size) but
14742 // snapshot is already serialising the full graph state, so the cost
14743 // is dominated.
14744 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
14745 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
14746 let archive_n = existing_archives
14747 .last()
14748 .copied()
14749 .unwrap_or(self.wal_horizon_floor)
14750 + live_frames_for_name.len() as u64;
14751 // What the WAL is replaced with: the store's declarations and
14752 // nothing else. Built before the rename, because it is also what
14753 // says whether there is anything to archive.
14754 let mut baseline_wal: Vec<u8> = Vec::new();
14755 // The multiplicity opt-in is a declaration like the two below it,
14756 // and it is re-emitted for the same reason: truncation must not
14757 // silently opt the store back out and stop counting.
14758 if self.multiplicity {
14759 baseline_wal
14760 .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14761 }
14762 for (label, field) in self.fulltext.enabled_pairs() {
14763 let rec = WalRecord::EnableFulltext {
14764 label: label.clone(),
14765 field: field.clone(),
14766 };
14767 baseline_wal.extend_from_slice(&encode_record(&rec));
14768 }
14769 for (label, field) in self.prop_index.enabled_pairs() {
14770 let rec = WalRecord::EnableIndex {
14771 label: label.clone(),
14772 field: field.clone(),
14773 };
14774 baseline_wal.extend_from_slice(&encode_record(&rec));
14775 }
14776 // A WAL that already holds exactly that has had nothing written
14777 // to it since the last snapshot put it there, and there is
14778 // nothing to archive. Archiving it anyway destroyed history: an
14779 // archive is named for the commit it ends at, so with no commit
14780 // since the last one this one took the last one's name, and the
14781 // rename put an empty file over it — after which the commit
14782 // numbers were handed out again and a read by commit answered
14783 // with a later state (row 100). The WAL stays where it is; it is
14784 // rewritten only if it ends in bytes that are no record. A store
14785 // with no archive yet has no name to collide with and is archived
14786 // as before.
14787 let (baseline_frames, _) = decode_all(&baseline_wal);
14788 //
14789 // An empty WAL has nothing to archive whatever the replacement
14790 // holds: it is what a kill between the rename and the write of
14791 // the replacement leaves, and on a store with a declaration it is
14792 // not the replacement. The rewrite below puts the declarations
14793 // back.
14794 let nothing_to_archive = (live_frames_for_name.is_empty()
14795 || live_frames_for_name == baseline_frames)
14796 && !self.fs.list_archives()?.is_empty();
14797 stopped(SnapshotStop::BeforeWalReplacement)?;
14798 if nothing_to_archive {
14799 if live_wal_bytes_for_name != baseline_wal {
14800 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14801 }
14802 } else {
14803 // And never over an archive that exists, whatever led to the
14804 // name: a snapshot that cannot be taken costs a longer open,
14805 // an archive written over costs the store's history.
14806 if existing_archives.contains(&archive_n) {
14807 return Err(GraphError::Corrupt {
14808 detail: format!(
14809 "snapshot: wal.{archive_n}.archive already exists; \
14810 not archiving the WAL over it"
14811 ),
14812 });
14813 }
14814 self.fs.archive_wal(archive_n)?;
14815
14816 // The replacement WAL goes in **immediately**, with no fallible call
14817 // between it and the rename above.
14818 //
14819 // The rename is what removes the store's live declarations — the
14820 // multiplicity opt-in, and every `EnableFulltext` / `EnableIndex` —
14821 // and this write is what puts them back. Every call that used to sit
14822 // in between (the genesis marker, the retention sweep's reads, the
14823 // floor write, the archive deletes) was a `?` that could leave the
14824 // store with neither, so a single transient `Err` was enough to lose
14825 // a declaration that no rebuild can recover (defect #22).
14826 //
14827 // Ordering alone cannot close the crash window between two
14828 // filesystem calls; for the multiplicity declaration the V10 stamp
14829 // does that on the open path. What ordering does close is the much
14830 // wider window in which an ordinary I/O error did it — and that half
14831 // covers all three declarations, not just the one with a stamp.
14832 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14833 }
14834
14835 // The genesis marker was written before the snapshot, above.
14836
14837 // Retention pruning: keep newest `keep` archives; delete oldest.
14838 // Pruning is the ONLY deletion site for archives.
14839 //
14840 // Crash-safety ordering (C1 fix):
14841 // 1. Count frames in surplus archives (reads only — no mutation).
14842 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
14843 // rename (atomic). A crash after this point leaves orphaned
14844 // archives on disk, but the floor is correct. The opening
14845 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
14846 // the next open, so the store is always safe to reopen.
14847 // 3. Delete the genesis marker (floor > 0 already blocks open_at
14848 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
14849 // 4. Delete surplus archives. A crash between any two deletes
14850 // leaves the floor committed and orphaned archives cleaned at
14851 // next open — never a stale floor with a missing archive prefix.
14852 if let Some(keep) = self.wal_archive_retention {
14853 if keep > 0 {
14854 let archives = self.fs.list_archives()?;
14855 // archives is sorted ascending (oldest first)
14856 if archives.len() as u32 > keep {
14857 let surplus = archives.len() - keep as usize;
14858 // Step 1: count pruned frames (reads, no mutation).
14859 let mut pruned_frames = 0u64;
14860 for &n in &archives[..surplus] {
14861 let bytes = self.fs.read_archive(n)?;
14862 let (frames, _) = decode_all(&bytes);
14863 pruned_frames += frames.len() as u64;
14864 }
14865 // Step 2: advance and persist floor FIRST.
14866 self.wal_horizon_floor += pruned_frames;
14867 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
14868 // The time map must not outlive the commits it
14869 // describes: an entry below the new floor would resolve
14870 // a date to a commit the engine can no longer replay,
14871 // which is worse than having no entry at all.
14872 self.commit_times.truncate_below(self.wal_horizon_floor);
14873 self.rewrite_commit_times();
14874 // Step 3: delete genesis marker (floor > 0 already
14875 // blocks open_at; this is belt-and-suspenders cleanup).
14876 if pruned_frames > 0 && self.archive_genesis_chain {
14877 self.fs.delete_genesis_marker()?;
14878 self.archive_genesis_chain = false;
14879 }
14880 // Step 4: delete surplus archives. Crash here →
14881 // orphaned archives; cleaned at next open.
14882 for &n in &archives[..surplus] {
14883 self.fs.delete_archive(n)?;
14884 }
14885 }
14886 }
14887 }
14888 } else if opts.keep_wal {
14889 // keep_wal=true: WAL is left untouched. The existing WAL already
14890 // contains the EnableFulltext records from the original enable calls;
14891 // replay is idempotent (guards in apply() skip already-live entries).
14892 // No baseline re-write is needed or safe here — the full WAL history
14893 // must remain intact for open_at to reach pre-snapshot commits.
14894 } else {
14895 // keep_wal=false (default): truncate by replacing the WAL with a
14896 // minimal baseline of one EnableFulltext record per active pair.
14897 //
14898 // Crash-ordering: write_atomic is atomic.
14899 // • Crash before snapshot write → WAL unchanged. Safe.
14900 // • Crash after snapshot write but before this WAL write → full
14901 // pre-snapshot WAL still present; open_with replays idempotently.
14902 // • Crash after both writes → normal post-snapshot state.
14903 //
14904 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
14905 // for any archives taken AFTER this point (their WAL slices would
14906 // not start at genesis). Delete any existing genesis marker so that
14907 // open_at refuses archive-resident commits. Future sessions are
14908 // covered by had_prior_snapshot: snapshot.bin written here persists
14909 // across sessions and prevents a later archiving session from
14910 // incorrectly claiming a complete genesis chain.
14911 if self.archive_genesis_chain {
14912 self.fs.delete_genesis_marker()?;
14913 self.archive_genesis_chain = false;
14914 }
14915 // And this handle can no longer prove the WAL is whole: it is about
14916 // to truncate it itself. Same-session archives after this point get
14917 // the conservative answer, exactly as cross-session ones do.
14918 self.snapshot_preserved_history = false;
14919 // The frames this discards keep their numbers. Everything the store
14920 // could still address — its archives and the live WAL — ends at
14921 // `wal_total_commits()`. That becomes the floor, and the replacement
14922 // WAL continues from it, exactly as the archive path's does after a
14923 // retention sweep.
14924 //
14925 // Leaving the floor where it was renumbered the new WAL over the
14926 // old one: `stats` reported `history_floor: 0`, which every reader
14927 // prints as "complete, nothing pruned", and a commit index a caller
14928 // already held resolved to an unrelated frame — a plausible graph,
14929 // not an error (ledger row 80).
14930 //
14931 // The floor is persisted FIRST, for the reason the retention sweep
14932 // gives: a crash after it leaves the old WAL and any archives on
14933 // disk under a floor that already disowns them. The next open
14934 // removes the archives as orphans and replays the WAL over the
14935 // snapshot idempotently. No number from before the snapshot is
14936 // answered in that state: all of them are below the floor.
14937 //
14938 // What that state does do is count the old WAL's frames again,
14939 // above the floor, until the next truncating snapshot — the same
14940 // history under new numbers, and a total inflated by its length.
14941 // Never a reused number, but not the original ones either. Closing
14942 // that needs the floor to land with the snapshot in one write,
14943 // which is a format change (ledger row 80, "what is left").
14944 let discarded_through = self.wal_total_commits()?;
14945 if discarded_through > self.wal_horizon_floor {
14946 self.fs.write_horizon_floor(discarded_through)?;
14947 self.wal_horizon_floor = discarded_through;
14948 }
14949 let mut baseline_wal: Vec<u8> = Vec::new();
14950 // The multiplicity opt-in is a declaration like the two below it,
14951 // and it is re-emitted for the same reason: truncation must not
14952 // silently opt the store back out and stop counting.
14953 if self.multiplicity {
14954 baseline_wal
14955 .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14956 }
14957 for (label, field) in self.fulltext.enabled_pairs() {
14958 let rec = WalRecord::EnableFulltext {
14959 label: label.clone(),
14960 field: field.clone(),
14961 };
14962 baseline_wal.extend_from_slice(&encode_record(&rec));
14963 }
14964 for (label, field) in self.prop_index.enabled_pairs() {
14965 let rec = WalRecord::EnableIndex {
14966 label: label.clone(),
14967 field: field.clone(),
14968 };
14969 baseline_wal.extend_from_slice(&encode_record(&rec));
14970 }
14971 stopped(SnapshotStop::BeforeWalReplacement)?;
14972 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14973 // Every archive now ends at or below the floor. They are the
14974 // history this snapshot was asked to discard; removing them here is
14975 // what `cleanup_orphaned_archives` would do at the next open.
14976 for n in self.fs.list_archives()? {
14977 if n <= self.wal_horizon_floor {
14978 self.fs.delete_archive(n)?;
14979 }
14980 }
14981 // This branch discards history rather than archiving it: the frames
14982 // the map describes are gone, and every entry it holds is below the
14983 // floor. A date before the floor has no commit to resolve to.
14984 //
14985 // A store that cannot answer a date says so by name
14986 // (`NoRecordedTime`). That is the honest state after discarding the
14987 // history the dates addressed.
14988 self.commit_times = core_storage::commit_times::CommitTimes::default();
14989 self.rewrite_commit_times();
14990 }
14991 // The WAL is the one that goes with the snapshot now. The intent has
14992 // done its work and must not outlive it.
14993 if replaces_wal {
14994 stopped(SnapshotStop::AfterWalReplacement)?;
14995 self.fs.write_atomic(FileId::SnapshotIntent, &[])?;
14996 self.intent_unsettled = false;
14997 }
14998 // After snapshot the overlay may have changed (V8 merge path clears
14999 // self.topo and self.props). Refresh the MVCC fold so future readers
15000 // see the post-snapshot state rather than stale overlay data.
15001 self.fold_now();
15002 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
15003 // markers this handle uses to detect other processes' work must be
15004 // re-taken from disk. Skipping this would make our own snapshot look
15005 // like a peer's on the next staleness check and force a needless
15006 // reload.
15007 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
15008 // A snapshot can replace the live WAL with a baseline, which has its
15009 // own frames. Re-derive the frame cursor from what the store now holds
15010 // (the floor, plus what is reachable above it) rather than carrying
15011 // the pre-snapshot count forward. `wal_total_commits` is the same
15012 // sequence the history surfaces index, and the snapshot has already
15013 // paid a far larger cost than one decode.
15014 self.wal_frames_written = self.wal_total_commits()?;
15015 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
15016 Ok(())
15017 }
15018}
15019
15020/// What a batch node insert does when its key is already taken.
15021///
15022/// A mirror rebuild writes a frame onto a store that already has content, so
15023/// "the key exists" is a routine answer rather than a failure. The decision is
15024/// made during the batch's existing validate pass, from one id-map lookup per
15025/// row, so the frame stays atomic and re-ingest stays O(n).
15026#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15027pub enum OnConflict {
15028 /// Refuse the whole frame with [`GraphError::DuplicateKey`]. The default,
15029 /// and the only behaviour before v0.6.10.
15030 #[default]
15031 Error,
15032 /// Leave the stored node exactly as it is — properties, label and edges —
15033 /// and count it in [`BatchOutcome::skipped`].
15034 Skip,
15035 /// Keep the key and make the node's properties **exactly** the supplied
15036 /// props: supplied fields are set, fields absent from the supplied props
15037 /// are removed. A supplied label that differs from the stored one, and a
15038 /// supplied `ns` that would move the node, are row errors — relabelling is
15039 /// [`GraphDb::rename_node`], not a side effect of a rebuild.
15040 ///
15041 /// Two properties are outside "exactly", both because they are not the
15042 /// caller's to supply:
15043 ///
15044 /// - `ns` is immutable, so an omitted `ns` leaves the node where it is
15045 /// rather than moving it to `default`;
15046 /// - a property a **view** owns is kept, not removed. Supplying one is a
15047 /// row error, so omitting it cannot be a request to delete it, and
15048 /// refusing the row instead would make `Replace` impossible for the whole
15049 /// population a view has written to. Each such field kept is counted in
15050 /// [`BatchOutcome::kept_view_owned`] — the row is still `replaced` and
15051 /// still raises no row error, so that count is the only signal a caller
15052 /// gets that the stored node carries a field its frame did not describe.
15053 Replace,
15054}
15055
15056/// What one [`OnConflict::Replace`] row resolves to.
15057///
15058/// The property writes that make the node exactly the supplied props —
15059/// `Some(value)` is a set, `None` a removal — paired with how many view-owned
15060/// fields the row kept instead of removing, which is the one way the result is
15061/// not exactly the supplied props. See [`MutPreview::plan_replace`].
15062type ReplacePlan = (Vec<(String, Option<Value>)>, usize);
15063
15064/// What one committed batch did.
15065///
15066/// [`BatchBuilder::commit`] returns the first two fields as a tuple; the rest
15067/// exist for [`OnConflict`] and are always zero / empty without it.
15068#[derive(Clone, Debug, Default, PartialEq, Eq)]
15069pub struct BatchOutcome {
15070 /// Node records actually written.
15071 pub nodes_inserted: usize,
15072 /// Edge records actually written. A duplicate edge is a silent no-op under
15073 /// every policy — adjacency is a set — and is not counted.
15074 pub edges_inserted: usize,
15075 /// Rows whose key was taken and whose policy was [`OnConflict::Skip`].
15076 pub skipped: usize,
15077 /// Rows whose key was taken and whose policy was [`OnConflict::Replace`].
15078 pub replaced: usize,
15079 /// View-owned properties an [`OnConflict::Replace`] row **kept** although
15080 /// the caller did not supply them — counted per field, so one row that
15081 /// keeps two contributes two.
15082 ///
15083 /// This is the one respect in which `Replace` does not make a node's props
15084 /// exactly the supplied ones (see [`OnConflict::Replace`]). Those rows
15085 /// still count in `replaced` and still raise no `row_errors`, because
15086 /// nothing went wrong: a view's property is not the caller's to supply or
15087 /// to remove. A mirror rebuild that needs its copy to be byte-exact reads
15088 /// this to learn that the store kept fields its frame did not describe.
15089 pub kept_view_owned: usize,
15090 /// `(row, why)` for rows an [`OnConflict::Replace`] refused. `row` counts
15091 /// node-insert ops in this batch from zero, which for a caller that queues
15092 /// its nodes in order is the index of the offending node. The rest of the
15093 /// frame still commits; the refused row changes nothing.
15094 pub row_errors: Vec<(usize, String)>,
15095}
15096
15097/// The `(nodes_inserted, edges_inserted)` pair every pre-0.6.10 commit entry
15098/// point returns. Keeps those signatures unchanged now that the validate pass
15099/// produces a [`BatchOutcome`].
15100fn inserted_pair(outcome: BatchOutcome) -> (usize, usize) {
15101 (outcome.nodes_inserted, outcome.edges_inserted)
15102}
15103
15104/// One entry of a frame the validate pass has decided on, before
15105/// [`GraphDb::rewrite_wal_dense_planned`] turns it into dense-id records.
15106///
15107/// Almost every entry is already a finished [`WalRecord`]. The exception is a
15108/// duplicate edge insert: its count names a dense triple, and on the batch path
15109/// the endpoints and the edge type may all be created by earlier records in the
15110/// *same* frame, so no id for them exists until the dense rewrite allocates it.
15111/// Carrying the keys this far and resolving them there is what lets the count
15112/// survive the shape a mirror rebuild writes (defect #24).
15113enum PlannedRec {
15114 Rec(WalRecord),
15115 DuplicateCount {
15116 edge_type: String,
15117 src_key: String,
15118 dst_key: String,
15119 },
15120}
15121
15122/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
15123///
15124/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
15125/// callers can build a set of mutations without holding `&mut GraphDb` and
15126/// hand them off to the group-committing writer for durable, batched I/O.
15127pub enum BatchOp {
15128 InsertNode {
15129 label: String,
15130 key: String,
15131 props: Vec<(String, Value)>,
15132 },
15133 InsertEdge {
15134 edge_type: String,
15135 src_key: String,
15136 dst_key: String,
15137 },
15138 SetProp {
15139 key: String,
15140 field: String,
15141 value: Value,
15142 },
15143 RemoveProp {
15144 key: String,
15145 field: String,
15146 },
15147 DeleteEdge {
15148 edge_type: String,
15149 src_key: String,
15150 dst_key: String,
15151 },
15152 DeleteNode {
15153 key: String,
15154 },
15155 CreateRule(RuleDef),
15156 DeleteRule {
15157 name: String,
15158 },
15159 /// Rename a node's key. Validated: old must exist, new must not.
15160 RenameNode {
15161 old_key: String,
15162 new_key: String,
15163 },
15164 /// Insert an edge, auto-creating any missing endpoint as a plain node with
15165 /// `placeholder_label` and no props. Rules fire and last-change is updated
15166 /// for each created endpoint (normal InsertNode semantics in the batch frame).
15167 InsertEdgeUpsert {
15168 edge_type: String,
15169 src_key: String,
15170 dst_key: String,
15171 placeholder_label: String,
15172 },
15173 /// Insert `key`, or — when the key is already taken — do what `on_conflict`
15174 /// says. Queued by [`BatchBuilder::insert_node_on_conflict`]; `Error`
15175 /// queues a plain [`BatchOp::InsertNode`] instead, so this variant only
15176 /// ever carries `Skip` or `Replace`.
15177 InsertNodeOnConflict {
15178 label: String,
15179 key: String,
15180 props: Vec<(String, Value)>,
15181 on_conflict: OnConflict,
15182 },
15183}
15184
15185/// Three-way node visibility status used by `check_single_op_authz`.
15186enum NodeAuthzStatus {
15187 /// Node exists in the store and is in the role's read mask.
15188 Visible(String), // carries the node's label
15189 /// Node exists in the store but is NOT in the role's read mask.
15190 Hidden,
15191 /// Node does not exist in the store.
15192 Absent,
15193}
15194
15195/// Overlay of ops already accepted earlier in the same batch. Never written
15196/// back to the database — validation only.
15197#[derive(Default)]
15198struct Overlay {
15199 extra_keys: BTreeSet<String>,
15200 /// Label of each node inserted earlier in this batch. The store does not
15201 /// have these keys yet, so `label_of` cannot answer for them, and
15202 /// `OnConflict::Replace` has to compare labels.
15203 extra_labels: BTreeMap<String, String>,
15204 deleted_keys: BTreeSet<String>,
15205 /// `new key → store key` for every node an earlier op in this batch
15206 /// renamed. Such a key is in `extra_keys`, but unlike one a batch inserts
15207 /// it has a stored node behind it — under the old key until the batch
15208 /// commits — and its properties, label and edges are read through this.
15209 renamed_from: BTreeMap<String, String>,
15210 extra_props: BTreeMap<(String, String), Value>,
15211 removed_props: BTreeSet<(String, String)>,
15212 extra_edges: BTreeSet<(String, String, String)>,
15213 deleted_edges: BTreeSet<(String, String, String)>,
15214 extra_rules: BTreeSet<String>,
15215 deleted_rules: BTreeSet<String>,
15216 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
15217 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
15218 /// sees only the rules already committed to the engine. Keyed by name so a
15219 /// later `DeleteRule` in the same batch drops the arc with the rule.
15220 extra_rule_arcs: BTreeMap<String, (String, String)>,
15221}
15222
15223/// Read-only view of live db state plus a batch overlay. Shared by single-op
15224/// public methods (empty overlay) and `commit_batch`.
15225struct MutPreview<'a, F: Fs> {
15226 db: &'a GraphDb<F>,
15227 overlay: Overlay,
15228}
15229
15230/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
15231/// `None` if `target` is unreachable.
15232///
15233/// Used for rule-chain cycle detection, where an arc is "a rule hops over
15234/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
15235/// reported path is stable for a given rule set, and iterative so a pathological
15236/// rule graph cannot overflow the stack.
15237fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
15238 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
15239 for (from, to) in arcs {
15240 adj.entry(from.as_str()).or_default().insert(to.as_str());
15241 }
15242 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
15243 let mut visited: BTreeSet<&str> = BTreeSet::new();
15244 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
15245 visited.insert(start);
15246 queue.push_back(start);
15247 while let Some(node) = queue.pop_front() {
15248 if node == target {
15249 let mut path = vec![node.to_string()];
15250 let mut cur = node;
15251 while let Some(&p) = parent.get(cur) {
15252 path.push(p.to_string());
15253 cur = p;
15254 }
15255 path.reverse();
15256 return Some(path);
15257 }
15258 for &next in adj.get(node).into_iter().flatten() {
15259 if visited.insert(next) {
15260 parent.insert(next, node);
15261 queue.push_back(next);
15262 }
15263 }
15264 }
15265 None
15266}
15267
15268impl<'a, F: Fs> MutPreview<'a, F> {
15269 fn new(db: &'a GraphDb<F>) -> Self {
15270 Self {
15271 db,
15272 overlay: Overlay::default(),
15273 }
15274 }
15275
15276 fn has_key(&self, key: &str) -> bool {
15277 if self.overlay.extra_keys.contains(key) {
15278 return true;
15279 }
15280 if self.overlay.deleted_keys.contains(key) {
15281 return false;
15282 }
15283 self.db.ids.get(key).is_some()
15284 }
15285
15286 /// The key the store holds `key`'s node under, or `None` when the batch
15287 /// sees no stored node there: `key` is deleted, or was first inserted in
15288 /// this batch. A key an earlier op renamed stands for the node still
15289 /// stored under its old key.
15290 fn store_key<'k>(&'k self, key: &'k str) -> Option<&'k str> {
15291 if self.overlay.deleted_keys.contains(key) {
15292 return None;
15293 }
15294 if let Some(origin) = self.overlay.renamed_from.get(key) {
15295 return Some(origin.as_str());
15296 }
15297 if self.overlay.extra_keys.contains(key) {
15298 return None;
15299 }
15300 Some(key)
15301 }
15302
15303 fn has_prop(&self, key: &str, field: &str) -> bool {
15304 if !self.has_key(key) {
15305 return false;
15306 }
15307 let k = (key.to_string(), field.to_string());
15308 if self.overlay.removed_props.contains(&k) {
15309 return false;
15310 }
15311 if self.overlay.extra_props.contains_key(&k) {
15312 return true;
15313 }
15314 // Fresh identity (first insert in this batch, or delete+reinsert):
15315 // ignore props still sitting on the soon-to-be-tombstoned slot.
15316 let Some(stored) = self.store_key(key) else {
15317 return false;
15318 };
15319 self.db.get_prop(stored, field).is_some()
15320 }
15321
15322 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15323 let k = (
15324 edge_type.to_string(),
15325 src_key.to_string(),
15326 dst_key.to_string(),
15327 );
15328 if self.overlay.deleted_edges.contains(&k) {
15329 return false;
15330 }
15331 if self.overlay.extra_edges.contains(&k) {
15332 return true;
15333 }
15334 // A key created in this batch (including reinsert) has no db edges.
15335 let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15336 else {
15337 return false;
15338 };
15339 let Some(src) = self.db.ids.get(src_key) else {
15340 return false;
15341 };
15342 let Some(dst) = self.db.ids.get(dst_key) else {
15343 return false;
15344 };
15345 let Some(sym) = self.db.syms.get(edge_type) else {
15346 return false;
15347 };
15348 self.db
15349 .topo_view()
15350 .neighbors(sym, Direction::Out, src)
15351 .binary_search(&dst)
15352 .is_ok()
15353 }
15354
15355 fn has_rule(&self, name: &str) -> bool {
15356 if self.overlay.extra_rules.contains(name) {
15357 return true;
15358 }
15359 if self.overlay.deleted_rules.contains(name) {
15360 return false;
15361 }
15362 self.db.engine.rules().any(|r| r.name == name)
15363 }
15364
15365 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15366 let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15367 else {
15368 return false;
15369 };
15370 let Some(src) = self.db.ids.get(src_key) else {
15371 return false;
15372 };
15373 let Some(dst) = self.db.ids.get(dst_key) else {
15374 return false;
15375 };
15376 let Some(et) = self.db.syms.get(edge_type) else {
15377 return false;
15378 };
15379 // extra_rules is deliberately not consulted: a CreateRule earlier in
15380 // this batch has not fired, so it contributes no provenance. That is
15381 // the documented rule-window gap (see GraphDb::batch).
15382 if self.overlay.deleted_rules.is_empty() {
15383 return self.db.engine.is_owned(et, src, dst);
15384 }
15385 for (rule, triples) in self.db.engine.provenance() {
15386 if self.overlay.deleted_rules.contains(rule) {
15387 continue;
15388 }
15389 if triples.contains(&(et, src, dst)) {
15390 return true;
15391 }
15392 }
15393 false
15394 }
15395
15396 /// The refusals a node creation makes, in the order it makes them.
15397 ///
15398 /// A view owns its property, and creating a node that carries one is a
15399 /// write to it exactly as `set_prop` is — so it is refused here, at the one
15400 /// choke-point `GraphDb::insert_node`, `BatchOp::InsertNode` and the
15401 /// no-conflict arm of `BatchOp::InsertNodeOnConflict` all pass through.
15402 ///
15403 /// Leaving creation exempt was not harmless. The value was stored and
15404 /// served: a created node the view has no reason to revisit keeps the
15405 /// caller's number for the life of the handle, and the backfill at the next
15406 /// open overwrites it — so the store answered `deg = 777` before a restart
15407 /// and `deg = 0` after, for a property every other surface calls read-only.
15408 /// It also split one op two ways: supplying a view-owned field under
15409 /// `OnConflict::Replace` was already a row error on a taken key while the
15410 /// same field on a fresh key was accepted.
15411 ///
15412 /// Checked before the key, like [`MutPreview::prepare_remove_prop`], so the
15413 /// answer does not depend on whether the key exists.
15414 fn check_insert_node(&self, key: &str, props: &[(String, Value)]) -> Result<()> {
15415 for (field, _) in props {
15416 if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15417 return Err(GraphError::ViewPropReadOnly {
15418 view_name: view_name.to_string(),
15419 });
15420 }
15421 }
15422 if self.has_key(key) {
15423 Err(GraphError::DuplicateKey { key: key.into() })
15424 } else {
15425 Ok(())
15426 }
15427 }
15428
15429 fn check_live_key(&self, key: &str) -> Result<()> {
15430 if self.has_key(key) {
15431 Ok(())
15432 } else {
15433 Err(GraphError::KeyNotFound { key: key.into() })
15434 }
15435 }
15436
15437 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15438 for k in [src_key, dst_key] {
15439 if !self.has_key(k) {
15440 return Err(GraphError::KeyNotFound { key: k.into() });
15441 }
15442 }
15443 if self.is_rule_owned(edge_type, src_key, dst_key) {
15444 return Err(GraphError::RuleOwned {
15445 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
15446 });
15447 }
15448 // A user-written edge stays inside one namespace. Derived edges do not
15449 // come through here — the engine adds them directly — and the rule
15450 // scoping check is what keeps those pure.
15451 let src_ns = self.namespace_in_batch(src_key);
15452 let dst_ns = self.namespace_in_batch(dst_key);
15453 if src_ns != dst_ns {
15454 return Err(GraphError::CrossNamespace {
15455 src: src_key.to_string(),
15456 src_ns,
15457 dst: dst_key.to_string(),
15458 dst_ns,
15459 });
15460 }
15461 Ok(!self.has_edge(edge_type, src_key, dst_key))
15462 }
15463
15464 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
15465 // A view owns its property, and the refusal has to live here rather
15466 // than on `GraphDb::remove_prop`: `BatchOp::RemoveProp` never meets
15467 // that one, and it is what the HTTP `DELETE /node/{key}/prop/{field}`
15468 // route, `Batch::remove_prop` and the CLI all submit. This is the one
15469 // choke-point every removal passes, exactly as it is for `ns` below.
15470 // Checked before the key, so the answer does not depend on whether the
15471 // key exists — which is also what `GraphDb::remove_prop` answered when
15472 // it carried the only copy of this guard.
15473 if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15474 return Err(GraphError::ViewPropReadOnly {
15475 view_name: view_name.to_string(),
15476 });
15477 }
15478 self.check_live_key(key)?;
15479 // Removing `ns` is changing the namespace — to `default`, the namespace
15480 // an absent property names. It goes through this one choke-point and NOT
15481 // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
15482 // the immutability rule has to be stated here as well. Without it the
15483 // node silently lands in `default` on the next open: the cross-namespace
15484 // edge guard is defeated and a default-bound role reads a tenant's node.
15485 if field == NS_PROP {
15486 let from = self.namespace_in_batch(key);
15487 if from != NS_DEFAULT {
15488 return Err(GraphError::NamespaceImmutable {
15489 key: key.to_string(),
15490 from,
15491 to: NS_DEFAULT.to_string(),
15492 });
15493 }
15494 // Already in `default`: the removal changes no namespace. It is the
15495 // no-op `set_prop` to the current namespace is, not an error.
15496 return Ok(false);
15497 }
15498 Ok(self.has_prop(key, field))
15499 }
15500
15501 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15502 for k in [src_key, dst_key] {
15503 if !self.has_key(k) {
15504 return Err(GraphError::KeyNotFound { key: k.into() });
15505 }
15506 }
15507 // Provenance-owned OR a live rule would derive this pair. User-first
15508 // edges that a later rule matches are not in `owned`, but deleting
15509 // them would leave a hole `rebuild_rule` immediately fills.
15510 if self.is_rule_owned(edge_type, src_key, dst_key) {
15511 return Err(GraphError::RuleOwned {
15512 detail: format!(
15513 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15514 delete or change the owning rule"
15515 ),
15516 });
15517 }
15518 if self.would_derive(edge_type, src_key, dst_key) {
15519 return Err(GraphError::RuleOwned {
15520 detail: format!(
15521 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15522 delete or change the owning rule, or a live rule would re-derive it"
15523 ),
15524 });
15525 }
15526 Ok(self.has_edge(edge_type, src_key, dst_key))
15527 }
15528
15529 /// True if any live rule (minus overlay-deleted names) would derive
15530 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
15531 /// CreateRule names in `extra_rules` are ignored — same documented
15532 /// same-batch rule-window as [`Self::is_rule_owned`].
15533 ///
15534 /// Mirrored by `guard_matches` in `memory/forget.rs`, which names the
15535 /// rules this refused for: change the two together (ledger row 67).
15536 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15537 if src_key == dst_key {
15538 return false;
15539 }
15540 let Some(src_label) = self.label_of(src_key) else {
15541 return false;
15542 };
15543 let Some(dst_label) = self.label_of(dst_key) else {
15544 return false;
15545 };
15546 for rule in self.db.engine.rules() {
15547 if self.overlay.deleted_rules.contains(&rule.name) {
15548 continue;
15549 }
15550 if rule.edge_type != edge_type {
15551 continue;
15552 }
15553 if rule.src_label != src_label || rule.dst_label != dst_label {
15554 continue;
15555 }
15556 let src_props = |f: &str| self.prop_value(src_key, f);
15557 let dst_props = |f: &str| self.prop_value(dst_key, f);
15558 let src_view = NodeView {
15559 key: src_key,
15560 props: &src_props,
15561 };
15562 let dst_view = NodeView {
15563 key: dst_key,
15564 props: &dst_props,
15565 };
15566 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
15567 return true;
15568 }
15569 }
15570 false
15571 }
15572
15573 fn label_of(&self, key: &str) -> Option<String> {
15574 if self.overlay.deleted_keys.contains(key) {
15575 return None;
15576 }
15577 // Fresh identities created in this batch have no stored label in the
15578 // overlay; they cannot be provenance-owned yet either. A key this
15579 // batch renamed has its label on the node stored under the old key.
15580 let stored = self
15581 .overlay
15582 .renamed_from
15583 .get(key)
15584 .map_or(key, String::as_str);
15585 let id = self.db.ids.get(stored)?;
15586 let sym = self.db.labels.get(id as usize).copied()?;
15587 if sym == u32::MAX {
15588 return None;
15589 }
15590 self.db.syms.resolve(sym).map(str::to_string)
15591 }
15592
15593 /// The label `key` carries as this batch sees it — including a node
15594 /// inserted earlier in the same batch, which the store does not have yet.
15595 fn label_in_batch(&self, key: &str) -> Option<String> {
15596 if self.overlay.deleted_keys.contains(key) {
15597 return None;
15598 }
15599 if let Some(label) = self.overlay.extra_labels.get(key) {
15600 return Some(label.clone());
15601 }
15602 self.label_of(key)
15603 }
15604
15605 /// The property writes that make `key`'s props exactly `props`, or why the
15606 /// row is refused.
15607 ///
15608 /// `Some(value)` is a set and `None` is a removal. `store_fields` is every
15609 /// field name the store knows, hoisted by the caller so a frame of N
15610 /// replaces reads the field list once rather than N times.
15611 ///
15612 /// The second half of the pair is how many view-owned fields this row kept
15613 /// rather than removed — the one part of "exactly the supplied props" that
15614 /// does not hold, and the caller's only signal that it did not.
15615 ///
15616 /// The refusals are row errors, not frame errors: a mirror rebuild should
15617 /// learn which of its rows disagree with the store without losing the rows
15618 /// that agree.
15619 fn plan_replace(
15620 &self,
15621 label: &str,
15622 key: &str,
15623 props: &[(String, Value)],
15624 store_fields: &[String],
15625 ) -> std::result::Result<ReplacePlan, String> {
15626 // A different label is a relabel, and a rebuild does not relabel: that
15627 // is `rename_node` or an explicit write, never a side effect here.
15628 let stored = self.label_in_batch(key).unwrap_or_default();
15629 if stored != label {
15630 return Err(format!(
15631 "node {key}: on_conflict=\"replace\" will not relabel {stored:?} to {label:?}; \
15632 relabelling is rename_node or an explicit write"
15633 ));
15634 }
15635 // `ns` is immutable. Replace removes what the supplied props omit, so
15636 // an omitted `ns` is a move to `default` exactly as a different `ns` is
15637 // a move to that one; both are the same refusal.
15638 let from = self.namespace_in_batch(key);
15639 let to = match props.iter().find(|(field, _)| field == NS_PROP) {
15640 Some((_, Value::Str(ns))) => ns.clone(),
15641 Some((_, value)) => {
15642 return Err(format!(
15643 "node {key}: {NS_PROP} must be a string naming a namespace, got {value:?}"
15644 ));
15645 }
15646 None => NS_DEFAULT.to_string(),
15647 };
15648 if to != from {
15649 return Err(format!(
15650 "node {key}: {NS_PROP} is immutable; on_conflict=\"replace\" cannot move it \
15651 from {from:?} to {to:?}"
15652 ));
15653 }
15654
15655 if let Some(why) = self.supplied_view_owned_prop(key, props) {
15656 return Err(why);
15657 }
15658
15659 let supplied: BTreeSet<&str> = props.iter().map(|(field, _)| field.as_str()).collect();
15660 let mut writes = Vec::new();
15661 for (field, value) in props {
15662 // `ns` names the namespace the node is already in, so the write is
15663 // the no-op the dense-rewrite seam would drop anyway.
15664 if field == NS_PROP {
15665 continue;
15666 }
15667 // Already exactly this value: a rebuild of an unchanged row should
15668 // cost no WAL record.
15669 if self.prop_value(key, field).as_ref() == Some(value) {
15670 continue;
15671 }
15672 writes.push((field.clone(), Some(value.clone())));
15673 }
15674 // Everything the node still carries that the supplied props do not.
15675 // `ns` is never removed: it is immutable, and the check above has
15676 // already established the node stays where it is.
15677 let overlay_fields = self
15678 .overlay
15679 .extra_props
15680 .keys()
15681 .filter(|(k, _)| k == key)
15682 .map(|(_, field)| field.as_str());
15683 //
15684 // A view-owned field is filtered out rather than refused. It is not the
15685 // caller's to supply (supplying one is still the row error above) and
15686 // so it is not part of what "exactly the supplied ones" ranges over:
15687 // omitting it is not a request to delete it. Refusing here instead
15688 // would make `replace` impossible for every node a view has written to
15689 // — which on a store carrying a view is the whole population a mirror
15690 // rebuild has to cover.
15691 let omitted: BTreeSet<&str> = store_fields
15692 .iter()
15693 .map(String::as_str)
15694 .chain(overlay_fields)
15695 .filter(|field| {
15696 *field != NS_PROP && !supplied.contains(field) && self.has_prop(key, field)
15697 })
15698 .collect();
15699 // The view-owned half is kept, and counted: the row still commits and
15700 // still reports no error, so without this number a mirror rebuild is
15701 // told it got exactly what it asked for when it did not (defect #18).
15702 let (stale, kept): (Vec<&str>, Vec<&str>) = omitted
15703 .into_iter()
15704 .partition(|field| self.db.view_store.view_for_prop(field).is_none());
15705 writes.extend(stale.into_iter().map(|field| (field.to_string(), None)));
15706 Ok((writes, kept.len()))
15707 }
15708
15709 /// The row error a supplied view-owned field earns, or `None`.
15710 ///
15711 /// Shared by [`MutPreview::plan_replace`] and the no-conflict arm of
15712 /// `BatchOp::InsertNodeOnConflict` so that one op answers a supplied
15713 /// view-owned field the same way whether or not the key was already taken.
15714 fn supplied_view_owned_prop(&self, key: &str, props: &[(String, Value)]) -> Option<String> {
15715 props.iter().find_map(|(field, _)| {
15716 self.db.view_store.view_for_prop(field).map(|view_name| {
15717 format!(
15718 "node {key}: property {field:?} is owned by view {view_name:?} and is \
15719 read-only"
15720 )
15721 })
15722 })
15723 }
15724
15725 /// The namespace `key` is in as this batch sees it — including a node
15726 /// inserted earlier in the same batch, which the store does not have yet.
15727 fn namespace_in_batch(&self, key: &str) -> String {
15728 namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
15729 }
15730
15731 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
15732 if !self.has_key(key) {
15733 return None;
15734 }
15735 let k = (key.to_string(), field.to_string());
15736 if self.overlay.removed_props.contains(&k) {
15737 return None;
15738 }
15739 if let Some(v) = self.overlay.extra_props.get(&k) {
15740 return Some(v.clone());
15741 }
15742 self.db.get_prop(self.store_key(key)?, field)
15743 }
15744
15745 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
15746 def.validate()
15747 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
15748 if self.has_rule(&def.name) {
15749 return Err(GraphError::RuleInvalid {
15750 detail: format!("rule {:?} already exists", def.name),
15751 });
15752 }
15753 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
15754 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
15755 // `edge_type`". A cycle in that graph is a rule set that would re-fire
15756 // itself forever; the engine's depth cap would silently truncate it
15757 // instead, leaving an arbitrary partial result. Reject it here, the one
15758 // place that sees the whole rule set.
15759 //
15760 // Rules accepted earlier in the same batch count too: the overlay
15761 // carries their arcs, so a cycle cannot be assembled one op at a time.
15762 if let Some(via) = def.via_edge.as_deref() {
15763 if via == def.edge_type {
15764 return Err(GraphError::RuleInvalid {
15765 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
15766 });
15767 }
15768 let mut arcs: Vec<(String, String)> = self
15769 .db
15770 .engine
15771 .rules()
15772 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
15773 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
15774 .collect();
15775 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
15776 arcs.push((via.to_string(), def.edge_type.clone()));
15777 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
15778 return Err(GraphError::RuleInvalid {
15779 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
15780 });
15781 }
15782 }
15783 Ok(())
15784 }
15785
15786 fn check_delete_rule(&self, name: &str) -> Result<()> {
15787 if self.has_rule(name) {
15788 Ok(())
15789 } else {
15790 Err(GraphError::RuleNotFound { name: name.into() })
15791 }
15792 }
15793
15794 fn note_insert_node(&mut self, label: &str, key: &str, props: &[(String, Value)]) {
15795 self.overlay.deleted_keys.remove(key);
15796 self.overlay.renamed_from.remove(key);
15797 self.overlay.extra_keys.insert(key.to_string());
15798 self.overlay
15799 .extra_labels
15800 .insert(key.to_string(), label.to_string());
15801 self.overlay.extra_props.retain(|(k, _), _| k != key);
15802 self.overlay.removed_props.retain(|(k, _)| k != key);
15803 for (field, value) in props {
15804 self.overlay
15805 .extra_props
15806 .insert((key.to_string(), field.clone()), value.clone());
15807 }
15808 }
15809
15810 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15811 let k = (
15812 edge_type.to_string(),
15813 src_key.to_string(),
15814 dst_key.to_string(),
15815 );
15816 self.overlay.deleted_edges.remove(&k);
15817 self.overlay.extra_edges.insert(k);
15818 }
15819
15820 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
15821 let k = (key.to_string(), field.to_string());
15822 self.overlay.removed_props.remove(&k);
15823 self.overlay.extra_props.insert(k, value.clone());
15824 }
15825
15826 fn note_remove_prop(&mut self, key: &str, field: &str) {
15827 let k = (key.to_string(), field.to_string());
15828 self.overlay.extra_props.remove(&k);
15829 self.overlay.removed_props.insert(k);
15830 }
15831
15832 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15833 let k = (
15834 edge_type.to_string(),
15835 src_key.to_string(),
15836 dst_key.to_string(),
15837 );
15838 self.overlay.extra_edges.remove(&k);
15839 self.overlay.deleted_edges.insert(k);
15840 }
15841
15842 fn note_delete_node(&mut self, key: &str) {
15843 self.overlay.extra_keys.remove(key);
15844 self.overlay.renamed_from.remove(key);
15845 self.overlay.deleted_keys.insert(key.to_string());
15846 self.overlay.extra_props.retain(|(k, _), _| k != key);
15847 self.overlay.removed_props.retain(|(k, _)| k != key);
15848 self.overlay
15849 .extra_edges
15850 .retain(|(_, s, d)| s != key && d != key);
15851 self.overlay
15852 .deleted_edges
15853 .retain(|(_, s, d)| s != key && d != key);
15854 }
15855
15856 fn note_create_rule(&mut self, def: &RuleDef) {
15857 self.overlay.deleted_rules.remove(&def.name);
15858 self.overlay.extra_rules.insert(def.name.clone());
15859 // Rules accepted earlier in this batch are not in the engine yet, so
15860 // the cycle check would not see their arcs. Keep the arc, not just the
15861 // name, so a batch cannot smuggle in a cycle one op at a time.
15862 if let Some(via) = def.via_edge.clone() {
15863 self.overlay
15864 .extra_rule_arcs
15865 .insert(def.name.clone(), (via, def.edge_type.clone()));
15866 }
15867 }
15868
15869 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
15870 if !self.has_key(old) {
15871 return Err(GraphError::KeyNotFound { key: old.into() });
15872 }
15873 if self.has_key(new) {
15874 return Err(GraphError::DuplicateKey { key: new.into() });
15875 }
15876 Ok(())
15877 }
15878
15879 fn note_rename_node(&mut self, old: &str, new: &str) {
15880 // The stored node `new` will stand for: the one `old` stood for if an
15881 // earlier op renamed it here, `old` itself if the store has it, and
15882 // none if `old` was first inserted in this batch.
15883 let origin = match self.overlay.renamed_from.remove(old) {
15884 Some(origin) => Some(origin),
15885 None if self.overlay.extra_keys.contains(old) => None,
15886 None => Some(old.to_string()),
15887 };
15888 // Mark old as deleted so subsequent batch ops cannot reference it.
15889 self.overlay.extra_keys.remove(old);
15890 self.overlay.deleted_keys.insert(old.to_string());
15891 // Mark new as extra so subsequent batch ops can reference it.
15892 self.overlay.deleted_keys.remove(new);
15893 self.overlay.extra_keys.insert(new.to_string());
15894 // Migrate any overlay props from old key to new key.
15895 let new_str = new.to_string();
15896 let transferred: Vec<((String, String), Value)> = self
15897 .overlay
15898 .extra_props
15899 .iter()
15900 .filter(|((k, _), _)| k.as_str() == old)
15901 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
15902 .collect();
15903 self.overlay
15904 .extra_props
15905 .retain(|(k, _), _| k.as_str() != old);
15906 for (k, v) in transferred {
15907 self.overlay.extra_props.insert(k, v);
15908 }
15909 // Migrate removed_props.
15910 let transferred_removed: Vec<(String, String)> = self
15911 .overlay
15912 .removed_props
15913 .iter()
15914 .filter(|(k, _)| k.as_str() == old)
15915 .map(|(_, f)| (new_str.clone(), f.clone()))
15916 .collect();
15917 self.overlay
15918 .removed_props
15919 .retain(|(k, _)| k.as_str() != old);
15920 for k in transferred_removed {
15921 self.overlay.removed_props.insert(k);
15922 }
15923 if let Some(label) = self.overlay.extra_labels.remove(old) {
15924 self.overlay.extra_labels.insert(new_str.clone(), label);
15925 }
15926 // Edges the batch added or deleted follow the node to its new key.
15927 let follow = |k: String| if k == old { new_str.clone() } else { k };
15928 self.overlay.extra_edges = std::mem::take(&mut self.overlay.extra_edges)
15929 .into_iter()
15930 .map(|(t, s, d)| (t, follow(s), follow(d)))
15931 .collect();
15932 self.overlay.deleted_edges = std::mem::take(&mut self.overlay.deleted_edges)
15933 .into_iter()
15934 .map(|(t, s, d)| (t, follow(s), follow(d)))
15935 .collect();
15936 if let Some(origin) = origin {
15937 self.overlay.renamed_from.insert(new_str, origin);
15938 }
15939 }
15940
15941 fn note_delete_rule(&mut self, name: &str) {
15942 self.overlay.extra_rules.remove(name);
15943 // Drop its chain arc too: a rule created and then deleted in the same
15944 // batch must not make a later, legal rule look like a cycle.
15945 self.overlay.extra_rule_arcs.remove(name);
15946 self.overlay.deleted_rules.insert(name.to_string());
15947 // Treat the deleted rule's current provenance as gone so a later
15948 // delete_edge of those triples is a no-op (matches sequential).
15949 //
15950 // Provenance names nodes by the key the store holds them under; the
15951 // overlay names them as the batch does. A node an earlier op renamed
15952 // is recorded under its new key, or a later read of that key would go
15953 // through to the store and find the rule's edge still there.
15954 let now: BTreeMap<&str, &str> = self
15955 .overlay
15956 .renamed_from
15957 .iter()
15958 .map(|(new, stored)| (stored.as_str(), new.as_str()))
15959 .collect();
15960 if let Some(triples) = self.db.engine.provenance().get(name) {
15961 for &(et, s, d) in triples {
15962 let Some(etype) = self.db.syms.resolve(et) else {
15963 continue;
15964 };
15965 let Some(src) = self.db.ids.key_of(s) else {
15966 continue;
15967 };
15968 let Some(dst) = self.db.ids.key_of(d) else {
15969 continue;
15970 };
15971 let src = now.get(src).copied().unwrap_or(src);
15972 let dst = now.get(dst).copied().unwrap_or(dst);
15973 let k = (etype.to_string(), src.to_string(), dst.to_string());
15974 self.overlay.extra_edges.remove(&k);
15975 self.overlay.deleted_edges.insert(k);
15976 }
15977 }
15978 }
15979}
15980
15981/// Collects mutations and commits them as one WAL `Batch` frame.
15982///
15983/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
15984/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
15985/// See [`GraphDb::batch`] for validation and atomicity rules.
15986pub struct BatchBuilder<'a, F: Fs> {
15987 db: &'a mut GraphDb<F>,
15988 ops: Vec<BatchOp>,
15989}
15990
15991impl<'a, F: Fs> BatchBuilder<'a, F> {
15992 pub fn insert_node(
15993 &mut self,
15994 label: &str,
15995 key: &str,
15996 props: Vec<(String, Value)>,
15997 ) -> &mut Self {
15998 self.ops.push(BatchOp::InsertNode {
15999 label: label.into(),
16000 key: key.into(),
16001 props,
16002 });
16003 self
16004 }
16005
16006 /// Queue a node insert whose answer to a taken key is `on_conflict`.
16007 ///
16008 /// [`OnConflict::Error`] queues exactly the op [`insert_node`](Self::insert_node)
16009 /// does, so the default path is unchanged.
16010 pub fn insert_node_on_conflict(
16011 &mut self,
16012 label: &str,
16013 key: &str,
16014 props: Vec<(String, Value)>,
16015 on_conflict: OnConflict,
16016 ) -> &mut Self {
16017 self.ops.push(match on_conflict {
16018 OnConflict::Error => BatchOp::InsertNode {
16019 label: label.into(),
16020 key: key.into(),
16021 props,
16022 },
16023 on_conflict => BatchOp::InsertNodeOnConflict {
16024 label: label.into(),
16025 key: key.into(),
16026 props,
16027 on_conflict,
16028 },
16029 });
16030 self
16031 }
16032
16033 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16034 self.ops.push(BatchOp::InsertEdge {
16035 edge_type: edge_type.into(),
16036 src_key: src_key.into(),
16037 dst_key: dst_key.into(),
16038 });
16039 self
16040 }
16041
16042 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
16043 self.ops.push(BatchOp::SetProp {
16044 key: key.into(),
16045 field: field.into(),
16046 value,
16047 });
16048 self
16049 }
16050
16051 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
16052 self.ops.push(BatchOp::RemoveProp {
16053 key: key.into(),
16054 field: field.into(),
16055 });
16056 self
16057 }
16058
16059 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16060 self.ops.push(BatchOp::DeleteEdge {
16061 edge_type: edge_type.into(),
16062 src_key: src_key.into(),
16063 dst_key: dst_key.into(),
16064 });
16065 self
16066 }
16067
16068 pub fn delete_node(&mut self, key: &str) -> &mut Self {
16069 self.ops.push(BatchOp::DeleteNode { key: key.into() });
16070 self
16071 }
16072
16073 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
16074 self.ops.push(BatchOp::CreateRule(def));
16075 self
16076 }
16077
16078 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
16079 self.ops.push(BatchOp::DeleteRule { name: name.into() });
16080 self
16081 }
16082
16083 /// Queue a node-rename in this batch.
16084 ///
16085 /// Validation (old exists, new not taken) runs at commit time.
16086 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
16087 self.ops.push(BatchOp::RenameNode {
16088 old_key: old_key.into(),
16089 new_key: new_key.into(),
16090 });
16091 self
16092 }
16093
16094 /// Queue an edge insert with endpoint auto-creation.
16095 ///
16096 /// Any missing endpoint is created as a plain node `{key, label:
16097 /// placeholder_label, no props}` inside this batch frame. Rules fire and
16098 /// last-change is updated for each auto-created node.
16099 pub fn insert_edge_upsert(
16100 &mut self,
16101 edge_type: &str,
16102 src_key: &str,
16103 dst_key: &str,
16104 placeholder_label: &str,
16105 ) -> &mut Self {
16106 self.ops.push(BatchOp::InsertEdgeUpsert {
16107 edge_type: edge_type.into(),
16108 src_key: src_key.into(),
16109 dst_key: dst_key.into(),
16110 placeholder_label: placeholder_label.into(),
16111 });
16112 self
16113 }
16114
16115 /// Validate every queued op, then log one `Batch` frame and apply.
16116 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
16117 /// A second `commit()` after a successful one is an empty-batch no-op
16118 /// (queued ops were taken).
16119 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
16120 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
16121 ///
16122 /// **Rule-window limitation:** batch validation cannot see edges that a
16123 /// rule created earlier in the *same* batch will derive at apply time, so
16124 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
16125 /// where sequential calls would return `Err(RuleOwned)`. State integrity
16126 /// is unaffected (idempotent apply, provenance intact). Create rules in
16127 /// their own batch, or sequentially, when later ops may touch derived
16128 /// edges.
16129 /// Validate every queued op and commit atomically.
16130 ///
16131 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
16132 /// WAL records actually written (duplicate edges are silent no-ops and are
16133 /// NOT counted). Both are 0 when the batch is empty or all-noop.
16134 pub fn commit(&mut self) -> Result<(usize, usize)> {
16135 let ops = std::mem::take(&mut self.ops);
16136 self.db.commit_batch(ops)
16137 }
16138
16139 /// [`commit`](Self::commit) with the full [`BatchOutcome`] — the counts a
16140 /// caller needs when its rows carry an [`OnConflict`] policy.
16141 pub fn commit_outcome(&mut self) -> Result<BatchOutcome> {
16142 let ops = std::mem::take(&mut self.ops);
16143 self.db.commit_logged_batch(ops, None, None)
16144 }
16145
16146 /// Same as [`commit`](Self::commit) but tail the inner events with
16147 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
16148 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
16149 let ops = std::mem::take(&mut self.ops);
16150 self.db
16151 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
16152 .map(inserted_pair)
16153 }
16154}
16155
16156pub struct NodeRef<'a, F: Fs> {
16157 db: &'a GraphDb<F>,
16158 id: u32,
16159}
16160
16161impl<'a, F: Fs> NodeRef<'a, F> {
16162 pub fn key(&self) -> &str {
16163 self.db.ids.key_of(self.id).expect("dense ids")
16164 }
16165
16166 pub fn label(&self) -> &str {
16167 let sym = self
16168 .db
16169 .labels
16170 .get(self.id as usize)
16171 .copied()
16172 .filter(|&s| s != u32::MAX)
16173 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16174 self.db.syms.resolve(sym).expect("interned label symbol")
16175 }
16176
16177 pub fn prop(&self, field: &str) -> Option<Value> {
16178 self.db
16179 .props_view()
16180 .get(self.id, field)
16181 .map(|vr| vr.into_value())
16182 }
16183
16184 /// All stored fields for this node, sorted by field name.
16185 ///
16186 /// Reads from the full base+overlay view so that props stored only in the
16187 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
16188 pub fn props(&self) -> BTreeMap<String, Value> {
16189 let mut out = BTreeMap::new();
16190 let pv = self.db.props_view();
16191 for field in pv.field_names() {
16192 if let Some(vr) = pv.get(self.id, &field) {
16193 out.insert(field, vr.into_value());
16194 }
16195 }
16196 out
16197 }
16198
16199 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
16200 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
16201 let view = self.db.view();
16202 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
16203 names
16204 .iter()
16205 .filter_map(|name| view.syms.get(name))
16206 .collect()
16207 });
16208 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
16209 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
16210 for (nid, d) in nb.nodes {
16211 let key = view.key_of(nid);
16212 let label = view
16213 .label_of(nid)
16214 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16215 rs.push_row(vec![
16216 Some(Value::Str(key.to_string())),
16217 Some(Value::Str(label.to_string())),
16218 Some(Value::Int(d as i64)),
16219 ]);
16220 }
16221 rs
16222 }
16223
16224 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
16225 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
16226 let view = self.db.view();
16227 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
16228 for e in expand(&view, self.id, None, Dir::Both) {
16229 // Skip edges with unknown etypes (only possible from corrupt large
16230 // TOPOLOGY section; function returns BTreeMap not Result).
16231 let Some(etype) = view.syms.resolve(e.etype) else {
16232 continue;
16233 };
16234 let etype = etype.to_string();
16235 let nbr = if e.src == self.id { e.dst } else { e.src };
16236 groups
16237 .entry(etype)
16238 .or_default()
16239 .insert(view.key_of(nbr).to_string());
16240 }
16241 groups
16242 .into_iter()
16243 .map(|(k, v)| (k, v.into_iter().collect()))
16244 .collect()
16245 }
16246}
16247
16248#[cfg(test)]
16249mod tests {
16250 use super::*;
16251 use core_rules::Predicate;
16252
16253 fn tmp_dir(name: &str) -> std::path::PathBuf {
16254 let d =
16255 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
16256 let _ = std::fs::remove_dir_all(&d);
16257 d
16258 }
16259
16260 fn fk_rule() -> RuleDef {
16261 RuleDef {
16262 name: "works_at".into(),
16263 src_label: "Person".into(),
16264 dst_label: "Org".into(),
16265 predicate: Predicate::KeyMatch {
16266 field: "org_id".into(),
16267 },
16268 edge_type: "WORKS_AT".into(),
16269 weight_prop: None,
16270 max_edges: None,
16271 approximate: false,
16272 via_label: None,
16273 via_edge: None,
16274 via_dir: None,
16275 namespace: None,
16276 }
16277 }
16278
16279 /// Regression guard for the no-views delta-copy fast path.
16280 ///
16281 /// When no views are defined, `pending_deltas_since().to_vec()` must never
16282 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
16283 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
16284 /// a count of 0 after the entire sequence proves the guard fires correctly.
16285 #[test]
16286 fn no_delta_copy_when_no_views() {
16287 DELTA_COPY_COUNT.with(|c| c.set(0));
16288 let dir = tmp_dir("no-delta-copy");
16289 {
16290 let mut db = GraphDb::open(&dir).unwrap();
16291 // Insert 50 Org + 50 Person nodes with FK links.
16292 for i in 0..50u32 {
16293 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16294 }
16295 for i in 0..50u32 {
16296 db.insert_node(
16297 "Person",
16298 &format!("p{i}"),
16299 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16300 )
16301 .unwrap();
16302 }
16303 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
16304 db.create_rule(fk_rule()).unwrap();
16305
16306 // Counter must stay 0 — no views, no copies.
16307 let copies = DELTA_COPY_COUNT.with(|c| c.get());
16308 assert_eq!(
16309 copies, 0,
16310 "pending_deltas_since().to_vec() called despite no views"
16311 );
16312
16313 // Derived edges must still be correct (the guard skips only the
16314 // empty delta propagation loop, not the rule application itself).
16315 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
16316 assert_eq!(
16317 nbrs,
16318 vec!["o0"],
16319 "rule must derive edges even with no views"
16320 );
16321 }
16322 let _ = std::fs::remove_dir_all(&dir);
16323 }
16324
16325 /// Gating regression: subscribe AFTER a backfill must see no stale events.
16326 /// subscribe BEFORE a backfill must see every edge-fire event.
16327 #[test]
16328 fn subscribe_after_backfill_no_stale_events() {
16329 let dir = tmp_dir("sub-after-backfill");
16330 {
16331 let mut db = GraphDb::open(&dir).unwrap();
16332 for i in 0..10u32 {
16333 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16334 db.insert_node(
16335 "Person",
16336 &format!("p{i}"),
16337 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16338 )
16339 .unwrap();
16340 }
16341 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
16342 db.create_rule(fk_rule()).unwrap();
16343
16344 // Subscribe AFTER the backfill — queue must be empty (no stale events).
16345 let sub = db.subscribe_all_rules().unwrap();
16346 // No events should have queued for the prior backfill.
16347 assert!(
16348 sub.try_recv().is_none(),
16349 "subscribe after backfill must see no stale events"
16350 );
16351
16352 // Inserting a new node now should fire an event (emit_deltas is now true).
16353 db.insert_node("Org", "o_new", vec![]).unwrap();
16354 db.insert_node(
16355 "Person",
16356 "p_new",
16357 vec![("org_id".into(), Value::Str("o_new".into()))],
16358 )
16359 .unwrap();
16360 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
16361 assert!(
16362 ev.is_some(),
16363 "edge-fire event must arrive after subscribe (emit_deltas=true)"
16364 );
16365 }
16366 let _ = std::fs::remove_dir_all(&dir);
16367 }
16368
16369 /// Gating regression: subscribe BEFORE a backfill → events flow.
16370 #[test]
16371 fn subscribe_before_backfill_events_flow() {
16372 let dir = tmp_dir("sub-before-backfill");
16373 {
16374 let mut db = GraphDb::open(&dir).unwrap();
16375 // Subscribe FIRST — emit_deltas becomes true.
16376 let sub = db.subscribe_all_rules().unwrap();
16377
16378 for i in 0..5u32 {
16379 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16380 db.insert_node(
16381 "Person",
16382 &format!("p{i}"),
16383 vec![("org_id".into(), Value::Str(format!("o{i}")))],
16384 )
16385 .unwrap();
16386 }
16387 // Backfill fires with emit_deltas=true → events queued.
16388 db.create_rule(fk_rule()).unwrap();
16389
16390 // Should receive at least one edge-fired event from the backfill.
16391 let mut received = 0usize;
16392 while sub.try_recv().is_some() {
16393 received += 1;
16394 }
16395 assert!(
16396 received > 0,
16397 "subscribe before backfill must receive edge-fire events (got 0)"
16398 );
16399 }
16400 let _ = std::fs::remove_dir_all(&dir);
16401 }
16402
16403 /// Companion: when a view IS defined, the delta path fires and view values update.
16404 #[test]
16405 fn delta_copy_fires_when_view_exists() {
16406 use core_rules::ViewSource;
16407 DELTA_COPY_COUNT.with(|c| c.set(0));
16408 let dir = tmp_dir("delta-copy-with-view");
16409 {
16410 let mut db = GraphDb::open(&dir).unwrap();
16411 db.insert_node("Org", "o1", vec![]).unwrap();
16412 db.insert_node(
16413 "Person",
16414 "p1",
16415 vec![("org_id".into(), Value::Str("o1".into()))],
16416 )
16417 .unwrap();
16418 // Declare a Degree view so is_empty() returns false.
16419 db.create_view(ViewDef {
16420 name: "degree_out".into(),
16421 label: "Person".into(),
16422 view_prop: "degree_out".into(),
16423 source: ViewSource::Degree {
16424 edge_type: "WORKS_AT".into(),
16425 direction: Direction::Out,
16426 },
16427 })
16428 .unwrap();
16429 db.create_rule(fk_rule()).unwrap();
16430
16431 // At least one delta copy should have happened (CreateRule backfill).
16432 let copies = DELTA_COPY_COUNT.with(|c| c.get());
16433 assert!(
16434 copies > 0,
16435 "expected delta copy to fire when a view is defined"
16436 );
16437
16438 // View value should be computed: p1 has one WORKS_AT out-edge.
16439 let info = db.node_info("p1").unwrap();
16440 let degree = info.props.get("degree_out");
16441 assert!(
16442 degree.is_some(),
16443 "view prop should be written to node props"
16444 );
16445 }
16446 let _ = std::fs::remove_dir_all(&dir);
16447 }
16448
16449 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
16450 /// derived-edge-driven view values reflect the as-of state rather than just
16451 /// the initial backfill written at `CreateView` time.
16452 ///
16453 /// Base WAL frames (indices 0..=5 before history markers):
16454 /// 0: insert Org "o1"
16455 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
16456 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
16457 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
16458 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
16459 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
16460 ///
16461 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
16462 /// no-op), so the total commit count is higher than the base frame count.
16463 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
16464 ///
16465 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
16466 /// initial backfill value (0) instead of reflecting the replayed derived edges.
16467 #[test]
16468 fn open_at_derived_edge_view_values_correct() {
16469 use core_rules::ViewSource;
16470 let dir = tmp_dir("open-at-view-rebuild");
16471 {
16472 let mut db = GraphDb::open(&dir).unwrap();
16473 // frame 0
16474 db.insert_node("Org", "o1", vec![]).unwrap();
16475 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
16476 db.create_view(ViewDef {
16477 name: "employee_count".into(),
16478 label: "Org".into(),
16479 view_prop: "emp".into(),
16480 source: ViewSource::Degree {
16481 edge_type: "WORKS_AT".into(),
16482 direction: Direction::In,
16483 },
16484 })
16485 .unwrap();
16486 // frame 2: create rule — no Persons yet; backfill is a no-op
16487 db.create_rule(fk_rule()).unwrap();
16488 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
16489 db.insert_node(
16490 "Person",
16491 "p1",
16492 vec![("org_id".into(), Value::Str("o1".into()))],
16493 )
16494 .unwrap();
16495 // frame 4: p2 — degree = 2
16496 db.insert_node(
16497 "Person",
16498 "p2",
16499 vec![("org_id".into(), Value::Str("o1".into()))],
16500 )
16501 .unwrap();
16502 // frame 5: p3 — degree = 3
16503 db.insert_node(
16504 "Person",
16505 "p3",
16506 vec![("org_id".into(), Value::Str("o1".into()))],
16507 )
16508 .unwrap();
16509 // Sanity: normal open sees degree = 3.
16510 assert_eq!(
16511 db.get_view_prop("o1", "emp"),
16512 Some(Value::Int(3)),
16513 "normal db must show degree 3 after 3 derived edges"
16514 );
16515 } // WAL flushed
16516
16517 // Re-open normally to get the authoritative reference value.
16518 let normal_db = GraphDb::open(&dir).unwrap();
16519 let normal_emp = normal_db.get_view_prop("o1", "emp");
16520 assert_eq!(
16521 normal_emp,
16522 Some(Value::Int(3)),
16523 "re-opened normal db must show degree 3"
16524 );
16525
16526 // Latest as-of (last WAL commit): must match the normal open.
16527 // History-marker frames are appended after each rule-fire, so the total
16528 // commit count is computed dynamically rather than hardcoded.
16529 let total = crate::wal_commit_count_at(&dir).unwrap();
16530 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
16531 assert_eq!(
16532 aof_latest.get_view_prop("o1", "emp"),
16533 normal_emp,
16534 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
16535 );
16536
16537 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
16538 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
16539 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
16540 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
16541 assert_eq!(
16542 aof_mid.get_view_prop("o1", "emp"),
16543 Some(Value::Int(1)),
16544 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
16545 );
16546
16547 let _ = std::fs::remove_dir_all(&dir);
16548 }
16549
16550 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
16551 /// as-of instances never commit, so distribute_events never runs and any
16552 /// subscription would wait forever.
16553 #[test]
16554 fn subscribe_on_as_of_returns_read_only_error() {
16555 let dir = tmp_dir("sub-as-of-read-only");
16556 {
16557 let mut db = GraphDb::open(&dir).unwrap();
16558 db.insert_node("Org", "o1", vec![]).unwrap();
16559 db.create_rule(fk_rule()).unwrap();
16560 }
16561 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
16562
16563 assert!(
16564 matches!(
16565 aof.subscribe_all_rules(),
16566 Err(core_storage::GraphError::ReadOnly)
16567 ),
16568 "subscribe_all_rules on as-of must return ReadOnly"
16569 );
16570 assert!(
16571 matches!(
16572 aof.subscribe_writes(),
16573 Err(core_storage::GraphError::ReadOnly)
16574 ),
16575 "subscribe_writes on as-of must return ReadOnly"
16576 );
16577 assert!(
16578 matches!(
16579 aof.subscribe_rule("works_at"),
16580 Err(core_storage::GraphError::ReadOnly)
16581 ),
16582 "subscribe_rule on as-of must return ReadOnly"
16583 );
16584 let _ = std::fs::remove_dir_all(&dir);
16585 }
16586
16587 /// Regression: a failed dense WAL rewrite must not leave speculative
16588 /// interns in `syms`. If it does, the next successful mutation logs an
16589 /// `Intern` record with an inflated id; replay (which never saw the
16590 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
16591 #[test]
16592 fn dense_rewrite_error_rolls_back_speculative_interns() {
16593 let dir = tmp_dir("dense-rewrite-rollback");
16594 {
16595 let mut db = GraphDb::open(&dir).unwrap();
16596 db.insert_node("Person", "a", vec![]).unwrap();
16597
16598 // Bypass MutPreview validation to hit the rewrite's own error path
16599 // (same shape as an id-exhaustion failure mid-rewrite). The
16600 // InsertEdge arm interns the edge type before it resolves keys.
16601 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
16602 edge_type: "ORPHAN_TYPE".into(),
16603 src_key: "missing".into(),
16604 dst_key: "a".into(),
16605 }]);
16606 assert!(err.is_err(), "rewrite of a missing src key must fail");
16607 assert_eq!(
16608 db.syms.get("ORPHAN_TYPE"),
16609 None,
16610 "failed rewrite must roll back speculative interns"
16611 );
16612
16613 // A later successful mutation must produce a replayable WAL.
16614 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
16615 }
16616 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
16617 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
16618 let _ = std::fs::remove_dir_all(&dir);
16619 }
16620}