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, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10 WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, 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;
37use serde::{Deserialize, Serialize};
38use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
39use std::sync::Arc;
40
41/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
42/// Zero-cost when the env var is absent (the var check is O(1) after first call).
43macro_rules! trace_open {
44 ($phase:literal, $t:expr) => {
45 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
46 eprintln!(
47 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
48 $phase,
49 $t.elapsed()
50 );
51 }
52 };
53}
54
55/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
56/// Zero-cost when the env var is absent (the var check is O(1) after first call).
57macro_rules! trace_migrate {
58 ($phase:literal, $t:expr) => {
59 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
60 eprintln!(
61 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
62 $phase,
63 $t.elapsed()
64 );
65 }
66 };
67}
68
69// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
70// executes (i.e., at least one view is defined). Used to verify the fast-path
71// guard skips the allocation when `view_store.is_empty()`.
72#[cfg(test)]
73thread_local! {
74 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
75}
76
77// Per-thread count of query-subscription `execute` calls in `distribute_events`.
78//
79// Incremented each time a query subscription actually runs its plan (i.e.,
80// the label-skip fast-path did not fire). Because `distribute_events` is
81// called synchronously on the writer thread, this thread-local correctly
82// isolates each test thread's count even when integration tests run in
83// parallel. Read via [`query_sub_exec_count`].
84thread_local! {
85 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
86}
87
88/// Return the number of query-subscription re-executions logged on this
89/// thread since the process started (or since last reset via
90/// [`reset_query_sub_exec_count`]).
91///
92/// Primarily for integration tests that verify the label-skip fast-path.
93#[doc(hidden)]
94pub fn query_sub_exec_count() -> usize {
95 QUERY_SUB_EXECS_TL.with(|c| c.get())
96}
97
98/// Reset the per-thread query-subscription execution counter to zero.
99#[doc(hidden)]
100pub fn reset_query_sub_exec_count() {
101 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
102}
103
104/// Internal state for a single `subscribe_query` subscription.
105///
106/// On every commit, `distribute_events` re-executes `ops` against the current
107/// graph state, diffs the result against `prev_rows`, and pushes
108/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
109///
110/// **Full re-run per commit; use LIMIT to bound execution cost.**
111/// (Differential evaluation is roadmap / Phase 5.)
112pub(crate) struct QuerySubEntry {
113 /// Compiled plan for the subscribed Cypher query.
114 ops: Vec<PlanOp>,
115 /// Column names from the first execution (fixed for the subscription lifetime).
116 columns: Vec<String>,
117 /// Serialized (JSON) row key → row data, representing the result set at
118 /// the end of the last commit. Used to diff against the new result.
119 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
120 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
121 inner: std::sync::Weak<SubInner>,
122 /// Interned label sym captured at subscribe time from the plan's leading scan
123 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
124 ///
125 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
126 /// with a concrete label), and this subscription must re-execute on every
127 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
128 /// queries are never skipped because edges can alter join results regardless
129 /// of which node labels were written.
130 scan_label: Option<u32>,
131}
132
133/// A post-commit mutation notification.
134///
135/// Emitted from `log_then_apply` after the WAL append, fsync, and
136/// in-memory `apply` all succeed. Never emitted for rejected operations
137/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
138/// deletes/removes). Event payloads carry user keys and rule names, never
139/// internal ids.
140///
141/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
142/// `apply` only. Emission lives exclusively in `log_then_apply`, so
143/// recovery is silent even if a sink were installed (it cannot be: the
144/// sink is in-memory and set after open).
145///
146/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
147/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
148/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
149/// or all-noop batch writes no WAL and emits nothing (including no summary).
150///
151/// **Derived edges:** rule-created or retracted edges are not individually
152/// evented — they are recoverable from the triggering mutation plus the live
153/// rule set. Only the triggering record is emitted.
154///
155/// **Wire form:** externally tagged snake_case JSON
156/// (`{"node_inserted":{"label":"A","key":"k"}}`).
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum MutationEvent {
160 NodeInserted {
161 label: String,
162 key: String,
163 },
164 PropSet {
165 key: String,
166 field: String,
167 },
168 PropRemoved {
169 key: String,
170 field: String,
171 },
172 EdgeInserted {
173 edge_type: String,
174 src: String,
175 dst: String,
176 },
177 EdgeDeleted {
178 edge_type: String,
179 src: String,
180 dst: String,
181 },
182 NodeDeleted {
183 key: String,
184 },
185 RuleCreated {
186 name: String,
187 },
188 RuleDeleted {
189 name: String,
190 },
191 RuleRebuilt {
192 name: String,
193 },
194 BatchApplied {
195 ops: usize,
196 },
197 Ingested {
198 label: String,
199 inserted: usize,
200 },
201}
202
203fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
204 match rec {
205 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
206 label: label.clone(),
207 key: key.clone(),
208 }),
209 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
210 label: intern.resolve(*label)?.to_string(),
211 key: key.clone(),
212 }),
213 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
214 key: key.clone(),
215 field: field.clone(),
216 }),
217 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
218 key: ids.key_of(*id)?.to_string(),
219 field: intern.resolve(*field)?.to_string(),
220 }),
221 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
222 key: key.clone(),
223 field: field.clone(),
224 }),
225 WalRecord::InsertEdge {
226 edge_type,
227 src_key,
228 dst_key,
229 } => Some(MutationEvent::EdgeInserted {
230 edge_type: edge_type.clone(),
231 src: src_key.clone(),
232 dst: dst_key.clone(),
233 }),
234 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
235 edge_type: intern.resolve(*etype)?.to_string(),
236 src: ids.key_of(*src)?.to_string(),
237 dst: ids.key_of(*dst)?.to_string(),
238 }),
239 WalRecord::DeleteEdge {
240 edge_type,
241 src_key,
242 dst_key,
243 } => Some(MutationEvent::EdgeDeleted {
244 edge_type: edge_type.clone(),
245 src: src_key.clone(),
246 dst: dst_key.clone(),
247 }),
248 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
249 WalRecord::CreateRule { def_bytes } => {
250 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
251 Some(MutationEvent::RuleCreated { name: def.name })
252 }
253 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
254 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
255 WalRecord::Batch(_)
256 | WalRecord::CreateView { .. }
257 | WalRecord::DeleteView { .. }
258 | WalRecord::EnableFulltext { .. }
259 | WalRecord::DisableFulltext { .. }
260 | WalRecord::EnableIndex { .. }
261 | WalRecord::DisableIndex { .. }
262 | WalRecord::Intern { .. }
263 // History markers are no-ops for mutation events — they carry no new
264 // state and rules re-derive deterministically on replay.
265 | WalRecord::DerivedEdgeAdded { .. }
266 | WalRecord::DerivedEdgeRetracted { .. }
267 // RenameNode carries no node/edge count change; no special event.
268 | WalRecord::RenameNode { .. } => None,
269 }
270}
271
272/// Database-wide counters plus per-rule budget/fire stats.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
274pub struct Stats {
275 pub nodes_live: usize,
276 pub nodes_tombstoned: usize,
277 pub edges: u64,
278 pub rules: Vec<RuleStats>,
279 /// How many writes hit the rule-chaining depth cap with work still pending,
280 /// since this handle was opened. Non-zero means some derived edges beyond
281 /// the cap are stale and no single later write will repair them: split the
282 /// rule chain or shorten it. Never persisted, so it resets on reopen.
283 #[serde(default)]
284 pub chain_truncations: u64,
285 /// The oldest commit index history still reaches (the WAL horizon floor).
286 /// `0` means nothing has been pruned and history is complete; a non-zero
287 /// value means events before that commit were pruned and are gone.
288 #[serde(default)]
289 pub history_floor: u64,
290 /// Live node counts per namespace, in name order. Always carries
291 /// `default` — a store is at least its default namespace — so a
292 /// single-tenant store reads `[{"name":"default", …}]` and a reader can
293 /// tell "no namespaces in use" from one entry.
294 #[serde(default)]
295 pub namespaces: Vec<NamespaceStats>,
296}
297
298/// Live node count for one namespace; one entry of [`Stats::namespaces`].
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct NamespaceStats {
301 pub name: String,
302 pub nodes_live: usize,
303}
304
305/// One rule's provenance size, trip latch, and fire counter.
306///
307/// `tripped` is a one-way latch: once set, the engine adds no new edges for
308/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
309/// set then fits). `fires` counts `on_node_changed` evaluations plus
310/// backfill/rebuild participant ticks (rebuild counts even when it is a
311/// provenance no-op).
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct RuleStats {
314 pub name: String,
315 pub edges: u64,
316 pub tripped: bool,
317 pub fires: u64,
318 /// Whether this rule uses the approximate IVF-Flat candidate path.
319 pub approximate: bool,
320 /// `Some` while this rule's vector index is still being built.
321 ///
322 /// The rule derives **no** edges until it is `None`: the backfill is one
323 /// commit that runs after the index is whole, so a caller never sees a
324 /// partial edge set. Absent from the JSON when the rule is not building,
325 /// which is every rule created over a corpus at or below
326 /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub building: Option<BuildProgress>,
329}
330
331/// One entry in the slow-query ring buffer.
332#[derive(Debug, Clone, Serialize)]
333pub struct SlowQueryEntry {
334 /// Execution time in whole milliseconds.
335 pub ms: u64,
336 /// The Cypher query string that was slow.
337 pub query: String,
338 /// The commit sequence number at the time the query ran.
339 pub at_commit: u64,
340}
341
342/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
343#[derive(Debug, Clone, Serialize)]
344pub struct SlowQuerySnapshot {
345 /// Current threshold in milliseconds (0 = disabled).
346 pub threshold_ms: u64,
347 /// Total number of slow queries ever recorded (not capped by ring size).
348 pub count: u64,
349 /// Most-recent slow queries (up to 16), oldest first.
350 pub last: Vec<SlowQueryEntry>,
351}
352
353/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
354/// write to it without a mutable borrow.
355struct SlowQueryLog {
356 entries: std::collections::VecDeque<SlowQueryEntry>,
357 total: u64,
358}
359
360/// Maximum number of entries kept in the slow-query ring buffer.
361const SLOW_QUERY_RING_CAP: usize = 16;
362
363/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
364/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct PredicateSummary {
367 pub kind: String,
368 pub fields: Vec<String>,
369 pub min: Option<f64>,
370 pub tolerance: Option<f64>,
371 pub km: Option<f64>,
372 pub parts: Option<Vec<PredicateSummary>>,
373 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
374 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
375 #[serde(default)]
376 pub approximate: bool,
377}
378
379impl From<&Predicate> for PredicateSummary {
380 fn from(p: &Predicate) -> Self {
381 match p {
382 Predicate::KeyMatch { field } => PredicateSummary {
383 kind: "key_match".into(),
384 fields: vec![field.clone()],
385 min: None,
386 tolerance: None,
387 km: None,
388 parts: None,
389 approximate: false,
390 },
391 Predicate::FieldEqual { field } => PredicateSummary {
392 kind: "field_equal".into(),
393 fields: vec![field.clone()],
394 min: None,
395 tolerance: None,
396 km: None,
397 parts: None,
398 approximate: false,
399 },
400 Predicate::Overlap { field, min } => PredicateSummary {
401 kind: "overlap".into(),
402 fields: vec![field.clone()],
403 min: Some(*min),
404 tolerance: None,
405 km: None,
406 parts: None,
407 approximate: false,
408 },
409 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
410 kind: "numeric_within".into(),
411 fields: vec![field.clone()],
412 min: None,
413 tolerance: Some(*tolerance),
414 km: None,
415 parts: None,
416 approximate: false,
417 },
418 Predicate::GeoRadius { field, km } => PredicateSummary {
419 kind: "geo_radius".into(),
420 fields: vec![field.clone()],
421 min: None,
422 tolerance: None,
423 km: Some(*km),
424 parts: None,
425 approximate: false,
426 },
427 Predicate::VectorSimilar { field, min } => PredicateSummary {
428 kind: "vector_similar".into(),
429 fields: vec![field.clone()],
430 min: Some(*min),
431 tolerance: None,
432 km: None,
433 parts: None,
434 approximate: false,
435 },
436 Predicate::All(inner) => {
437 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
438 let mut fields = Vec::new();
439 for part in &parts {
440 for f in &part.fields {
441 if !fields.contains(f) {
442 fields.push(f.clone());
443 }
444 }
445 }
446 PredicateSummary {
447 kind: "all".into(),
448 fields,
449 min: None,
450 tolerance: None,
451 km: None,
452 parts: Some(parts),
453 approximate: false,
454 }
455 }
456 Predicate::Any(inner) => {
457 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
458 let mut fields = Vec::new();
459 for part in &parts {
460 for f in &part.fields {
461 if !fields.contains(f) {
462 fields.push(f.clone());
463 }
464 }
465 }
466 PredicateSummary {
467 kind: "any".into(),
468 fields,
469 min: None,
470 tolerance: None,
471 km: None,
472 parts: Some(parts),
473 approximate: false,
474 }
475 }
476 }
477 }
478}
479
480/// Snapshot of a live node's key, label, and columnar properties.
481///
482/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
483/// regardless of insert order or the columnar store's `HashMap` iteration.
484///
485/// Deliberately does not derive `Serialize`: `Value`'s serde form is
486/// internally tagged. Wire JSON is built by `value_to_json` in the server.
487#[derive(Debug, Clone, PartialEq)]
488pub struct NodeInfo {
489 pub key: String,
490 pub label: String,
491 pub props: BTreeMap<String, Value>,
492}
493
494/// Counts returned by [`GraphDb::delete_node`].
495#[derive(Debug, Clone, PartialEq, Eq, Default)]
496pub struct DeleteReport {
497 /// Number of manual (user-inserted) edges removed.
498 pub manual_edges: u64,
499 /// Number of derived (rule-owned) edges retracted.
500 pub derived_edges: u64,
501}
502
503/// One directed edge incident on a node, with provenance membership.
504///
505/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
506/// Plan-8 `by_node` provenance index.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct EdgeInfo {
509 pub edge_type: String,
510 pub src_key: String,
511 pub dst_key: String,
512 pub derived: bool,
513}
514
515/// One directed edge incident on a node at a point in WAL history, with the
516/// rule that derived it when it is rule-owned.
517///
518/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
519/// and by [`GraphDb::what_if_set_prop`].
520#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
521pub struct EdgeAt {
522 pub edge_type: String,
523 pub src_key: String,
524 pub dst_key: String,
525 /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
526 /// live provenance entry).
527 pub derived: bool,
528 /// The rule that derived the edge. `None` for a manual edge.
529 pub rule: Option<String>,
530}
531
532/// The derived edges a hypothetical property change would retract and derive.
533///
534/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
535/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
537pub struct WhatIf {
538 /// Derived edges that exist now and would be retracted.
539 pub lost: Vec<EdgeAt>,
540 /// Derived edges that do not exist now and would be derived.
541 pub gained: Vec<EdgeAt>,
542}
543
544/// An edge with mask-aware endpoint visibility.
545///
546/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
547/// mode — hidden endpoints carry `*_restricted: true`.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct MaskedEdge {
550 pub edge_type: String,
551 pub src_key: String,
552 /// `true` when `src_key` is in the DB but hidden from the mask.
553 pub src_restricted: bool,
554 pub dst_key: String,
555 /// `true` when `dst_key` is in the DB but hidden from the mask.
556 pub dst_restricted: bool,
557 pub derived: bool,
558}
559
560/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
561///
562/// `None` from that method means the key does not exist (→ 404).
563/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
564#[derive(Debug, PartialEq)]
565pub enum MaskedNodeResult {
566 Visible(NodeInfo),
567 /// Node exists in the DB but is hidden from this mask.
568 Restricted,
569}
570
571/// One rule-owned edge between two nodes, with the rule name, edge type,
572/// direction (src_key → dst_key), and weight if the rule stores one.
573#[derive(Debug, Clone, PartialEq, Serialize)]
574pub struct Explanation {
575 pub rule: String,
576 pub edge_type: String,
577 pub src_key: String,
578 pub dst_key: String,
579 pub weight: Option<f64>,
580 pub predicate: PredicateSummary,
581 /// For a via-hop rule, the edge type the rule hops over to reach its
582 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
583 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
584 /// was written by another rule in the same commit.
585 #[serde(default)]
586 pub via_edge: Option<String>,
587}
588
589/// Report returned by [`GraphDb::backup_to`].
590#[derive(Debug, Clone)]
591pub struct BackupReport {
592 /// Filenames copied into the destination directory (sorted ascending).
593 pub files: Vec<String>,
594 /// Total bytes written across all copied files.
595 pub bytes: u64,
596 /// `true` when the destination opened cleanly and passed post-copy checks.
597 ///
598 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
599 /// matched **and** the destination opened without error.
600 ///
601 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
602 /// CRC-check; `verified` is `true` when the destination opened and
603 /// replayed the WAL without error (record-level checksums in the WAL
604 /// provide the integrity signal, not section CRCs).
605 pub verified: bool,
606}
607
608/// One directed edge in export form, with optional rule attribution for derived edges.
609///
610/// Returned by [`GraphDb::all_edges_for_export`].
611///
612/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
613/// order. Callers that need a stable edge ordering already sort by
614/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
615#[derive(Debug, Clone, PartialEq, PartialOrd)]
616pub struct ExportEdge {
617 pub edge_type: String,
618 pub src: String,
619 pub dst: String,
620 pub derived: bool,
621 /// Rule name that created this edge, if derived. `None` for manual edges.
622 pub rule: Option<String>,
623 /// The creating rule's declared `weight_prop`, read off this edge, when
624 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
625 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
626 pub weight: Option<f64>,
627}
628
629/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
630///
631/// Deliberately per *type* and not per edge: everything here is a summary a
632/// caller can print in one line, and none of it costs a record per edge.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct EdgeTypeCensus {
635 pub edge_type: String,
636 /// Directed edges of this type. Counted the way
637 /// [`GraphDb::edge_count`] counts: each edge once, from its source.
638 pub edges: u64,
639 /// Every label seen on a source of this type, sorted.
640 pub src_labels: Vec<String>,
641 /// Every label seen on a destination of this type, sorted.
642 pub dst_labels: Vec<String>,
643 /// The rules that declare this `edge_type`, sorted. Empty for a type
644 /// written by hand.
645 pub rules: Vec<String>,
646 /// `(src key, dst key)` of the first edge of this type in the store's own
647 /// id order — a real pair to quote in an example.
648 pub sample: Option<(String, String)>,
649}
650
651/// Construct the standard write-query result set (columns: created, properties_set, deleted).
652fn write_result_set() -> ResultSet {
653 ResultSet::new(vec![
654 "created".into(),
655 "properties_set".into(),
656 "deleted".into(),
657 ])
658}
659
660fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
661 match op {
662 Operand::Lit(v) => Ok(v.clone()),
663 Operand::Param(name) => params
664 .get(name)
665 .cloned()
666 .ok_or_else(|| GraphError::QueryError {
667 detail: format!("missing parameter `{name}`"),
668 }),
669 _ => Err(GraphError::QueryError {
670 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
671 }),
672 }
673}
674
675fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
676 match op {
677 Operand::Prop { var, .. } | Operand::Var(var) => {
678 if !out.contains(var) {
679 out.push(var.clone());
680 }
681 }
682 Operand::FuncCall { args, .. } => {
683 for arg in args {
684 operand_node_vars(arg, out);
685 }
686 }
687 Operand::BinArith { left, right, .. } => {
688 operand_node_vars(left, out);
689 operand_node_vars(right, out);
690 }
691 Operand::Case { branches, default } => {
692 // Branch conditions reference vars already bound (and mask-filtered)
693 // by the MATCH phase, so collecting from the value operands + ELSE
694 // is sufficient for RETURN-projection var discovery.
695 for (_, value) in branches {
696 operand_node_vars(value, out);
697 }
698 if let Some(d) = default {
699 operand_node_vars(d, out);
700 }
701 }
702 Operand::Index { base, index } => {
703 operand_node_vars(base, out);
704 operand_node_vars(index, out);
705 }
706 Operand::Lit(_) | Operand::Param(_) => {}
707 }
708}
709
710fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
711 let mut out = Vec::new();
712 for item in items {
713 match &item.value {
714 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
715 if !out.contains(v) {
716 out.push(v.clone());
717 }
718 }
719 RetVal::FuncCall { args, .. } => {
720 for arg in args {
721 operand_node_vars(arg, &mut out);
722 }
723 }
724 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
725 RetVal::Agg { .. } => {}
726 }
727 }
728 out
729}
730
731fn add_var(out: &mut Vec<String>, v: &str) {
732 if !out.iter().any(|x| x == v) {
733 out.push(v.to_string());
734 }
735}
736
737fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
738 let mut out = Vec::new();
739 for p in pats {
740 if let Some(v) = &p.start.var {
741 add_var(&mut out, v);
742 }
743 for (_, dest) in &p.chain {
744 if let Some(v) = &dest.var {
745 add_var(&mut out, v);
746 }
747 }
748 }
749 out
750}
751
752fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
753 let mut out = Vec::new();
754 for p in pats {
755 for (rel, _) in &p.chain {
756 if rel.hops.is_none() {
757 if let Some(v) = &rel.var {
758 add_var(&mut out, v);
759 }
760 }
761 }
762 }
763 out
764}
765
766fn rel_type_alias(var: &str) -> String {
767 format!("__rt_{var}")
768}
769
770fn ret_column_name(item: &RetItem) -> String {
771 if let Some(alias) = &item.alias {
772 return alias.clone();
773 }
774 // The same naming rule the planner and the executor use, so a
775 // write-statement RETURN names its columns exactly as a read query does.
776 // An aggregate is not legal in a write-statement RETURN; it keeps the
777 // placeholder it always had.
778 ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
779}
780
781fn eval_set_return_operand<F: Fs>(
782 db: &GraphDb<F>,
783 match_rs: &ResultSet,
784 row: usize,
785 rel_vars: &[String],
786 op: &Operand,
787 params: &BTreeMap<String, Value>,
788) -> Result<Option<Value>> {
789 match op {
790 Operand::Lit(v) => Ok(Some(v.clone())),
791 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
792 detail: format!("missing parameter `{name}`"),
793 }).map(Some),
794 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
795 detail: format!(
796 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
797 ),
798 }),
799 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
800 Operand::Prop { var, field } => {
801 if rel_vars.iter().any(|r| r == var) {
802 return Ok(None);
803 }
804 let Some(Value::Str(key)) = match_rs.get(row, var) else {
805 return Ok(None);
806 };
807 Ok(db.get_prop(key, field))
808 }
809 Operand::FuncCall { name, args } => {
810 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
811 }
812 Operand::BinArith { op, left, right } => {
813 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
814 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
815 eval_set_return_arith(op, lv, rv)
816 }
817 // CASE is supported in read-query RETURN; in a write-statement RETURN
818 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
819 Operand::Case { .. } => Err(GraphError::QueryError {
820 detail: "CASE is not supported in a write-statement RETURN projection; \
821 use a read query"
822 .into(),
823 }),
824 // Same as CASE: a list subscript is supported in a read-query RETURN
825 // but not yet in a write-statement RETURN projection.
826 Operand::Index { .. } => Err(GraphError::QueryError {
827 detail: "a list subscript is not supported in a write-statement RETURN \
828 projection; use a read query"
829 .into(),
830 }),
831 }
832}
833
834fn eval_set_return_arith(
835 op: &ArithOp,
836 lv: Option<Value>,
837 rv: Option<Value>,
838) -> Result<Option<Value>> {
839 match (lv, rv) {
840 (None, _) | (_, None) => Ok(None),
841 (Some(Value::Int(a)), Some(Value::Int(b))) => {
842 let result = match op {
843 ArithOp::Sub => a.saturating_sub(b),
844 ArithOp::Mul => a.saturating_mul(b),
845 ArithOp::Add => a.saturating_add(b),
846 ArithOp::Div => {
847 if b == 0 {
848 return Err(GraphError::QueryError {
849 detail: "division by zero".into(),
850 });
851 }
852 a.checked_div(b).unwrap_or(i64::MAX)
853 }
854 };
855 Ok(Some(Value::Int(result)))
856 }
857 (Some(lv), Some(rv)) => {
858 let a = match &lv {
859 Value::Float(f) => *f,
860 Value::Int(i) => *i as f64,
861 _ => {
862 return Err(GraphError::QueryError {
863 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
864 })
865 }
866 };
867 let b = match &rv {
868 Value::Float(f) => *f,
869 Value::Int(i) => *i as f64,
870 _ => {
871 return Err(GraphError::QueryError {
872 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
873 })
874 }
875 };
876 let result = match op {
877 ArithOp::Sub => a - b,
878 ArithOp::Mul => a * b,
879 ArithOp::Add => a + b,
880 ArithOp::Div => {
881 if b == 0.0 {
882 return Err(GraphError::QueryError {
883 detail: "division by zero".into(),
884 });
885 }
886 a / b
887 }
888 };
889 Ok(Some(Value::Float(result)))
890 }
891 }
892}
893
894fn eval_set_return_func<F: Fs>(
895 db: &GraphDb<F>,
896 match_rs: &ResultSet,
897 row: usize,
898 rel_vars: &[String],
899 name: &str,
900 args: &[Operand],
901 params: &BTreeMap<String, Value>,
902) -> Result<Option<Value>> {
903 let norm = name.to_ascii_lowercase();
904 if norm == "type" {
905 if args.len() != 1 {
906 return Err(GraphError::QueryError {
907 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
908 });
909 }
910 let Operand::Var(rel) = &args[0] else {
911 return Err(GraphError::QueryError {
912 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
913 });
914 };
915 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
916 }
917 if norm == "key" {
918 if args.len() != 1 {
919 return Err(GraphError::QueryError {
920 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
921 });
922 }
923 let Operand::Var(var) = &args[0] else {
924 return Err(GraphError::QueryError {
925 detail: "key() argument must be a node variable (e.g. key(n))".into(),
926 });
927 };
928 if rel_vars.iter().any(|r| r == var) {
929 return Err(GraphError::QueryError {
930 detail: format!("key() argument `{var}` is a relationship, not a node"),
931 });
932 }
933 // MATCH rows bind node variables to their key string, so the column
934 // value *is* the key.
935 return Ok(match_rs.get(row, var).cloned());
936 }
937 let mut vals = Vec::with_capacity(args.len());
938 for arg in args {
939 vals.push(eval_set_return_operand(
940 db, match_rs, row, rel_vars, arg, params,
941 )?);
942 }
943 match norm.as_str() {
944 "tolower" => {
945 if vals.len() != 1 {
946 return Err(GraphError::QueryError {
947 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
948 });
949 }
950 Ok(vals[0].clone().map(|val| match val {
951 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
952 other => other,
953 }))
954 }
955 "toupper" => {
956 if vals.len() != 1 {
957 return Err(GraphError::QueryError {
958 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
959 });
960 }
961 Ok(vals[0].clone().map(|val| match val {
962 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
963 other => other,
964 }))
965 }
966 "size" => match vals.first().cloned().flatten() {
967 None => Ok(None),
968 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
969 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
970 Some(_) => Ok(None),
971 },
972 "coalesce" => Ok(vals.into_iter().flatten().next()),
973 "abs" => match vals.first().cloned().flatten() {
974 None => Ok(None),
975 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
976 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
977 Some(_) => Ok(None),
978 },
979 "round" => match vals.first().cloned().flatten() {
980 None => Ok(None),
981 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
982 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
983 Some(_) => Ok(None),
984 },
985 "decay" => {
986 if vals.len() != 3 {
987 return Err(GraphError::QueryError {
988 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
989 });
990 }
991 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
992 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
993 (Some(b), Some(a), Some(h)) => {
994 let numeric = |v: Value| -> Result<f64> {
995 match v {
996 Value::Int(n) => Ok(n as f64),
997 Value::Float(f) => Ok(f),
998 other => Err(GraphError::QueryError {
999 detail: format!(
1000 "decay() requires numeric arguments, got {other:?}"
1001 ),
1002 }),
1003 }
1004 };
1005 let b = numeric(b)?;
1006 let a = numeric(a)?;
1007 let h = numeric(h)?;
1008 if h <= 0.0 {
1009 return Err(GraphError::QueryError {
1010 detail: "decay() requires halflife > 0".into(),
1011 });
1012 }
1013 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1014 }
1015 }
1016 }
1017 _ => Err(GraphError::QueryError {
1018 detail: format!(
1019 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
1020 ),
1021 }),
1022 }
1023}
1024
1025fn eval_set_return_item<F: Fs>(
1026 db: &GraphDb<F>,
1027 match_rs: &ResultSet,
1028 row: usize,
1029 rel_vars: &[String],
1030 item: &RetItem,
1031 params: &BTreeMap<String, Value>,
1032) -> Result<Option<Value>> {
1033 match &item.value {
1034 RetVal::Var(v) => eval_set_return_operand(
1035 db,
1036 match_rs,
1037 row,
1038 rel_vars,
1039 &Operand::Var(v.clone()),
1040 params,
1041 ),
1042 RetVal::Prop { var, field } => eval_set_return_operand(
1043 db,
1044 match_rs,
1045 row,
1046 rel_vars,
1047 &Operand::Prop {
1048 var: var.clone(),
1049 field: field.clone(),
1050 },
1051 params,
1052 ),
1053 RetVal::FuncCall { name, args } => {
1054 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1055 }
1056 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1057 RetVal::Agg { .. } => Err(GraphError::QueryError {
1058 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1059 }),
1060 }
1061}
1062
1063/// Project user RETURN from original MATCH rows after SET. No rematch.
1064fn project_set_return_rows<F: Fs>(
1065 db: &GraphDb<F>,
1066 rel_vars: &[String],
1067 match_rs: &ResultSet,
1068 returns: &[RetItem],
1069 params: &BTreeMap<String, Value>,
1070) -> Result<ResultSet> {
1071 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1072 let mut out = ResultSet::new(columns);
1073 for row in 0..match_rs.len() {
1074 let mut cells = Vec::with_capacity(returns.len());
1075 for item in returns {
1076 cells.push(eval_set_return_item(
1077 db, match_rs, row, rel_vars, item, params,
1078 )?);
1079 }
1080 out.push_row(cells);
1081 }
1082 Ok(out)
1083}
1084
1085/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1086/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1087/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1088/// Returns `None` for non-list values or lists with non-numeric elements.
1089/// Extra candidates pulled from an approximate index before re-scoring, over and
1090/// above the `k` asked for.
1091///
1092/// The index orders candidates by `f32` distances, which agree with the exact
1093/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1094/// candidates inside a band that narrow — it cannot move a hit past one that is
1095/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1096/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1097/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1098/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1099/// then the members are interchangeable anyway. `min` is applied to the exact
1100/// score, never to the index's, so a hit sitting on the threshold is decided
1101/// exactly.
1102const VECTOR_RESCORE_MARGIN: usize = 16;
1103
1104/// Cosine similarity between an already-unit query and node `id`'s `field`
1105/// vector, read from the **`f64`** properties. `None` when the node has no
1106/// numeric-list vector there, or its norm is zero.
1107///
1108/// The single definition of the score this API reports. Both the brute-force
1109/// scan and the re-scoring step that follows an index lookup go through it, so
1110/// the two paths cannot disagree — which is the property
1111/// `index_and_brute_force_agree_on_scores` pins.
1112fn exact_vector_similarity(
1113 view: &GraphView<'_>,
1114 id: u32,
1115 field: &str,
1116 q_unit: &[f64],
1117) -> Option<f64> {
1118 let v = view.prop(id, field)?;
1119 let xs = value_as_float_list(&v.into_value())?;
1120 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1121 if v_norm == 0.0 {
1122 return None;
1123 }
1124 Some(
1125 q_unit
1126 .iter()
1127 .zip(xs.iter())
1128 .map(|(a, b)| a * (b / v_norm))
1129 .sum(),
1130 )
1131}
1132
1133fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1134 match v {
1135 Value::List(items) => items
1136 .iter()
1137 .map(|item| match item {
1138 Value::Float(f) => Some(*f),
1139 Value::Int(i) => Some(*i as f64),
1140 _ => None,
1141 })
1142 .collect(),
1143 _ => None,
1144 }
1145}
1146
1147fn make_graph_mut<'a>(
1148 ids: &'a IdMap,
1149 syms: &'a mut Interner,
1150 labels: &'a [u32],
1151 props: core_storage::v8::seam::ColumnsView<'a>,
1152 topo: &'a mut Topology,
1153 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1154 edge_props: &'a mut EdgeProps,
1155) -> GraphMut<'a> {
1156 GraphMut {
1157 ids,
1158 syms,
1159 labels,
1160 props,
1161 topo,
1162 base_topo: base_csr(base),
1163 edge_props,
1164 }
1165}
1166
1167/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1168///
1169/// A store opened from a snapshot keeps its edges in the mapping and its
1170/// overlay empty, so a rule that reads the graph's shape has to see both.
1171fn base_csr(
1172 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1173) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1174 base.as_ref().map(|b| {
1175 b.topology()
1176 .expect("base topology section bounds validated at open")
1177 })
1178}
1179
1180/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1181///
1182/// Takes explicit field references rather than `&self` so the caller can hold
1183/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1184fn build_props_view<'a>(
1185 props: &'a ColumnStore,
1186 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1187) -> core_storage::v8::seam::ColumnsView<'a> {
1188 match base {
1189 None => core_storage::v8::seam::ColumnsView::owned(props),
1190 Some(b) => {
1191 let archived = b
1192 .columns()
1193 .expect("base columns section bounds validated at open");
1194 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1195 .with_shared_strings(base_string_table(b))
1196 }
1197 }
1198}
1199
1200/// The base columns section paired with the string table that resolves its
1201/// string ids — what `ViewStore` needs to read a neighbour's string property
1202/// out of a V9 snapshot.
1203fn base_columns(
1204 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1205) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1206 base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1207 cols: b
1208 .columns()
1209 .expect("base columns section bounds validated at open"),
1210 strings: base_string_table(b),
1211 })
1212}
1213
1214/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1215///
1216/// Every `ColumnsView` built over a base must carry it: without it a V9
1217/// snapshot's string columns, whose own tables are empty, read back as absent.
1218fn base_string_table(
1219 base: &core_storage::v8::MappedBase,
1220) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1221 base.string_table()
1222 .transpose()
1223 .expect("base strings section bounds validated at open")
1224}
1225
1226fn build_topo_view<'a>(
1227 overlay: &'a Topology,
1228 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1229) -> core_storage::v8::seam::TopologyView<'a> {
1230 match base {
1231 None => core_storage::v8::seam::TopologyView::owned(overlay),
1232 Some(b) => {
1233 let archived_csr = b
1234 .topology()
1235 .expect("base topology section bounds validated at open");
1236 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1237 }
1238 }
1239}
1240
1241/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1242///
1243/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1244/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1245/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1246/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1247/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1248#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1249pub enum FsyncPolicy {
1250 /// Every WAL commit calls `fs.sync` (today's behavior).
1251 #[default]
1252 Strict,
1253 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1254 /// this policy is set on the database.
1255 Batched,
1256 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1257 Relaxed,
1258}
1259
1260/// A precondition for a compare-and-set batch write.
1261///
1262/// All preconditions in a [`GraphDb::write_batch_cas`] or
1263/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1264/// any operation in the batch is applied. If any precondition fails, the
1265/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1266/// is written.
1267///
1268/// # Touch definition
1269///
1270/// A node's last-change commit (`last_changed`) is updated when any of the
1271/// following state-changing WAL records touch it:
1272///
1273/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1274/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1275/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1276/// endpoints (an edge change touches both sides).
1277/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1278/// for deleted keys so the pre-deletion entry is never observed.
1279///
1280/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1281/// state no-ops. The underlying mutation that triggered rule firing already
1282/// updated the relevant nodes' last-change entries. Rule-management records
1283/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1284/// do not touch any node's last-change.
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum Precondition {
1287 /// The node's last-change commit must equal `expected`.
1288 ///
1289 /// Fails with [`GraphError::CasConflict`] when:
1290 /// - The node does not exist (`last_changed` returns `None`), or
1291 /// - The recorded commit seq does not match `expected`.
1292 NodeUnchangedSince { key: String, expected: u64 },
1293 /// The node must not exist (not inserted, or already deleted).
1294 ///
1295 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1296 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1297 NodeAbsent { key: String },
1298}
1299
1300pub struct GraphDb<F: Fs> {
1301 fs: F,
1302 ids: IdMap,
1303 syms: Interner,
1304 topo: Topology,
1305 props: ColumnStore,
1306 labels: Vec<u32>, // node id -> label symbol
1307 /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1308 /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1309 ///
1310 /// A private table rather than the shared [`Interner`]: interning
1311 /// `"default"` at open would add a symbol to the store's symbol table and
1312 /// change the bytes of the next snapshot of a store that has no namespaces
1313 /// at all.
1314 ns_names: Vec<String>,
1315 /// Namespace index per dense node id, into [`Self::ns_names`];
1316 /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1317 ///
1318 /// Derived: built by one pass over the `ns` column at open (which reads
1319 /// nothing when the column does not exist) and maintained at every node
1320 /// insert. Never written to a snapshot or the WAL, because the property it
1321 /// mirrors already is. A namespace cannot change, so no other record shape
1322 /// can move a node between namespaces.
1323 node_ns: Vec<u32>,
1324 edge_props: EdgeProps,
1325 engine: RuleEngine,
1326 view_store: ViewStore,
1327 /// Incremental inverted index for full-text-lite search.
1328 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1329 fulltext: FulltextIndex,
1330 /// Opt-in equality index over scalar node properties.
1331 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1332 /// open end (mirrors `fulltext`).
1333 prop_index: PropertyIndex,
1334 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1335 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1336 fsync: FsyncPolicy,
1337 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1338 /// call increments this once; all events emitted from that call share the same
1339 /// `commit_seq` value.
1340 commit_seq: u64,
1341 /// RBAC role definitions loaded from `roles.json` at open.
1342 ///
1343 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1344 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1345 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1346 roles: Option<Vec<RoleDef>>,
1347 /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1348 /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1349 ///
1350 /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1351 /// taken from this handle. Replaced (not cleared) whenever the role
1352 /// definitions change or the store is reloaded, which `commit_seq` does not
1353 /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1354 role_masks: Arc<crate::mask::RoleMaskCache>,
1355 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1356 /// distribute_events call.
1357 subscriptions: Vec<SubEntry>,
1358 /// Live query subscriptions. Re-executed on every commit when non-empty.
1359 /// Dead `Weak` entries are pruned inside `distribute_events`.
1360 query_subscriptions: Vec<QuerySubEntry>,
1361 /// Queue capacity for new subscriptions created by this db. Default is
1362 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1363 /// to test Lagged behaviour with small queues.
1364 sub_capacity: usize,
1365 /// True for as-of instances opened via [`GraphDb::open_at`].
1366 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1367 /// when this flag is set.
1368 read_only: bool,
1369 /// Total WAL commit count at the time [`open_at`] was called.
1370 /// 0 for normal (non-as-of) instances.
1371 total_wal_commits: u64,
1372 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1373 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1374 /// reads go through a merged `TopologyView`. `self.props` is always
1375 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1376 base: Option<Arc<core_storage::v8::MappedBase>>,
1377 // ── MVCC epoch reader state ───────────────────────────────────────────────
1378 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1379 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1380 /// `None` only between struct creation and the first fold.
1381 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1382 /// Per-commit deltas accumulated since the last fold.
1383 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1384 /// How many commits have occurred since the last fold.
1385 commits_since_fold: usize,
1386 /// When true, `log_then_apply_with` buffers event notifications instead of
1387 /// firing them immediately. Used by the group-commit drain thread to defer
1388 /// events until after the group fsync (R2: durability before notification).
1389 /// Cleared to false once the drain thread flushes or discards the buffer.
1390 defer_events: bool,
1391 /// Buffered events accumulated while `defer_events` is true.
1392 deferred_events: Vec<DeferredEvent>,
1393 /// Set to true by the group-commit drain thread when a group fsync fails
1394 /// after WAL truncation. All subsequent mutation attempts return an IO
1395 /// error until the database is reopened.
1396 degraded: bool,
1397 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1398 /// HNSW, and IVF sections from the mmap base into the engine's retained
1399 /// fields. `false` on all opens until first use; always `true` for non-V8
1400 /// opens (base is None, fast-path sets flag immediately).
1401 v8_sections_loaded: std::sync::atomic::AtomicBool,
1402 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1403 v8_sections_mutex: std::sync::Mutex<()>,
1404 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1405 /// the node was last modified by commit `seq`.
1406 ///
1407 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1408 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1409 /// nodes return `None` from `last_changed` until they are next mutated.
1410 ///
1411 /// See [`Precondition`] for the full touch definition.
1412 last_change: HashMap<u32, u64>,
1413 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1414 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1415 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1416 wal_archive_retention: Option<u32>,
1417 /// Global frame index of the first commit that is still reachable through
1418 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1419 /// Default 0 = all history reachable.
1420 wal_horizon_floor: u64,
1421 /// True when the surviving archive chain forms a continuous WAL history
1422 /// starting from the store's first commit (the genesis chain).
1423 ///
1424 /// `open_at` may replay archive-resident commits from empty state only when
1425 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1426 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1427 /// already exist (breaks the chain for subsequent archives), or
1428 /// - any archive is pruned (floor advances past zero).
1429 ///
1430 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1431 archive_genesis_chain: bool,
1432 /// Transient write-authz context set by `write_batch_authz` /
1433 /// `query_write_authz` for the duration of ONE mutation call.
1434 /// Always `None` at rest. Never serialized, never WAL-replayed.
1435 pending_write_authz: Option<WriteAuthz>,
1436 /// Slow-query threshold in milliseconds. 0 = disabled.
1437 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1438 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1439 /// — env vars are process-global and race parallel test threads).
1440 slow_query_threshold_ms: u64,
1441 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1442 /// can record entries without requiring `&mut self`).
1443 slow_queries: std::sync::Mutex<SlowQueryLog>,
1444 /// Instant at which the database was opened (used by `/metrics` uptime).
1445 started_at: std::time::Instant,
1446 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1447 /// Byte offset of the WAL prefix already applied to in-memory state.
1448 ///
1449 /// Advanced by exactly the encoded length of every frame this handle
1450 /// appends, and by the decoded byte count of every tail
1451 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1452 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1453 /// drain thread truncates a failed group. Compared against the WAL's
1454 /// on-disk length to decide staleness.
1455 wal_consumed: u64,
1456 /// Identity of the snapshot this handle's base state came from, as
1457 /// `(len, mtime_nanos)`. A different value means another process replaced
1458 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1459 snapshot_ident: Option<(u64, u64)>,
1460 /// The options this handle was opened with. Replayed verbatim when
1461 /// `refresh` has to rebuild from disk.
1462 open_opts: OpenOptions,
1463 /// True when this handle holds the cross-process write lock for its whole
1464 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1465 /// no-op on such a handle, and never releases the lock.
1466 holds_lifetime_lock: bool,
1467 /// True between a failed lock acquisition and the end of the write scope
1468 /// that failed. Makes every WAL-appending mutation in that scope return
1469 /// [`GraphError::Busy`] instead of writing.
1470 lock_denied: bool,
1471 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1472 /// pinned to one commit, so it is never stale and never refreshes — later
1473 /// commits by any process are deliberately invisible to it.
1474 pinned: bool,
1475}
1476
1477/// One group of deferred event notifications, held until the group fsync
1478/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1479struct DeferredEvent {
1480 rec: core_storage::WalRecord,
1481 engine_deltas: Vec<EngineEdgeDelta>,
1482 seq: u64,
1483 ingest: Option<(String, usize)>,
1484}
1485
1486/// Options for [`GraphDb::open_with_options`].
1487#[derive(Clone, Copy, Debug)]
1488pub struct OpenOptions {
1489 /// Rewrite an old-format snapshot to the current VERSION after a
1490 /// successful load (default `true`). The old snapshot is kept as
1491 /// `snapshot.bin.bak` until the next clean open at the current version,
1492 /// at which point the `.bak` is deleted.
1493 ///
1494 /// Set to `false` to open a store without touching any on-disk files
1495 /// (useful for read-only inspection of a store at an older format).
1496 pub auto_migrate: bool,
1497
1498 /// Write the valid WAL prefix back over a torn tail on open (default
1499 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1500 ///
1501 /// Set to `false` for an unattended reader. The valid prefix is still
1502 /// decoded and replayed in memory, but nothing is written: a reader that
1503 /// opens while another process is mid-append would otherwise discard a
1504 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1505 /// every prompt, passes `false` for exactly this reason.
1506 pub repair_wal: bool,
1507
1508 /// Open without ever writing to the store (default `false`).
1509 ///
1510 /// A read-only handle:
1511 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1512 /// `snapshot()`;
1513 /// - performs no disk write at open — no WAL repair write-back and no
1514 /// auto-migration rewrite, whatever the other two flags say;
1515 /// - never takes the cross-process write lock, so it opens immediately even
1516 /// while another process is writing, and never makes a writer wait.
1517 ///
1518 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1519 /// normally, so a read-only handle can follow another process's commits.
1520 pub read_only: bool,
1521}
1522
1523impl Default for OpenOptions {
1524 fn default() -> Self {
1525 Self {
1526 auto_migrate: true,
1527 repair_wal: true,
1528 read_only: false,
1529 }
1530 }
1531}
1532
1533/// How long a writer polls for the cross-process write lock before giving up
1534/// with [`GraphError::Busy`].
1535///
1536/// Long enough to ride out another process's commit (a batch apply plus one
1537/// fsync), short enough that a stuck peer surfaces as an error rather than a
1538/// hang.
1539pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1540
1541/// Refusal when a `MERGE` create cannot choose a namespace.
1542///
1543/// A role bound to two or more namespaces cannot have its create arm land in
1544/// `default`, and the statement did not name `ns`. The role must name one.
1545pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1546 "role-bound token: MERGE create requires the role to name one namespace";
1547
1548/// Interval between poll attempts while waiting for the cross-process lock.
1549pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1550
1551/// Why `load_from_disk` is running, which decides whether it may repair.
1552#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1553enum LoadOrigin {
1554 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1555 /// the signature of a crash and truncating it is correct, and archives
1556 /// orphaned by an interrupted prune can be swept.
1557 Open,
1558 /// A reload driven by [`GraphDb::refresh`], because another process
1559 /// replaced the snapshot. Nothing here is crash recovery — the store is
1560 /// live and someone else is writing it — so this origin writes nothing.
1561 Reload,
1562}
1563
1564/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1565///
1566/// `None` at the call site = full authority (today's zero-cost behavior).
1567/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1568/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1569/// record is built. A denial returns an error with no WAL frame written.
1570///
1571/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1572/// hidden-node existence to callers.
1573#[derive(Clone, Debug)]
1574pub struct WriteAuthz {
1575 pub role: String,
1576 pub scope: WriteScope,
1577 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1578 /// Always `Omit`-mode — never `Stub`.
1579 pub mask: crate::mask::NodeMask,
1580}
1581
1582/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1583///
1584/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1585/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1586/// syncs the directory entry. This is the only correct path for writing the
1587/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1588/// the directory sync.
1589pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1590 use core_storage::fs::{FileId, Fs as _};
1591 RealFs::new(dir)
1592 .map_err(core_storage::GraphError::Io)?
1593 .write_atomic(FileId::SnapshotBak, bytes)
1594 .map_err(core_storage::GraphError::Io)
1595}
1596
1597/// Return the on-disk snapshot format version without decoding the full snapshot.
1598///
1599/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1600/// snapshot file exists (WAL-only store). Returns an error if the header is
1601/// malformed.
1602pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1603 use std::io::Read as _;
1604 let path = dir.join("snapshot.bin");
1605 let mut header = [0u8; 6];
1606 let n = match std::fs::File::open(&path) {
1607 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1608 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1609 Err(e) => return Err(core_storage::GraphError::Io(e)),
1610 };
1611 core_storage::snapshot::peek_version(&header[..n])
1612}
1613
1614/// Options for [`GraphDb::snapshot_with`].
1615#[derive(Debug, Clone, Default)]
1616pub struct SnapshotOptions {
1617 /// When `true`, the WAL is preserved after the snapshot write.
1618 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1619 /// When `false` (the default), the WAL is truncated to a minimal
1620 /// baseline so cold-start replay stays fast.
1621 pub keep_wal: bool,
1622 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1623 /// before a fresh WAL baseline is written (history-preserving snapshot).
1624 ///
1625 /// This is the feature opt-in: `false` (the default) leaves the existing
1626 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1627 /// precedence over `keep_wal` when both are set.
1628 ///
1629 /// Archives can be scanned by [`GraphDb::node_history`],
1630 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1631 /// [`GraphDb::open_at`], extending the reachable history horizon across
1632 /// snapshot boundaries.
1633 pub archive_wal: bool,
1634}
1635
1636/// Derive the scan-label sym for the commit-skip fast-path.
1637///
1638/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1639/// or `IndexIntersect`) with a concrete label string, then interns it.
1640///
1641/// Returns `None` in all cases where skipping is unsafe:
1642/// - Any `Expand` op is present (edge traversal; edges change results regardless
1643/// of node labels).
1644/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1645/// - No recognizable leading scan op is found.
1646///
1647/// This is the conservative v0.4.3 boundary. The caller stores the result in
1648/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1649fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1650 // Any Expand → must always re-execute (edges can change join results).
1651 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1652 return None;
1653 }
1654 for op in ops {
1655 match op {
1656 PlanOp::ScanLabel {
1657 label: Some(label), ..
1658 } => return Some(syms.intern(label)),
1659 PlanOp::IndexScan {
1660 label: Some(label), ..
1661 } => return Some(syms.intern(label)),
1662 PlanOp::IndexIntersect {
1663 label: Some(label), ..
1664 } => return Some(syms.intern(label)),
1665 _ => {}
1666 }
1667 }
1668 None
1669}
1670
1671/// How an as-of read is restricted — the argument to
1672/// [`GraphDb::query_at_scoped`].
1673///
1674/// Every variant is resolved against the graph **as it was at the requested
1675/// commit**, not against the current graph.
1676#[derive(Debug, Clone, Copy)]
1677pub enum AsOfScope<'a> {
1678 /// Everything the named role may see. The role *definition* is the current
1679 /// one — `roles.json` is a sidecar and has no past version — but its
1680 /// `keys` and `labels` are resolved against the as-of graph.
1681 Role(&'a str),
1682 /// An explicit node-key allow-list. Keys that did not exist at that commit
1683 /// resolve to nothing.
1684 Keys(&'a [String]),
1685 /// A role intersected with a client-supplied allow-list. The intersection
1686 /// is the never-widen rule: a client mask can only narrow a role.
1687 RoleAndKeys(&'a str, &'a [String]),
1688 /// Every live node in one namespace, as the graph was at that commit.
1689 ///
1690 /// A namespace cannot change — it is set at insert and immutable — so the
1691 /// answer is simply "the nodes that existed then and are in this
1692 /// namespace". A name no node uses resolves to nothing, never to
1693 /// everything.
1694 Namespace(&'a str),
1695}
1696
1697impl GraphDb<RealFs> {
1698 /// Open the database at `dir` with default options.
1699 ///
1700 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1701 /// Old-format snapshots (V5, V6) are automatically migrated to the
1702 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1703 pub fn open(dir: &std::path::Path) -> Result<Self> {
1704 Self::open_with_options(dir, OpenOptions::default())
1705 }
1706
1707 /// Open the database at `dir` with explicit options.
1708 ///
1709 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1710 /// snapshot is an older format version, this function:
1711 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1712 /// + fsynced) before any modification.
1713 /// 2. Rewrites `snapshot.bin` at the current format version via
1714 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1715 ///
1716 /// If migration fails the error is returned and the original files are
1717 /// intact (the `.bak` was written before the new snapshot was attempted).
1718 ///
1719 /// A clean open that finds the snapshot already at the current version
1720 /// deletes any leftover `.bak` file.
1721 ///
1722 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1723 ///
1724 /// `opts.repair_wal` controls the other write this function can make; see
1725 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1726 /// no file on disk.
1727 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1728 Self::open_dir(dir, opts, true)
1729 }
1730
1731 /// Open without taking the cross-process write lock for the handle's
1732 /// lifetime.
1733 ///
1734 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1735 /// its handle open indefinitely, so it takes the lock per write instead of
1736 /// keeping every other process out of the store for as long as it runs.
1737 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1738 Self::open_dir(dir, OpenOptions::default(), false)
1739 }
1740
1741 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1742 // Header-only peek — 6 bytes, no full decode.
1743 let snap_version = snapshot_version_at(dir)?;
1744
1745 // Full load: decode snapshot + replay WAL + rebuild indexes.
1746 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1747
1748 // A read-only handle writes nothing at open, so it never migrates —
1749 // the old-format snapshot is loaded and left exactly as it is.
1750 if opts.auto_migrate && !opts.read_only {
1751 match snap_version {
1752 Some(ver) if ver < core_storage::snapshot::VERSION => {
1753 let _tm = std::time::Instant::now();
1754 // Copy the original snapshot to .bak at OS level — no in-memory
1755 // buffer required for a 2+ GiB file.
1756 //
1757 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1758 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1759 // A torn .bak on crash is acceptable because the original
1760 // snapshot.bin is the authoritative source until after the rename.
1761 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1762 .map_err(core_storage::GraphError::Io)?;
1763 trace_migrate!("bak copy done", _tm);
1764 // Rewrite snapshot at current version; keep WAL intact.
1765 db.snapshot_with(SnapshotOptions {
1766 keep_wal: true,
1767 ..SnapshotOptions::default()
1768 })?;
1769 trace_migrate!("snapshot_with done", _tm);
1770 }
1771 Some(_) => {
1772 // Already current version: remove any leftover .bak.
1773 let bak = dir.join("snapshot.bin.bak");
1774 if bak.exists() {
1775 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1776 }
1777 }
1778 None => {
1779 // WAL-only store — nothing to migrate on open.
1780 }
1781 }
1782 }
1783
1784 Ok(db)
1785 }
1786
1787 /// Open a read-only view of the database as it existed after `commit`.
1788 ///
1789 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1790 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1791 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1792 ///
1793 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1794 /// so as-of can only reach commits recorded in the current WAL (those
1795 /// written after the most recent snapshot, or all commits if no snapshot
1796 /// was ever taken). Commit 0 in `open_at` always refers to the first
1797 /// frame in the WAL that exists on disk, not the first ever write to the
1798 /// database. When the on-disk snapshot recorded that it truncated the
1799 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1800 /// before frame replay, so the as-of view includes all pre-snapshot data.
1801 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1802 /// are ignored and replay is WAL-only, as before.
1803 ///
1804 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1805 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1806 /// `stats()` work normally.
1807 ///
1808 /// # Errors
1809 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1810 /// when the WAL is empty after a snapshot).
1811 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1812 Self::open_at_with(RealFs::new(dir)?, commit)
1813 }
1814
1815 /// Run a **read-only** Cypher query against the graph as it existed at
1816 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1817 /// of this store's directory at that commit and executes the read there.
1818 ///
1819 /// The current instance is unaffected. Write statements are rejected (the
1820 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1821 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1822 /// state. Prefer this over holding many historical instances open.
1823 ///
1824 /// # Errors
1825 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1826 /// - A query error for a malformed or write query.
1827 pub fn query_at(
1828 &self,
1829 commit: u64,
1830 cypher: &str,
1831 params: &std::collections::BTreeMap<String, Value>,
1832 ) -> Result<ResultSet> {
1833 let temporal = self.open_at_for_read(commit, cypher)?;
1834 temporal.query(cypher, params)
1835 }
1836
1837 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1838 ///
1839 /// The **graph** is as of `commit`; the **role definition** is as it is
1840 /// now, because `roles.json` is a sidecar and is never a WAL record — it
1841 /// has no past version to read. A role's `keys` and `labels` are resolved
1842 /// against the commit-`commit` graph, so a role that may see a label sees
1843 /// exactly the nodes that carried it then, and an explicit key that did
1844 /// not exist yet resolves to nothing.
1845 ///
1846 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1847 /// only narrow what a role may see, never widen it.
1848 ///
1849 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1850 /// them.
1851 ///
1852 /// # Errors
1853 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1854 /// range; the error carries that range.
1855 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1856 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1857 /// - A query error for a malformed or write query.
1858 pub fn query_at_scoped(
1859 &self,
1860 commit: u64,
1861 cypher: &str,
1862 params: &std::collections::BTreeMap<String, Value>,
1863 scope: AsOfScope<'_>,
1864 ) -> Result<ResultSet> {
1865 let temporal = self.open_at_for_read(commit, cypher)?;
1866 let mask = temporal.mask_at_scope(scope)?;
1867 temporal.query_masked(cypher, params, &mask)
1868 }
1869
1870 /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1871 /// whatever `scope` resolves to.
1872 ///
1873 /// This is what a surface needs when a caller passes `namespace` beside a
1874 /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1875 /// restriction, and the namespace is a second one that composes with it
1876 /// rather than replacing it. The intersection is the never-widen rule — a
1877 /// namespace can only narrow what the scope already allows — and both legs
1878 /// are resolved against the graph as it was at `commit`.
1879 ///
1880 /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1881 pub fn query_at_scoped_in_namespace(
1882 &self,
1883 commit: u64,
1884 cypher: &str,
1885 params: &std::collections::BTreeMap<String, Value>,
1886 scope: AsOfScope<'_>,
1887 namespace: &str,
1888 ) -> Result<ResultSet> {
1889 let temporal = self.open_at_for_read(commit, cypher)?;
1890 let mask = temporal
1891 .mask_at_scope(scope)?
1892 .intersect(&temporal.mask_for_namespace(namespace));
1893 temporal.query_masked(cypher, params, &mask)
1894 }
1895
1896 /// Open the temporal view for a time-travel read and refuse write Cypher.
1897 ///
1898 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1899 /// resolve the commit and reject writes identically.
1900 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1901 let dir = self.fs.dir().to_path_buf();
1902 let temporal = Self::open_at(&dir, commit)?;
1903 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1904 detail: format!("lex: {e}"),
1905 })?) {
1906 return Err(GraphError::QueryError {
1907 detail: "query_at is read-only: write statements are not permitted in a \
1908 time-travel query"
1909 .into(),
1910 });
1911 }
1912 Ok(temporal)
1913 }
1914}
1915
1916impl<F: Fs> GraphDb<F> {
1917 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1918 pub fn open_with(fs: F) -> Result<Self> {
1919 Self::open_with_repair(fs, true)
1920 }
1921
1922 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1923 /// prefix without writing the truncation back. See
1924 /// [`OpenOptions::repair_wal`].
1925 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1926 Self::open_generic(
1927 fs,
1928 OpenOptions {
1929 repair_wal,
1930 ..OpenOptions::default()
1931 },
1932 true,
1933 )
1934 }
1935
1936 /// Shared open path.
1937 ///
1938 /// `hold_lock` requests the cross-process write lock for the whole handle
1939 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1940 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1941 /// `false` and takes the lock per write instead, so that a long-lived
1942 /// server does not keep every other process out of the store.
1943 ///
1944 /// A read-only open never takes the lock regardless of `hold_lock`.
1945 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1946 let mut db = Self::new_empty(fs, opts);
1947 db.read_only = opts.read_only;
1948 if hold_lock && !opts.read_only {
1949 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1950 return Err(GraphError::Busy { holder: None });
1951 }
1952 db.holds_lifetime_lock = true;
1953 }
1954 db.load_from_disk(LoadOrigin::Open)?;
1955 Ok(db)
1956 }
1957
1958 /// A handle with no state loaded: every field at its empty value, the
1959 /// filesystem and options in place. Only [`load_from_disk`] makes it
1960 /// usable.
1961 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1962 Self {
1963 fs,
1964 ids: IdMap::new(),
1965 syms: Interner::new(),
1966 topo: Topology::new(),
1967 props: ColumnStore::new(),
1968 labels: Vec::new(),
1969 ns_names: vec![NS_DEFAULT.to_string()],
1970 node_ns: Vec::new(),
1971 edge_props: EdgeProps::new(),
1972 engine: RuleEngine::new(),
1973 view_store: ViewStore::new(),
1974 fulltext: FulltextIndex::new(),
1975 prop_index: PropertyIndex::new(),
1976 event_sink: None,
1977 fsync: FsyncPolicy::Strict,
1978 commit_seq: 0,
1979 roles: Some(vec![]),
1980 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1981 subscriptions: Vec::new(),
1982 query_subscriptions: Vec::new(),
1983 sub_capacity: DEFAULT_SUB_CAPACITY,
1984 read_only: false,
1985 total_wal_commits: 0,
1986 base: None,
1987 fold_overlay: None,
1988 delta_tail: Vec::new(),
1989 commits_since_fold: 0,
1990 defer_events: false,
1991 deferred_events: Vec::new(),
1992 degraded: false,
1993 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1994 v8_sections_mutex: std::sync::Mutex::new(()),
1995 last_change: HashMap::new(),
1996 wal_archive_retention: None,
1997 wal_horizon_floor: 0,
1998 archive_genesis_chain: false,
1999 pending_write_authz: None,
2000 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2001 .ok()
2002 .and_then(|v| v.parse().ok())
2003 .unwrap_or(100),
2004 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2005 entries: std::collections::VecDeque::new(),
2006 total: 0,
2007 }),
2008 started_at: std::time::Instant::now(),
2009 wal_consumed: 0,
2010 snapshot_ident: None,
2011 open_opts: opts,
2012 holds_lifetime_lock: false,
2013 lock_denied: false,
2014 pinned: false,
2015 }
2016 }
2017
2018 /// Return every field describing stored graph state to its empty value,
2019 /// leaving this handle's own identity alone.
2020 ///
2021 /// Preserved on purpose: the filesystem, open options, lock ownership, the
2022 /// event sink and subscriptions, fsync policy, degraded flag, and the
2023 /// slow-query configuration and log. A caller that registered a sink or a
2024 /// subscription keeps it across a reload.
2025 fn reset_for_reload(&mut self) {
2026 self.ids = IdMap::new();
2027 self.syms = Interner::new();
2028 self.topo = Topology::new();
2029 self.props = ColumnStore::new();
2030 self.labels = Vec::new();
2031 self.ns_names = vec![NS_DEFAULT.to_string()];
2032 self.node_ns = Vec::new();
2033 self.edge_props = EdgeProps::new();
2034 self.engine = RuleEngine::new();
2035 self.view_store = ViewStore::new();
2036 self.fulltext = FulltextIndex::new();
2037 self.prop_index = PropertyIndex::new();
2038 self.commit_seq = 0;
2039 self.roles = Some(vec![]);
2040 // A fresh cache, not a cleared one: any reader snapshot still holding
2041 // the old `Arc` keeps it to itself, so nothing it memoised against the
2042 // pre-reload store can be read back through this handle.
2043 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2044 self.total_wal_commits = 0;
2045 self.base = None;
2046 self.fold_overlay = None;
2047 self.delta_tail = Vec::new();
2048 self.commits_since_fold = 0;
2049 self.deferred_events = Vec::new();
2050 self.v8_sections_loaded
2051 .store(false, std::sync::atomic::Ordering::Release);
2052 self.last_change = HashMap::new();
2053 self.wal_horizon_floor = 0;
2054 self.archive_genesis_chain = false;
2055 self.pending_write_authz = None;
2056 self.wal_consumed = 0;
2057 self.snapshot_ident = None;
2058 }
2059
2060 /// Load the snapshot base and replay the WAL into an empty handle — the
2061 /// whole of what opening a store does after the struct exists.
2062 ///
2063 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2064 /// rebuild a handle in place, without ownership of `F`, when another
2065 /// process replaces the snapshot underneath it.
2066 ///
2067 /// `origin` decides whether the two repair writes this function can make
2068 /// are appropriate; see [`LoadOrigin`].
2069 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2070 // Both writes below are crash recovery, and only an open is entitled to
2071 // perform them. A read-only handle promises to touch nothing, and a
2072 // reload driven by `refresh` is looking at a store another process is
2073 // actively writing: what looks like a torn tail there is a peer
2074 // mid-append, and what looks like an orphaned archive may be one that
2075 // peer is about to reference.
2076 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2077 let repair_wal = self.open_opts.repair_wal && may_repair;
2078 let db = self;
2079 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2080 db.archive_genesis_chain = db.fs.has_genesis_marker();
2081 // Opening cleanup: remove orphaned archives — archives whose frames all
2082 // fall below the horizon floor. Orphans arise when a crash interrupted
2083 // the retention-prune sequence after the floor was written but before
2084 // all surplus archives were deleted. Safe to delete: floor already
2085 // accounts for their frames.
2086 if may_repair {
2087 db.cleanup_orphaned_archives()?;
2088 }
2089 let _t0 = std::time::Instant::now();
2090 // Peek 6 bytes to determine snapshot version without reading the full
2091 // file. For RealFs this is a true partial read (O(1)); for SimFs the
2092 // default impl reads all bytes and truncates (still correct).
2093 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2094 // V8 and V9 share the mmap-able container; V9 only adds section 12.
2095 let is_v8 = snap_header.len() >= 6
2096 && &snap_header[0..4] == b"GDB1"
2097 && matches!(
2098 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2099 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2100 );
2101 if is_v8 {
2102 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2103 // No 2.4GB heap Vec is allocated on RealFs.
2104 let mapped = Arc::new(
2105 if let Some(snap_path) = db.fs.snapshot_path() {
2106 core_storage::v8::MappedBase::map(&snap_path)
2107 } else {
2108 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2109 core_storage::v8::MappedBase::from_bytes(snap_bytes)
2110 }
2111 .map_err(|e| GraphError::Corrupt {
2112 detail: format!("v8: mmap open: {e:?}"),
2113 })?,
2114 );
2115 db.restore_v8_base(Arc::clone(&mapped))?;
2116 trace_open!("restore_v8_base", _t0);
2117 db.base = Some(mapped);
2118 trace_open!("base assigned", _t0);
2119 } else if !snap_header.is_empty() {
2120 // Legacy V5-V7: full read required for decode.
2121 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2122 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2123 db.restore_snapshot_state(state)?;
2124 }
2125 }
2126 // else: snap_header is empty = no snapshot file, fresh store.
2127 //
2128 // Seed commit_seq from the highest seq persisted in last_change so that
2129 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2130 // already stored in the snapshot. Without this, a db with one snapshot
2131 // commit would save last_change["a"]=1, then on reopen the first WAL
2132 // frame would replay at seq=1 again — colliding and making WAL-tail
2133 // mutations indistinguishable from the snapshot baseline.
2134 //
2135 // Safety invariant (seq-recycling):
2136 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2137 // last_change because they belonged to a previous db lifetime — a new
2138 // db starts at commit_seq=0 with an empty last_change. Therefore no
2139 // CAS precondition can carry a recycled seq as its `expected` value
2140 // and accidentally match a live node's last_change entry.
2141 //
2142 // `expected:0` on a deleted-then-reinserted node:
2143 // After deletion, last_changed() returns None; callers that call
2144 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2145 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2146 // expected=0 correctly conflicts. The only way to observe actual=0 in
2147 // a CasConflict would be a caller that invented expected=0 without ever
2148 // calling last_changed() — unreachable via the documented API contract.
2149 if let Some(&max_seq) = db.last_change.values().max() {
2150 db.commit_seq = db.commit_seq.max(max_seq);
2151 }
2152 let bytes = db.fs.read(FileId::Wal)?;
2153 let (records, valid_len) = decode_all(&bytes);
2154 // The valid prefix is replayed either way; `repair_wal` only decides
2155 // whether the truncation is written back. A reader that races a live
2156 // appender must not persist a truncation the writer never asked for.
2157 if valid_len < bytes.len() && repair_wal {
2158 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2159 }
2160 // WAL-present path: build indexes eagerly BEFORE replay so that the
2161 // first replayed record does not trigger the lazy-init guard (which
2162 // would call reindex_all_load_state on an empty graph, defeating the
2163 // point of restoring IVF/HNSW blobs from the snapshot).
2164 if !records.is_empty() {
2165 db.ensure_v8_base_sections_loaded();
2166 trace_open!("lazy sections loaded (WAL path)", _t0);
2167 }
2168 let replayed = db.apply_frames(records)?;
2169 // The cursor sits at the end of the valid prefix, not the end of the
2170 // file: a torn or still-being-written tail is unconsumed by definition
2171 // and stays visible to `is_stale` until it decodes.
2172 db.wal_consumed = valid_len as u64;
2173 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2174 trace_open!("wal replay done", _t0);
2175 // Rebuild view values after WAL replay only when there is no V8 base.
2176 // With a V8 base, view values are correct in the snapshot and are updated
2177 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2178 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2179 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2180 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2181 // base value).
2182 if db.base.is_none() {
2183 let topo_view = TopologyView::owned(&db.topo);
2184 db.view_store
2185 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2186 }
2187 // Rebuild full-text index after WAL replay. Corrects drift from
2188 // per-record incremental apply during replay.
2189 db.fulltext.rebuild_all(
2190 &db.ids,
2191 &db.labels,
2192 &db.syms,
2193 build_props_view(&db.props, &db.base),
2194 );
2195 db.prop_index.rebuild_all(
2196 &db.ids,
2197 &db.labels,
2198 &db.syms,
2199 build_props_view(&db.props, &db.base),
2200 );
2201 // Namespaces: one pass over the `ns` column, after the snapshot is
2202 // restored and the WAL replayed. Replay maintains `node_ns` record by
2203 // record as well; this pass is what makes a snapshot-only open right,
2204 // and it reads nothing on a store with no `ns` column.
2205 db.rebuild_node_ns();
2206 // A mid-build snapshot's HNSW blob carries `complete == false`.
2207 // Register it so `serve`'s ticker sees work without waiting for a write.
2208 db.register_outstanding_index_builds();
2209 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2210 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2211 db.roles = Self::load_roles_from_fs(&db.fs)?;
2212 // Capture the initial MVCC fold so reader() is ready immediately.
2213 db.fold_now();
2214 trace_open!("open_with complete", _t0);
2215 Ok(replayed)
2216 }
2217
2218 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2219 /// replay does — same `apply` calls, same per-frame delta drain, same
2220 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2221 /// edges appear identically whether a frame arrives at open, from a local
2222 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2223 ///
2224 /// Returns the number of frames applied.
2225 ///
2226 /// Deltas are drained and discarded per frame: replayed frames are already
2227 /// reflected on disk, so they are not news to a subscriber, and draining
2228 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2229 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2230 if records.is_empty() {
2231 return Ok(0);
2232 }
2233 // Materialize any state retained in the mmap base before the first
2234 // frame lands, so a replayed record cannot trip the lazy-init guard and
2235 // rebuild indexes from an empty graph. Both calls are idempotent.
2236 self.ensure_v8_base_sections_loaded();
2237 self.engine.consume_retained_state_eager(
2238 &self.ids,
2239 &self.syms,
2240 &self.labels,
2241 build_props_view(&self.props, &self.base),
2242 );
2243 let applied = records.len();
2244 for rec in records {
2245 self.apply(&rec)?;
2246 let _ = self.engine.drain_deltas();
2247 // Track commit_seq during replay so last_change entries are
2248 // consistent with the seqs assigned by log_then_apply_with on
2249 // subsequent live commits. After N replayed frames, commit_seq=N;
2250 // live commits begin at N+1.
2251 self.commit_seq += 1;
2252 let replay_seq = self.commit_seq;
2253 self.update_last_change_from_rec(&rec, replay_seq);
2254 }
2255 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2256 // this assert catches the regression in debug builds immediately.
2257 debug_assert_eq!(
2258 self.engine.pending_delta_count(),
2259 0,
2260 "pending_deltas non-empty after replay — \
2261 per-frame drain must run inside the loop to keep memory O(1)"
2262 );
2263 // T2 note: the per-frame drain IS the suppression seam for replay.
2264 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2265 // replaying subscribers; the mechanism is already in place.
2266 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2267 Ok(applied)
2268 }
2269
2270 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2271 //
2272 // mushroomdb is many-readers / one-writer across processes. Writers take an
2273 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2274 // Every handle tracks how much of the WAL it has consumed, so it can pick
2275 // up another process's commits by decoding only the new tail rather than
2276 // reopening. See `docs/site/concurrency.md`.
2277
2278 /// Whether the store on disk has moved ahead of (or out from under) this
2279 /// handle's in-memory state.
2280 ///
2281 /// True when the WAL's length differs from this handle's cursor — another
2282 /// process committed, or is mid-append — or when the snapshot file's
2283 /// identity changed. Costs two metadata lookups and reads no file contents,
2284 /// so it is cheap enough for a read path to call.
2285 ///
2286 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2287 /// pinned to one commit and later commits are deliberately invisible to it.
2288 pub fn is_stale(&self) -> Result<bool> {
2289 if self.pinned {
2290 return Ok(false);
2291 }
2292 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2293 return Ok(true);
2294 }
2295 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2296 }
2297
2298 /// Bring this handle up to date with every commit other processes have made,
2299 /// and return how many frames were applied.
2300 ///
2301 /// The WAL tail is decoded from this handle's cursor and applied through the
2302 /// same path the open replay uses, so rules fire and derived edges appear
2303 /// exactly as they would on a fresh open. Interners, id maps and indexes
2304 /// stay valid for the same reason.
2305 ///
2306 /// A frame another process is still writing is left alone: a trailing
2307 /// partial frame is a wait, not a corruption, and the handle stays stale
2308 /// until that frame is complete. Nothing is written to disk, so a read-only
2309 /// handle can refresh freely.
2310 ///
2311 /// When the snapshot file's identity changed, or the WAL is shorter than
2312 /// this handle's cursor, the WAL no longer continues our state — another
2313 /// process snapshotted or archived. The handle is then rebuilt from disk
2314 /// with the options it was opened with, and the return value is the number
2315 /// of frames in the new WAL.
2316 ///
2317 /// Returns 0 for an as-of view, which never follows later commits.
2318 ///
2319 /// # Errors
2320 ///
2321 /// An error here leaves the handle **degraded**: it got partway through
2322 /// applying the tail, or partway through a reload, so its in-memory state
2323 /// no longer matches any point on disk. Further mutations are refused and
2324 /// the handle must be reopened. Nothing on disk was damaged — the store
2325 /// itself is fine, and a fresh open recovers it.
2326 pub fn refresh(&mut self) -> Result<u64> {
2327 if self.pinned {
2328 return Ok(0);
2329 }
2330 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2331 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2332 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2333 // The WAL no longer continues our state: rebuild from disk. State
2334 // is cleared first, so a failed load leaves an empty handle — mark
2335 // it degraded rather than let a caller read an empty graph as if
2336 // it were the store's contents.
2337 self.reset_for_reload();
2338 return match self.load_from_disk(LoadOrigin::Reload) {
2339 Ok(frames) => Ok(frames as u64),
2340 Err(e) => {
2341 self.degraded = true;
2342 Err(e)
2343 }
2344 };
2345 }
2346 if wal_len == self.wal_consumed {
2347 return Ok(0);
2348 }
2349 let tail = self
2350 .fs
2351 .read_range(FileId::Wal, self.wal_consumed)
2352 .map_err(GraphError::Io)?;
2353 let (records, valid_len) = decode_all(&tail);
2354 let applied = match self.apply_frames(records) {
2355 Ok(n) => n,
2356 Err(e) => {
2357 // Some frames landed and some did not, and the cursor cannot
2358 // say how many. Advancing it would skip the rest; leaving it
2359 // would replay what already applied. Neither is recoverable in
2360 // place, so refuse further writes and require a reopen.
2361 self.degraded = true;
2362 return Err(e);
2363 }
2364 };
2365 // Advance by the bytes actually decoded, never by the file length: an
2366 // incomplete trailing frame stays unconsumed for the next refresh.
2367 self.wal_consumed += valid_len as u64;
2368 if applied > 0 {
2369 // Peer commits must reach `reader()` snapshots taken from here on.
2370 // A full fold is what open does; refresh does not build per-commit
2371 // deltas, so there is nothing cheaper that stays correct.
2372 self.fold_now();
2373 }
2374 Ok(applied as u64)
2375 }
2376
2377 /// Byte offset of the WAL prefix this handle has applied.
2378 ///
2379 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2380 #[doc(hidden)]
2381 pub fn wal_consumed(&self) -> u64 {
2382 self.wal_consumed
2383 }
2384
2385 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2386 /// failed group off the tail, so the cursor still describes the file.
2387 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2388 self.wal_consumed = len;
2389 }
2390
2391 /// One non-blocking attempt at the cross-process write lock.
2392 ///
2393 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2394 /// in-process write guard. That ordering is what keeps a busy peer in
2395 /// another process from stalling this process's readers.
2396 ///
2397 /// A handle that owns the lock for its lifetime always succeeds.
2398 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2399 if self.holds_lifetime_lock {
2400 return Ok(true);
2401 }
2402 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2403 }
2404
2405 /// Poll for the cross-process write lock until `wait` elapses.
2406 ///
2407 /// One attempt is always made, so a zero wait is a single try. Returns
2408 /// `false` when the lock is still held elsewhere at the deadline; nothing
2409 /// has been written and retrying later is safe.
2410 ///
2411 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2412 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2413 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2414 /// that it holds no in-process guard while it waits.
2415 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2416 let deadline = std::time::Instant::now() + wait;
2417 loop {
2418 if self.try_cross_process_lock()? {
2419 return Ok(true);
2420 }
2421 let now = std::time::Instant::now();
2422 if now >= deadline {
2423 return Ok(false);
2424 }
2425 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2426 }
2427 }
2428
2429 /// Open a cross-process write scope, given the outcome of an already-made
2430 /// lock attempt.
2431 ///
2432 /// The caller polls for the lock first — outside any in-process guard — and
2433 /// passes what it got. On success this refreshes, so the writes about to
2434 /// happen land on top of every other process's commits. On failure the
2435 /// handle refuses WAL-appending mutations and `snapshot()` with
2436 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2437 /// closes the scope, so a caller holding a guard cannot write behind
2438 /// another process's back.
2439 ///
2440 /// A handle that already owns the lock for its lifetime skips the refresh:
2441 /// no other process can have written, so there is nothing to pick up.
2442 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2443 self.lock_denied = !acquired;
2444 if !acquired || self.holds_lifetime_lock {
2445 return Ok(());
2446 }
2447 if let Err(e) = self.refresh() {
2448 // Do not hold a lock we cannot use: release it and let the caller
2449 // see the underlying failure.
2450 let _ = self.fs.unlock();
2451 self.lock_denied = true;
2452 return Err(e);
2453 }
2454 Ok(())
2455 }
2456
2457 /// Close a cross-process write scope opened by
2458 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2459 /// clear the Busy latch. Safe to call when the lock was never taken.
2460 pub(crate) fn end_write_lock(&mut self) {
2461 self.lock_denied = false;
2462 if !self.holds_lifetime_lock {
2463 // Releasing a lock we do not hold is a no-op; a failure to release
2464 // is reported by the OS closing the descriptor at handle drop.
2465 let _ = self.fs.unlock();
2466 }
2467 }
2468
2469 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2470 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2471 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2472 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2473 /// Restore all persisted state from a decoded snapshot. Shared by
2474 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2475 fn restore_snapshot_state(
2476 &mut self,
2477 state: core_storage::snapshot::SnapshotState,
2478 ) -> Result<()> {
2479 self.ids = state.ids;
2480 self.syms = state.syms;
2481 self.topo = state.topo;
2482 self.props = state.props;
2483 self.labels = state.labels;
2484 self.edge_props = state.edge_props;
2485 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2486 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2487 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2488 // open successfully and panic later in `NodeRef::label()` or
2489 // `neighborhood_masked()`. Catching it here turns those into typed
2490 // `GraphError::Corrupt` at open time.
2491 {
2492 let ids_len = self.ids.len();
2493 if self.labels.len() != ids_len {
2494 return Err(GraphError::Corrupt {
2495 detail: format!(
2496 "snapshot: labels vec has {} entries but id table has {} total slots",
2497 self.labels.len(),
2498 ids_len,
2499 ),
2500 });
2501 }
2502 let syms_len = self.syms.len() as u32;
2503 for (i, &sym) in self.labels.iter().enumerate() {
2504 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2505 if sym == u32::MAX {
2506 if !is_tombstoned {
2507 return Err(GraphError::Corrupt {
2508 detail: format!(
2509 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2510 ),
2511 });
2512 }
2513 } else if sym >= syms_len {
2514 return Err(GraphError::Corrupt {
2515 detail: format!(
2516 "snapshot: label at id slot {i} references sym {sym} \
2517 which is out of interner range ({syms_len})"
2518 ),
2519 });
2520 }
2521 }
2522 }
2523 let defs: Vec<RuleDef> = state
2524 .rule_defs
2525 .iter()
2526 .map(|b| {
2527 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2528 detail: format!("snapshot rule_def deserialize: {e}"),
2529 })
2530 })
2531 .collect::<Result<Vec<_>>>()?;
2532 self.engine =
2533 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2534 // Candidate indexes are rebuilt lazily on the first mutation (see
2535 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2536 // snapshot are retained without deserializing so that:
2537 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2538 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2539 // lazy-init guard which calls reindex_all_load_state (the scan
2540 // skips the HNSW build for every side the blob supplies).
2541 // - WAL-present: open_with calls consume_retained_state_eager before
2542 // replay so HNSW/IVF are live before any record fires the hooks.
2543 let ivf_bytes = if state.ivf_state.is_empty() {
2544 Vec::new()
2545 } else {
2546 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2547 };
2548 // Store blobs without eagerly deserializing them.
2549 // `self.ids` is the snapshot's id table at this point — WAL replay has
2550 // not run — so its length is the line an interrupted build is detected
2551 // against.
2552 let snapshot_ids = self.ids.len() as u32;
2553 self.engine
2554 .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2555 // Restore view defs from snapshot (V5).
2556 // The ColumnStore already contains view values from the snapshot;
2557 // use restore_view (no collision check, no backfill) so the store
2558 // is aware of the definitions. rebuild_all runs after WAL replay.
2559 for def_bytes in &state.view_defs {
2560 let def: ViewDef =
2561 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2562 detail: format!("snapshot view_def deserialize: {e}"),
2563 })?;
2564 self.view_store
2565 .restore_view(def)
2566 .map_err(|e| GraphError::Corrupt {
2567 detail: format!("snapshot view restore: {e}"),
2568 })?;
2569 }
2570 Ok(())
2571 }
2572
2573 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2574 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2575 ///
2576 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2577 /// deserialization and view rebuild have access to all column data.
2578 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2579 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2580 detail: format!("v8: ids section: {e:?}"),
2581 })?);
2582 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2583 detail: format!("v8: syms section: {e:?}"),
2584 })?);
2585
2586 // C1: self.props is left as an empty overlay. Column reads go through
2587 // props_view() (ColumnsView::with_base), which consults the archived base
2588 // section zero-copy. This avoids the O(columns) heap copy at every open.
2589
2590 // self.topo deliberately left as Topology::new() — overlay path.
2591
2592 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2593 detail: format!("v8: meta section: {e:?}"),
2594 })?)
2595 .map_err(|e| GraphError::Corrupt {
2596 detail: format!("v8: meta decode: {e:?}"),
2597 })?;
2598 self.labels = meta.labels;
2599 // Cross-section label integrity: labels must cover every id slot (live
2600 // and tombstoned), every non-sentinel sym must be within the interner's
2601 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2602 // sentinel label. Without this check, a crafted snapshot where the META
2603 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2604 // sym ids, or a sentinel label on a live node, would open successfully
2605 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2606 // related read paths. Catching the inconsistency here converts those
2607 // panics into typed `GraphError::Corrupt` at open time.
2608 {
2609 let ids_len = self.ids.len();
2610 if self.labels.len() != ids_len {
2611 return Err(GraphError::Corrupt {
2612 detail: format!(
2613 "v8: labels section has {} entries but id table has {} total slots",
2614 self.labels.len(),
2615 ids_len,
2616 ),
2617 });
2618 }
2619 let syms_len = self.syms.len() as u32;
2620 for (i, &sym) in self.labels.iter().enumerate() {
2621 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2622 if sym == u32::MAX {
2623 // Sentinel is only valid for tombstoned slots.
2624 if !is_tombstoned {
2625 return Err(GraphError::Corrupt {
2626 detail: format!(
2627 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2628 ),
2629 });
2630 }
2631 } else if sym >= syms_len {
2632 return Err(GraphError::Corrupt {
2633 detail: format!(
2634 "v8: label at id slot {i} references sym {sym} \
2635 which is out of interner range ({syms_len})"
2636 ),
2637 });
2638 }
2639 }
2640 }
2641 // C3: self.edge_props stays as an empty overlay. Reads go through
2642 // edge_props_view() which consults the mmap'd base section zero-copy
2643 // via EdgePropsView::with_base. No heap decode at open time.
2644
2645 // Restore rule engine.
2646 let (rule_def_bytes, rule_tripped, rule_fires) =
2647 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2648 GraphError::Corrupt {
2649 detail: format!("v8: rules_meta section: {e:?}"),
2650 }
2651 })?);
2652 let defs: Vec<RuleDef> = rule_def_bytes
2653 .iter()
2654 .map(|b| {
2655 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2656 detail: format!("v8: rule_def deserialize: {e}"),
2657 })
2658 })
2659 .collect::<Result<Vec<_>>>()?;
2660 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2661 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2662 // `ensure_v8_base_sections_loaded` reads them on first use from
2663 // `self.base` (set by the caller immediately after this returns).
2664 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2665
2666 // Restore view definitions.
2667 let view_defs =
2668 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2669 detail: format!("v8: views section: {e:?}"),
2670 })?);
2671 for def_bytes in &view_defs {
2672 let def: ViewDef =
2673 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2674 detail: format!("v8: view_def deserialize: {e}"),
2675 })?;
2676 self.view_store
2677 .restore_view(def)
2678 .map_err(|e| GraphError::Corrupt {
2679 detail: format!("v8: view restore: {e}"),
2680 })?;
2681 }
2682 // Load the last-change map from section 11 (small section; load eagerly).
2683 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2684 // in that case and `decode_last_change_bytes` returns an empty map.
2685 let last_change_raw = mapped
2686 .last_change_bytes()
2687 .map_err(|e| GraphError::Corrupt {
2688 detail: format!("v8: last_change section: {e:?}"),
2689 })?;
2690 self.last_change = decode_last_change_bytes(last_change_raw);
2691
2692 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2693 // the file. Pure bounds check — no bytes read, no page faults triggered.
2694 // Catches truncated snapshots at open time before the lazy deferred reads.
2695 mapped.validate_section_bounds().map_err(|e| match e {
2696 GraphError::Corrupt { detail } => GraphError::Corrupt {
2697 detail: format!("v8: section bounds: {detail}"),
2698 },
2699 other => other,
2700 })?;
2701 Ok(())
2702 }
2703
2704 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2705 /// engine's retained fields on first call. Subsequent calls are a no-op
2706 /// (AtomicBool fast-path).
2707 ///
2708 /// Must be called before any code path that reads or mutates engine
2709 /// provenance, HNSW, or IVF state:
2710 /// - WAL replay (before `consume_retained_state_eager`)
2711 /// - First mutation (`log_then_apply_with`)
2712 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2713 /// - Snapshot (`snapshot_with`)
2714 ///
2715 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2716 fn ensure_v8_base_sections_loaded(&self) {
2717 use std::sync::atomic::Ordering;
2718 if self.v8_sections_loaded.load(Ordering::Acquire) {
2719 return;
2720 }
2721 let _guard = self
2722 .v8_sections_mutex
2723 .lock()
2724 .expect("v8 sections mutex poisoned");
2725 if self.v8_sections_loaded.load(Ordering::Acquire) {
2726 return; // another caller populated while we waited
2727 }
2728 let _t = std::time::Instant::now();
2729 if let Some(base) = &self.base {
2730 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2731 // Bounds are already validated at open time (restore_v8_base →
2732 // validate_section_bounds) — unreachable post-validate_section_bounds;
2733 // unwrap_or_default is a safety belt against impossible errors.
2734 let prov_bytes = base
2735 .provenance_raw_bytes()
2736 .map(|b| b.to_vec())
2737 .unwrap_or_default();
2738 self.engine.store_provenance_bytes(prov_bytes);
2739 // HNSW: decode rkyv blobs into owned map.
2740 let hnsw_state = base
2741 .hnsw_section()
2742 .map(archived_hnsw_to_owned)
2743 .unwrap_or_default();
2744 // IVF: raw bincode bytes; deserialized on first mutation/query.
2745 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2746 // Called before WAL replay on a WAL-present open (`open_with`) and
2747 // before any write on a clean one, so this is the snapshot's count.
2748 let snapshot_ids = self.ids.len() as u32;
2749 self.engine
2750 .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2751 }
2752 self.v8_sections_loaded.store(true, Ordering::Release);
2753 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2754 eprintln!(
2755 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2756 _t.elapsed()
2757 );
2758 }
2759 }
2760
2761 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2762 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2763 /// the full merged topology without going through `self.view()`.
2764 fn topo_view(&self) -> TopologyView<'_> {
2765 match self.base {
2766 None => TopologyView::owned(&self.topo),
2767 Some(ref base) => {
2768 // SAFETY: base lives as long as self; section bounds validated at open.
2769 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2770 let archived = base
2771 .topology()
2772 .expect("base topology section bounds validated at open");
2773 TopologyView::with_base(&self.topo, archived)
2774 }
2775 }
2776 }
2777
2778 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2779 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2780 /// overlay first, then fall through to the archived base section zero-copy.
2781 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2782 match self.base {
2783 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2784 Some(ref base) => {
2785 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2786 let archived = base
2787 .columns()
2788 .expect("base columns section bounds validated at open");
2789 core_storage::v8::seam::ColumnsView::with_base_cached(
2790 &self.props,
2791 archived,
2792 base.mixed_cache(),
2793 )
2794 .with_shared_strings(base_string_table(base))
2795 }
2796 }
2797 }
2798
2799 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2800 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2801 ///
2802 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2803 /// through to the archived base section zero-copy. Tombstones in the
2804 /// overlay mask deleted-from-base entries.
2805 fn edge_props_view(&self) -> EdgePropsView<'_> {
2806 match self.base {
2807 None => EdgePropsView::owned(&self.edge_props),
2808 Some(ref base) => {
2809 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2810 let archived = base
2811 .edge_props_section()
2812 .expect("base edge_props section bounds validated at open");
2813 EdgePropsView::with_base(&self.edge_props, archived)
2814 }
2815 }
2816 }
2817
2818 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2819 // An as-of view never writes and is pinned to one commit: it takes no
2820 // cross-process lock and does not follow later commits.
2821 let mut db = Self::new_empty(
2822 fs,
2823 OpenOptions {
2824 repair_wal: false,
2825 auto_migrate: false,
2826 read_only: true,
2827 },
2828 );
2829 db.pinned = true; // read_only is set after replay, but pinning is immediate
2830 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2831 db.archive_genesis_chain = db.fs.has_genesis_marker();
2832 // Same orphaned-archive cleanup as open_with: floor was written first
2833 // during pruning, so a crash may have left stale archives below floor.
2834 db.cleanup_orphaned_archives()?;
2835 // Collect archive frames (oldest-first) and live WAL frames.
2836 // Archives represent pre-snapshot history; the snapshot captures the
2837 // cumulative state at the time of archiving. Crash-window guarantee:
2838 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2839 // B: crash after rename, before new WAL → archive present, WAL
2840 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2841 // C: crash after new baseline WAL written → normal post-archive.
2842 let archive_ns = db.fs.list_archives()?;
2843 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2844 for n in &archive_ns {
2845 let arc_bytes = db.fs.read_archive(*n)?;
2846 let (arc_frames, _) = decode_all(&arc_bytes);
2847 archive_frames_all.extend(arc_frames);
2848 }
2849 let total_archive_frames = archive_frames_all.len() as u64;
2850
2851 let live_bytes = db.fs.read(FileId::Wal)?;
2852 let (live_records, _valid_len) = decode_all(&live_bytes);
2853 let total_surviving = total_archive_frames + live_records.len() as u64;
2854 // Global total including any pruned history below the horizon floor.
2855 let total = db.wal_horizon_floor + total_surviving;
2856
2857 // Horizon and range check.
2858 if commit < db.wal_horizon_floor {
2859 return Err(GraphError::CommitOutOfRange {
2860 commit,
2861 total,
2862 floor: db.wal_horizon_floor,
2863 });
2864 }
2865 if commit >= total {
2866 return Err(GraphError::CommitOutOfRange {
2867 commit,
2868 total,
2869 floor: db.wal_horizon_floor,
2870 });
2871 }
2872
2873 // Local index into surviving frames (0 = first frame of oldest archive).
2874 let local = commit - db.wal_horizon_floor;
2875
2876 if local < total_archive_frames {
2877 // Target commit is in an archive. Correct replay from empty state
2878 // is only possible when the archive chain is an uninterrupted
2879 // genesis chain (first archive taken from a fresh store, no prior
2880 // WAL truncation) and no archives have been pruned (floor == 0).
2881 //
2882 // If either condition is violated the prefix needed to reconstruct
2883 // the requested state is gone; refuse rather than return wrong data.
2884 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2885 return Err(GraphError::CommitOutOfRange {
2886 commit,
2887 total,
2888 floor: db.wal_horizon_floor,
2889 });
2890 }
2891 // Replay all archive frames up to and including the target commit
2892 // from an empty database state. Archives must be replayed in order
2893 // so that dense-id intern tables are built up correctly.
2894 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2895 db.apply(&rec)?;
2896 let _ = db.engine.drain_deltas();
2897 }
2898 } else {
2899 // Target commit is in the live WAL: load snapshot as base, then
2900 // replay the needed live WAL prefix.
2901 //
2902 // Base state: a truncating snapshot (wal_truncated=true) compacts
2903 // all pre-truncation / pre-archive commits. Dense-id records in
2904 // the live WAL reference ids/interns that the snapshot provides.
2905 // Peek 6 bytes (same pattern as open_with).
2906 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2907 let is_v8 = snap_header.len() >= 6
2908 && &snap_header[0..4] == b"GDB1"
2909 && matches!(
2910 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2911 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2912 );
2913 if is_v8 {
2914 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2915 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2916 GraphError::Corrupt {
2917 detail: format!("v8: open_at mmap: {e:?}"),
2918 }
2919 })?;
2920 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2921 } else {
2922 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2923 core_storage::snapshot::decode(&snap_bytes)?
2924 };
2925 if let Some(state) = state {
2926 if state.wal_truncated {
2927 db.restore_snapshot_state(state)?;
2928 }
2929 }
2930 } else if !snap_header.is_empty() {
2931 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2932 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2933 if state.wal_truncated {
2934 db.restore_snapshot_state(state)?;
2935 }
2936 }
2937 }
2938 // else: snap_header empty = no snapshot file.
2939 let live_local = local - total_archive_frames;
2940 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2941 db.apply(&rec)?;
2942 let _ = db.engine.drain_deltas();
2943 }
2944 }
2945 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2946 // post-loop assert in open_with.
2947 debug_assert_eq!(
2948 db.engine.pending_delta_count(),
2949 0,
2950 "pending_deltas non-empty after open_at replay — \
2951 per-frame drain must run inside the loop to keep memory O(1)"
2952 );
2953 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2954 // Rebuild view values after WAL replay so derived-edge-driven views
2955 // reflect the as-of state. open_at always uses the legacy path (no V8
2956 // base), so topo_view is always owned.
2957 {
2958 let topo_view = TopologyView::owned(&db.topo);
2959 db.view_store
2960 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2961 }
2962 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2963 db.fulltext.rebuild_all(
2964 &db.ids,
2965 &db.labels,
2966 &db.syms,
2967 build_props_view(&db.props, &db.base),
2968 );
2969 db.prop_index.rebuild_all(
2970 &db.ids,
2971 &db.labels,
2972 &db.syms,
2973 build_props_view(&db.props, &db.base),
2974 );
2975 // Namespaces on the temporal handle, built by the same pass the live
2976 // open uses, so an as-of mask narrows by the namespaces of that commit.
2977 db.rebuild_node_ns();
2978 // Load roles sidecar (current roles, not point-in-time).
2979 db.roles = Self::load_roles_from_fs(&db.fs)?;
2980 db.read_only = true;
2981 db.total_wal_commits = total;
2982 // Capture initial fold so reader() is immediately usable.
2983 db.fold_now();
2984 Ok(db)
2985 }
2986
2987 /// Whether this instance is a read-only as-of view.
2988 pub fn is_read_only(&self) -> bool {
2989 self.read_only
2990 }
2991
2992 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2993
2994 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2995 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2996 /// the end of `open_with` / `open_at_with` to prime the reader.
2997 fn fold_now(&mut self) {
2998 let frozen = crate::reader::FrozenOverlay {
2999 ids: self.ids.clone(),
3000 syms: self.syms.clone(),
3001 topo: self.topo.clone(),
3002 props: self.props.clone(),
3003 labels: self.labels.clone(),
3004 edge_props: self.edge_props.clone(),
3005 roles: self.roles.clone(),
3006 fulltext: self.fulltext.clone(),
3007 };
3008 self.fold_overlay = Some(Arc::new(frozen));
3009 self.delta_tail.clear();
3010 self.commits_since_fold = 0;
3011 }
3012
3013 /// Capture a lock-free reader snapshot of the current db state.
3014 ///
3015 /// The read lock is held only for the duration of this call (to clone a
3016 /// handful of `Arc` handles). Subsequent query operations run without any
3017 /// lock.
3018 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3019 crate::reader::ReaderSnapshot::new(
3020 self.fold_overlay
3021 .clone()
3022 .expect("fold_overlay is always Some after open_with; call reader() after open"),
3023 self.base.clone(),
3024 self.delta_tail.clone(),
3025 // The snapshot's effective state is exactly this handle's state at
3026 // this commit, so it shares the memo and its version key.
3027 self.commit_seq,
3028 Arc::clone(&self.role_masks),
3029 )
3030 }
3031
3032 /// Total number of WAL commits at the time [`open_at`] was called.
3033 /// Returns 0 for normal (non-as-of) instances.
3034 pub fn total_wal_commits(&self) -> u64 {
3035 self.total_wal_commits
3036 }
3037
3038 /// Apply a record to in-memory state. Used by both live writes and replay,
3039 /// so replay is definitionally identical to the original execution.
3040 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3041 // Before the record mutates anything: a store restored from a snapshot
3042 // defers building its candidate indexes until the first write, and that
3043 // build is a full node scan. Left where it used to fire — inside the
3044 // engine hook, after `props.set` and the label assignment — the scan
3045 // read the half-applied record and took the in-flight node's vector for
3046 // one the snapshot should have carried, which read as an interrupted
3047 // vector-index build and cost a full `RebuildRule` on the first
3048 // embedded write after every reopen. Hoisted here the scan sees exactly
3049 // the persisted state; the record's own hook then files its vector
3050 // through the ordinary insert path a line later.
3051 self.populate_indexes_before_write();
3052 match rec {
3053 WalRecord::InsertNode { label, key, props } => {
3054 let id = self.ids.try_insert(key)?;
3055 let sym = self.syms.intern(label);
3056 if self.labels.len() <= id as usize {
3057 // gap slots are sentinels, never valid label symbols
3058 self.labels.resize(id as usize + 1, u32::MAX);
3059 }
3060 self.labels[id as usize] = sym;
3061 let mut ns_name = NS_DEFAULT.to_string();
3062 for (field, value) in props {
3063 if field == NS_PROP {
3064 ns_name = namespace_of_value(Some(value)).to_string();
3065 }
3066 self.props.set(id, field, value.clone());
3067 }
3068 self.set_node_ns(id, &ns_name);
3069 // Initialize view values for the new node before the engine runs so
3070 // delta-based increments start from a known zero baseline.
3071 self.view_store
3072 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3073 // Fire rules for the newly inserted node.
3074 let cursor = self.engine.pending_delta_count();
3075 let mut eng = std::mem::take(&mut self.engine);
3076 {
3077 let mut gm = make_graph_mut(
3078 &self.ids,
3079 &mut self.syms,
3080 &self.labels,
3081 build_props_view(&self.props, &self.base),
3082 &mut self.topo,
3083 &self.base,
3084 &mut self.edge_props,
3085 );
3086 eng.on_node_changed(id, None, &mut gm);
3087 }
3088 self.engine = eng;
3089 // Process derived-edge deltas for view maintenance.
3090 // Fast path: skip the O(delta_count) allocation when no views exist.
3091 if !self.view_store.is_empty() {
3092 #[cfg(test)]
3093 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3094 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3095 for d in &new_deltas {
3096 self.view_store.on_edge_changed(
3097 d.etype_sym,
3098 d.src_id,
3099 d.dst_id,
3100 d.fired,
3101 &mut self.props,
3102 &build_topo_view(&self.topo, &self.base),
3103 &self.ids,
3104 &self.syms,
3105 &self.labels,
3106 base_columns(&self.base),
3107 );
3108 }
3109 }
3110 // Full-text index maintenance: index enabled fields for this label.
3111 if self.fulltext.has_label(label) {
3112 for (field, value) in props {
3113 if self.fulltext.is_enabled(label, field) {
3114 self.fulltext.add_tokens(id, field, value);
3115 }
3116 }
3117 }
3118 // Property (equality) index maintenance.
3119 if self.prop_index.has_label(label) {
3120 for (field, value) in props {
3121 self.prop_index.set(label, field, id, value);
3122 }
3123 }
3124 }
3125 WalRecord::InsertEdge {
3126 edge_type,
3127 src_key,
3128 dst_key,
3129 } => {
3130 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3131 detail: format!("wal replay references unknown key {src_key}"),
3132 })?;
3133 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3134 detail: format!("wal replay references unknown key {dst_key}"),
3135 })?;
3136 let etype = self.syms.intern(edge_type);
3137 // Skip if the edge is already visible in the merged base+overlay
3138 // view. This keeps WAL replay idempotent when the WAL contains
3139 // pre-snapshot records that are already encoded in a V8 base
3140 // (keep_wal=true opens and crash-before-truncation scenarios).
3141 if self.base.is_some()
3142 && self
3143 .topo_view()
3144 .neighbors(etype, Direction::Out, src)
3145 .contains(&dst)
3146 {
3147 return Ok(());
3148 }
3149 self.topo.add_edge(etype, src, dst);
3150 // View maintenance for manual edge insert.
3151 self.view_store.on_edge_changed(
3152 etype,
3153 src,
3154 dst,
3155 true,
3156 &mut self.props,
3157 &build_topo_view(&self.topo, &self.base),
3158 &self.ids,
3159 &self.syms,
3160 &self.labels,
3161 base_columns(&self.base),
3162 );
3163 // Rule engine: via-hop rules must update when user edges change.
3164 let cursor = self.engine.pending_delta_count();
3165 let mut eng = std::mem::take(&mut self.engine);
3166 {
3167 let mut gm = make_graph_mut(
3168 &self.ids,
3169 &mut self.syms,
3170 &self.labels,
3171 build_props_view(&self.props, &self.base),
3172 &mut self.topo,
3173 &self.base,
3174 &mut self.edge_props,
3175 );
3176 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3177 }
3178 self.engine = eng;
3179 if !self.view_store.is_empty() {
3180 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3181 for d in &new_deltas {
3182 self.view_store.on_edge_changed(
3183 d.etype_sym,
3184 d.src_id,
3185 d.dst_id,
3186 d.fired,
3187 &mut self.props,
3188 &build_topo_view(&self.topo, &self.base),
3189 &self.ids,
3190 &self.syms,
3191 &self.labels,
3192 base_columns(&self.base),
3193 );
3194 }
3195 }
3196 }
3197 WalRecord::SetProp { key, field, value } => {
3198 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3199 detail: format!("wal replay references unknown key {key}"),
3200 })?;
3201 let old_value = build_props_view(&self.props, &self.base)
3202 .get(id, field)
3203 .map(|vr| vr.into_value());
3204 self.props.set(id, field, value.clone());
3205 // Fire rules for the changed field.
3206 let cursor = self.engine.pending_delta_count();
3207 let mut eng = std::mem::take(&mut self.engine);
3208 {
3209 let mut gm = make_graph_mut(
3210 &self.ids,
3211 &mut self.syms,
3212 &self.labels,
3213 build_props_view(&self.props, &self.base),
3214 &mut self.topo,
3215 &self.base,
3216 &mut self.edge_props,
3217 );
3218 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3219 }
3220 self.engine = eng;
3221 // Derived-edge deltas → view updates.
3222 if !self.view_store.is_empty() {
3223 #[cfg(test)]
3224 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3225 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3226 for d in &new_deltas {
3227 self.view_store.on_edge_changed(
3228 d.etype_sym,
3229 d.src_id,
3230 d.dst_id,
3231 d.fired,
3232 &mut self.props,
3233 &build_topo_view(&self.topo, &self.base),
3234 &self.ids,
3235 &self.syms,
3236 &self.labels,
3237 base_columns(&self.base),
3238 );
3239 }
3240 }
3241 // Neighbor-aggregate views that read `field` must also update.
3242 self.view_store.on_prop_changed(
3243 id,
3244 field,
3245 &mut self.props,
3246 &build_topo_view(&self.topo, &self.base),
3247 &self.ids,
3248 &self.syms,
3249 &self.labels,
3250 base_columns(&self.base),
3251 );
3252 // Full-text index maintenance: update tokens for this field if indexed.
3253 if self.fulltext.field_indexed(field) {
3254 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3255 if sym == u32::MAX {
3256 None
3257 } else {
3258 self.syms.resolve(sym)
3259 }
3260 });
3261 if let Some(label) = label_opt {
3262 if self.fulltext.is_enabled(label, field) {
3263 self.fulltext.remove_node_field(id, field);
3264 self.fulltext.add_tokens(id, field, value);
3265 }
3266 }
3267 }
3268 // Property (equality) index maintenance: re-key this node's value.
3269 if self.prop_index.field_indexed(field) {
3270 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3271 if sym == u32::MAX {
3272 None
3273 } else {
3274 self.syms.resolve(sym)
3275 }
3276 });
3277 if let Some(label) = label_opt {
3278 self.prop_index.set(label, field, id, value);
3279 }
3280 }
3281 }
3282 WalRecord::Intern { id, text } => {
3283 if let Some(existing) = self.syms.get(text) {
3284 if existing != *id {
3285 return Err(GraphError::Corrupt {
3286 detail: format!(
3287 "wal intern mismatch for {text:?}: have {existing}, record {id}"
3288 ),
3289 });
3290 }
3291 } else {
3292 let got = self.syms.intern(text);
3293 if got != *id {
3294 return Err(GraphError::Corrupt {
3295 detail: format!(
3296 "wal intern assigned {got} for {text:?}, record wanted {id}"
3297 ),
3298 });
3299 }
3300 }
3301 }
3302 WalRecord::InsertNodeId { label, key, props } => {
3303 let id = self.ids.try_insert(key)?;
3304 if self.labels.len() <= id as usize {
3305 self.labels.resize(id as usize + 1, u32::MAX);
3306 }
3307 self.labels[id as usize] = *label;
3308 let label_str = self
3309 .syms
3310 .resolve(*label)
3311 .ok_or_else(|| GraphError::Corrupt {
3312 detail: format!("wal InsertNodeId unknown label intern {label}"),
3313 })?
3314 .to_string();
3315 let mut ns_name = NS_DEFAULT.to_string();
3316 for (field_sym, value) in props {
3317 let field =
3318 self.syms
3319 .resolve(*field_sym)
3320 .ok_or_else(|| GraphError::Corrupt {
3321 detail: format!(
3322 "wal InsertNodeId unknown field intern {field_sym}"
3323 ),
3324 })?;
3325 if field == NS_PROP {
3326 ns_name = namespace_of_value(Some(value)).to_string();
3327 }
3328 self.props.set(id, field, value.clone());
3329 }
3330 self.set_node_ns(id, &ns_name);
3331 self.view_store
3332 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3333 let cursor = self.engine.pending_delta_count();
3334 let mut eng = std::mem::take(&mut self.engine);
3335 {
3336 let mut gm = make_graph_mut(
3337 &self.ids,
3338 &mut self.syms,
3339 &self.labels,
3340 build_props_view(&self.props, &self.base),
3341 &mut self.topo,
3342 &self.base,
3343 &mut self.edge_props,
3344 );
3345 eng.on_node_changed(id, None, &mut gm);
3346 }
3347 self.engine = eng;
3348 if !self.view_store.is_empty() {
3349 #[cfg(test)]
3350 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3351 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3352 for d in &new_deltas {
3353 self.view_store.on_edge_changed(
3354 d.etype_sym,
3355 d.src_id,
3356 d.dst_id,
3357 d.fired,
3358 &mut self.props,
3359 &build_topo_view(&self.topo, &self.base),
3360 &self.ids,
3361 &self.syms,
3362 &self.labels,
3363 base_columns(&self.base),
3364 );
3365 }
3366 }
3367 if self.fulltext.has_label(&label_str) {
3368 for (field_sym, value) in props {
3369 let Some(field) = self.syms.resolve(*field_sym) else {
3370 continue;
3371 };
3372 if self.fulltext.is_enabled(&label_str, field) {
3373 self.fulltext.add_tokens(id, field, value);
3374 }
3375 }
3376 }
3377 if self.prop_index.has_label(&label_str) {
3378 for (field_sym, value) in props {
3379 let Some(field) = self.syms.resolve(*field_sym) else {
3380 continue;
3381 };
3382 self.prop_index.set(&label_str, field, id, value);
3383 }
3384 }
3385 }
3386 WalRecord::InsertEdgeId { etype, src, dst } => {
3387 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3388 // already be tombstoned. Skip rather than attaching edges to
3389 // dead ids (DeleteNode keys the live re-insert, not the old id).
3390 if self.ids.is_tombstoned(*src)
3391 || self.ids.is_tombstoned(*dst)
3392 || self.ids.key_of(*src).is_none()
3393 || self.ids.key_of(*dst).is_none()
3394 {
3395 return Ok(());
3396 }
3397 // Skip if already visible in the merged view (same idempotency
3398 // guard as InsertEdge above: prevents double-counting when
3399 // pre-snapshot WAL records are replayed over a V8 base).
3400 if self.base.is_some()
3401 && self
3402 .topo_view()
3403 .neighbors(*etype, Direction::Out, *src)
3404 .contains(dst)
3405 {
3406 return Ok(());
3407 }
3408 self.topo.add_edge(*etype, *src, *dst);
3409 self.view_store.on_edge_changed(
3410 *etype,
3411 *src,
3412 *dst,
3413 true,
3414 &mut self.props,
3415 &build_topo_view(&self.topo, &self.base),
3416 &self.ids,
3417 &self.syms,
3418 &self.labels,
3419 base_columns(&self.base),
3420 );
3421 // Rule engine: via-hop rules fire when user via-edges are inserted.
3422 // Resolve etype back to string so on_edge_changed can match rules by name.
3423 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3424 let cursor = self.engine.pending_delta_count();
3425 let mut eng = std::mem::take(&mut self.engine);
3426 {
3427 let mut gm = make_graph_mut(
3428 &self.ids,
3429 &mut self.syms,
3430 &self.labels,
3431 build_props_view(&self.props, &self.base),
3432 &mut self.topo,
3433 &self.base,
3434 &mut self.edge_props,
3435 );
3436 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3437 }
3438 self.engine = eng;
3439 if !self.view_store.is_empty() {
3440 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3441 for d in &new_deltas {
3442 self.view_store.on_edge_changed(
3443 d.etype_sym,
3444 d.src_id,
3445 d.dst_id,
3446 d.fired,
3447 &mut self.props,
3448 &build_topo_view(&self.topo, &self.base),
3449 &self.ids,
3450 &self.syms,
3451 &self.labels,
3452 base_columns(&self.base),
3453 );
3454 }
3455 }
3456 }
3457 }
3458 WalRecord::SetPropId { id, field, value } => {
3459 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3460 return Ok(());
3461 }
3462 let field_str = self
3463 .syms
3464 .resolve(*field)
3465 .ok_or_else(|| GraphError::Corrupt {
3466 detail: format!("wal SetPropId unknown field intern {field}"),
3467 })?
3468 .to_string();
3469 let old_value = build_props_view(&self.props, &self.base)
3470 .get(*id, &field_str)
3471 .map(|vr| vr.into_value());
3472 self.props.set(*id, &field_str, value.clone());
3473 let cursor = self.engine.pending_delta_count();
3474 let mut eng = std::mem::take(&mut self.engine);
3475 {
3476 let mut gm = make_graph_mut(
3477 &self.ids,
3478 &mut self.syms,
3479 &self.labels,
3480 build_props_view(&self.props, &self.base),
3481 &mut self.topo,
3482 &self.base,
3483 &mut self.edge_props,
3484 );
3485 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3486 }
3487 self.engine = eng;
3488 if !self.view_store.is_empty() {
3489 #[cfg(test)]
3490 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3491 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3492 for d in &new_deltas {
3493 self.view_store.on_edge_changed(
3494 d.etype_sym,
3495 d.src_id,
3496 d.dst_id,
3497 d.fired,
3498 &mut self.props,
3499 &build_topo_view(&self.topo, &self.base),
3500 &self.ids,
3501 &self.syms,
3502 &self.labels,
3503 base_columns(&self.base),
3504 );
3505 }
3506 }
3507 self.view_store.on_prop_changed(
3508 *id,
3509 &field_str,
3510 &mut self.props,
3511 &build_topo_view(&self.topo, &self.base),
3512 &self.ids,
3513 &self.syms,
3514 &self.labels,
3515 base_columns(&self.base),
3516 );
3517 if self.fulltext.field_indexed(&field_str) {
3518 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3519 if sym == u32::MAX {
3520 None
3521 } else {
3522 self.syms.resolve(sym)
3523 }
3524 });
3525 if let Some(label) = label_opt {
3526 if self.fulltext.is_enabled(label, &field_str) {
3527 self.fulltext.remove_node_field(*id, &field_str);
3528 self.fulltext.add_tokens(*id, &field_str, value);
3529 }
3530 }
3531 }
3532 if self.prop_index.field_indexed(&field_str) {
3533 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3534 if sym == u32::MAX {
3535 None
3536 } else {
3537 self.syms.resolve(sym)
3538 }
3539 });
3540 if let Some(label) = label_opt {
3541 self.prop_index.set(label, &field_str, *id, value);
3542 }
3543 }
3544 }
3545 WalRecord::CreateRule { def_bytes } => {
3546 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3547 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3548 })?;
3549 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3550 // so the engine already has it; silently skip to avoid a spurious
3551 // RuleInvalid error in the crash window between snapshot write and WAL
3552 // truncation.
3553 if self.engine.rules().any(|r| r.name == def.name) {
3554 return Ok(());
3555 }
3556 let cursor = self.engine.pending_delta_count();
3557 let mut eng = std::mem::take(&mut self.engine);
3558 let result = {
3559 let mut gm = make_graph_mut(
3560 &self.ids,
3561 &mut self.syms,
3562 &self.labels,
3563 build_props_view(&self.props, &self.base),
3564 &mut self.topo,
3565 &self.base,
3566 &mut self.edge_props,
3567 );
3568 eng.create_rule(def, &mut gm)
3569 };
3570 self.engine = eng;
3571 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3572 // Derived-edge fires from backfill → view updates.
3573 // Fast path: skip O(edge_count) allocation when no views exist.
3574 if !self.view_store.is_empty() {
3575 #[cfg(test)]
3576 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3577 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3578 for d in &new_deltas {
3579 self.view_store.on_edge_changed(
3580 d.etype_sym,
3581 d.src_id,
3582 d.dst_id,
3583 d.fired,
3584 &mut self.props,
3585 &build_topo_view(&self.topo, &self.base),
3586 &self.ids,
3587 &self.syms,
3588 &self.labels,
3589 base_columns(&self.base),
3590 );
3591 }
3592 }
3593 }
3594 WalRecord::DeleteRule { name } => {
3595 // Replay-over-snapshot idempotency: the snapshot already captured the
3596 // post-delete state so the rule is absent; silently skip to avoid a
3597 // spurious RuleNotFound error in the crash window between snapshot write
3598 // and WAL truncation.
3599 if !self.engine.rules().any(|r| r.name == *name) {
3600 return Ok(());
3601 }
3602 let cursor = self.engine.pending_delta_count();
3603 let mut eng = std::mem::take(&mut self.engine);
3604 let result = {
3605 let mut gm = make_graph_mut(
3606 &self.ids,
3607 &mut self.syms,
3608 &self.labels,
3609 build_props_view(&self.props, &self.base),
3610 &mut self.topo,
3611 &self.base,
3612 &mut self.edge_props,
3613 );
3614 eng.delete_rule(name, &mut gm)
3615 };
3616 self.engine = eng;
3617 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3618 // Derived-edge retractions → view updates.
3619 if !self.view_store.is_empty() {
3620 #[cfg(test)]
3621 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3622 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3623 for d in &new_deltas {
3624 self.view_store.on_edge_changed(
3625 d.etype_sym,
3626 d.src_id,
3627 d.dst_id,
3628 d.fired,
3629 &mut self.props,
3630 &build_topo_view(&self.topo, &self.base),
3631 &self.ids,
3632 &self.syms,
3633 &self.labels,
3634 base_columns(&self.base),
3635 );
3636 }
3637 }
3638 }
3639 WalRecord::RemoveProp { key, field } => {
3640 // Recovery-safe: unknown key or already-absent field is a
3641 // clean no-op. Crash-window replay over a snapshot that
3642 // already applied this record must not Err.
3643 let Some(id) = self.ids.get(key) else {
3644 return Ok(());
3645 };
3646 // Read old value through the seam for rule retraction.
3647 let old = build_props_view(&self.props, &self.base)
3648 .get(id, field)
3649 .map(|vr| vr.into_value());
3650 self.props.remove(id, field);
3651 // If the base still supplies the value after the overlay removal,
3652 // record a tombstone so ColumnsView::get does not resurrect it.
3653 // This covers both the base-only case AND the both-resident case:
3654 // base-only (in_overlay=false): old prop was only in base, remove
3655 // is a no-op on overlay, base still visible → tombstone needed.
3656 // both-resident (in_overlay=true): overlay had v2, base has v1;
3657 // removing overlay uncovers v1 → tombstone needed.
3658 // Idempotent on double-replay: second pass sees the tombstone →
3659 // get() returns None → condition is false → no duplicate tombstone.
3660 if build_props_view(&self.props, &self.base)
3661 .get(id, field)
3662 .is_some()
3663 {
3664 self.props.record_prop_tombstone(id, field);
3665 }
3666 let cursor = self.engine.pending_delta_count();
3667 let mut eng = std::mem::take(&mut self.engine);
3668 {
3669 let mut gm = make_graph_mut(
3670 &self.ids,
3671 &mut self.syms,
3672 &self.labels,
3673 build_props_view(&self.props, &self.base),
3674 &mut self.topo,
3675 &self.base,
3676 &mut self.edge_props,
3677 );
3678 eng.on_node_changed(id, Some((field, old)), &mut gm);
3679 }
3680 self.engine = eng;
3681 // Derived-edge deltas → view updates.
3682 if !self.view_store.is_empty() {
3683 #[cfg(test)]
3684 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3685 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3686 for d in &new_deltas {
3687 self.view_store.on_edge_changed(
3688 d.etype_sym,
3689 d.src_id,
3690 d.dst_id,
3691 d.fired,
3692 &mut self.props,
3693 &build_topo_view(&self.topo, &self.base),
3694 &self.ids,
3695 &self.syms,
3696 &self.labels,
3697 base_columns(&self.base),
3698 );
3699 }
3700 }
3701 // Neighbor-aggregate views that read `field` must also update.
3702 self.view_store.on_prop_changed(
3703 id,
3704 field,
3705 &mut self.props,
3706 &build_topo_view(&self.topo, &self.base),
3707 &self.ids,
3708 &self.syms,
3709 &self.labels,
3710 base_columns(&self.base),
3711 );
3712 // Full-text index maintenance: remove tokens for this field.
3713 if self.fulltext.field_indexed(field) {
3714 self.fulltext.remove_node_field(id, field);
3715 }
3716 // Property (equality) index maintenance: drop this node's entry.
3717 if self.prop_index.field_indexed(field) {
3718 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3719 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3720 }) {
3721 self.prop_index.remove_node(label, field, id);
3722 }
3723 }
3724 }
3725 WalRecord::DeleteEdge {
3726 edge_type,
3727 src_key,
3728 dst_key,
3729 } => {
3730 // Recovery-safe: unknown keys, unknown etype, or already-
3731 // absent edge is a clean no-op (remove_edge returns false).
3732 let Some(src) = self.ids.get(src_key) else {
3733 return Ok(());
3734 };
3735 let Some(dst) = self.ids.get(dst_key) else {
3736 return Ok(());
3737 };
3738 let Some(etype) = self.syms.get(edge_type) else {
3739 return Ok(());
3740 };
3741 // I3: phantom-tombstone guard. When a V8 base is present, a
3742 // DeleteEdge WAL record for an edge that was already absorbed into
3743 // the new base (i.e. neither in overlay nor in base) must be skipped.
3744 // Without this guard, remove_edge records a tombstone for an edge
3745 // that no longer exists, incorrectly understating edge_count.
3746 if self.base.is_some()
3747 && !self
3748 .topo_view()
3749 .neighbors(etype, core_storage::topology::Direction::Out, src)
3750 .contains(&dst)
3751 {
3752 return Ok(());
3753 }
3754 self.topo.remove_edge(etype, src, dst);
3755 self.edge_props.remove_edge(etype, src, dst);
3756 // View maintenance for manual edge delete (topo already updated above).
3757 self.view_store.on_edge_changed(
3758 etype,
3759 src,
3760 dst,
3761 false,
3762 &mut self.props,
3763 &build_topo_view(&self.topo, &self.base),
3764 &self.ids,
3765 &self.syms,
3766 &self.labels,
3767 base_columns(&self.base),
3768 );
3769 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3770 let cursor = self.engine.pending_delta_count();
3771 let mut eng = std::mem::take(&mut self.engine);
3772 {
3773 let mut gm = make_graph_mut(
3774 &self.ids,
3775 &mut self.syms,
3776 &self.labels,
3777 build_props_view(&self.props, &self.base),
3778 &mut self.topo,
3779 &self.base,
3780 &mut self.edge_props,
3781 );
3782 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3783 }
3784 self.engine = eng;
3785 if !self.view_store.is_empty() {
3786 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3787 for d in &new_deltas {
3788 self.view_store.on_edge_changed(
3789 d.etype_sym,
3790 d.src_id,
3791 d.dst_id,
3792 d.fired,
3793 &mut self.props,
3794 &build_topo_view(&self.topo, &self.base),
3795 &self.ids,
3796 &self.syms,
3797 &self.labels,
3798 base_columns(&self.base),
3799 );
3800 }
3801 }
3802 }
3803 WalRecord::DeleteNode { key } => {
3804 // Recovery-safe: already-tombstoned / unknown key is a clean
3805 // no-op. Crash-window replay over a snapshot that already
3806 // applied this record cannot recover the retired id from the
3807 // key (`IdMap::get` is None), so every subsequent step is
3808 // skipped. Each step is independently idempotent if invoked
3809 // twice on a still-live id: retraction is a no-op on empty
3810 // provenance, `remove_edge` returns false, `remove_all` is a
3811 // no-op, `ids.delete` returns None, label sentinel is sticky.
3812 let Some(n) = self.ids.get(key) else {
3813 return Ok(());
3814 };
3815
3816 // (1) Retract derived edges + de-index while props/labels live.
3817 let cursor = self.engine.pending_delta_count();
3818 let mut eng = std::mem::take(&mut self.engine);
3819 {
3820 let mut gm = make_graph_mut(
3821 &self.ids,
3822 &mut self.syms,
3823 &self.labels,
3824 build_props_view(&self.props, &self.base),
3825 &mut self.topo,
3826 &self.base,
3827 &mut self.edge_props,
3828 );
3829 eng.on_node_removed(n, &mut gm);
3830 }
3831 self.engine = eng;
3832 // Derived-edge retractions → view updates for neighbors.
3833 if !self.view_store.is_empty() {
3834 #[cfg(test)]
3835 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3836 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3837 for d in &new_deltas {
3838 self.view_store.on_edge_changed(
3839 d.etype_sym,
3840 d.src_id,
3841 d.dst_id,
3842 d.fired,
3843 &mut self.props,
3844 &build_topo_view(&self.topo, &self.base),
3845 &self.ids,
3846 &self.syms,
3847 &self.labels,
3848 base_columns(&self.base),
3849 );
3850 }
3851 }
3852
3853 // (2) Sweep ALL remaining edges incident to n, both directions,
3854 // every etype. This cascade is intentionally mask-independent:
3855 // topology integrity requires removing every edge touching the
3856 // deleted node regardless of the caller's visibility scope.
3857 // (The mask limits which nodes a role's read phase can return;
3858 // the WAL delete always executes with full storage authority.)
3859 // Collect then remove so neighbor slices stay valid during
3860 // iteration. Remove from topo first, then call view maintenance
3861 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3862 let etypes: Vec<u32> = self.topo.etypes().collect();
3863 let mut doomed = Vec::new();
3864 for et in &etypes {
3865 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3866 doomed.push((*et, n, dst));
3867 }
3868 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3869 doomed.push((*et, src, n));
3870 }
3871 }
3872 for (et, s, d) in doomed {
3873 self.topo.remove_edge(et, s, d);
3874 self.edge_props.remove_edge(et, s, d);
3875 // View maintenance: n's own view values will be cleared by
3876 // remove_all below; only update surviving neighbors.
3877 self.view_store.on_edge_changed(
3878 et,
3879 s,
3880 d,
3881 false,
3882 &mut self.props,
3883 &build_topo_view(&self.topo, &self.base),
3884 &self.ids,
3885 &self.syms,
3886 &self.labels,
3887 base_columns(&self.base),
3888 );
3889 }
3890
3891 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3892 self.props.remove_all(n);
3893 // Full-text index maintenance: remove all tokens for this node.
3894 self.fulltext.remove_node(n);
3895 // Property (equality) index maintenance: drop all entries for n.
3896 self.prop_index.remove_node_all(n);
3897
3898 // (4) Retire the dense id and stamp the label sentinel.
3899 self.ids.delete(key);
3900 if let Some(slot) = self.labels.get_mut(n as usize) {
3901 *slot = u32::MAX;
3902 }
3903 }
3904 WalRecord::Batch(inner) => {
3905 // Apply each inner record in order through the same apply path.
3906 // Inner records are validated free of nested Batch by encode_record.
3907 for rec in inner {
3908 self.apply(rec)?;
3909 }
3910 }
3911 WalRecord::RebuildRule { name } => {
3912 // Replay-over-snapshot idempotency: the snapshot may already
3913 // reflect a later delete_rule, so the rule is absent; skip.
3914 if !self.engine.rules().any(|r| r.name == *name) {
3915 return Ok(());
3916 }
3917 let cursor = self.engine.pending_delta_count();
3918 let mut eng = std::mem::take(&mut self.engine);
3919 let result = {
3920 let mut gm = make_graph_mut(
3921 &self.ids,
3922 &mut self.syms,
3923 &self.labels,
3924 build_props_view(&self.props, &self.base),
3925 &mut self.topo,
3926 &self.base,
3927 &mut self.edge_props,
3928 );
3929 eng.rebuild(name, &mut gm)
3930 };
3931 self.engine = eng;
3932 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3933 // Derived-edge delta changes → view updates.
3934 if !self.view_store.is_empty() {
3935 #[cfg(test)]
3936 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3937 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3938 for d in &new_deltas {
3939 self.view_store.on_edge_changed(
3940 d.etype_sym,
3941 d.src_id,
3942 d.dst_id,
3943 d.fired,
3944 &mut self.props,
3945 &build_topo_view(&self.topo, &self.base),
3946 &self.ids,
3947 &self.syms,
3948 &self.labels,
3949 base_columns(&self.base),
3950 );
3951 }
3952 }
3953 }
3954 WalRecord::CreateView { def_bytes } => {
3955 let def: ViewDef =
3956 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3957 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3958 })?;
3959 // Replay-over-snapshot idempotency: view already present → skip.
3960 if self.view_store.has_view(&def.name) {
3961 return Ok(());
3962 }
3963 self.view_store
3964 .create_view(
3965 def,
3966 &mut self.props,
3967 &build_topo_view(&self.topo, &self.base),
3968 &self.ids,
3969 &self.syms,
3970 &self.labels,
3971 )
3972 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3973 }
3974 WalRecord::DeleteView { name } => {
3975 // Replay-over-snapshot idempotency: view already absent → skip.
3976 if !self.view_store.has_view(name) {
3977 return Ok(());
3978 }
3979 self.view_store
3980 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3981 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3982 }
3983 WalRecord::EnableFulltext { label, field } => {
3984 // Replay-over-snapshot idempotency: already enabled → skip.
3985 if self.fulltext.is_enabled(label, field) {
3986 return Ok(());
3987 }
3988 self.fulltext.enable(label, field);
3989 // Backfill: index all live nodes of this label that have the field.
3990 let n = self.ids.len() as u32;
3991 for id in 0..n {
3992 let Some(&sym) = self.labels.get(id as usize) else {
3993 continue;
3994 };
3995 if sym == u32::MAX {
3996 continue; // tombstoned
3997 }
3998 let Some(lbl) = self.syms.resolve(sym) else {
3999 continue;
4000 };
4001 if lbl != label {
4002 continue;
4003 }
4004 if let Some(value) = build_props_view(&self.props, &self.base)
4005 .get(id, field)
4006 .map(|vr| vr.into_value())
4007 {
4008 self.fulltext.add_tokens(id, field, &value);
4009 }
4010 }
4011 }
4012 WalRecord::DisableFulltext { label, field } => {
4013 // Replay-over-snapshot idempotency: already disabled → skip.
4014 if !self.fulltext.is_enabled(label, field) {
4015 return Ok(());
4016 }
4017 // If another label still indexes this field, the postings column
4018 // is kept — but it must not contain node_ids from the now-disabled
4019 // label. Remove them before calling disable() so the field_indexed
4020 // guard inside disable() sees the correct post-removal state.
4021 if self.fulltext.field_indexed_by_other(label, field) {
4022 if let Some(label_sym) = self.syms.get(label) {
4023 for (node_id, &lsym) in self.labels.iter().enumerate() {
4024 if lsym == label_sym {
4025 self.fulltext.remove_node_field(node_id as u32, field);
4026 }
4027 }
4028 }
4029 }
4030 self.fulltext.disable(label, field);
4031 }
4032 WalRecord::EnableIndex { label, field } => {
4033 // Replay-over-snapshot idempotency: already enabled → skip.
4034 if self.prop_index.is_enabled(label, field) {
4035 return Ok(());
4036 }
4037 self.prop_index.enable(label, field);
4038 // Backfill: index all live nodes of this label that have the field.
4039 let n = self.ids.len() as u32;
4040 for id in 0..n {
4041 let Some(&sym) = self.labels.get(id as usize) else {
4042 continue;
4043 };
4044 if sym == u32::MAX {
4045 continue; // tombstoned
4046 }
4047 let Some(lbl) = self.syms.resolve(sym) else {
4048 continue;
4049 };
4050 if lbl != label {
4051 continue;
4052 }
4053 if let Some(value) = build_props_view(&self.props, &self.base)
4054 .get(id, field)
4055 .map(|vr| vr.into_value())
4056 {
4057 self.prop_index.set(label, field, id, &value);
4058 }
4059 }
4060 }
4061 WalRecord::DisableIndex { label, field } => {
4062 self.prop_index.disable(label, field);
4063 }
4064 // History markers carry no replay state — rules re-derive edges
4065 // deterministically on open/replay. Skip unconditionally.
4066 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4067 // ── rename_node ──────────────────────────────────────────────────
4068 WalRecord::RenameNode { old_key, new_key } => {
4069 // Recovery-safe: if old_key is already gone (key was renamed
4070 // by a snapshot or a prior replay frame), skip cleanly.
4071 if self.ids.get(old_key).is_none() {
4072 return Ok(());
4073 }
4074 // The rename only updates the key-table; the dense id, all
4075 // topo edges, props, labels, and rule state are id-indexed and
4076 // require no change.
4077 self.ids
4078 .rename(old_key, new_key)
4079 .map_err(|e| GraphError::Corrupt {
4080 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4081 })?;
4082 }
4083 }
4084 Ok(())
4085 }
4086
4087 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4088 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4089 /// idempotent when the string is already bound. Always emit: after
4090 /// `snapshot()` the WAL is truncated and live intern is not on disk.
4091 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4092 let id = if let Some(id) = self.syms.get(s) {
4093 id
4094 } else {
4095 self.syms.intern(s)
4096 };
4097 (
4098 id,
4099 WalRecord::Intern {
4100 id,
4101 text: s.to_string(),
4102 },
4103 )
4104 }
4105
4106 /// Rewrite user-facing records into dense-id records. On `Err`, no live
4107 /// state is left mutated: speculative interns made while building the
4108 /// output are rolled back, so a later successful mutation cannot log an
4109 /// `Intern` record whose id replay would never reproduce.
4110 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4111 let syms_checkpoint = self.syms.len();
4112 let result = self.rewrite_wal_dense_inner(recs);
4113 if result.is_err() {
4114 self.syms.truncate(syms_checkpoint);
4115 }
4116 result
4117 }
4118
4119 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4120 let mut out = Vec::with_capacity(recs.len());
4121 // Node ids allocated by later apply(InsertNodeId) in this same batch.
4122 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4123 // Namespace of each node inserted earlier in this same frame, so a SET
4124 // on a node this frame created is measured against the namespace it was
4125 // created in rather than against the store, where it does not exist yet.
4126 let mut pending_ns: std::collections::HashMap<String, String> =
4127 std::collections::HashMap::new();
4128 let mut interned = std::collections::HashSet::<u32>::new();
4129 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4130 detail: "id space exhausted".into(),
4131 })?;
4132 let lookup = |ids: &IdMap,
4133 pending: &std::collections::HashMap<String, u32>,
4134 key: &str|
4135 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4136 for rec in recs {
4137 match rec {
4138 WalRecord::InsertNode { label, key, props } => {
4139 // Namespace validation and normalisation, on the one seam
4140 // every user-visible node insert passes through: insert_node,
4141 // a batch, ingest, Cypher CREATE and MERGE all arrive here
4142 // before the WAL append, and replay never does.
4143 let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4144 pending_ns.insert(key.clone(), ns_name);
4145 let (label_id, intern) = self.intern_wal(&label);
4146 if interned.insert(label_id) {
4147 out.push(intern);
4148 }
4149 let mut props_id = Vec::with_capacity(props.len());
4150 for (field, value) in props {
4151 let (field_id, intern) = self.intern_wal(&field);
4152 if interned.insert(field_id) {
4153 out.push(intern);
4154 }
4155 props_id.push((field_id, value));
4156 }
4157 if lookup(&self.ids, &pending, &key).is_none() {
4158 pending.insert(key.clone(), next);
4159 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4160 detail: "id space exhausted".into(),
4161 })?;
4162 }
4163 out.push(WalRecord::InsertNodeId {
4164 label: label_id,
4165 key,
4166 props: props_id,
4167 });
4168 }
4169 WalRecord::SetProp { key, field, value } => {
4170 // A namespace is set at insert and fixed after: the write is
4171 // refused when it would move the node, and dropped when it
4172 // names the namespace the node is already in. Checked here
4173 // so set_prop, a batch, Cypher SET/MERGE and every upsert
4174 // that merges props get the same answer.
4175 if field == NS_PROP {
4176 let Value::Str(ref to) = value else {
4177 return Err(GraphError::RuleInvalid {
4178 detail: format!(
4179 "node {key}: {NS_PROP} must be a string naming a namespace, \
4180 got {value:?}"
4181 ),
4182 });
4183 };
4184 let from = pending_ns
4185 .get(&key)
4186 .cloned()
4187 .or_else(|| self.namespace_of(&key))
4188 .unwrap_or_else(|| NS_DEFAULT.to_string());
4189 let to = to.clone();
4190 if to != from {
4191 return Err(GraphError::NamespaceImmutable {
4192 key: key.clone(),
4193 from,
4194 to,
4195 });
4196 }
4197 continue;
4198 }
4199 let id =
4200 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4201 detail: format!("dense WAL rewrite missing key {key}"),
4202 })?;
4203 let (field_id, intern) = self.intern_wal(&field);
4204 if interned.insert(field_id) {
4205 out.push(intern);
4206 }
4207 out.push(WalRecord::SetPropId {
4208 id,
4209 field: field_id,
4210 value,
4211 });
4212 }
4213 WalRecord::InsertEdge {
4214 edge_type,
4215 src_key,
4216 dst_key,
4217 } => {
4218 let (etype, intern) = self.intern_wal(&edge_type);
4219 if interned.insert(etype) {
4220 out.push(intern);
4221 }
4222 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4223 GraphError::Corrupt {
4224 detail: format!("dense WAL rewrite missing src {src_key}"),
4225 }
4226 })?;
4227 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4228 GraphError::Corrupt {
4229 detail: format!("dense WAL rewrite missing dst {dst_key}"),
4230 }
4231 })?;
4232 out.push(WalRecord::InsertEdgeId { etype, src, dst });
4233 }
4234 WalRecord::RenameNode {
4235 ref old_key,
4236 ref new_key,
4237 } => {
4238 // Track the rename in `pending` so subsequent InsertEdge /
4239 // SetProp records in this batch can resolve the new key.
4240 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4241 GraphError::Corrupt {
4242 detail: format!(
4243 "dense WAL rewrite: RenameNode old key {old_key} not found"
4244 ),
4245 }
4246 })?;
4247 pending.remove(old_key.as_str());
4248 pending.insert(new_key.clone(), id);
4249 out.push(rec);
4250 }
4251 // # Symbol-order invariant (load-bearing)
4252 //
4253 // Write-time and replay-time symbol assignment must agree: every
4254 // symbol in a `Batch` frame has to receive the same dense id when
4255 // the frame's records are replayed in order as it received when
4256 // the frame was written.
4257 //
4258 // A rule's backfill interns its `edge_type` lazily
4259 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4260 // site), and that backfill runs from `apply` — during the
4261 // `CreateRule` record itself, and again from any later
4262 // `InsertNodeId` in the same frame that makes the rule fire. At
4263 // write time the whole batch is rewritten before any of it is
4264 // applied, so a later `InsertEdge` in the same batch would win the
4265 // lower id for its edge type; on replay the rule's lazy intern
4266 // gets there first and steals it, and the `Intern` record fails at
4267 // the `wal intern assigned …` check in `apply`.
4268 //
4269 // Pre-interning the rule's `edge_type` here, and emitting its
4270 // `Intern` record ahead of the `CreateRule` record, makes both
4271 // orders identical. `weight_prop` needs no pre-intern:
4272 // `EdgeProps::set` keys props by `String`, never through the
4273 // interner. `via_edge` needs none either: via-hop rules resolve it
4274 // with `syms.get` and skip when it is absent.
4275 //
4276 // `RebuildRule` and `DeleteRule` need no such handling here:
4277 // `RebuildRule` has no `BatchOp` variant, so it never appears
4278 // inside a `Batch` today — it is only ever issued as its own
4279 // standalone commit (`rebuild_rule`, or the auto-rebuild path
4280 // that logs it as a second commit after the triggering op).
4281 // `DeleteRule` does have a `BatchOp` variant and can appear
4282 // inside a `Batch`, but it carries only a rule `name` — no
4283 // `edge_type` or other symbol that needs pre-interning — so
4284 // only `CreateRule` needs this arm.
4285 WalRecord::CreateRule { ref def_bytes } => {
4286 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4287 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4288 })?;
4289 let (etype, intern) = self.intern_wal(&def.edge_type);
4290 if interned.insert(etype) {
4291 out.push(intern);
4292 }
4293 out.push(rec);
4294 }
4295 other => out.push(other),
4296 }
4297 }
4298 Ok(out)
4299 }
4300
4301 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4302 let recs = self.rewrite_wal_dense(recs)?;
4303 match recs.len() {
4304 0 => Ok(()),
4305 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4306 _ => self.log_then_apply(WalRecord::Batch(recs)),
4307 }
4308 }
4309
4310 /// Durable write, then notify the event sink. Replay (`apply` during
4311 /// `open`) never enters this function, so it is the replay-silent seam.
4312 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4313 self.log_then_apply_with(rec, None, self.fsync)
4314 }
4315
4316 /// Whether this frame must fsync under `policy`.
4317 ///
4318 /// Batched contract: user-visible batches (>1 mutation) fsync; single
4319 /// mutations do not. The dense rewrite wraps a single mutation in a
4320 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4321 /// from the count — removing that filter would make every single-op write
4322 /// fsync under Batched (or, if the threshold were raised instead, skip a
4323 /// needed fsync for real two-op batches).
4324 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4325 match policy {
4326 FsyncPolicy::Relaxed => false,
4327 FsyncPolicy::Strict => true,
4328 FsyncPolicy::Batched => match rec {
4329 // Intern + one mutation is the single-op rewrite, not a user batch.
4330 WalRecord::Batch(inner) => {
4331 inner
4332 .iter()
4333 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4334 .count()
4335 > 1
4336 }
4337 _ => false,
4338 },
4339 }
4340 }
4341
4342 /// # Apply-infallibility invariant (load-bearing)
4343 ///
4344 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4345 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4346 /// the full frame while in-memory state would reflect only the ops before
4347 /// the failure. On reopen, WAL replay would then apply the entire batch —
4348 /// diverging permanently from what the pre-crash process had in memory.
4349 ///
4350 /// For `Batch` frames this situation cannot arise because:
4351 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4352 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4353 /// use, with no concurrent mutation between validation exit and apply entry.
4354 /// - Every `apply` arm for a validated op is either infallible by construction
4355 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4356 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4357 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4358 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4359 ///
4360 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4361 /// `Err` for a `Batch` frame, making any future regression immediately visible
4362 /// in tests rather than silently diverging crash-recovery behaviour.
4363 fn log_then_apply_with(
4364 &mut self,
4365 rec: WalRecord,
4366 ingest: Option<(String, usize)>,
4367 policy: FsyncPolicy,
4368 ) -> Result<()> {
4369 // Read-only guard: as-of instances must never write the WAL.
4370 if self.read_only {
4371 return Err(GraphError::ReadOnly);
4372 }
4373 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4374 // partway; in-memory state is ahead of (or out of step with) the
4375 // on-disk WAL, so further mutations would deepen the divergence.
4376 // Reopen the database to recover. Checked before the lock guard: this
4377 // is the more serious condition and the more useful error.
4378 if self.degraded {
4379 return Err(GraphError::Io(std::io::Error::other(
4380 "database degraded after group-commit fsync failure; reopen required",
4381 )));
4382 }
4383 // Cross-process guard: this write scope asked for the store's write
4384 // lock and did not get it. Writing anyway would append frames on top of
4385 // a WAL another process is extending, so refuse instead.
4386 if self.lock_denied {
4387 return Err(GraphError::Busy { holder: None });
4388 }
4389 // Ensure retained provenance bytes are decoded into the live mutable
4390 // fields before any mutation touches self.engine.provenance. This is a
4391 // no-op if provenance was never stored (fresh store) or has already been
4392 // consumed (subsequent mutations). WAL replay calls apply() directly
4393 // and is covered by consume_retained_state_eager before replay.
4394 self.ensure_v8_base_sections_loaded();
4395 self.engine.ensure_provenance_loaded_mut();
4396 // Invariant (I-1): no stale deltas may enter from a previous apply.
4397 // If any engine method ever accumulates deltas before erroring, they would
4398 // contaminate the *next* commit's event stream. This assert fires in debug
4399 // builds, making any future regression visible at the earliest point.
4400 debug_assert_eq!(
4401 self.engine.pending_delta_count(),
4402 0,
4403 "stale engine deltas at log_then_apply_with entry — \
4404 a previous apply arm may have accumulated deltas before erroring; \
4405 the caller must drain_deltas() on any error path before returning"
4406 );
4407 let frame = encode_record(&rec);
4408 self.fs.append(FileId::Wal, &frame)?;
4409 // The cursor advances by exactly the bytes appended: these frames are
4410 // ours and already applied, so a later refresh must not replay them.
4411 self.wal_consumed += frame.len() as u64;
4412 if Self::wal_needs_sync(policy, &rec) {
4413 self.fs.sync(FileId::Wal)?;
4414 }
4415 // Marker writing always needs the engine deltas, but the engine only
4416 // accumulates them when emit_deltas is true (normally gated on subscribers
4417 // or views being present). Enable emission for this apply if it is
4418 // currently off, then restore the original state unconditionally via an
4419 // RAII guard — this prevents a panic in apply() from leaking the flag.
4420 // The same guard resets the engine's transient chaining state. A panic
4421 // unwinding out of a rule hook would otherwise leave `chain_depth`
4422 // non-zero, which makes every later `begin_chain` decide chaining is
4423 // already running and silently switch it off for good.
4424 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4425 impl Drop for RestoreEmitDeltas {
4426 fn drop(&mut self) {
4427 // SAFETY: pointer into self (GraphDb); guard is dropped within
4428 // this frame before log_then_apply_with returns.
4429 unsafe {
4430 (*self.0).set_emit_deltas(self.1);
4431 (*self.0).reset_chain_state();
4432 }
4433 }
4434 }
4435 let original_emit = self.engine.emit_deltas();
4436 if !original_emit {
4437 self.engine.set_emit_deltas(true);
4438 }
4439 // SAFETY: raw pointer into self; guard dropped within this frame.
4440 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4441
4442 let apply_result = self.apply(&rec);
4443 // For Batch frames, post-validation apply must be infallible (see above).
4444 // A debug_assert here catches any future change that makes apply fallible
4445 // before the caller notices via silent WAL/memory divergence.
4446 if matches!(&rec, WalRecord::Batch(_)) {
4447 debug_assert!(
4448 apply_result.is_ok(),
4449 "Batch apply returned Err after successful WAL write — \
4450 the validate-then-apply invariant has been violated; \
4451 see log_then_apply_with invariant doc"
4452 );
4453 }
4454 if apply_result.is_err() {
4455 // Discard any partial deltas accumulated by the failed apply.
4456 // They must not ride the next commit's event stream (I-1).
4457 // _emit_guard restores emit_deltas on drop automatically.
4458 let _ = self.engine.drain_deltas();
4459 let _ = self.engine.take_rebuild_needed();
4460 apply_result?;
4461 }
4462 self.commit_seq += 1;
4463 let seq = self.commit_seq;
4464 // Update per-node last-change map for the committed record.
4465 // Must happen after commit_seq is incremented so the seq is correct.
4466 self.update_last_change_from_rec(&rec, seq);
4467 // Drain engine deltas and distribute to subscribers before the existing
4468 // MutationEvent sink fires — both happen post-fsync, post-apply.
4469 // _emit_guard restores emit_deltas after this line when it drops.
4470 let engine_deltas = self.engine.drain_deltas();
4471
4472 // Append history-marker WAL records for any derived-edge changes so
4473 // that `edge_history` and `was_linked` can surface rule-attributed
4474 // events. Markers are STATE NO-OPS during replay; they are written
4475 // without an additional fsync (the triggering commit's sync already
4476 // happened; the next commit's sync covers these lazily).
4477 if !engine_deltas.is_empty() {
4478 let markers: Vec<WalRecord> = engine_deltas
4479 .iter()
4480 .map(|d| {
4481 if d.fired {
4482 WalRecord::DerivedEdgeAdded {
4483 rule: d.rule.clone(),
4484 edge_type: d.edge_type.clone(),
4485 src_key: d.src_key.clone(),
4486 dst_key: d.dst_key.clone(),
4487 }
4488 } else {
4489 WalRecord::DerivedEdgeRetracted {
4490 rule: d.rule.clone(),
4491 edge_type: d.edge_type.clone(),
4492 src_key: d.src_key.clone(),
4493 dst_key: d.dst_key.clone(),
4494 }
4495 }
4496 })
4497 .collect();
4498 let marker_frame = if markers.len() == 1 {
4499 markers.into_iter().next().unwrap()
4500 } else {
4501 WalRecord::Batch(markers)
4502 };
4503 // Ignore append errors: markers are best-effort history
4504 // annotations. Losing them does not affect state correctness.
4505 // The cursor only advances when the bytes actually landed.
4506 let marker_bytes = encode_record(&marker_frame);
4507 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4508 self.wal_consumed += marker_bytes.len() as u64;
4509 }
4510 }
4511
4512 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4513 // stored as-is (including any nested Batch / Intern records); the
4514 // ReaderSnapshot's apply_one function handles all variants.
4515 {
4516 let derived_inserts = engine_deltas
4517 .iter()
4518 .filter(|d| d.fired)
4519 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4520 .collect();
4521 let derived_deletes = engine_deltas
4522 .iter()
4523 .filter(|d| !d.fired)
4524 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4525 .collect();
4526 let delta = Arc::new(crate::reader::CommitDelta {
4527 records: vec![rec.clone()],
4528 derived_inserts,
4529 derived_deletes,
4530 });
4531 self.delta_tail.push(delta);
4532 self.commits_since_fold += 1;
4533 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4534 self.fold_now();
4535 }
4536 }
4537
4538 if self.defer_events {
4539 // Group-commit drain thread: hold events until after the group
4540 // fsync so subscribers only observe durable data (R2).
4541 self.deferred_events.push(DeferredEvent {
4542 rec: rec.clone(),
4543 engine_deltas,
4544 seq,
4545 ingest,
4546 });
4547 } else {
4548 self.distribute_events(&rec, &engine_deltas, seq);
4549 self.emit_committed(&rec, ingest);
4550 }
4551 // Drift is only known after apply, so auto-rebuild cannot join the
4552 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4553 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4554 // retrigger loop is impossible if the fit succeeded, but we still
4555 // drain the flag so a leftover cannot re-enter.
4556 // One slice of any outstanding vector-index build rides here too, so a
4557 // store that is being written to finishes its build without anyone
4558 // calling `pump_index_build`. A rule that becomes whole joins the same
4559 // RebuildRule loop below.
4560 let mut rebuilds = self.engine.take_rebuild_needed();
4561 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4562 // Not after `CreateRule`: that record's own apply already did the
4563 // rule's first slice, and pumping again here would make one
4564 // `create_rule` call do two slices' work under one lock.
4565 // Nothing pending is the overwhelmingly common case and must cost
4566 // a map lookup, not an engine swap: a store being written to has
4567 // long since populated its indexes, so the `pump_index_build`
4568 // entry point owns the not-yet-populated case on its own.
4569 if !matches!(&rec, WalRecord::CreateRule { .. })
4570 && !self.engine.builds_in_progress().is_empty()
4571 {
4572 rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4573 }
4574 let mut failed = Vec::new();
4575 for name in rebuilds {
4576 if self.engine.rules().any(|r| r.name == name) {
4577 // User op is already durable. A failed second commit must
4578 // not surface as the caller's error.
4579 if let Err(e) =
4580 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4581 {
4582 eprintln!(
4583 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4584 );
4585 failed.push(name);
4586 }
4587 }
4588 }
4589 for name in failed {
4590 self.engine.queue_rebuild_needed(name);
4591 }
4592 }
4593 Ok(())
4594 }
4595
4596 /// Install a post-commit hook. Replaces any previous sink.
4597 ///
4598 /// The sink runs inside `log_then_apply` after a successful
4599 /// durable commit, while the caller still holds `&mut self`. When this
4600 /// database is behind a [`crate::SharedDb`], that means the **write
4601 /// guard is held**. The sink must never call `read` / `write` (or any
4602 /// other method) on the same `SharedDb` — the `RwLock` is not
4603 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4604 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4605 /// Intended examples: `std::sync::mpsc::SyncSender`,
4606 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4607 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4608 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4609 self.event_sink = Some(sink);
4610 }
4611
4612 /// Whether a post-commit event sink is currently installed.
4613 pub fn has_event_sink(&self) -> bool {
4614 self.event_sink.is_some()
4615 }
4616
4617 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4618 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4619 self.fsync = p;
4620 }
4621
4622 /// Return the current WAL fsync cadence.
4623 pub fn fsync_policy(&self) -> FsyncPolicy {
4624 self.fsync
4625 }
4626
4627 // ── Group-commit event deferral ───────────────────────────────────────────
4628
4629 /// Enable or disable deferred event mode.
4630 ///
4631 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4632 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4633 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4634 /// or [`discard_deferred_events`] if the fsync failed and the group must
4635 /// be treated as lost.
4636 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4637 self.defer_events = defer;
4638 }
4639
4640 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4641 /// was set to true. Clears the buffer.
4642 ///
4643 /// Called by the drain thread AFTER a successful group fsync, so
4644 /// subscribers observe only data that is durably on disk.
4645 pub fn flush_deferred_events(&mut self) {
4646 let events = std::mem::take(&mut self.deferred_events);
4647 for de in events {
4648 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4649 self.emit_committed(&de.rec, de.ingest);
4650 }
4651 }
4652
4653 /// Discard all buffered events without firing them.
4654 ///
4655 /// Called by the drain thread when a group fsync fails: the WAL has been
4656 /// truncated back to the pre-group offset, so the committed-but-unsynced
4657 /// ops must not be observable to subscribers.
4658 pub fn discard_deferred_events(&mut self) {
4659 self.deferred_events.clear();
4660 }
4661
4662 // ── Degraded state ────────────────────────────────────────────────────────
4663
4664 /// Mark this database as degraded.
4665 ///
4666 /// Called by the group-commit drain thread after a group fsync failure and
4667 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4668 /// further mutations would deepen the divergence. All subsequent calls to
4669 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4670 pub fn set_degraded(&mut self) {
4671 self.degraded = true;
4672 }
4673
4674 fn emit(&self, ev: MutationEvent) {
4675 if let Some(sink) = &self.event_sink {
4676 sink(ev);
4677 }
4678 }
4679
4680 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4681 match rec {
4682 WalRecord::Batch(inner) => {
4683 for r in inner {
4684 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4685 self.emit(ev);
4686 }
4687 }
4688 match ingest {
4689 Some((label, inserted)) => {
4690 self.emit(MutationEvent::Ingested { label, inserted })
4691 }
4692 None => {
4693 let ops = inner
4694 .iter()
4695 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4696 .count();
4697 if ops > 1 {
4698 self.emit(MutationEvent::BatchApplied { ops });
4699 }
4700 }
4701 }
4702 }
4703 other => {
4704 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4705 self.emit(ev);
4706 }
4707 }
4708 }
4709 }
4710
4711 // -----------------------------------------------------------------------
4712 // Subscription API
4713 // -----------------------------------------------------------------------
4714
4715 /// Distribute post-commit events to all live subscribers.
4716 ///
4717 /// Build a row-key → row-data map from a [`ResultSet`].
4718 ///
4719 /// Each row is serialized to JSON to form its key; a debug fallback is used
4720 /// if serialization fails. Used by both the initial-seed path in
4721 /// [`Self::subscribe_query`] and the per-commit diff path in
4722 /// [`Self::distribute_events`] to keep the two in sync.
4723 fn result_to_row_map(
4724 result: &core_query::ResultSet,
4725 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4726 (0..result.len())
4727 .map(|i| {
4728 let row = result.row(i).to_vec();
4729 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4730 (key, row)
4731 })
4732 .collect()
4733 }
4734
4735 /// Collect the set of label syms touched by a WAL record.
4736 ///
4737 /// Returns `Some(set)` when every record in this commit can be attributed to
4738 /// a known label sym. Returns `None` when the commit must not be skipped:
4739 /// edge records, unresolvable key→label lookups, or any record type not in
4740 /// the explicit handled set.
4741 ///
4742 /// Handled record types and their actions:
4743 /// - `InsertNode` → look up label in interner (fails → None)
4744 /// - `InsertNodeId` → label sym is carried directly
4745 /// - `SetProp` → resolve key→id→label (fails → None)
4746 /// - `DeleteNode` → resolve key→id→label (fails → None)
4747 /// - `Batch` → recurse into every inner record
4748 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4749 /// - everything else → None (conservative)
4750 fn commit_touched_labels(
4751 rec: &WalRecord,
4752 syms: &Interner,
4753 ids: &IdMap,
4754 labels: &[u32],
4755 ) -> Option<BTreeSet<u32>> {
4756 let mut out = BTreeSet::new();
4757 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4758 Some(out)
4759 } else {
4760 None
4761 }
4762 }
4763
4764 fn collect_touched_labels(
4765 rec: &WalRecord,
4766 syms: &Interner,
4767 ids: &IdMap,
4768 labels: &[u32],
4769 out: &mut BTreeSet<u32>,
4770 ) -> bool {
4771 match rec {
4772 // String-key insert: the dense rewrite converts this to
4773 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4774 // records written before the dense path was added.
4775 WalRecord::InsertNode { label, .. } => {
4776 if let Some(sym) = syms.get(label) {
4777 out.insert(sym);
4778 true
4779 } else {
4780 false
4781 }
4782 }
4783 // Dense-id insert (produced by rewrite_wal_dense for every
4784 // insert_node call in the current codebase).
4785 WalRecord::InsertNodeId { label, .. } => {
4786 out.insert(*label);
4787 true
4788 }
4789 // String-key prop set: dense path converts to [Intern, SetPropId].
4790 WalRecord::SetProp { key, .. } => {
4791 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4792 out.insert(sym);
4793 true
4794 } else {
4795 false
4796 }
4797 }
4798 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4799 WalRecord::SetPropId { id, .. } => {
4800 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4801 out.insert(sym);
4802 true
4803 } else {
4804 false
4805 }
4806 }
4807 WalRecord::DeleteNode { key } => {
4808 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4809 out.insert(sym);
4810 true
4811 } else {
4812 false
4813 }
4814 }
4815 WalRecord::Batch(inner) => inner
4816 .iter()
4817 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4818 // Intern is a pure metadata record — it does not touch any node's
4819 // label and is safe to skip for the label-skip predicate.
4820 WalRecord::Intern { .. } => true,
4821 // Edge records: always re-execute (edges can change join results).
4822 WalRecord::InsertEdge { .. }
4823 | WalRecord::DeleteEdge { .. }
4824 | WalRecord::InsertEdgeId { .. } => false,
4825 _ => false,
4826 }
4827 }
4828
4829 /// Resolve a node key to its label sym via the dense id table.
4830 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4831 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4832 let id = ids.get(key)?;
4833 let sym = labels.get(id as usize).copied()?;
4834 (sym != u32::MAX).then_some(sym)
4835 }
4836
4837 /// Distribute post-commit events to all live subscribers.
4838 ///
4839 /// Called from `log_then_apply_with` after apply + fsync, before the
4840 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4841 ///
4842 /// Query subscriptions (subscribe_query) re-execute their plan on every
4843 /// call and diff the result against the previous run. Zero overhead when
4844 /// no query subscriptions are active.
4845 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4846 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4847 return;
4848 }
4849
4850 if !self.subscriptions.is_empty() {
4851 // Build write events from the WAL record.
4852 let write_events: Vec<DbEvent> =
4853 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4854
4855 // Build edge events from engine deltas. Weight is looked up from
4856 // edge_props at distribution time (after apply), so it's always fresh.
4857 let edge_events: Vec<DbEvent> = engine_deltas
4858 .iter()
4859 .map(|d| {
4860 if d.fired {
4861 // The score lives under the rule's declared weight_prop,
4862 // which is not always the literal "weight".
4863 let prop = self
4864 .engine
4865 .rules()
4866 .find(|r| r.name == d.rule)
4867 .and_then(|r| r.weight_prop.as_deref());
4868 let weight = prop.and_then(|p| {
4869 self.edge_props
4870 .get(d.etype_sym, d.src_id, d.dst_id, p)
4871 .and_then(|v| {
4872 if let core_storage::Value::Float(f) = v {
4873 Some(*f)
4874 } else {
4875 None
4876 }
4877 })
4878 });
4879 DbEvent::EdgeFired {
4880 rule: d.rule.clone(),
4881 src_key: d.src_key.clone(),
4882 dst_key: d.dst_key.clone(),
4883 edge_type: d.edge_type.clone(),
4884 weight,
4885 commit_seq: seq,
4886 }
4887 } else {
4888 DbEvent::EdgeRetracted {
4889 rule: d.rule.clone(),
4890 src_key: d.src_key.clone(),
4891 dst_key: d.dst_key.clone(),
4892 edge_type: d.edge_type.clone(),
4893 commit_seq: seq,
4894 }
4895 }
4896 })
4897 .collect();
4898
4899 // Prune dead entries; push matching events to live ones.
4900 self.subscriptions.retain(|entry| {
4901 let Some(inner) = entry.inner.upgrade() else {
4902 return false;
4903 };
4904 for ev in &write_events {
4905 if event_matches(ev, &entry.filter) {
4906 inner.push(ev.clone());
4907 }
4908 }
4909 for ev in &edge_events {
4910 if event_matches(ev, &entry.filter) {
4911 inner.push(ev.clone());
4912 }
4913 }
4914 true
4915 });
4916
4917 // Turn off delta accumulation if all subscribers dropped and no views remain.
4918 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4919 self.engine.set_emit_deltas(false);
4920 }
4921 }
4922
4923 // Query subscriptions: full re-run per commit, then diff rows.
4924 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4925 // Differential evaluation is roadmap / Phase 5.
4926 if !self.query_subscriptions.is_empty() {
4927 // Take the list out so we can call self.view() without borrow conflict.
4928 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4929 let empty_params = BTreeMap::new();
4930 query_subs.retain_mut(|entry| {
4931 let Some(inner) = entry.inner.upgrade() else {
4932 return false; // subscriber dropped — prune
4933 };
4934 // Label-skip: if the plan has a known scan label and this commit
4935 // can be proven to touch only different labels (and no rule-derived
4936 // edge deltas fired), the result set cannot have changed — skip.
4937 if let Some(scan_sym) = entry.scan_label {
4938 if engine_deltas.is_empty() {
4939 let touched =
4940 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4941 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4942 return true; // safe to skip — result set unchanged
4943 }
4944 }
4945 }
4946 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4947 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4948 Ok(r) => r,
4949 Err(e) => {
4950 // Keep the subscription alive; skip the diff for this commit.
4951 // Re-run errors are transient (e.g., planner change) and
4952 // self-heal when the next commit succeeds.
4953 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4954 return true;
4955 }
4956 };
4957 // Build new row map: serialized-key → row data.
4958 let new_row_map = Self::result_to_row_map(&result);
4959 // Removed rows: in prev but not in new.
4960 for (key, row) in &entry.prev_row_map {
4961 if !new_row_map.contains_key(key) {
4962 inner.push(DbEvent::QueryRowRemoved {
4963 columns: entry.columns.clone(),
4964 row: row.clone(),
4965 });
4966 }
4967 }
4968 // Added rows: in new but not in prev.
4969 for (key, row) in &new_row_map {
4970 if !entry.prev_row_map.contains_key(key) {
4971 inner.push(DbEvent::QueryRowAdded {
4972 columns: entry.columns.clone(),
4973 row: row.clone(),
4974 });
4975 }
4976 }
4977 entry.prev_row_map = new_row_map;
4978 true
4979 });
4980 self.query_subscriptions = query_subs;
4981 }
4982 }
4983
4984 /// Returns `true` if any live subscriber or view definition requires delta
4985 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4986 fn needs_emit_deltas(&self) -> bool {
4987 !self.view_store.is_empty()
4988 || self
4989 .subscriptions
4990 .iter()
4991 .any(|e| e.inner.upgrade().is_some())
4992 }
4993
4994 /// Convert a WAL record into `DbEvent` write events with the given seq.
4995 fn write_events_from_record(
4996 rec: &WalRecord,
4997 seq: u64,
4998 intern: &Interner,
4999 ids: &IdMap,
5000 ) -> Vec<DbEvent> {
5001 match rec {
5002 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
5003 label: label.clone(),
5004 key: key.clone(),
5005 commit_seq: seq,
5006 }],
5007 // *Id arms run after a successful apply, so resolution can only
5008 // fail on a programming error. Skip the event rather than emit a
5009 // fabricated "" that clients can't tell from a real empty value
5010 // (mirrors event_from_record returning None).
5011 WalRecord::InsertNodeId { label, key, .. } => intern
5012 .resolve(*label)
5013 .map(|label| DbEvent::NodeInserted {
5014 label: label.to_string(),
5015 key: key.clone(),
5016 commit_seq: seq,
5017 })
5018 .into_iter()
5019 .collect(),
5020 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5021 key: key.clone(),
5022 field: field.clone(),
5023 commit_seq: seq,
5024 }],
5025 WalRecord::SetPropId { id, field, .. } => ids
5026 .key_of(*id)
5027 .zip(intern.resolve(*field))
5028 .map(|(key, field)| DbEvent::PropSet {
5029 key: key.to_string(),
5030 field: field.to_string(),
5031 commit_seq: seq,
5032 })
5033 .into_iter()
5034 .collect(),
5035 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5036 key: key.clone(),
5037 field: field.clone(),
5038 commit_seq: seq,
5039 }],
5040 WalRecord::InsertEdge {
5041 edge_type,
5042 src_key,
5043 dst_key,
5044 } => vec![DbEvent::EdgeInserted {
5045 edge_type: edge_type.clone(),
5046 src: src_key.clone(),
5047 dst: dst_key.clone(),
5048 commit_seq: seq,
5049 }],
5050 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5051 Some(DbEvent::EdgeInserted {
5052 edge_type: intern.resolve(*etype)?.to_string(),
5053 src: ids.key_of(*src)?.to_string(),
5054 dst: ids.key_of(*dst)?.to_string(),
5055 commit_seq: seq,
5056 })
5057 })()
5058 .into_iter()
5059 .collect(),
5060 WalRecord::DeleteEdge {
5061 edge_type,
5062 src_key,
5063 dst_key,
5064 } => vec![DbEvent::EdgeDeleted {
5065 edge_type: edge_type.clone(),
5066 src: src_key.clone(),
5067 dst: dst_key.clone(),
5068 commit_seq: seq,
5069 }],
5070 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5071 key: key.clone(),
5072 commit_seq: seq,
5073 }],
5074 WalRecord::Batch(inner) => inner
5075 .iter()
5076 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5077 .collect(),
5078 WalRecord::CreateRule { .. }
5079 | WalRecord::DeleteRule { .. }
5080 | WalRecord::RebuildRule { .. }
5081 | WalRecord::CreateView { .. }
5082 | WalRecord::DeleteView { .. }
5083 | WalRecord::EnableFulltext { .. }
5084 | WalRecord::DisableFulltext { .. }
5085 | WalRecord::EnableIndex { .. }
5086 | WalRecord::DisableIndex { .. }
5087 | WalRecord::Intern { .. }
5088 // History markers produce no DbEvent — the engine delta already
5089 // fired the EdgeFired/EdgeRetracted subscription events.
5090 | WalRecord::DerivedEdgeAdded { .. }
5091 | WalRecord::DerivedEdgeRetracted { .. }
5092 | WalRecord::RenameNode { .. } => vec![],
5093 }
5094 }
5095
5096 /// Subscribe to edge-fire and edge-retract events for one named rule.
5097 ///
5098 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5099 /// currently registered. Dropping the returned [`Subscription`] handle
5100 /// unregisters the subscriber — no further events are queued, no
5101 /// resources leak.
5102 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5103 if self.read_only {
5104 return Err(core_storage::GraphError::ReadOnly);
5105 }
5106 if !self.engine.rules().any(|r| r.name == rule_name) {
5107 return Err(core_storage::GraphError::RuleNotFound {
5108 name: rule_name.to_string(),
5109 });
5110 }
5111 let inner = SubInner::new(self.sub_capacity());
5112 self.subscriptions.push(SubEntry {
5113 filter: SubFilter::Rule(rule_name.to_string()),
5114 inner: std::sync::Arc::downgrade(&inner),
5115 });
5116 self.engine.set_emit_deltas(true);
5117 Ok(Subscription(inner))
5118 }
5119
5120 /// Subscribe to edge-fire and edge-retract events for **all** rules.
5121 ///
5122 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5123 /// as-of instances never commit, so `distribute_events` never runs and the
5124 /// subscription would never deliver events.
5125 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5126 if self.read_only {
5127 return Err(core_storage::GraphError::ReadOnly);
5128 }
5129 let inner = SubInner::new(self.sub_capacity());
5130 self.subscriptions.push(SubEntry {
5131 filter: SubFilter::AllRules,
5132 inner: std::sync::Arc::downgrade(&inner),
5133 });
5134 self.engine.set_emit_deltas(true);
5135 Ok(Subscription(inner))
5136 }
5137
5138 /// Subscribe to write events: node insert/delete, prop set/remove.
5139 ///
5140 /// Does not include edge-fire / edge-retract (rule-derived edge events).
5141 ///
5142 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5143 /// as-of instances never commit, so `distribute_events` never runs and the
5144 /// subscription would never deliver events.
5145 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5146 if self.read_only {
5147 return Err(core_storage::GraphError::ReadOnly);
5148 }
5149 let inner = SubInner::new(self.sub_capacity());
5150 self.subscriptions.push(SubEntry {
5151 filter: SubFilter::Writes,
5152 inner: std::sync::Arc::downgrade(&inner),
5153 });
5154 self.engine.set_emit_deltas(true);
5155 Ok(Subscription(inner))
5156 }
5157
5158 /// Subscribe to incremental Cypher query results.
5159 ///
5160 /// Parses and plans `cypher`; rejects the query if the plan is not in the
5161 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5162 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5163 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
5164 ///
5165 /// SKIP is not supported — it shifts the result window on every commit,
5166 /// causing spurious Added/Removed churn for rows whose data never changed.
5167 /// Multi-hop Expand chains are not supported; each additional MATCH clause
5168 /// widens scope beyond the documented single-scan / single-hop subset.
5169 ///
5170 /// After each successful commit, the plan is **fully re-executed** and the
5171 /// result is diffed against the previous run. Added rows produce
5172 /// [`DbEvent::QueryRowAdded`]; removed rows produce
5173 /// [`DbEvent::QueryRowRemoved`].
5174 ///
5175 /// **Full re-run per commit; use LIMIT to bound execution cost.**
5176 /// The existing 1 M intermediate-row cap applies. Differential evaluation
5177 /// is roadmap / Phase 5.
5178 ///
5179 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5180 /// as-of instances never commit, so `distribute_events` never runs and the
5181 /// subscription would never deliver events.
5182 ///
5183 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5184 /// or if the plan shape is not in the allowlist.
5185 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5186 if self.read_only {
5187 return Err(GraphError::ReadOnly);
5188 }
5189 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5190 detail: format!("lex: {e}"),
5191 })?;
5192 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5193 detail: format!("parse: {e}"),
5194 })?;
5195 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5196 detail: format!("plan: {e}"),
5197 })?;
5198 if !is_subscribable(&ops) {
5199 return Err(GraphError::QueryError {
5200 detail: "subscribe_query only supports allowlisted plan shapes: \
5201 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5202 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5203 Not supported: multi-hop Expand chains, SKIP (creates \
5204 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5205 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5206 Use LIMIT to bound re-execution cost."
5207 .to_string(),
5208 });
5209 }
5210 // Execute once to capture initial state (initial rows are not emitted as
5211 // events — the subscriber learns the baseline via the first query call).
5212 let empty_params = BTreeMap::new();
5213 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5214 GraphError::QueryError {
5215 detail: format!("execute: {e}"),
5216 }
5217 })?;
5218 let columns = initial.columns().to_vec();
5219 let prev_row_map = Self::result_to_row_map(&initial);
5220 let inner = SubInner::new(self.sub_capacity());
5221 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
5222 // or unrecognized leading scan → None (always re-execute).
5223 let scan_label = extract_scan_label(&ops, &mut self.syms);
5224 self.query_subscriptions.push(QuerySubEntry {
5225 ops,
5226 columns,
5227 prev_row_map,
5228 inner: std::sync::Arc::downgrade(&inner),
5229 scan_label,
5230 });
5231 Ok(Subscription(inner))
5232 }
5233
5234 /// Queue capacity used for new subscriptions.
5235 fn sub_capacity(&self) -> usize {
5236 self.sub_capacity
5237 }
5238
5239 /// Override per-subscriber queue capacity for subsequently created
5240 /// subscriptions on this db instance.
5241 ///
5242 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5243 /// value in tests to exercise the [`DbEvent::Lagged`] path without
5244 /// generating tens of thousands of events.
5245 ///
5246 /// This is a test-support escape hatch. Calling it in production reduces
5247 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5248 /// to discourage accidental production use.
5249 #[doc(hidden)]
5250 pub fn set_sub_capacity(&mut self, capacity: usize) {
5251 self.sub_capacity = capacity;
5252 }
5253
5254 // -----------------------------------------------------------------------
5255
5256 /// Start an atomic batch.
5257 ///
5258 /// The returned [`BatchBuilder`] borrows `self` mutably until
5259 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5260 /// validation, no WAL I/O. `commit` validates every queued op against
5261 /// live state plus preceding ops in this batch (duplicate key inside
5262 /// the batch is `Err`; an edge between two nodes created earlier in
5263 /// the batch is valid; `delete_node` then insert of the same key is a
5264 /// fresh identity). Validation never mutates the database. Any failure
5265 /// leaves WAL bytes and in-memory state identical to before `commit`.
5266 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5267 /// and each inner record is applied in order so rules fire per record.
5268 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5269 ///
5270 /// **Rule-window limitation:** batch validation cannot see edges that a
5271 /// rule created earlier in the *same* batch will derive at apply time, so
5272 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5273 /// where sequential calls would return `Err(RuleOwned)`. State integrity
5274 /// is unaffected (idempotent apply, provenance intact). Create rules in
5275 /// their own batch, or sequentially, when later ops may touch derived
5276 /// edges.
5277 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5278 BatchBuilder {
5279 db: self,
5280 ops: Vec::new(),
5281 }
5282 }
5283
5284 /// Closure-style atomic write batch.
5285 ///
5286 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5287 /// then committing. All ops queued inside `build` are validated in order and
5288 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5289 /// once per inner record, in order, after commit — semantically identical to
5290 /// sequential single-op writes.
5291 ///
5292 /// **Error semantics — validate-then-apply.** `build` queues ops without
5293 /// touching the database. [`BatchBuilder::commit`] validates every op against
5294 /// live state plus earlier ops in this batch before writing anything. If op N
5295 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5296 /// entire batch is rejected: no WAL bytes are written and no in-memory state
5297 /// changes. The database is identical to its state before `write_batch` was
5298 /// called.
5299 ///
5300 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5301 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5302 /// either fully applied or not at all. However, while applying a committed
5303 /// batch, concurrent readers may observe intermediate states as ops are applied
5304 /// sequentially in memory. There is no interactive transaction isolation in v1.
5305 /// This is documented as "crash-atomic write batches; no interactive
5306 /// transactions or read isolation."
5307 ///
5308 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5309 /// writes zero WAL bytes and returns `(0, 0)`.
5310 ///
5311 /// # Example
5312 ///
5313 /// ```rust,ignore
5314 /// let (nodes, edges) = db.write_batch(|b| {
5315 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5316 /// b.insert_node("Person", "bob", vec![]);
5317 /// b.insert_edge("KNOWS", "alice", "bob");
5318 /// b.set_prop("alice", "role", Value::Str("admin".into()));
5319 /// b.delete_node("old_key");
5320 /// })?;
5321 /// // One fsync; on crash replay: all five ops land or none do.
5322 /// ```
5323 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5324 where
5325 C: FnOnce(&mut BatchBuilder<'_, F>),
5326 {
5327 let mut b = self.batch();
5328 build(&mut b);
5329 b.commit()
5330 }
5331
5332 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5333 /// auto-declared KeyMatch rules (if any) first, then the accepted node
5334 /// inserts, so incremental fire sees the new rules. Per-row key problems
5335 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5336 /// `Err` means nothing was applied.
5337 ///
5338 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5339 /// distinct source labels sharing an FK field each get their own rule.
5340 pub fn ingest(
5341 &mut self,
5342 label: &str,
5343 rows: Vec<BTreeMap<String, Value>>,
5344 opts: &IngestOptions,
5345 ) -> Result<IngestReport> {
5346 self.ingest_with_edges(label, rows, opts, &[])
5347 }
5348
5349 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5350 /// A failing edge rejects the whole request; nothing is applied.
5351 pub fn ingest_with_edges(
5352 &mut self,
5353 label: &str,
5354 rows: Vec<BTreeMap<String, Value>>,
5355 opts: &IngestOptions,
5356 edges: &[(String, String, String)],
5357 ) -> Result<IngestReport> {
5358 crate::ingest::run(self, label, rows, opts, edges)
5359 }
5360
5361 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5362 ///
5363 /// JSON `null` fields are silently omitted (not stored, not a row error).
5364 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5365 /// Parse failures and a top-level value that is not an array of objects
5366 /// return [`GraphError::IngestError`].
5367 pub fn ingest_json(
5368 &mut self,
5369 label: &str,
5370 json: &str,
5371 opts: &IngestOptions,
5372 ) -> Result<IngestReport> {
5373 crate::ingest::run_json(self, label, json, opts)
5374 }
5375
5376 fn commit_logged_batch(
5377 &mut self,
5378 ops: Vec<BatchOp>,
5379 ingest: Option<(String, usize)>,
5380 // Two-source rule: write_batch_authz threads authz here directly (never
5381 // touches pending_write_authz); query_write_authz sets the field instead
5382 // and passes None. Only one source is non-None per call.
5383 param_authz: Option<WriteAuthz>,
5384 ) -> Result<(usize, usize)> {
5385 // Read-only guard: catches empty-batch calls before the early-return
5386 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5387 if self.read_only {
5388 return Err(GraphError::ReadOnly);
5389 }
5390 // Ensure provenance is decoded before MutPreview accesses it
5391 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5392 self.engine.ensure_provenance_loaded_mut();
5393
5394 // ── Authz pre-check ──────────────────────────────────────────────────
5395 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5396 // produces no WAL frame (all-or-nothing at the authz boundary extends
5397 // the existing validate-then-apply contract to role-scope checks).
5398 //
5399 // `batch_created` tracks key→label for nodes created by earlier ops in
5400 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5401 // as visible without needing to call `self.ids.get` on not-yet-committed
5402 // keys (they won't be there yet).
5403 //
5404 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5405 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5406 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5407 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5408 if let Some(ref authz) = authz_opt {
5409 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5410 for op in &ops {
5411 self.check_single_op_authz(authz, op, &batch_created)?;
5412 // Update batch_created after a passing authz check so that
5413 // subsequent ops in this batch see the nodes as "about to exist".
5414 match op {
5415 BatchOp::InsertNode { label, key, .. } => {
5416 // Only track genuinely new nodes (absent from the
5417 // snapshot at authz-check time). A pre-existing visible
5418 // key would be a DuplicateKey — not a real creation —
5419 // so MutPreview handles it. Letting it into batch_created
5420 // would allow a later SetProp to bypass update_labels
5421 // via the "batch-created → always updatable" ruling
5422 // (delete+recreate exploit, fix for I1 review round 2).
5423 //
5424 // Accepted edge: for a delete+recreate-with-different-
5425 // label batch, node_status resolves the pre-delete
5426 // (store) label for any subsequent update checks. This
5427 // grants no net-new capability — a role that can delete+
5428 // create can already place arbitrary props via
5429 // InsertNode's own props field.
5430 if self.ids.get(key.as_str()).is_none() {
5431 batch_created.insert(key.clone(), label.clone());
5432 }
5433 }
5434 BatchOp::InsertEdgeUpsert {
5435 placeholder_label,
5436 src_key,
5437 dst_key,
5438 ..
5439 } => {
5440 // Both endpoints will be created if not already in store.
5441 for ep_key in [src_key, dst_key] {
5442 if self.ids.get(ep_key.as_str()).is_none()
5443 && !batch_created.contains_key(ep_key.as_str())
5444 {
5445 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5446 }
5447 }
5448 }
5449 _ => {}
5450 }
5451 }
5452 }
5453
5454 let recs = {
5455 let mut preview = MutPreview::new(self);
5456 let mut recs = Vec::with_capacity(ops.len());
5457 for op in ops {
5458 match op {
5459 BatchOp::InsertNode { label, key, props } => {
5460 preview.check_insert_node(&key)?;
5461 preview.note_insert_node(&key, &props);
5462 recs.push(WalRecord::InsertNode { label, key, props });
5463 }
5464 BatchOp::InsertEdge {
5465 edge_type,
5466 src_key,
5467 dst_key,
5468 } => {
5469 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5470 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5471 recs.push(WalRecord::InsertEdge {
5472 edge_type,
5473 src_key,
5474 dst_key,
5475 });
5476 }
5477 }
5478 BatchOp::SetProp { key, field, value } => {
5479 if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
5480 return Err(GraphError::ViewPropReadOnly {
5481 view_name: view_name.to_string(),
5482 });
5483 }
5484 preview.check_live_key(&key)?;
5485 preview.note_set_prop(&key, &field, &value);
5486 recs.push(WalRecord::SetProp { key, field, value });
5487 }
5488 BatchOp::RemoveProp { key, field } => {
5489 if preview.prepare_remove_prop(&key, &field)? {
5490 preview.note_remove_prop(&key, &field);
5491 recs.push(WalRecord::RemoveProp { key, field });
5492 }
5493 }
5494 BatchOp::DeleteEdge {
5495 edge_type,
5496 src_key,
5497 dst_key,
5498 } => {
5499 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5500 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5501 recs.push(WalRecord::DeleteEdge {
5502 edge_type,
5503 src_key,
5504 dst_key,
5505 });
5506 }
5507 }
5508 BatchOp::DeleteNode { key } => {
5509 preview.check_live_key(&key)?;
5510 preview.note_delete_node(&key);
5511 recs.push(WalRecord::DeleteNode { key });
5512 }
5513 BatchOp::CreateRule(def) => {
5514 preview.check_create_rule(&def)?;
5515 let def_bytes =
5516 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5517 detail: format!("serialize rule: {e}"),
5518 })?;
5519 preview.note_create_rule(&def);
5520 recs.push(WalRecord::CreateRule { def_bytes });
5521 }
5522 BatchOp::DeleteRule { name } => {
5523 preview.check_delete_rule(&name)?;
5524 preview.note_delete_rule(&name);
5525 recs.push(WalRecord::DeleteRule { name });
5526 }
5527 BatchOp::RenameNode { old_key, new_key } => {
5528 preview.check_rename_node(&old_key, &new_key)?;
5529 preview.note_rename_node(&old_key, &new_key);
5530 recs.push(WalRecord::RenameNode { old_key, new_key });
5531 }
5532 BatchOp::InsertEdgeUpsert {
5533 edge_type,
5534 src_key,
5535 dst_key,
5536 placeholder_label,
5537 } => {
5538 // Auto-create any missing endpoints as plain InsertNode ops.
5539 // Rules fire and last-change is updated for each created node.
5540 for key in [&src_key, &dst_key] {
5541 if !preview.has_key(key) {
5542 preview.check_insert_node(key)?;
5543 preview.note_insert_node(key, &[]);
5544 recs.push(WalRecord::InsertNode {
5545 label: placeholder_label.clone(),
5546 key: key.clone(),
5547 props: vec![],
5548 });
5549 }
5550 }
5551 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5552 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5553 recs.push(WalRecord::InsertEdge {
5554 edge_type,
5555 src_key,
5556 dst_key,
5557 });
5558 }
5559 }
5560 }
5561 }
5562 recs
5563 };
5564 if recs.is_empty() {
5565 return Ok((0, 0));
5566 }
5567 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5568 // *Id form, so only the dense variants can appear in `recs` here.
5569 let recs = self.rewrite_wal_dense(recs)?;
5570 // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5571 // namespace the node is already in is a no-op and is dropped there. An
5572 // empty `Batch` frame would still take a commit sequence and a WAL
5573 // record, so a batch that turns out to be nothing writes nothing.
5574 if recs.is_empty() {
5575 return Ok((0, 0));
5576 }
5577 let nodes_inserted = recs
5578 .iter()
5579 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5580 .count();
5581 let edges_inserted = recs
5582 .iter()
5583 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5584 .count();
5585 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5586 // under Strict. Pass self.fsync directly so Strict stays Strict —
5587 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5588 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5589 // short-circuit on single-op batches and silently skip the fsync.
5590 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5591 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5592 Ok((nodes_inserted, edges_inserted))
5593 }
5594
5595 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5596 self.commit_logged_batch(ops, None, None)
5597 }
5598
5599 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5600 /// and the group-commit drain thread, which do a single group fsync later.
5601 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5602 // Restore fsync policy even on panic via a raw-pointer drop guard.
5603 // A panic here would poison the RwLock anyway, but the correct policy
5604 // must be in place if the guard is ever unwrapped.
5605 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5606 impl Drop for RestoreFsync {
5607 fn drop(&mut self) {
5608 // SAFETY: the pointer is valid for the full duration of
5609 // commit_batch_nosync; the guard is dropped before the frame
5610 // returns, and GraphDb outlives this frame.
5611 unsafe {
5612 *self.0 = self.1;
5613 }
5614 }
5615 }
5616 let saved = self.fsync;
5617 // SAFETY: raw pointer into self; guard dropped within this frame.
5618 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5619 self.fsync = FsyncPolicy::Relaxed;
5620 self.commit_logged_batch(ops, None, None)
5621 }
5622
5623 /// Commit multiple op-batches as a **group**: each submission gets its own
5624 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5625 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5626 ///
5627 /// # Durability semantics
5628 ///
5629 /// A crash before the group fsync may lose **all** submissions in the group.
5630 /// A crash after the group fsync preserves all of them. No submission is
5631 /// ever torn: each WAL frame is either fully applied on replay or dropped
5632 /// in its entirety (CRC-protected frame boundaries).
5633 ///
5634 /// Events and subscription notifications fire per-submission immediately
5635 /// after apply, which may be before the group fsync. From a subscriber's
5636 /// perspective this is equivalent to the `Relaxed` durability window.
5637 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5638 /// fsync, so from their perspective durability is fully guaranteed.
5639 ///
5640 /// # MVCC interplay
5641 ///
5642 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5643 /// increments per submission (not per group), preserving existing reader
5644 /// snapshot semantics.
5645 ///
5646 /// # Returns
5647 ///
5648 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5649 /// in order. Failures are per-submission (validation errors); the group
5650 /// fsync error (if any) is returned as the second tuple element.
5651 pub fn commit_group(
5652 &mut self,
5653 groups: Vec<Vec<BatchOp>>,
5654 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5655 let mut results = Vec::with_capacity(groups.len());
5656 for ops in groups {
5657 results.push(self.commit_batch_nosync(ops));
5658 }
5659 let any_ok = results.iter().any(|r| r.is_ok());
5660 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5661 self.fs
5662 .sync(core_storage::fs::FileId::Wal)
5663 .map_err(GraphError::Io)
5664 .err()
5665 } else {
5666 None
5667 };
5668 (results, sync_err)
5669 }
5670
5671 /// Like [`commit_group`] but skips the group fsync entirely.
5672 ///
5673 /// Used by the drain thread to apply submissions under the write lock and
5674 /// then perform the single fsync OUTSIDE the lock (via
5675 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5676 /// to concurrent readers.
5677 pub fn commit_group_nosync(
5678 &mut self,
5679 groups: Vec<Vec<BatchOp>>,
5680 ) -> Vec<Result<(usize, usize)>> {
5681 let mut results = Vec::with_capacity(groups.len());
5682 for ops in groups {
5683 results.push(self.commit_batch_nosync(ops));
5684 }
5685 results
5686 }
5687
5688 pub fn insert_node(
5689 &mut self,
5690 label: &str,
5691 key: &str,
5692 props: Vec<(String, Value)>,
5693 ) -> Result<()> {
5694 if self.read_only {
5695 return Err(GraphError::ReadOnly);
5696 }
5697 MutPreview::new(self).check_insert_node(key)?;
5698 self.log_dense(vec![WalRecord::InsertNode {
5699 label: label.into(),
5700 key: key.into(),
5701 props,
5702 }])
5703 }
5704
5705 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5706 if self.read_only {
5707 return Err(GraphError::ReadOnly);
5708 }
5709 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5710 return Ok(false);
5711 }
5712 self.log_dense(vec![WalRecord::InsertEdge {
5713 edge_type: edge_type.into(),
5714 src_key: src_key.into(),
5715 dst_key: dst_key.into(),
5716 }])?;
5717 Ok(true)
5718 }
5719
5720 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5721 if self.read_only {
5722 return Err(GraphError::ReadOnly);
5723 }
5724 if let Some(view_name) = self.view_store.view_for_prop(field) {
5725 return Err(GraphError::ViewPropReadOnly {
5726 view_name: view_name.to_string(),
5727 });
5728 }
5729 MutPreview::new(self).check_live_key(key)?;
5730 self.log_dense(vec![WalRecord::SetProp {
5731 key: key.into(),
5732 field: field.into(),
5733 value,
5734 }])
5735 }
5736
5737 /// Set several properties on one live node in a single WAL commit.
5738 ///
5739 /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
5740 /// names, live key, the `ns` immutability rule and its type — is evaluated
5741 /// for the whole list before any record is logged. The first refusal
5742 /// returns and the node is unchanged. An empty list writes nothing.
5743 pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
5744 if self.read_only {
5745 return Err(GraphError::ReadOnly);
5746 }
5747 MutPreview::new(self).check_live_key(key)?;
5748 for (field, _) in &props {
5749 if let Some(view_name) = self.view_store.view_for_prop(field) {
5750 return Err(GraphError::ViewPropReadOnly {
5751 view_name: view_name.to_string(),
5752 });
5753 }
5754 }
5755 if props.is_empty() {
5756 return Ok(());
5757 }
5758 self.write_batch(|b| {
5759 for (field, value) in props {
5760 b.set_prop(key, &field, value);
5761 }
5762 })
5763 .map(|_| ())
5764 }
5765
5766 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5767 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5768 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5769 if self.read_only {
5770 return Err(GraphError::ReadOnly);
5771 }
5772 if let Some(view_name) = self.view_store.view_for_prop(field) {
5773 return Err(GraphError::ViewPropReadOnly {
5774 view_name: view_name.to_string(),
5775 });
5776 }
5777 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5778 return Ok(false);
5779 }
5780 self.log_then_apply(WalRecord::RemoveProp {
5781 key: key.into(),
5782 field: field.into(),
5783 })?;
5784 Ok(true)
5785 }
5786
5787 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5788 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5789 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5790 /// (the rule would just put the edge back; delete or change the rule).
5791 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5792 if self.read_only {
5793 return Err(GraphError::ReadOnly);
5794 }
5795 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5796 return Ok(false);
5797 }
5798 self.log_then_apply(WalRecord::DeleteEdge {
5799 edge_type: edge_type.into(),
5800 src_key: src_key.into(),
5801 dst_key: dst_key.into(),
5802 })?;
5803 Ok(true)
5804 }
5805
5806 /// Delete a live node. Unknown or already-tombstoned keys are
5807 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5808 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5809 /// (crash window) is a clean no-op.
5810 ///
5811 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5812 /// removed (computed from live state before the deletion is applied).
5813 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5814 if self.read_only {
5815 return Err(GraphError::ReadOnly);
5816 }
5817 // Provenance must be loaded before we query provenance_touching.
5818 self.engine.ensure_provenance_loaded_mut();
5819 let id = self
5820 .ids
5821 .get(key)
5822 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5823
5824 // Count edges before the delete is applied so we can report counts.
5825 let derived_set: BTreeSet<(u32, u32, u32)> = self
5826 .engine
5827 .provenance_touching(id)
5828 .map(|(_, etype, src, dst)| (etype, src, dst))
5829 .collect();
5830 let derived_edges = derived_set.len() as u64;
5831
5832 let mut total_topo = 0u64;
5833 let tv = self.topo_view();
5834 for et in tv.etypes() {
5835 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5836 + tv.neighbors(et, Direction::In, id).len() as u64;
5837 }
5838 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5839 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5840 // The subtraction remains correct because both counts include both directions.
5841 let manual_edges = total_topo.saturating_sub(derived_edges);
5842
5843 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5844 Ok(DeleteReport {
5845 manual_edges,
5846 derived_edges,
5847 })
5848 }
5849
5850 /// Rename a live node's key. The dense id (and therefore all edges,
5851 /// props, history, and last-change tracking) is unaffected.
5852 ///
5853 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5854 /// Returns `Err(DuplicateKey)` if `new` is already live.
5855 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5856 if self.read_only {
5857 return Err(GraphError::ReadOnly);
5858 }
5859 MutPreview::new(self).check_rename_node(old, new)?;
5860 self.log_then_apply(WalRecord::RenameNode {
5861 old_key: old.into(),
5862 new_key: new.into(),
5863 })
5864 }
5865
5866 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5867 /// `None` if the rule does not exist or is not approximate.
5868 ///
5869 /// The drift counter increments on IVF insert/remove after the last fit.
5870 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5871 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5872 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5873 // SideIvfExport = (centroids, node→cluster, drift)
5874 self.engine
5875 .export_ivf_state()
5876 .remove(rule)
5877 .map(|(_src, dst)| dst.2)
5878 }
5879
5880 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5881 /// Validation and duplicate-name check run before logging so invalid rules
5882 /// never enter the WAL.
5883 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5884 if self.read_only {
5885 return Err(GraphError::ReadOnly);
5886 }
5887 MutPreview::new(self).check_create_rule(&def)?;
5888 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5889 detail: format!("serialize rule: {e}"),
5890 })?;
5891 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5892 }
5893
5894 /// Override this handle's HNSW build-slice size, or `None` to restore
5895 /// [`core_rules::HNSW_BUILD_BATCH`].
5896 ///
5897 /// Exposed for tests that need a small slice without a large corpus; not
5898 /// part of the stable surface.
5899 #[doc(hidden)]
5900 pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
5901 self.engine.set_hnsw_build_batch(batch);
5902 }
5903
5904 /// Rules whose vector index is still being built, in name order.
5905 ///
5906 /// The same list [`GraphDb::stats`] reports per rule in `building`.
5907 /// After a clean open this includes a build a snapshot cut short, so
5908 /// `serve`'s ticker can pump it without a write.
5909 pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
5910 self.engine.builds_in_progress()
5911 }
5912
5913 /// Advance any vector index still building and backfill each rule that
5914 /// finishes. Returns what is still outstanding.
5915 ///
5916 /// A map lookup when nothing is pending, so it is cheap to call on a timer.
5917 /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
5918 /// inserts per pending rule per call, so a caller can drive a large build
5919 /// to completion without ever holding the lock for more than a slice.
5920 ///
5921 /// A rule that finishes here is backfilled through the same
5922 /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
5923 /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
5924 /// and appear all at once.
5925 ///
5926 /// Every ordinary write pumps one slice on its own (see the post-commit
5927 /// hook in `log_then_apply_with`), so this is for quiescent stores and for
5928 /// operators who want the build finished before traffic arrives.
5929 pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
5930 Ok(self.pump_index_build_reporting()?.1)
5931 }
5932
5933 /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
5934 /// call finished, so a progress display can say so.
5935 ///
5936 /// A build can be registered and completed inside a single call — that is
5937 /// what a mid-build snapshot looks like on reopen, where the index scan
5938 /// finishes the graph and only the backfill is outstanding — and the
5939 /// outstanding list alone cannot show that anything happened.
5940 pub fn pump_index_build_reporting(
5941 &mut self,
5942 ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
5943 // A read-only handle cannot issue the `RebuildRule` a finished build
5944 // needs, so it would advance the index and then silently fail to
5945 // produce the edges. Refusing is the honest answer.
5946 if self.read_only {
5947 return Err(GraphError::ReadOnly);
5948 }
5949 let finished = self.pump_one_slice();
5950 for done in &finished {
5951 // The index is whole but the rule still owns no edges. A failed
5952 // second commit must leave the rule re-pumpable rather than
5953 // silently edge-less, so the error is surfaced here — unlike the
5954 // post-commit hook, this call is not riding someone else's commit.
5955 self.log_then_apply(WalRecord::RebuildRule {
5956 name: done.rule.clone(),
5957 })?;
5958 }
5959 Ok((finished, self.engine.builds_in_progress()))
5960 }
5961
5962 /// Run the deferred candidate-index build, if it is still owed, against the
5963 /// graph as it stands *now* — before the caller applies anything.
5964 ///
5965 /// A no-op bool test once the indexes are populated, which is after the
5966 /// first write of the handle's life, and for a store with no rules at all.
5967 fn populate_indexes_before_write(&mut self) {
5968 if !self.engine.needs_index_population() {
5969 return;
5970 }
5971 // The retained snapshot blobs arrive with the V8 base sections; without
5972 // them the scan would rebuild every graph the snapshot already holds.
5973 self.ensure_v8_base_sections_loaded();
5974 if !self.engine.needs_index_population() {
5975 return;
5976 }
5977 let mut eng = std::mem::take(&mut self.engine);
5978 {
5979 let gm = make_graph_mut(
5980 &self.ids,
5981 &mut self.syms,
5982 &self.labels,
5983 build_props_view(&self.props, &self.base),
5984 &mut self.topo,
5985 &self.base,
5986 &mut self.edge_props,
5987 );
5988 eng.populate_indexes(&gm);
5989 }
5990 self.engine = eng;
5991 }
5992
5993 /// One slice of build work for every pending rule. Returns the rules whose
5994 /// index just became whole, which the caller must `RebuildRule`.
5995 ///
5996 /// Goes through the engine even with nothing pending when the indexes have
5997 /// not been populated yet: that call adopts the persisted graphs and, for
5998 /// an incomplete blob already registered at open, leaves the remainder to
5999 /// this slice rather than inserting it inline.
6000 fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
6001 // The retained snapshot blobs — and the id count an interrupted build
6002 // is recognised against — arrive with the V8 base sections, which a
6003 // clean open reads lazily. Without this a freshly opened handle pumps
6004 // against empty retained state and concludes there is nothing to do,
6005 // which is precisely the store `build-index` exists for.
6006 self.ensure_v8_base_sections_loaded();
6007 let mut eng = std::mem::take(&mut self.engine);
6008 let finished = {
6009 let mut gm = make_graph_mut(
6010 &self.ids,
6011 &mut self.syms,
6012 &self.labels,
6013 build_props_view(&self.props, &self.base),
6014 &mut self.topo,
6015 &self.base,
6016 &mut self.edge_props,
6017 );
6018 eng.pump_index_build(&mut gm)
6019 };
6020 self.engine = eng;
6021 finished
6022 }
6023
6024 /// Register a sliced build a snapshot cut short, from blobs with
6025 /// `complete == false`.
6026 ///
6027 /// Peeks the V8 mmap for incomplete entries without copying complete
6028 /// graphs. V5–V7 already hold the blobs in the engine from restore.
6029 fn register_outstanding_index_builds(&mut self) {
6030 if self.engine.indexes_populated() {
6031 return;
6032 }
6033 let extra = self.collect_incomplete_hnsw_blobs();
6034 let mut eng = std::mem::take(&mut self.engine);
6035 {
6036 let gm = make_graph_mut(
6037 &self.ids,
6038 &mut self.syms,
6039 &self.labels,
6040 build_props_view(&self.props, &self.base),
6041 &mut self.topo,
6042 &self.base,
6043 &mut self.edge_props,
6044 );
6045 eng.register_incomplete_hnsw_builds(&extra, &gm);
6046 }
6047 self.engine = eng;
6048 }
6049
6050 /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
6051 /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
6052 /// engine's retained map instead).
6053 fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
6054 let Some(base) = &self.base else {
6055 return BTreeMap::new();
6056 };
6057 let Ok(archived) = base.hnsw_section() else {
6058 return BTreeMap::new();
6059 };
6060 archived
6061 .rules
6062 .iter()
6063 .filter_map(|e| {
6064 let src = e.src_blob.as_slice();
6065 let dst = e.dst_blob.as_slice();
6066 if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
6067 || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
6068 {
6069 Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
6070 } else {
6071 None
6072 }
6073 })
6074 .collect()
6075 }
6076
6077 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
6078 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
6079 if self.read_only {
6080 return Err(GraphError::ReadOnly);
6081 }
6082 MutPreview::new(self).check_delete_rule(name)?;
6083 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
6084 }
6085
6086 /// Return a snapshot of all registered rules.
6087 pub fn rules(&self) -> Vec<RuleDef> {
6088 self.engine.rules().cloned().collect()
6089 }
6090
6091 // -----------------------------------------------------------------------
6092 // Rule suggestion API
6093 // -----------------------------------------------------------------------
6094
6095 /// Profile the database and suggest linking rules with previewed edge counts.
6096 ///
6097 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
6098 /// sampling. Suggestions are sorted by estimated edge count (descending).
6099 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6100 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6101 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6102 }
6103
6104 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6105 /// reproducibility. Same seed + same data = identical output.
6106 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6107 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6108 .suggestions
6109 }
6110
6111 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6112 ///
6113 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6114 /// and a `truncated` flag indicating whether the global budget fired before all
6115 /// candidates were evaluated.
6116 pub fn suggest_rules_with_config(
6117 &self,
6118 config: &core_rules::suggest::SuggestConfig,
6119 seed: u64,
6120 ) -> core_rules::SuggestReport {
6121 use std::collections::BTreeMap;
6122
6123 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6124 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6125 for id in 0..self.ids.len() as u32 {
6126 let Some(key) = self.ids.key_of(id) else {
6127 continue;
6128 };
6129 let Some(&sym) = self.labels.get(id as usize) else {
6130 continue;
6131 };
6132 if sym == u32::MAX {
6133 continue; // tombstoned
6134 }
6135 let Some(label) = self.syms.resolve(sym) else {
6136 continue;
6137 };
6138 label_nodes
6139 .entry(label.to_string())
6140 .or_default()
6141 .push((id, key.to_string()));
6142 }
6143
6144 let existing = self.rules();
6145 let pv = build_props_view(&self.props, &self.base);
6146 let all_fields: Vec<String> = pv.field_names();
6147
6148 core_rules::suggest::suggest_rules(
6149 &label_nodes,
6150 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6151 &all_fields,
6152 &existing,
6153 config,
6154 seed,
6155 )
6156 }
6157
6158 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6159 /// plus later mutations replay identically (rebuild is a pure function
6160 /// of state).
6161 ///
6162 /// Only exit from the tripped latch: if the full desired set fits the
6163 /// budget, it is applied completely and `tripped` clears; if it still
6164 /// exceeds the budget, provenance is left untouched and `tripped` stays
6165 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6166 /// Unknown rule → `RuleNotFound`, nothing logged.
6167 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6168 if self.read_only {
6169 return Err(GraphError::ReadOnly);
6170 }
6171 if !self.engine.rules().any(|r| r.name == name) {
6172 return Err(GraphError::RuleNotFound { name: name.into() });
6173 }
6174 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6175 }
6176
6177 // -----------------------------------------------------------------------
6178 // Materialized view API
6179 // -----------------------------------------------------------------------
6180
6181 /// Register a new materialized property view, backfill its values for all
6182 /// existing nodes, and WAL-log the definition.
6183 ///
6184 /// # Errors
6185 /// - `ReadOnly`: called on an as-of instance.
6186 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6187 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6188 if self.read_only {
6189 return Err(GraphError::ReadOnly);
6190 }
6191 // Pre-validate before WAL write.
6192 def.validate()
6193 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6194 if self.view_store.has_view(&def.name) {
6195 return Err(GraphError::RuleInvalid {
6196 detail: format!("view {:?} already exists", def.name),
6197 });
6198 }
6199 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6200 return Err(GraphError::RuleInvalid {
6201 detail: format!(
6202 "view_prop {:?} is already used by view {:?}",
6203 def.view_prop, existing
6204 ),
6205 });
6206 }
6207 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6208 detail: format!("serialize view: {e}"),
6209 })?;
6210 // Enable delta accumulation before the view is registered so subsequent
6211 // incremental edge events reach view maintenance from this point onward.
6212 // (The backfill inside create_view reads topo directly; it does not rely
6213 // on pending deltas.)
6214 self.engine.set_emit_deltas(true);
6215 self.log_then_apply(WalRecord::CreateView { def_bytes })
6216 }
6217
6218 /// Remove a named view and delete its values from every node.
6219 ///
6220 /// # Errors
6221 /// - `ReadOnly`: called on an as-of instance.
6222 /// - `RuleNotFound`: view does not exist.
6223 pub fn delete_view(&mut self, name: &str) -> Result<()> {
6224 if self.read_only {
6225 return Err(GraphError::ReadOnly);
6226 }
6227 if !self.view_store.has_view(name) {
6228 return Err(GraphError::RuleNotFound { name: name.into() });
6229 }
6230 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6231 // After deletion, disable accumulation if no listeners remain.
6232 if !self.needs_emit_deltas() {
6233 self.engine.set_emit_deltas(false);
6234 }
6235 result
6236 }
6237
6238 /// Snapshot of all registered view definitions.
6239 pub fn views(&self) -> Vec<ViewDef> {
6240 self.view_store.views().cloned().collect()
6241 }
6242
6243 // -----------------------------------------------------------------------
6244 // Full-text-lite API
6245 // -----------------------------------------------------------------------
6246
6247 /// Enable full-text indexing for all nodes of `label` on property `field`.
6248 ///
6249 /// After this call, every subsequent write to `(label, field)` is reflected
6250 /// in the index incrementally. Existing nodes are backfilled immediately.
6251 /// The declaration is persisted as a WAL record; the index itself is rebuilt
6252 /// from scratch on re-open (no snapshot format changes).
6253 ///
6254 /// # Errors
6255 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6256 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6257 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6258 if self.read_only {
6259 return Err(GraphError::ReadOnly);
6260 }
6261 if self.fulltext.is_enabled(label, field) {
6262 return Err(GraphError::RuleInvalid {
6263 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6264 });
6265 }
6266 self.log_then_apply(WalRecord::EnableFulltext {
6267 label: label.into(),
6268 field: field.into(),
6269 })
6270 }
6271
6272 /// Disable full-text indexing for `(label, field)` and drop its postings.
6273 ///
6274 /// # Errors
6275 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6276 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6277 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6278 if self.read_only {
6279 return Err(GraphError::ReadOnly);
6280 }
6281 if !self.fulltext.is_enabled(label, field) {
6282 return Err(GraphError::RuleNotFound {
6283 name: format!("fulltext({label},{field})"),
6284 });
6285 }
6286 self.log_then_apply(WalRecord::DisableFulltext {
6287 label: label.into(),
6288 field: field.into(),
6289 })
6290 }
6291
6292 /// Whether `(label, field)` is currently indexed for full-text search.
6293 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6294 self.fulltext.is_enabled(label, field)
6295 }
6296
6297 /// Every `(label, field)` pair with a live full-text index, sorted.
6298 ///
6299 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6300 /// declares which nodes are *indexed*, so callers that want to search
6301 /// everything indexed should query each distinct field once.
6302 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6303 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6304 v.sort();
6305 v
6306 }
6307
6308 /// Enable an equality index for all nodes of `label` on scalar property
6309 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6310 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6311 /// declaration persists via WAL and the postings rebuild on re-open.
6312 ///
6313 /// # Errors
6314 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6315 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6316 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6317 if self.read_only {
6318 return Err(GraphError::ReadOnly);
6319 }
6320 if self.prop_index.is_enabled(label, field) {
6321 return Err(GraphError::RuleInvalid {
6322 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6323 });
6324 }
6325 self.log_then_apply(WalRecord::EnableIndex {
6326 label: label.into(),
6327 field: field.into(),
6328 })
6329 }
6330
6331 /// Disable the equality index for `(label, field)` and drop its postings.
6332 ///
6333 /// # Errors
6334 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6335 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6336 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6337 if self.read_only {
6338 return Err(GraphError::ReadOnly);
6339 }
6340 if !self.prop_index.is_enabled(label, field) {
6341 return Err(GraphError::RuleNotFound {
6342 name: format!("index({label},{field})"),
6343 });
6344 }
6345 self.log_then_apply(WalRecord::DisableIndex {
6346 label: label.into(),
6347 field: field.into(),
6348 })
6349 }
6350
6351 /// Whether `(label, field)` currently has an equality index.
6352 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6353 self.prop_index.is_enabled(label, field)
6354 }
6355
6356 /// Search a full-text-indexed field.
6357 ///
6358 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6359 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
6360 ///
6361 /// **Query syntax:**
6362 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6363 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6364 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6365 /// - `AND` keyword is accepted explicitly and is the default.
6366 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6367 ///
6368 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6369 /// Pin: this is the documented, tested, stable behavior for v1.
6370 ///
6371 /// **Memory / performance:** O(postings) lookup; no scan. The index is
6372 /// in-memory and proportional to total indexed text across all enabled fields.
6373 ///
6374 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6375 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6376 /// key ascending for deterministic tiebreaking.
6377 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6378 // Resolve node_ids to keys (excluding tombstones) then re-sort by
6379 // (score DESC, key ASC) to give a deterministic, key-lexicographic
6380 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
6381 // which diverges from key order when nodes were not inserted in key-lex order.
6382 let mut results: Vec<(String, f64)> = self
6383 .fulltext
6384 .search(field, query, 0)
6385 .into_iter()
6386 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6387 .collect();
6388 results.sort_by(|a, b| {
6389 b.1.partial_cmp(&a.1)
6390 .unwrap_or(std::cmp::Ordering::Equal)
6391 .then(a.0.cmp(&b.0))
6392 });
6393 results
6394 }
6395
6396 /// [`search`](Self::search), stopping at the `k` best hits.
6397 ///
6398 /// Same ranking and the same deterministic tiebreak, but the index drops
6399 /// everything past `k` before any key is resolved, so a caller that wants
6400 /// the top few out of a field that matched thousands does not pay to
6401 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6402 /// [`search`](Self::search) behaves.
6403 ///
6404 /// The BM25 scoring itself is not bounded by `k` — every candidate is
6405 /// scored either way — so this trims the resolve and the sort, not the
6406 /// search.
6407 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6408 // A tombstoned id resolves to nothing, so asking the index for exactly
6409 // `k` could return fewer. Over-fetching a little and truncating after
6410 // the filter keeps the count right without unbounding the call.
6411 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6412 let mut results: Vec<(String, f64)> = self
6413 .fulltext
6414 .search(field, query, want)
6415 .into_iter()
6416 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6417 .collect();
6418 results.sort_by(|a, b| {
6419 b.1.partial_cmp(&a.1)
6420 .unwrap_or(std::cmp::Ordering::Equal)
6421 .then(a.0.cmp(&b.0))
6422 });
6423 if k > 0 {
6424 results.truncate(k);
6425 }
6426 results
6427 }
6428
6429 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6430 ///
6431 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6432 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6433 /// them with RRF using a fixed constant of 60.
6434 ///
6435 /// ```text
6436 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
6437 /// ```
6438 ///
6439 /// Returns the top `k` nodes by fused score, ties broken by node key
6440 /// ascending (deterministic).
6441 ///
6442 /// # Vector leg fallback
6443 ///
6444 /// When `query_vec` is empty the vector leg is skipped entirely and
6445 /// results are ranked by the text list alone through the same RRF path
6446 /// (each text result scores `1/(60 + rank)` from that single list).
6447 ///
6448 /// When `label` is `None`, the vector leg **always** returns empty results.
6449 /// Internally `label` is mapped to `""`, which does not match any rule-created
6450 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6451 /// the brute-force fallback finds no nodes with an empty label. The fused
6452 /// ranking is therefore text-only in this case.
6453 pub fn search_hybrid(
6454 &self,
6455 text_field: &str,
6456 query_text: &str,
6457 vector_field: &str,
6458 query_vec: &[f64],
6459 label: Option<&str>,
6460 k: usize,
6461 ) -> Vec<(String, f64)> {
6462 use std::collections::HashMap;
6463
6464 const RRF_K: f64 = 60.0;
6465 let pool = 4 * k;
6466
6467 // Accumulate per-node RRF scores.
6468 let mut scores: HashMap<String, f64> = HashMap::new();
6469
6470 // Text leg.
6471 let text_hits = self.search(text_field, query_text);
6472 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6473 let rank = (rank0 + 1) as f64;
6474 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6475 }
6476
6477 // Vector leg (skipped when query_vec is empty).
6478 if !query_vec.is_empty() {
6479 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6480 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6481 let rank = (rank0 + 1) as f64;
6482 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6483 }
6484 }
6485
6486 // Sort: score DESC, then key ASC for deterministic tie-breaking.
6487 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6488 ranked.sort_by(|a, b| {
6489 b.1.partial_cmp(&a.1)
6490 .unwrap_or(std::cmp::Ordering::Equal)
6491 .then(a.0.cmp(&b.0))
6492 });
6493 ranked.truncate(k);
6494 ranked
6495 }
6496
6497 /// For DST/testing: scratch BM25 search over live nodes without the index.
6498 /// Walks every live node, re-stems field tokens, computes corpus stats, and
6499 /// returns BM25-ranked results.
6500 ///
6501 /// The oracle: the ordered key list of `search(field, q)` must equal that of
6502 /// `scratch_search(field, q)` at every quiescent state.
6503 #[doc(hidden)]
6504 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6505 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6506 use std::collections::BTreeMap;
6507
6508 let groups = parse_query(query);
6509 if groups.is_empty() {
6510 return vec![];
6511 }
6512
6513 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6514 struct NodeData {
6515 key: String,
6516 /// stemmed_token → positions (sorted)
6517 tokens: BTreeMap<String, Vec<u32>>,
6518 dl: u32,
6519 }
6520
6521 let mut nodes: Vec<NodeData> = Vec::new();
6522 for id in 0..self.ids.len() as u32 {
6523 let Some(key) = self.ids.key_of(id) else {
6524 continue;
6525 };
6526 let Some(&sym) = self.labels.get(id as usize) else {
6527 continue;
6528 };
6529 if sym == u32::MAX {
6530 continue;
6531 }
6532 let label = match self.syms.resolve(sym) {
6533 Some(l) => l,
6534 None => continue,
6535 };
6536 if !self.fulltext.is_enabled(label, field) {
6537 continue;
6538 }
6539 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6540 continue;
6541 };
6542 // Use value_tokens_stemmed_with_positions so list elements are
6543 // separated by POSITION_GAP — identical to the index path, which
6544 // prevents phrase queries from matching across element boundaries.
6545 let stemmed_with_pos = match &value {
6546 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6547 _ => continue,
6548 };
6549 let dl = stemmed_with_pos.len() as u32;
6550 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6551 for (tok, pos) in stemmed_with_pos {
6552 tok_map.entry(tok).or_default().push(pos);
6553 }
6554 nodes.push(NodeData {
6555 key: key.to_string(),
6556 tokens: tok_map,
6557 dl,
6558 });
6559 }
6560
6561 if nodes.is_empty() {
6562 return vec![];
6563 }
6564
6565 // --- BM25 corpus stats ---
6566 let n = nodes.len() as f64;
6567 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6568 // df per stemmed token across all live indexed nodes.
6569 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6570 for nd in &nodes {
6571 for tok in nd.tokens.keys() {
6572 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6573 }
6574 }
6575
6576 const K1: f64 = 1.2;
6577 const B: f64 = 0.75;
6578
6579 // --- Pass 2: score each node against each OR-group ---
6580 let mut results: Vec<(String, f64)> = Vec::new();
6581 for nd in &nodes {
6582 let dl = nd.dl as f64;
6583 let mut total_score = 0.0f64;
6584
6585 'group: for group in &groups {
6586 let mut group_score = 0.0f64;
6587
6588 for term in group {
6589 if term.negated {
6590 // Negated: if doc has this stemmed token → group fails.
6591 let present = if term.prefix {
6592 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6593 } else {
6594 nd.tokens.contains_key(term.token.as_str())
6595 };
6596 if present {
6597 continue 'group;
6598 }
6599 continue;
6600 }
6601 if term.prefix {
6602 // Prefix: sum BM25 for all matching stemmed tokens.
6603 let mut prefix_matched = false;
6604 for (tok, positions) in &nd.tokens {
6605 if tok.starts_with(term.token.as_str()) {
6606 let tf = positions.len() as f64;
6607 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6608 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6609 let tf_norm =
6610 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6611 group_score += idf * tf_norm;
6612 prefix_matched = true;
6613 }
6614 }
6615 if !prefix_matched {
6616 continue 'group;
6617 }
6618 } else {
6619 // term.token is already stemmed by parse_query; use directly.
6620 match nd.tokens.get(term.token.as_str()) {
6621 None => continue 'group,
6622 Some(positions) => {
6623 let tf = positions.len() as f64;
6624 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6625 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6626 let tf_norm =
6627 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6628 group_score += idf * tf_norm;
6629 }
6630 }
6631 }
6632 }
6633
6634 if group_score > 0.0 {
6635 total_score += group_score;
6636 }
6637 }
6638
6639 if total_score > 0.0 {
6640 results.push((nd.key.clone(), total_score));
6641 }
6642 }
6643
6644 results.sort_by(|a, b| {
6645 b.1.partial_cmp(&a.1)
6646 .unwrap_or(std::cmp::Ordering::Equal)
6647 .then(a.0.cmp(&b.0))
6648 });
6649 results
6650 }
6651
6652 /// Return the current view-maintained value of `view_prop` for node `key`.
6653 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6654 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6655 let id = self.ids.get(key)?;
6656 self.props_view()
6657 .get(id, view_prop)
6658 .map(|vr| vr.into_value())
6659 }
6660
6661 /// For testing / DST oracle: scratch recompute of a view value for one node.
6662 ///
6663 /// Returns `None` if the node does not exist, the view does not exist, or
6664 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6665 #[doc(hidden)]
6666 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6667 let node = self.ids.get(key)?;
6668 let def = self.view_store.views().find(|v| v.name == view_name)?;
6669 // Use TopologyView so that NeighborAgg sees base + overlay edges
6670 // without materialising a temporary Topology (I1).
6671 let topo_view = self.topo_view();
6672 core_rules::views::compute_view_value(
6673 def,
6674 node,
6675 self.props_view(),
6676 &topo_view,
6677 &self.ids,
6678 &self.syms,
6679 &self.labels,
6680 )
6681 }
6682
6683 // -----------------------------------------------------------------------
6684 // Graph algorithm API
6685 // -----------------------------------------------------------------------
6686
6687 /// Run PageRank over the unified topology (manual + derived edges).
6688 ///
6689 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6690 /// ascending). Set `config.edge_type` to restrict to one edge type.
6691 /// `config.converged` is `true` only when the power iteration converged
6692 /// within `config.max_iters` and within any time budget.
6693 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6694 let topo = build_topo_view(&self.topo, &self.base);
6695 let edge_props = self.edge_props_view();
6696 crate::algo::pagerank(
6697 &topo,
6698 &self.ids,
6699 &self.syms,
6700 &self.labels,
6701 &edge_props,
6702 config,
6703 )
6704 }
6705
6706 /// Weakly-connected components over the unified topology (treated as
6707 /// undirected regardless of how edges were inserted).
6708 ///
6709 /// Component IDs are the key of the smallest member in the component
6710 /// (deterministic). Result sorted by (component_id, key).
6711 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6712 let topo = build_topo_view(&self.topo, &self.base);
6713 let edge_props = self.edge_props_view();
6714 crate::algo::wcc(
6715 &topo,
6716 &self.ids,
6717 &self.syms,
6718 &self.labels,
6719 &edge_props,
6720 config,
6721 )
6722 }
6723
6724 /// Degree centrality for every live node.
6725 ///
6726 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6727 /// `AlgoDir::Both` = out + in (total directed degree).
6728 ///
6729 /// For one-shot ranking use this; for a live property updated on every
6730 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6731 pub fn degree_centrality(
6732 &self,
6733 config: &crate::algo::DegreeConfig,
6734 ) -> crate::algo::DegreeReport {
6735 let topo = build_topo_view(&self.topo, &self.base);
6736 let edge_props = self.edge_props_view();
6737 crate::algo::degree_centrality(
6738 &topo,
6739 &self.ids,
6740 &self.syms,
6741 &self.labels,
6742 &edge_props,
6743 config,
6744 )
6745 }
6746
6747 /// Louvain community detection over the unified topology (undirected).
6748 ///
6749 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6750 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6751 /// result (communities sorted size-desc, then smallest member key asc).
6752 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6753 let topo = build_topo_view(&self.topo, &self.base);
6754 let edge_props = self.edge_props_view();
6755 crate::algo::louvain(
6756 &topo,
6757 &self.ids,
6758 &self.syms,
6759 &self.labels,
6760 &edge_props,
6761 config,
6762 )
6763 }
6764
6765 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6766 /// atomically via a single write-batch (one WAL frame, one fsync).
6767 ///
6768 /// # Errors
6769 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6770 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6771 /// (collision check mirrors `create_view`).
6772 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6773 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6774 if self.read_only {
6775 return Err(GraphError::ReadOnly);
6776 }
6777 // Collision check: refuse if prop_name is view-managed.
6778 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6779 return Err(GraphError::RuleInvalid {
6780 detail: format!(
6781 "prop {:?} is managed by view {:?} and cannot be written as scores",
6782 prop_name, view_name
6783 ),
6784 });
6785 }
6786 // Refuse if prop_name is a view name itself (confusing namespace collision).
6787 if self.view_store.has_view(prop_name) {
6788 return Err(GraphError::RuleInvalid {
6789 detail: format!(
6790 "prop_name {:?} collides with an existing view name",
6791 prop_name
6792 ),
6793 });
6794 }
6795 // Write all scores in a single crash-atomic batch.
6796 self.write_batch(|b| {
6797 for (key, score) in scores {
6798 b.set_prop(key, prop_name, Value::Float(*score));
6799 }
6800 })?;
6801 Ok(())
6802 }
6803
6804 /// Return the value of `field` for the node with key `key`, or `None` if
6805 /// the node or field is absent. Reads through the overlay-over-base
6806 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6807 /// overlay hits; one clone per base hit).
6808 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6809 let id = self.ids.get(key)?;
6810 self.props_view().get(id, field).map(|vr| vr.into_value())
6811 }
6812
6813 pub fn has_node(&self, key: &str) -> bool {
6814 self.ids.get(key).is_some()
6815 }
6816
6817 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6818 pub(crate) fn ids(&self) -> &IdMap {
6819 &self.ids
6820 }
6821
6822 // -----------------------------------------------------------------------
6823 // Namespaces
6824 // -----------------------------------------------------------------------
6825
6826 /// The index `name` already has in `ns_names`, if any.
6827 fn ns_index_of(&self, name: &str) -> Option<u32> {
6828 self.ns_names
6829 .iter()
6830 .position(|n| n == name)
6831 .map(|i| i as u32)
6832 }
6833
6834 /// The index for `name`, appending it to `ns_names` when it is new.
6835 ///
6836 /// The table holds one entry per distinct namespace in the store — a
6837 /// tenant count, not a node count — so the linear scan is cheaper than a
6838 /// map and keeps `namespaces()` allocation-free of a second index.
6839 fn ns_index_for(&mut self, name: &str) -> u32 {
6840 match self.ns_index_of(name) {
6841 Some(i) => i,
6842 None => {
6843 self.ns_names.push(name.to_string());
6844 (self.ns_names.len() - 1) as u32
6845 }
6846 }
6847 }
6848
6849 /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6850 /// does not know (unreachable; the default is the narrowing answer).
6851 fn ns_name(&self, idx: u32) -> &str {
6852 self.ns_names
6853 .get(idx as usize)
6854 .map(String::as_str)
6855 .unwrap_or(NS_DEFAULT)
6856 }
6857
6858 /// The namespace index of dense node `id`, defaulting for an id with no
6859 /// entry (a node inserted before this handle rebuilt the array cannot
6860 /// exist: every insert path maintains it).
6861 fn node_ns_idx(&self, id: u32) -> u32 {
6862 self.node_ns
6863 .get(id as usize)
6864 .copied()
6865 .unwrap_or(NS_DEFAULT_IDX)
6866 }
6867
6868 /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6869 /// grows. Called from `apply` for every node insert, live and replayed.
6870 fn set_node_ns(&mut self, id: u32, name: &str) {
6871 let idx = if name == NS_DEFAULT {
6872 NS_DEFAULT_IDX
6873 } else {
6874 self.ns_index_for(name)
6875 };
6876 if self.node_ns.len() <= id as usize {
6877 self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6878 }
6879 self.node_ns[id as usize] = idx;
6880 }
6881
6882 /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6883 /// open or a reload, after the snapshot is restored and the WAL replayed.
6884 ///
6885 /// A store with no `ns` column reads nothing: the column-name check fails
6886 /// and the vector is filled with one constant.
6887 fn rebuild_node_ns(&mut self) {
6888 let total = self.ids.len();
6889 self.ns_names.truncate(1);
6890 self.node_ns.clear();
6891 self.node_ns.resize(total, NS_DEFAULT_IDX);
6892 let has_ns_column = {
6893 let cv = self.props_view();
6894 cv.field_names().iter().any(|f| f == NS_PROP)
6895 };
6896 if !has_ns_column {
6897 return;
6898 }
6899 // Collected first so the props view is released before `ns_index_for`
6900 // takes `&mut self`.
6901 let named: Vec<(u32, String)> = {
6902 let cv = self.props_view();
6903 (0..total as u32)
6904 .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
6905 Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
6906 _ => None,
6907 })
6908 .collect()
6909 };
6910 for (id, name) in named {
6911 let idx = self.ns_index_for(&name);
6912 self.node_ns[id as usize] = idx;
6913 }
6914 }
6915
6916 /// Every namespace with at least one live node, in name order.
6917 ///
6918 /// `["default"]` on any store that has never named a namespace, including
6919 /// an empty one: a store is always at least its default namespace.
6920 pub fn namespaces(&self) -> Vec<String> {
6921 let mut out: BTreeSet<&str> = BTreeSet::new();
6922 out.insert(NS_DEFAULT);
6923 for (id, &idx) in self.node_ns.iter().enumerate() {
6924 if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
6925 continue;
6926 }
6927 out.insert(self.ns_name(idx));
6928 }
6929 out.into_iter().map(str::to_string).collect()
6930 }
6931
6932 /// The namespace of `key`, or `None` when the key names no live node.
6933 pub fn namespace_of(&self, key: &str) -> Option<String> {
6934 let id = self.ids.get(key)?;
6935 if !self.is_live_node(id) {
6936 return None;
6937 }
6938 Some(self.ns_name(self.node_ns_idx(id)).to_string())
6939 }
6940
6941 /// Every live node in `namespace`, as a visibility mask.
6942 ///
6943 /// Built off `node_ns` on whichever handle this is, so on a temporal handle
6944 /// it is the namespace's membership at that commit. A name no node uses
6945 /// gives an empty mask — a namespace scope never widens.
6946 pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
6947 let Some(idx) = self.ns_index_of(namespace) else {
6948 return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
6949 };
6950 let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
6951 .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
6952 .collect();
6953 crate::mask::NodeMask::from_ids(visible)
6954 }
6955
6956 /// Live-node test used by the namespace accessors: a deleted node keeps its
6957 /// dense id and its `node_ns` slot, and the label sentinel is what marks it
6958 /// gone — the same test `mask_for_role`'s label leg applies implicitly.
6959 fn is_live_node(&self, id: u32) -> bool {
6960 self.labels
6961 .get(id as usize)
6962 .is_some_and(|&sym| sym != u32::MAX)
6963 && self.ids.key_of(id).is_some()
6964 }
6965
6966 /// Per-namespace live node counts for [`Stats`], in name order.
6967 fn namespace_stats(&self) -> Vec<NamespaceStats> {
6968 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
6969 counts.insert(NS_DEFAULT, 0);
6970 for id in 0..self.ids.len() as u32 {
6971 if !self.is_live_node(id) {
6972 continue;
6973 }
6974 *counts
6975 .entry(self.ns_name(self.node_ns_idx(id)))
6976 .or_insert(0) += 1;
6977 }
6978 counts
6979 .into_iter()
6980 .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
6981 .map(|(name, nodes_live)| NamespaceStats {
6982 name: name.to_string(),
6983 nodes_live,
6984 })
6985 .collect()
6986 }
6987
6988 /// The namespace a create-class op would put its node in: the `ns` entry of
6989 /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
6990 fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
6991 Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
6992 }
6993
6994 /// The one `ns` entry in a node's props, or `None` when it carries none.
6995 ///
6996 /// A props list naming `ns` twice is refused. Without that refusal the
6997 /// write path and the authorisation path can read the same list
6998 /// differently — one taking the first entry, the other the last — and
6999 /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
7000 /// the role was checked against the other of. One entry is the only shape
7001 /// where "the node's namespace" is a single fact, so it is the only shape
7002 /// accepted, and every reader of it agrees by construction.
7003 fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
7004 let mut found: Option<&'a Value> = None;
7005 for (field, value) in props {
7006 if field != NS_PROP {
7007 continue;
7008 }
7009 if found.is_some() {
7010 return Err(GraphError::RuleInvalid {
7011 detail: format!(
7012 "node {key}: {NS_PROP} is given more than once; a node has exactly \
7013 one namespace"
7014 ),
7015 });
7016 }
7017 found = Some(value);
7018 }
7019 Ok(found)
7020 }
7021
7022 /// The definition of the role a write authorisation names.
7023 ///
7024 /// `None` when `roles.json` was corrupt at open or the role has since been
7025 /// removed — neither can reach a write, because the authorisation carries a
7026 /// mask `mask_for_role` already resolved for that name.
7027 fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
7028 self.roles.as_ref()?.iter().find(|r| r.name == role)
7029 }
7030
7031 /// Validate the `ns` entry of a node's props and drop an explicit default.
7032 ///
7033 /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
7034 /// a record that reached the WAL was already accepted here.
7035 fn normalise_insert_ns(
7036 key: &str,
7037 props: Vec<(String, Value)>,
7038 ) -> Result<(Vec<(String, Value)>, String)> {
7039 // One `ns` or none: this is where that is enforced, so every later
7040 // reader of the list — the authorisation gate, the two `apply` arms,
7041 // `node_ns` — is looking at a single entry and cannot disagree about
7042 // which one counts.
7043 Self::sole_ns_entry(key, &props)?;
7044 let mut name = NS_DEFAULT.to_string();
7045 let mut out = Vec::with_capacity(props.len());
7046 for (field, value) in props {
7047 if field != NS_PROP {
7048 out.push((field, value));
7049 continue;
7050 }
7051 let Value::Str(ref s) = value else {
7052 return Err(GraphError::RuleInvalid {
7053 detail: format!(
7054 "node {key}: {NS_PROP} must be a string naming a namespace, \
7055 got {value:?}"
7056 ),
7057 });
7058 };
7059 if !valid_namespace(s) {
7060 return Err(GraphError::RuleInvalid {
7061 detail: format!(
7062 "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
7063 characters of [A-Za-z0-9_.-]"
7064 ),
7065 });
7066 }
7067 name = s.clone();
7068 // An explicit default stores nothing, so a single-tenant store
7069 // never grows an `ns` column.
7070 if name != NS_DEFAULT {
7071 out.push((field, value));
7072 }
7073 }
7074 Ok((out, name))
7075 }
7076
7077 // -----------------------------------------------------------------------
7078 // RBAC role resolution
7079 // -----------------------------------------------------------------------
7080
7081 /// Parse `roles.json` bytes from `fs`.
7082 ///
7083 /// Return values:
7084 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
7085 /// and valid; in both cases `mask_for_role` uses the
7086 /// list normally (an absent file means no roles defined).
7087 /// `Ok(None)` — file present but corrupt or unrecognised version
7088 /// → poisoned state; `mask_for_role` returns `Err` for
7089 /// any role name until the file is fixed and the DB
7090 /// re-opened (or `apply_schema` is called to repair it).
7091 ///
7092 /// Note: `None` signals corruption, not absence — the opposite of what an
7093 /// optional "file missing" convention would suggest. The open path stores
7094 /// this result on `db.roles` directly.
7095 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
7096 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
7097 if bytes.is_empty() {
7098 // Empty bytes means either the file is absent or zero-byte — both
7099 // are treated identically as "no roles defined". A zero-byte
7100 // roles.json does NOT widen access: an absent file and a zero-byte
7101 // file both resolve to an empty role list (sees nothing by default).
7102 return Ok(Some(vec![]));
7103 }
7104 match serde_json::from_slice::<RolesFile>(&bytes) {
7105 Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7106 // Corrupt or unrecognised version (>4): poison the roles state.
7107 // Never widen: a version this binary does not know may carry a
7108 // narrowing this binary would not apply.
7109 _ => Ok(None),
7110 }
7111 }
7112
7113 /// Resolve a role to a node-visibility mask against the current graph state.
7114 ///
7115 /// Returns `Err` when:
7116 /// - `roles.json` was present but corrupt at open (poisoned state), or
7117 /// - `role` does not match any defined role name.
7118 ///
7119 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7120 /// all live nodes carrying any label in `labels` that also pass the role's
7121 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7122 /// has one. Label resolution is live — new nodes of an allowed label are
7123 /// visible without re-applying the schema, and a property edited out of the
7124 /// predicate takes its node out of the mask on the next read. An empty
7125 /// union = empty mask = sees nothing.
7126 ///
7127 /// This is the one resolver every read path calls, live and as-of alike, so
7128 /// the predicate applies everywhere at once. On an as-of handle the role
7129 /// *definition* is the current one and the graph is the historical one: the
7130 /// predicate is evaluated against the property values at the commit being
7131 /// read.
7132 ///
7133 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7134 /// between two writes resolves the role once. See
7135 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7136 /// stale.
7137 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7138 self.role_masks
7139 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7140 .map(|m| (*m).clone())
7141 }
7142
7143 /// The mask an [`AsOfScope`] names, resolved against this handle.
7144 ///
7145 /// Shared by [`GraphDb::query_at_scoped`] and
7146 /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7147 /// however the namespace leg is added.
7148 fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7149 // One resolver answers "what may this role see" — `mask_for_role` — and
7150 // it runs against this handle, so on a temporal one the answer is the
7151 // as-of one.
7152 Ok(match scope {
7153 AsOfScope::Role(role) => self.mask_for_role(role)?,
7154 AsOfScope::Keys(keys) => {
7155 crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7156 }
7157 AsOfScope::RoleAndKeys(role, keys) => {
7158 self.mask_for_role(role)?
7159 .intersect(&crate::mask::NodeMask::from_keys(
7160 self,
7161 keys.iter().map(String::as_str),
7162 ))
7163 }
7164 AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7165 })
7166 }
7167
7168 /// Resolve `role` against the current graph, ignoring the memo.
7169 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7170 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7171 detail:
7172 "roles.json was corrupt at open; fix the file and re-open to restore role access"
7173 .into(),
7174 })?;
7175 let def = roles
7176 .iter()
7177 .find(|r| r.name == role)
7178 .ok_or_else(|| GraphError::KeyNotFound {
7179 key: format!("role:{role}"),
7180 })?;
7181
7182 let mut visible = std::collections::HashSet::new();
7183
7184 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7185 // An administrative grant, never narrowed by the predicate.
7186 for key in &def.keys {
7187 if let Some(id) = self.ids.get(key) {
7188 visible.insert(id);
7189 }
7190 }
7191
7192 // Label leg: live scan — iterate labels vec for matching symbol, and
7193 // when the role carries a predicate, test the property as well. The
7194 // property comes from the store's own merged view (overlay over the
7195 // mmap'd base), so an as-of handle reads the values of its own commit.
7196 let props = def.visible_where.as_ref().map(|_| self.props_view());
7197 for label_name in &def.labels {
7198 if let Some(sym) = self.syms.get(label_name) {
7199 for (i, &s) in self.labels.iter().enumerate() {
7200 if s != sym {
7201 continue;
7202 }
7203 let id = i as u32;
7204 match (&def.visible_where, &props) {
7205 (Some(pred), Some(view)) => {
7206 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7207 if pred.holds(value.as_ref()) {
7208 visible.insert(id);
7209 }
7210 }
7211 _ => {
7212 visible.insert(id);
7213 }
7214 }
7215 }
7216 }
7217 }
7218
7219 // Namespace leg: an intersection over the whole union, the key leg
7220 // included. A namespace is a tenancy boundary, so a key naming a node in
7221 // another tenant's namespace is not an administrative grant — and
7222 // `apply_schema` has already refused that role, so this only has to be
7223 // right about the node that moved into existence afterwards.
7224 if def.namespaces.is_some() {
7225 visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7226 }
7227
7228 Ok(crate::mask::NodeMask::from_ids(visible))
7229 }
7230
7231 /// Return the current list of role definitions.
7232 ///
7233 /// Returns an empty list when no roles are defined or when `roles.json`
7234 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7235 /// the fail-loud error in that case).
7236 pub fn roles(&self) -> Vec<RoleDef> {
7237 self.roles.as_deref().unwrap_or(&[]).to_vec()
7238 }
7239
7240 // ── Role-scoped write authz ───────────────────────────────────────────────
7241
7242 /// Execute `ops` with optional role-scoped write authorization.
7243 ///
7244 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7245 /// (zero-cost bypass of all authz checks).
7246 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7247 /// record is built. A denial returns an error with no WAL frame written
7248 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7249 ///
7250 /// See the plan's "authz decision table" section for the full semantics.
7251 pub fn write_batch_authz(
7252 &mut self,
7253 authz: Option<&WriteAuthz>,
7254 ops: Vec<BatchOp>,
7255 ) -> Result<(usize, usize)> {
7256 // Thread authz as a direct parameter — never touches pending_write_authz.
7257 self.commit_logged_batch(ops, None, authz.cloned())
7258 }
7259
7260 /// Execute a Cypher write statement with role-scoped write authorization.
7261 ///
7262 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7263 /// lifetime as execution, satisfying §5 lock discipline). The resolved
7264 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7265 /// call so that all inner `batch.commit()` calls are authz-checked.
7266 ///
7267 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7268 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7269 /// timing-oracle item (hidden ≡ absent for unscoped roles).
7270 ///
7271 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7272 /// "this endpoint is not permitted".
7273 pub fn query_write_authz(
7274 &mut self,
7275 role: &str,
7276 cypher: &str,
7277 params: &BTreeMap<String, Value>,
7278 ) -> Result<ResultSet> {
7279 // Resolve scope (fails fast if role has no write scope).
7280 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7281 let scope =
7282 {
7283 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7284 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7285 })?;
7286 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7287 GraphError::KeyNotFound {
7288 key: format!("role:{role}"),
7289 }
7290 })?;
7291 def.write
7292 .clone()
7293 .ok_or_else(|| GraphError::RoleWriteDenied {
7294 reason: "role-bound token: writes are not permitted".into(),
7295 })?
7296 };
7297 // Resolve mask inside the call (same guard, §5 coherence).
7298 let mask = self.mask_for_role(role)?;
7299 self.pending_write_authz = Some(WriteAuthz {
7300 role: role.into(),
7301 scope,
7302 mask,
7303 });
7304 // RAII guard: always clears pending_write_authz on scope exit, including
7305 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7306 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7307 impl Drop for ClearPendingAuthzOnDrop {
7308 fn drop(&mut self) {
7309 // SAFETY: pointer into the owning GraphDb; guard is dropped
7310 // within this function's frame before it returns.
7311 unsafe { *self.0 = None };
7312 }
7313 }
7314 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7315 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7316 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7317 detail: format!("lex: {e}"),
7318 })?;
7319 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7320 detail: format!("parse: {e}"),
7321 })?;
7322 self.exec_write_stmt(stmt, params)
7323 }
7324
7325 /// Execute `ops` with optional role-scoped write authorization, suppressing
7326 /// fsync (for use inside the group-commit drain thread, which performs one
7327 /// group fsync after releasing the write lock).
7328 ///
7329 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7330 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7331 /// contract established by [`commit_batch_nosync`].
7332 pub(crate) fn write_batch_authz_nosync(
7333 &mut self,
7334 authz: Option<&WriteAuthz>,
7335 ops: Vec<BatchOp>,
7336 ) -> Result<(usize, usize)> {
7337 let saved = self.fsync;
7338 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7339 impl Drop for RestoreFsync {
7340 fn drop(&mut self) {
7341 // SAFETY: pointer into the owning GraphDb; guard is dropped
7342 // within the enclosing function's frame before it returns.
7343 unsafe { *self.0 = self.1 };
7344 }
7345 }
7346 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7347 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7348 self.fsync = FsyncPolicy::Relaxed;
7349 self.commit_logged_batch(ops, None, authz.cloned())
7350 }
7351
7352 /// Execute a `/ingest` request with role-scoped write authorization.
7353 ///
7354 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7355 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7356 /// Sets `pending_write_authz` for the duration of the call so that the
7357 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7358 /// and evaluates the decision table per-op before any WAL write.
7359 ///
7360 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7361 /// denied by the decision table with the appropriate §4.3 scope reason;
7362 /// no special HTTP-layer check is needed.
7363 ///
7364 /// Roles with `write: None` return `RoleWriteDenied` with
7365 /// "writes are not permitted" (byte-identical to v1 blanket 403).
7366 pub fn ingest_with_edges_authz(
7367 &mut self,
7368 role: &str,
7369 label: &str,
7370 rows: Vec<std::collections::BTreeMap<String, Value>>,
7371 opts: &crate::ingest::IngestOptions,
7372 edges: &[(String, String, String)],
7373 ) -> Result<crate::ingest::IngestReport> {
7374 // Resolve scope (fails fast if role has no write scope).
7375 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7376 let scope =
7377 {
7378 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7379 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7380 })?;
7381 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7382 GraphError::KeyNotFound {
7383 key: format!("role:{role}"),
7384 }
7385 })?;
7386 def.write
7387 .clone()
7388 .ok_or_else(|| GraphError::RoleWriteDenied {
7389 reason: "role-bound token: writes are not permitted".into(),
7390 })?
7391 };
7392 let mask = self.mask_for_role(role)?;
7393 self.pending_write_authz = Some(WriteAuthz {
7394 role: role.into(),
7395 scope,
7396 mask,
7397 });
7398 // RAII guard: always clears pending_write_authz on scope exit, including
7399 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7400 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7401 impl Drop for ClearPendingAuthzOnDrop {
7402 fn drop(&mut self) {
7403 // SAFETY: pointer into the owning GraphDb; guard is dropped
7404 // within this function's frame before it returns.
7405 unsafe { *self.0 = None };
7406 }
7407 }
7408 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7409 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7410 self.ingest_with_edges(label, rows, opts, edges)
7411 }
7412
7413 /// Evaluate the write-authz decision table for one `BatchOp`.
7414 ///
7415 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7416 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
7417 /// the remaining ops are not evaluated and no WAL frame is written.
7418 ///
7419 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7420 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
7421 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7422 /// batch creates counts as visible if its label passed the create-class gate").
7423 fn check_single_op_authz(
7424 &self,
7425 authz: &WriteAuthz,
7426 op: &BatchOp,
7427 batch_created: &BTreeMap<String, String>,
7428 ) -> Result<()> {
7429 // Helper: 3-way node status under the authz mask.
7430 //
7431 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7432 // as Visible with their recorded label — their create gate already passed
7433 // and they are not yet in self.ids (not committed). This fixes the
7434 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7435 // the SetProp must not see the node as Absent.
7436 let node_status = |key: &str| -> NodeAuthzStatus {
7437 if let Some(label) = batch_created.get(key) {
7438 return NodeAuthzStatus::Visible(label.clone());
7439 }
7440 match self.ids.get(key) {
7441 None => NodeAuthzStatus::Absent,
7442 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7443 Some(id) => {
7444 let label = self
7445 .labels
7446 .get(id as usize)
7447 .and_then(|&sym| {
7448 if sym == u32::MAX {
7449 None
7450 } else {
7451 self.syms.resolve(sym).map(str::to_string)
7452 }
7453 })
7454 .unwrap_or_default();
7455 NodeAuthzStatus::Visible(label)
7456 }
7457 }
7458 };
7459
7460 // Helper: is an InsertEdgeUpsert endpoint visible?
7461 // A same-batch placeholder counts as visible if its label passed
7462 // the create-class gate (spec "upsert placeholder-counts-as-visible").
7463 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7464 // In store and visible?
7465 if let Some(id) = self.ids.get(ep_key) {
7466 return authz.mask.contains_id(id);
7467 }
7468 // Created by an earlier op in this batch?
7469 if let Some(created_label) = batch_created.get(ep_key) {
7470 return authz.scope.create_labels.contains(created_label);
7471 }
7472 // Will be created by THIS InsertEdgeUpsert: placeholder_label
7473 // must pass the create-class gate.
7474 authz
7475 .scope
7476 .create_labels
7477 .contains(&placeholder_label.to_string())
7478 };
7479
7480 match op {
7481 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7482 // These ops are never routed to role-scoped paths by the HTTP layer,
7483 // but we 403 them here to close any future bypass route.
7484 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7485 return Err(GraphError::RoleWriteDenied {
7486 reason: "role-bound token: this endpoint is not permitted".into(),
7487 });
7488 }
7489
7490 // ── CREATE-class: InsertNode ─────────────────────────────────────
7491 //
7492 // Decision table row 1 (scope-before-lookup): check label in
7493 // create_labels BEFORE any key lookup. This is the structural
7494 // closure of the §6.2 timing-oracle item — the denial fires even
7495 // when the store is EMPTY (see test_create_scope_denied_empty_store).
7496 BatchOp::InsertNode { label, key, props } => {
7497 if !authz.scope.create_labels.contains(label) {
7498 return Err(GraphError::RoleWriteDenied {
7499 reason: format!(
7500 "role-bound token: label '{}' not in write scope (create_labels)",
7501 label
7502 ),
7503 });
7504 }
7505 // A role bound to namespaces may only create inside them. The
7506 // never-widen rule is about what a write makes visible to *any*
7507 // party, not only to the writer: a node this role could never
7508 // read back is a write into somebody else's tenancy. Also a
7509 // scope check, so it runs before the key lookup — it discloses
7510 // nothing about the store. Covers Cypher `CREATE` and the node
7511 // `MERGE` creates, both of which arrive as this op.
7512 // Resolved before the role lookup so a props list naming `ns`
7513 // twice is refused for every role, scoped or not: it is the same
7514 // malformed write the seam refuses, and leaving it to the seam
7515 // would mean the gate had already read one of the two.
7516 let target = Self::created_namespace(key, props)?;
7517 if let Some(def) = self.role_def_for(&authz.role) {
7518 if !def.sees_namespace(target) {
7519 return Err(GraphError::RoleWriteDenied {
7520 reason: format!(
7521 "role-bound token: namespace '{target}' not in the role's \
7522 namespaces"
7523 ),
7524 });
7525 }
7526 }
7527 // Row 2/3: key lookup.
7528 match self.ids.get(key.as_str()) {
7529 Some(id) if authz.mask.contains_id(id) => {
7530 // Visible: DuplicateKey — let MutPreview handle this.
7531 }
7532 Some(_) => {
7533 // Hidden: indistinguishable from absent to the role.
7534 return Err(GraphError::RoleWriteDenied {
7535 reason: "role-bound token: target node not visible".into(),
7536 });
7537 }
7538 None => {
7539 // Absent: proceed (create).
7540 }
7541 }
7542 }
7543
7544 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7545 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7546 if batch_created.contains_key(key.as_str()) {
7547 // Batch-created node: create gate already passed this batch.
7548 // Updating it in the same batch is always allowed, regardless
7549 // of update_labels (ruling §3.5: "writer just created it").
7550 } else {
7551 let label = match node_status(key) {
7552 NodeAuthzStatus::Visible(lbl) => lbl,
7553 _ => {
7554 return Err(GraphError::RoleWriteDenied {
7555 reason: "role-bound token: target node not visible".into(),
7556 });
7557 }
7558 };
7559 if !authz.scope.update_labels.contains(&label) {
7560 return Err(GraphError::RoleWriteDenied {
7561 reason: format!(
7562 "role-bound token: label '{}' not in write scope (update_labels)",
7563 label
7564 ),
7565 });
7566 }
7567 }
7568 }
7569
7570 // ── DELETE-class: DeleteNode ─────────────────────────────────────
7571 BatchOp::DeleteNode { key } => {
7572 let label = match node_status(key) {
7573 NodeAuthzStatus::Visible(lbl) => lbl,
7574 _ => {
7575 return Err(GraphError::RoleWriteDenied {
7576 reason: "role-bound token: target node not visible".into(),
7577 });
7578 }
7579 };
7580 if !authz.scope.delete_labels.contains(&label) {
7581 return Err(GraphError::RoleWriteDenied {
7582 reason: format!(
7583 "role-bound token: label '{}' not in write scope (delete_labels)",
7584 label
7585 ),
7586 });
7587 }
7588 }
7589
7590 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7591 //
7592 // Derived-edge rejection runs BEFORE the delete_edge_types scope
7593 // check (spec §3.5: "existing derived-edge rejection precedes
7594 // delete_edge_types check").
7595 BatchOp::DeleteEdge {
7596 edge_type,
7597 src_key,
7598 dst_key,
7599 } => {
7600 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7601 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7602 self.ids.get(src_key.as_str()),
7603 self.ids.get(dst_key.as_str()),
7604 self.syms.get(edge_type.as_str()),
7605 ) {
7606 if self.engine.is_owned(et_sym, src_id, dst_id) {
7607 return Err(GraphError::RuleOwned {
7608 detail: format!(
7609 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7610 delete or change the owning rule"
7611 ),
7612 });
7613 }
7614 // Also check would_derive via MutPreview (empty overlay, pre-batch).
7615 let preview = MutPreview::new(self);
7616 if preview.would_derive(edge_type, src_key, dst_key) {
7617 return Err(GraphError::RuleOwned {
7618 detail: format!(
7619 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7620 delete or change the owning rule, or a live rule would \
7621 re-derive it"
7622 ),
7623 });
7624 }
7625 }
7626 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7627 if !authz.scope.delete_edge_types.contains(edge_type) {
7628 return Err(GraphError::RoleWriteDenied {
7629 reason: format!(
7630 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7631 edge_type
7632 ),
7633 });
7634 }
7635 // Both endpoints must be visible.
7636 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7637 match self.ids.get(ep_key) {
7638 None => {
7639 return Err(GraphError::RoleWriteDenied {
7640 reason: "role-bound token: edge endpoint not visible".into(),
7641 });
7642 }
7643 Some(id) if !authz.mask.contains_id(id) => {
7644 return Err(GraphError::RoleWriteDenied {
7645 reason: "role-bound token: edge endpoint not visible".into(),
7646 });
7647 }
7648 _ => {}
7649 }
7650 }
7651 }
7652
7653 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7654 //
7655 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7656 BatchOp::InsertEdge {
7657 edge_type,
7658 src_key,
7659 dst_key,
7660 } => {
7661 if !authz.scope.create_edge_types.contains(edge_type) {
7662 return Err(GraphError::RoleWriteDenied {
7663 reason: format!(
7664 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7665 edge_type
7666 ),
7667 });
7668 }
7669 // Both endpoints must be visible. A node created by an earlier
7670 // InsertNode in the same batch (tracked in batch_created) counts
7671 // as visible if its label passed the create-class gate.
7672 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7673 if batch_created.contains_key(ep_key) {
7674 // Created earlier this batch — already scope-checked.
7675 continue;
7676 }
7677 match self.ids.get(ep_key) {
7678 None => {
7679 return Err(GraphError::RoleWriteDenied {
7680 reason: "role-bound token: edge endpoint not visible".into(),
7681 });
7682 }
7683 Some(id) if !authz.mask.contains_id(id) => {
7684 return Err(GraphError::RoleWriteDenied {
7685 reason: "role-bound token: edge endpoint not visible".into(),
7686 });
7687 }
7688 _ => {}
7689 }
7690 }
7691 }
7692
7693 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7694 //
7695 // Scope check first; then endpoint visibility using same-batch
7696 // placeholder awareness (spec: "a placeholder endpoint the SAME
7697 // batch creates counts as visible if its label passed the
7698 // create-class gate").
7699 BatchOp::InsertEdgeUpsert {
7700 edge_type,
7701 src_key,
7702 dst_key,
7703 placeholder_label,
7704 } => {
7705 if !authz.scope.create_edge_types.contains(edge_type) {
7706 return Err(GraphError::RoleWriteDenied {
7707 reason: format!(
7708 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7709 edge_type
7710 ),
7711 });
7712 }
7713 // Check placeholder label against create_labels (create-class gate).
7714 // This ensures the auto-created endpoints are scope-allowed.
7715 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7716 if !upsert_ep_visible(ep_key, placeholder_label) {
7717 return Err(GraphError::RoleWriteDenied {
7718 reason: "role-bound token: edge endpoint not visible".into(),
7719 });
7720 }
7721 }
7722 // A placeholder is created with no props, so it lands in the
7723 // default namespace. A role that cannot read `default` must not
7724 // create one there, for the same reason it may not create a node
7725 // there outright.
7726 //
7727 // The refusal is byte-identical to the hidden-endpoint one above,
7728 // and deliberately so: this arm fires only for an endpoint that
7729 // does **not** exist, and the one above only for an endpoint that
7730 // does. Two different strings would make the pair an existence
7731 // oracle — ask for an upsert and read off whether the key is
7732 // taken. Hidden ≡ absent is the rule everywhere else in this
7733 // table and it holds here too.
7734 if let Some(def) = self.role_def_for(&authz.role) {
7735 if !def.sees_namespace(NS_DEFAULT) {
7736 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7737 if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7738 {
7739 return Err(GraphError::RoleWriteDenied {
7740 reason: "role-bound token: edge endpoint not visible".into(),
7741 });
7742 }
7743 }
7744 }
7745 }
7746 }
7747 }
7748 Ok(())
7749 }
7750
7751 /// Write `roles` to `roles.json` atomically and update the in-memory list.
7752 ///
7753 /// Called by `apply_schema` when roles change. Never called on unchanged
7754 /// re-apply — this preserves byte-identical idempotency.
7755 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7756 let file = RolesFile::new_versioned(roles.clone());
7757 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7758 detail: format!("roles serialization: {e}"),
7759 })?;
7760 self.fs
7761 .write_atomic(FileId::Roles, &bytes)
7762 .map_err(GraphError::Io)?;
7763 self.roles = Some(roles);
7764 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7765 // and a memoised mask would still match its version. Install a fresh
7766 // cache instead of clearing the shared one: a reader snapshot frozen
7767 // against the old definitions keeps the old `Arc` to itself and can
7768 // never publish an answer this handle would read back.
7769 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7770 // Refresh the MVCC frozen overlay so that reader() immediately sees the
7771 // updated role definitions without waiting for the next K-commit fold.
7772 self.fold_now();
7773 Ok(())
7774 }
7775
7776 fn view(&self) -> GraphView<'_> {
7777 GraphView {
7778 ids: &self.ids,
7779 syms: &self.syms,
7780 labels: &self.labels,
7781 props: self.props_view(),
7782 topo: self.topo_view(),
7783 edge_props: self.edge_props_view(),
7784 mask: None,
7785 prop_index: Some(&self.prop_index),
7786 }
7787 }
7788
7789 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7790 GraphView {
7791 ids: &self.ids,
7792 syms: &self.syms,
7793 labels: &self.labels,
7794 props: self.props_view(),
7795 topo: self.topo_view(),
7796 edge_props: self.edge_props_view(),
7797 mask: Some(&mask.visible),
7798 prop_index: Some(&self.prop_index),
7799 }
7800 }
7801
7802 /// Execute a read-only Cypher query with a node visibility mask.
7803 ///
7804 /// Only nodes whose key is in `mask` are accessible: label scans, key
7805 /// lookups, and neighbor expansions all respect the mask. Edges where
7806 /// either endpoint is hidden are silently dropped.
7807 ///
7808 /// Returns `Err` with a "masked queries are read-only" message when
7809 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7810 pub fn query_masked(
7811 &self,
7812 cypher: &str,
7813 params: &std::collections::BTreeMap<String, Value>,
7814 mask: &crate::mask::NodeMask,
7815 ) -> Result<ResultSet> {
7816 // Reject write statements up front.
7817 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7818 detail: format!("lex: {e}"),
7819 })?;
7820 if is_write_tokens(&tokens) {
7821 return Err(GraphError::MaskedReadOnly);
7822 }
7823 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7824 detail: format!("parse: {e}"),
7825 })?;
7826 // Each UNION part executes against the same masked view, so the mask
7827 // applies uniformly across the chain.
7828 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7829 GraphError::QueryError {
7830 detail: format!("execute: {e}"),
7831 }
7832 })
7833 }
7834
7835 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7836 let id = self.ids.get(key)?;
7837 Some(NodeRef { db: self, id })
7838 }
7839
7840 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7841 ///
7842 /// Hidden nodes are never used as traversal intermediaries in either
7843 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7844 /// only through a hidden node will not appear in results.
7845 ///
7846 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7847 /// a visited visible node are appended to the result as stub rows
7848 /// (`label` column is `null`, same key+depth columns as visible rows).
7849 /// They are NOT added to the BFS frontier.
7850 ///
7851 /// Returns `None` when `key` does not exist (caller should 404).
7852 ///
7853 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7854 /// stub rows are never produced on the role path.
7855 pub fn neighborhood_masked(
7856 &self,
7857 key: &str,
7858 depth: u32,
7859 edge_types: Option<&[&str]>,
7860 dir: Dir,
7861 mask: &crate::mask::NodeMask,
7862 ) -> Option<ResultSet> {
7863 let start_id = self.ids.get(key)?;
7864 let view = self.view_masked(mask);
7865 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7866 names
7867 .iter()
7868 .filter_map(|name| view.syms.get(name))
7869 .collect()
7870 });
7871 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7872 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7873 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7874 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7875 visited.push((start_id, 0));
7876 for (nid, d) in &nb.nodes {
7877 let k = view.key_of(*nid);
7878 let label = view
7879 .label_of(*nid)
7880 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7881 rs.push_row(vec![
7882 Some(Value::Str(k.to_string())),
7883 Some(Value::Str(label.to_string())),
7884 Some(Value::Int(*d as i64)),
7885 ]);
7886 visited.push((*nid, *d));
7887 }
7888 // Stub mode: add hidden direct neighbours of each visited node as stubs.
7889 // Hidden nodes are edge-endpoints only — they are not added to the BFS
7890 // frontier, so the BFS never expands through them.
7891 if mask.mode() == crate::mask::MaskMode::Stub {
7892 let raw_view = self.view();
7893 let mut seen: std::collections::HashSet<u32> =
7894 visited.iter().map(|(id, _)| *id).collect();
7895 for (node_id, node_depth) in &visited {
7896 if *node_depth >= depth {
7897 continue;
7898 }
7899 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7900 let nbr = if e.src == *node_id { e.dst } else { e.src };
7901 if !mask.contains_id(nbr) && seen.insert(nbr) {
7902 if let Some(k) = self.ids.key_of(nbr) {
7903 rs.push_row(vec![
7904 Some(Value::Str(k.to_string())),
7905 None,
7906 Some(Value::Int((*node_depth + 1) as i64)),
7907 ]);
7908 }
7909 }
7910 }
7911 }
7912 }
7913 Some(rs)
7914 }
7915
7916 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7917 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7918 let n = self.node_ref(key)?;
7919 Some(NodeInfo {
7920 key: n.key().to_string(),
7921 label: n.label().to_string(),
7922 props: n.props(),
7923 })
7924 }
7925
7926 /// Look up a node with mask awareness.
7927 ///
7928 /// | Key state | Omit mode | Stub mode |
7929 /// |-------------------|-----------------|------------------------|
7930 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
7931 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
7932 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
7933 ///
7934 /// **SECURITY**: only call from client-mask (full-token) paths.
7935 /// Role-token paths must use [`node_info`] after an explicit visibility check.
7936 pub fn node_info_masked(
7937 &self,
7938 key: &str,
7939 mask: &crate::mask::NodeMask,
7940 ) -> Option<MaskedNodeResult> {
7941 let id = self.ids.get(key)?;
7942 if mask.contains_id(id) {
7943 Some(MaskedNodeResult::Visible(self.node_info(key)?))
7944 } else {
7945 match mask.mode() {
7946 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7947 crate::mask::MaskMode::Omit => None,
7948 }
7949 }
7950 }
7951
7952 /// Get edges for `key` with mask-aware hidden-endpoint handling.
7953 ///
7954 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7955 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7956 /// is `true` for each hidden endpoint.
7957 ///
7958 /// Unknown key → [`GraphError::KeyNotFound`].
7959 ///
7960 /// **SECURITY**: only call from client-mask (full-token) paths.
7961 pub fn node_edges_masked(
7962 &self,
7963 key: &str,
7964 mask: &crate::mask::NodeMask,
7965 ) -> Result<Vec<MaskedEdge>> {
7966 self.ensure_v8_base_sections_loaded();
7967 let id = self
7968 .ids
7969 .get(key)
7970 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7971 let derived: BTreeSet<(u32, u32, u32)> = self
7972 .engine
7973 .provenance_touching(id)
7974 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7975 .collect();
7976 let mut edges = Vec::new();
7977 let tv = self.topo_view();
7978 for etype in tv.etypes() {
7979 // etype comes from the archived CSR (access_unchecked, no eager CRC).
7980 // A bit-flip in the large TOPOLOGY section can produce an etype id
7981 // that is not in the interner. Return Corrupt rather than panic.
7982 let edge_type = self
7983 .syms
7984 .resolve(etype)
7985 .ok_or_else(|| GraphError::Corrupt {
7986 detail: format!("v8: topology etype {etype} not in interner"),
7987 })?
7988 .to_string();
7989 for dir in [Direction::Out, Direction::In] {
7990 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7991 let nbr_restricted = !mask.contains_id(nbr);
7992 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7993 continue;
7994 }
7995 let nbr_key = self
7996 .ids
7997 .key_of(nbr)
7998 .ok_or_else(|| GraphError::Corrupt {
7999 detail: format!("topology id {nbr} has no key"),
8000 })?
8001 .to_string();
8002 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
8003 match dir {
8004 Direction::Out => {
8005 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
8006 }
8007 Direction::In => {
8008 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
8009 }
8010 };
8011 edges.push(MaskedEdge {
8012 edge_type: edge_type.clone(),
8013 src_key,
8014 src_restricted,
8015 dst_key,
8016 dst_restricted,
8017 derived: derived.contains(&(etype, src_id, dst_id)),
8018 });
8019 }
8020 }
8021 }
8022 edges.sort_by(|a, b| {
8023 a.edge_type
8024 .cmp(&b.edge_type)
8025 .then(a.src_key.cmp(&b.src_key))
8026 .then(a.dst_key.cmp(&b.dst_key))
8027 });
8028 edges.dedup_by(|a, b| {
8029 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
8030 });
8031 Ok(edges)
8032 }
8033
8034 /// Every directed edge incident on `key`, both directions, every etype.
8035 ///
8036 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
8037 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
8038 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
8039 /// Unknown key → [`GraphError::KeyNotFound`].
8040 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
8041 self.ensure_v8_base_sections_loaded();
8042 let id = self
8043 .ids
8044 .get(key)
8045 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8046 let derived: BTreeSet<(u32, u32, u32)> = self
8047 .engine
8048 .provenance_touching(id)
8049 .map(|(_rule, etype, src, dst)| (etype, src, dst))
8050 .collect();
8051 let mut edges = Vec::new();
8052 let tv = self.topo_view();
8053 for etype in tv.etypes() {
8054 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
8055 let edge_type = self
8056 .syms
8057 .resolve(etype)
8058 .ok_or_else(|| GraphError::Corrupt {
8059 detail: format!("v8: topology etype {etype} not in interner"),
8060 })?
8061 .to_string();
8062 for dir in [Direction::Out, Direction::In] {
8063 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8064 let (src, dst, src_key, dst_key) = match dir {
8065 Direction::Out => (
8066 id,
8067 nbr,
8068 key.to_string(),
8069 self.ids
8070 .key_of(nbr)
8071 .ok_or_else(|| GraphError::Corrupt {
8072 detail: format!("topology id {nbr} has no key"),
8073 })?
8074 .to_string(),
8075 ),
8076 Direction::In => (
8077 nbr,
8078 id,
8079 self.ids
8080 .key_of(nbr)
8081 .ok_or_else(|| GraphError::Corrupt {
8082 detail: format!("topology id {nbr} has no key"),
8083 })?
8084 .to_string(),
8085 key.to_string(),
8086 ),
8087 };
8088 edges.push(EdgeInfo {
8089 edge_type: edge_type.clone(),
8090 src_key,
8091 dst_key,
8092 derived: derived.contains(&(etype, src, dst)),
8093 });
8094 }
8095 }
8096 }
8097 edges.sort_by(|a, b| {
8098 a.edge_type
8099 .cmp(&b.edge_type)
8100 .then(a.src_key.cmp(&b.src_key))
8101 .then(a.dst_key.cmp(&b.dst_key))
8102 });
8103 // Self-loops appear in both Out and In; sort makes the pair adjacent
8104 // (sort key matches PartialEq for this case) so one pass drops the dup.
8105 edges.dedup();
8106 Ok(edges)
8107 }
8108
8109 // ── Backup ────────────────────────────────────────────────────────────────
8110
8111 /// Copy this store to `dest` as a consistent, verified snapshot.
8112 ///
8113 /// Copies every durable file in the database directory — `snapshot.bin`,
8114 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8115 /// `roles.json` — into a freshly created `dest` directory using OS-level
8116 /// `copy` calls (no large in-process buffers).
8117 ///
8118 /// # Consistency guarantee
8119 ///
8120 /// The guarantee is **process-local**: the caller holds `&self`, which
8121 /// prevents any concurrent writer in the **same process** from modifying
8122 /// the files during the copy. Running `mushroomdb backup` against a
8123 /// directory that is **concurrently being written by another process** (e.g.
8124 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
8125 /// `verified: true` result reduces but does not eliminate the risk of a
8126 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8127 /// consistent mid-write snapshot).
8128 ///
8129 /// **The safe path for a live-served store is `POST /backup` on the HTTP
8130 /// server.** That handler acquires the read lock on the shared database
8131 /// before calling this method, which is the correct cross-process
8132 /// synchronisation point because the server is the single process writing
8133 /// the files.
8134 ///
8135 /// After copying, opens the destination read-only and runs the CRC section
8136 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8137 /// `BackupReport::verified` reflects whether both checks passed.
8138 ///
8139 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8140 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8141 // Derive source directory from snapshot_path (RealFs only).
8142 let src_dir = match self.fs.snapshot_path() {
8143 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8144 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8145 })?,
8146 None => {
8147 return Err(GraphError::Io(std::io::Error::other(
8148 "backup_to requires a real filesystem (RealFs)",
8149 )))
8150 }
8151 };
8152
8153 std::fs::create_dir_all(dest)?;
8154
8155 let mut files: Vec<String> = Vec::new();
8156 let mut bytes: u64 = 0;
8157
8158 // Helper: copy src_dir/name → dest/name if the file exists.
8159 let mut try_copy = |name: &str| -> std::io::Result<()> {
8160 let src_path = src_dir.join(name);
8161 if src_path.exists() {
8162 let n = std::fs::copy(&src_path, dest.join(name))?;
8163 bytes += n;
8164 files.push(name.to_string());
8165 }
8166 Ok(())
8167 };
8168
8169 try_copy("snapshot.bin")?;
8170 try_copy("snapshot.bin.bak")?;
8171 try_copy("wal.bin")?;
8172 try_copy("wal.floor")?;
8173 try_copy("wal.genesis")?;
8174 try_copy("roles.json")?;
8175
8176 // Copy WAL archives.
8177 let archives = self.fs.list_archives()?;
8178 for n in &archives {
8179 let name = format!("wal.{n}.archive");
8180 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8181 bytes += n_bytes;
8182 files.push(name);
8183 }
8184
8185 files.sort();
8186
8187 // Post-copy verification: open dest and run CRC checks.
8188 let snap_in_dest = dest.join("snapshot.bin").exists();
8189 let crc_ok = if snap_in_dest {
8190 crate::verify_snapshot(dest)
8191 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8192 .unwrap_or(false)
8193 } else {
8194 true // WAL-only store: nothing to CRC-check in snapshot
8195 };
8196 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8197 let verified = crc_ok && opens_ok;
8198
8199 Ok(BackupReport {
8200 files,
8201 bytes,
8202 verified,
8203 })
8204 }
8205
8206 // ── Export helpers ────────────────────────────────────────────────────────
8207
8208 /// All live nodes, sorted by key (deterministic).
8209 ///
8210 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8211 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8212 self.ensure_v8_base_sections_loaded();
8213 let pv = self.props_view();
8214 let mut nodes = Vec::new();
8215 for id in 0..self.ids.len() as u32 {
8216 let Some(key) = self.ids.key_of(id) else {
8217 continue;
8218 };
8219 let Some(&sym) = self.labels.get(id as usize) else {
8220 continue;
8221 };
8222 if sym == u32::MAX {
8223 continue; // tombstoned
8224 }
8225 let Some(label) = self.syms.resolve(sym) else {
8226 continue;
8227 };
8228 let mut props = BTreeMap::new();
8229 for field in pv.field_names() {
8230 if let Some(vr) = pv.get(id, &field) {
8231 props.insert(field, vr.into_value());
8232 }
8233 }
8234 nodes.push(NodeInfo {
8235 key: key.to_string(),
8236 label: label.to_string(),
8237 props,
8238 });
8239 }
8240 nodes.sort_by(|a, b| a.key.cmp(&b.key));
8241 nodes
8242 }
8243
8244 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8245 ///
8246 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8247 /// Manual edges carry `derived: false` and `rule: None`.
8248 /// `weight` is the creating rule's `weight_prop` value read off the edge
8249 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8250 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8251 /// store state.
8252 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8253 self.ensure_v8_base_sections_loaded();
8254
8255 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8256 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8257 for (rule_name, triples) in self.engine.provenance() {
8258 for &(etype, src, dst) in triples {
8259 prov.insert((etype, src, dst), rule_name.clone());
8260 }
8261 }
8262
8263 // rule_name → weight_prop, for O(1) lookup per derived edge.
8264 let weight_props: HashMap<&str, Option<&str>> = self
8265 .engine
8266 .rules()
8267 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8268 .collect();
8269
8270 let tv = self.topo_view();
8271 let ep = self.edge_props_view();
8272 let mut edges = Vec::new();
8273
8274 for id in 0..self.ids.len() as u32 {
8275 let Some(key) = self.ids.key_of(id) else {
8276 continue;
8277 };
8278 let Some(&lsym) = self.labels.get(id as usize) else {
8279 continue;
8280 };
8281 if lsym == u32::MAX {
8282 continue; // tombstoned
8283 }
8284
8285 for etype_sym in tv.etypes() {
8286 // etype from archived CSR (access_unchecked, no eager CRC).
8287 // Skip edges whose etype is not in the interner; this can only
8288 // occur with a corrupt large TOPOLOGY section (bit-flip on an
8289 // etype field in the archived data). The function returns Vec,
8290 // not Result, so we continue rather than propagate.
8291 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8292 continue;
8293 };
8294 let edge_type = edge_type.to_string();
8295 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8296 let Some(dst_key) = self.ids.key_of(nbr) else {
8297 continue; // skip corrupt entries
8298 };
8299 let prov_key = (etype_sym, id, nbr);
8300 let rule = prov.get(&prov_key).cloned();
8301 let derived = rule.is_some();
8302 let weight = rule
8303 .as_deref()
8304 .and_then(|rn| weight_props.get(rn).copied().flatten())
8305 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8306 Some(Value::Float(f)) => Some(f),
8307 Some(Value::Int(i)) => Some(i as f64),
8308 _ => None,
8309 });
8310 edges.push(ExportEdge {
8311 edge_type: edge_type.clone(),
8312 src: key.to_string(),
8313 dst: dst_key.to_string(),
8314 derived,
8315 rule,
8316 weight,
8317 });
8318 }
8319 }
8320 }
8321
8322 edges.sort_by(|a, b| {
8323 a.edge_type
8324 .cmp(&b.edge_type)
8325 .then(a.src.cmp(&b.src))
8326 .then(a.dst.cmp(&b.dst))
8327 });
8328 edges
8329 }
8330
8331 /// What each edge type *is*, without building one record per edge.
8332 ///
8333 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8334 /// question by materialising every edge — three `String`s apiece, a
8335 /// provenance `HashMap` over every derived edge, and a final sort. That is
8336 /// the right shape for an export, and the wrong one for a summary: on a
8337 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8338 /// produce nine lines. This walks the topology instead, summing neighbour
8339 /// slice lengths and collecting *label symbols* rather than label strings,
8340 /// so the per-edge cost is an integer add and a set insert on a set with
8341 /// as many members as the store has labels.
8342 ///
8343 /// The rule names come off the rule *definitions*, which each declare the
8344 /// `edge_type` they derive, so naming them costs one pass over the rules
8345 /// rather than one provenance lookup per edge. That is also why `rules`
8346 /// is a list: two rules may derive the same type — the association store
8347 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8348 /// talent→job rule — and naming only one of them would be a half-truth.
8349 /// A type with no rules is one written by hand.
8350 ///
8351 /// `sample` is the first edge of the type in the store's own id order,
8352 /// which is insertion order: deterministic for a given store, and not the
8353 /// same as key order, which cannot be had without resolving a key per
8354 /// edge. Sorted by `edge_type`.
8355 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8356 self.ensure_v8_base_sections_loaded();
8357
8358 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8359 for r in self.engine.rules() {
8360 rules_by_type
8361 .entry(r.edge_type.as_str())
8362 .or_default()
8363 .insert(r.name.as_str());
8364 }
8365
8366 let tv = self.topo_view();
8367 let node_count = self.ids.len() as u32;
8368 let mut out = Vec::new();
8369 for etype_sym in tv.etypes() {
8370 // An etype the interner cannot resolve means a corrupt TOPOLOGY
8371 // section; skip it rather than name it, as `all_edges_for_export`
8372 // does for the same reason.
8373 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8374 continue;
8375 };
8376 let mut edges: u64 = 0;
8377 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8378 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8379 let mut sample: Option<(u32, u32)> = None;
8380 for id in 0..node_count {
8381 let Some(&lsym) = self.labels.get(id as usize) else {
8382 continue;
8383 };
8384 if lsym == u32::MAX {
8385 continue; // tombstoned
8386 }
8387 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8388 let nbrs = nbrs.as_ref();
8389 if nbrs.is_empty() {
8390 continue;
8391 }
8392 edges += nbrs.len() as u64;
8393 src_syms.insert(lsym);
8394 for &nbr in nbrs {
8395 if let Some(&dsym) = self.labels.get(nbr as usize) {
8396 if dsym != u32::MAX {
8397 dst_syms.insert(dsym);
8398 }
8399 }
8400 }
8401 if sample.is_none() {
8402 sample = Some((id, nbrs[0]));
8403 }
8404 }
8405 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8406 syms.iter()
8407 .filter_map(|&s| self.syms.resolve(s))
8408 .map(ToString::to_string)
8409 .collect()
8410 };
8411 out.push(EdgeTypeCensus {
8412 edge_type: edge_type.to_string(),
8413 edges,
8414 src_labels: resolve(&src_syms),
8415 dst_labels: resolve(&dst_syms),
8416 rules: rules_by_type
8417 .get(edge_type)
8418 .map(|rs| rs.iter().map(ToString::to_string).collect())
8419 .unwrap_or_default(),
8420 sample: sample.and_then(|(s, d)| {
8421 Some((
8422 self.ids.key_of(s)?.to_string(),
8423 self.ids.key_of(d)?.to_string(),
8424 ))
8425 }),
8426 });
8427 }
8428 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8429 out
8430 }
8431
8432 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8433 /// on each edge when given.
8434 ///
8435 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8436 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8437 /// `None` — callers that want a default weight (e.g. `1.0` for missing
8438 /// props) apply it themselves, matching the convention used internally
8439 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8440 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8441 ///
8442 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8443 /// (manual + rule-derived edges). An unknown `edge_type` returns an
8444 /// empty vec.
8445 pub fn weighted_edges(
8446 &self,
8447 edge_type: &str,
8448 weight_prop: Option<&str>,
8449 ) -> Vec<(String, String, Option<f64>)> {
8450 let Some(etype_sym) = self.syms.get(edge_type) else {
8451 return Vec::new();
8452 };
8453 let tv = self.topo_view();
8454 let ep = self.edge_props_view();
8455 let mut out = Vec::new();
8456 for id in 0..self.ids.len() as u32 {
8457 let Some(key) = self.ids.key_of(id) else {
8458 continue;
8459 };
8460 let Some(&sym) = self.labels.get(id as usize) else {
8461 continue;
8462 };
8463 if sym == u32::MAX {
8464 continue; // tombstoned
8465 }
8466 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8467 let Some(dst_key) = self.ids.key_of(nbr) else {
8468 continue;
8469 };
8470 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8471 Some(Value::Float(f)) => Some(f),
8472 Some(Value::Int(i)) => Some(i as f64),
8473 _ => None,
8474 });
8475 out.push((key.to_string(), dst_key.to_string(), weight));
8476 }
8477 }
8478 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8479 out
8480 }
8481
8482 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8483 self.view()
8484 .nodes_with_label(label)
8485 .into_iter()
8486 .map(|id| NodeRef { db: self, id })
8487 .collect()
8488 }
8489
8490 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8491 let view = self.view();
8492 view.nodes_with_label(label)
8493 .into_iter()
8494 .filter(|&id| {
8495 eval_filter(filter, &|field| {
8496 view.prop(id, field).map(|vr| vr.into_value())
8497 })
8498 })
8499 .map(|id| NodeRef { db: self, id })
8500 .collect()
8501 }
8502
8503 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8504 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
8505 /// with `label = None` will use the native ANN path rather than the O(n)
8506 /// brute-force scan.
8507 pub fn has_vector_rule(&self, field: &str) -> bool {
8508 self.engine.hnsw_has_rule(field)
8509 }
8510
8511 /// How many HNSW graphs this handle has built from scratch since it was
8512 /// opened (one per side of an approximate rule).
8513 ///
8514 /// An open that restored every graph from the snapshot reports `0`.
8515 /// Exposed for tests that assert the open path reuses the persisted index
8516 /// rather than rebuilding it; not part of the stable surface.
8517 #[doc(hidden)]
8518 pub fn hnsw_build_count(&self) -> u64 {
8519 self.engine.hnsw_build_count()
8520 }
8521
8522 /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8523 ///
8524 /// Zero before the first ANN query on a clean open, and again once the
8525 /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8526 /// Exposed for tests that assert the lazy copies are released; not part of
8527 /// the stable surface.
8528 #[doc(hidden)]
8529 pub fn lazy_hnsw_len(&self) -> usize {
8530 self.engine.lazy_hnsw_len()
8531 }
8532
8533 /// Find nodes whose `field` vector is most similar to `q` (cosine
8534 /// similarity), returning up to `k` results with similarity ≥ `min`,
8535 /// sorted descending.
8536 ///
8537 /// When `label` is `None` the search spans all labels (via
8538 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8539 /// `Some(lbl)` it restricts to nodes with that label.
8540 ///
8541 /// Uses the HNSW index when one is available (fast path); otherwise falls
8542 /// back to an O(n) brute-force scan.
8543 ///
8544 /// **The index supplies candidates, never scores.** Its own distances are
8545 /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8546 /// every candidate is re-scored from the `f64` property vectors by
8547 /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8548 /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8549 /// the re-ordering cannot drop a true top-`k` member; see that constant for
8550 /// the rule. The score a caller receives is therefore the same number the
8551 /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8552 /// finds an exact duplicate.
8553 pub fn find_similar_vector(
8554 &self,
8555 field: &str,
8556 label: Option<&str>,
8557 q: &[f64],
8558 k: usize,
8559 min: f64,
8560 ) -> Vec<(String, f64)> {
8561 self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
8562 .expect("find_similar_vector_filtered is infallible without where_")
8563 }
8564
8565 /// Like [`find_similar_vector`] but restricts results to nodes visible in
8566 /// `mask`. Hidden nodes never appear in results; the mask is applied
8567 /// **before** k-truncation so a caller still receives up to `k` visible
8568 /// hits.
8569 ///
8570 /// # HNSW path (widening beam)
8571 ///
8572 /// When an HNSW index covers the request, the beam starts at an over-fetch
8573 /// of `k × n / |visible|` (plus the rescore margin) when the mask's
8574 /// selectivity is known from the index length, otherwise at `k` plus that
8575 /// margin. If fewer than `k` visible candidates remain after the mask and
8576 /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
8577 /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
8578 /// or a beam that comes back short of its own width, falls through to the
8579 /// exhaustive masked scan rather than returning a short result.
8580 ///
8581 /// Every surviving candidate is re-scored from the `f64` property vectors,
8582 /// exactly as [`find_similar_vector`] does and for the same reason.
8583 ///
8584 /// # Brute-force path
8585 ///
8586 /// When no HNSW index covers the request, or the beam cannot admit `k`
8587 /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
8588 /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
8589 /// results (or all visible nodes if fewer than `k` exist).
8590 pub fn find_similar_vector_masked(
8591 &self,
8592 field: &str,
8593 label: Option<&str>,
8594 q: &[f64],
8595 k: usize,
8596 min: f64,
8597 mask: &crate::mask::NodeMask,
8598 ) -> Vec<(String, f64)> {
8599 self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
8600 .expect("find_similar_vector_filtered is infallible without where_")
8601 }
8602
8603 /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
8604 ///
8605 /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
8606 /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
8607 /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
8608 /// uses HNSW when an index covers the field.
8609 #[allow(clippy::too_many_arguments)]
8610 pub fn find_similar_vector_filtered(
8611 &self,
8612 field: &str,
8613 label: Option<&str>,
8614 q: &[f64],
8615 k: usize,
8616 min: f64,
8617 mask: Option<&crate::mask::NodeMask>,
8618 where_: Option<&PropPredicate>,
8619 exact: bool,
8620 ) -> Result<Vec<(String, f64)>> {
8621 if let Some(pred) = where_ {
8622 pred.validate_named("where")
8623 .map_err(|detail| GraphError::QueryError { detail })?;
8624 }
8625
8626 // Ensure any HNSW blobs retained from the snapshot are deserialized
8627 // before the first ANN query on a clean-open (no-WAL) path. The
8628 // section read has to come first: on a clean open nothing else has
8629 // called it, so without it `retained_hnsw_blobs` is empty,
8630 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8631 // approximate query on the handle runs brute force — correct results,
8632 // silently off the index. Both calls are idempotent and cheap once hot.
8633 self.ensure_v8_base_sections_loaded();
8634 self.engine.ensure_hnsw_loaded();
8635 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8636 if norm == 0.0 {
8637 return Ok(vec![]);
8638 }
8639 if let Some(m) = mask {
8640 if k == 0 || m.is_empty() {
8641 return Ok(vec![]);
8642 }
8643 }
8644 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8645
8646 // `where` implies exact: a predicate must not ride a silent ANN.
8647 let skip_hnsw = exact || where_.is_some();
8648 if !skip_hnsw {
8649 if let Some(mask) = mask {
8650 if let Some(out) =
8651 self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask)
8652 {
8653 return Ok(out);
8654 }
8655 } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
8656 return Ok(out);
8657 }
8658 }
8659
8660 let view = match mask {
8661 Some(m) => self.view_masked(m),
8662 None => self.view(),
8663 };
8664 let candidate_ids = Self::vector_candidates(&view, label, where_);
8665 Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
8666 }
8667
8668 /// Unmasked HNSW path. `None` when no populated index covers the request.
8669 fn find_similar_hnsw(
8670 &self,
8671 field: &str,
8672 label: Option<&str>,
8673 q_unit: &[f64],
8674 k: usize,
8675 min: f64,
8676 ) -> Option<Vec<(String, f64)>> {
8677 // Try HNSW fast path.
8678 // `None` label searches across all VectorSimilar rules covering `field`
8679 // (merging their results); `Some(lbl)` restricts to rules whose
8680 // dst_label matches. Returns `None` when no populated HNSW index
8681 // covers the request — the O(n) brute-force fallback handles that case.
8682 let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8683 let hits = match label {
8684 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
8685 None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
8686 };
8687 // Candidates only: the index's `f32` similarity is discarded and
8688 // each hit is re-scored against the `f64` vectors.
8689 let view = self.view();
8690 let mut out: Vec<(String, f64)> = hits
8691 .into_iter()
8692 .filter_map(|(id, _)| {
8693 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8694 if sim < min {
8695 return None;
8696 }
8697 Some((self.ids.key_of(id)?.to_string(), sim))
8698 })
8699 .collect();
8700 out.sort_by(|a, b| {
8701 b.1.partial_cmp(&a.1)
8702 .unwrap_or(std::cmp::Ordering::Equal)
8703 .then_with(|| a.0.cmp(&b.0))
8704 });
8705 out.truncate(k);
8706 Some(out)
8707 }
8708
8709 /// Masked HNSW widening beam. `None` when no index covers the request or
8710 /// the beam cannot admit `k` visible hits (caller falls through to brute).
8711 fn find_similar_hnsw_masked(
8712 &self,
8713 field: &str,
8714 label: Option<&str>,
8715 q_unit: &[f64],
8716 k: usize,
8717 min: f64,
8718 mask: &crate::mask::NodeMask,
8719 ) -> Option<Vec<(String, f64)>> {
8720 let index_len = match label {
8721 Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
8722 None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
8723 };
8724 let n = index_len?;
8725 // Same ceiling the exact-rule widening loop in `hnsw_candidates`
8726 // consults — including the `with_ef_max` test hook.
8727 let cap = ef_max();
8728 let visible = mask.len();
8729 let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
8730 if visible > 0 && n > 0 {
8731 let over = k
8732 .saturating_mul(n)
8733 .div_ceil(visible)
8734 .saturating_add(VECTOR_RESCORE_MARGIN);
8735 ef = ef.max(over);
8736 }
8737 loop {
8738 let hits = match label {
8739 Some(lbl) => self
8740 .engine
8741 .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
8742 None => self
8743 .engine
8744 .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
8745 };
8746 let hits = hits?;
8747 let full = hits.len() == ef;
8748 let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
8749 if out.len() >= k {
8750 out.truncate(k);
8751 return Some(out);
8752 }
8753 // Short of its width (frontier exhausted) or at the ceiling:
8754 // a wider beam reaches nothing new, so the scan answers.
8755 if !full || ef >= cap {
8756 return None;
8757 }
8758 ef = ef.saturating_mul(2);
8759 }
8760 }
8761
8762 /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
8763 /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
8764 fn vector_candidates(
8765 view: &GraphView<'_>,
8766 label: Option<&str>,
8767 where_: Option<&PropPredicate>,
8768 ) -> Vec<u32> {
8769 if let (Some(lbl), Some(pred)) = (label, where_) {
8770 let indexed = view
8771 .prop_index
8772 .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
8773 if indexed {
8774 match (&pred.eq, &pred.in_) {
8775 (Some(eq), None) => {
8776 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
8777 return ids;
8778 }
8779 }
8780 (None, Some(allowed)) => {
8781 let mut seen = HashSet::new();
8782 let mut out = Vec::new();
8783 for v in allowed {
8784 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
8785 for id in ids {
8786 if seen.insert(id) {
8787 out.push(id);
8788 }
8789 }
8790 }
8791 }
8792 return out;
8793 }
8794 _ => {}
8795 }
8796 }
8797 }
8798
8799 let mut ids: Vec<u32> = match label {
8800 Some(lbl) => view
8801 .nodes_with_label(lbl)
8802 .into_iter()
8803 .filter(|&id| view.visible(id))
8804 .collect(),
8805 None => view.nodes_all(),
8806 };
8807 if let Some(pred) = where_ {
8808 ids.retain(|&id| match view.prop(id, &pred.field) {
8809 None => pred.holds(None),
8810 Some(vr) => pred.holds(Some(vr.as_value())),
8811 });
8812 }
8813 ids
8814 }
8815
8816 /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
8817 /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
8818 fn brute_vector_hits(
8819 &self,
8820 view: &GraphView<'_>,
8821 candidate_ids: impl IntoIterator<Item = u32>,
8822 field: &str,
8823 q_unit: &[f64],
8824 k: usize,
8825 min: f64,
8826 ) -> Vec<(String, f64)> {
8827 let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
8828 .into_iter()
8829 .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
8830 .collect();
8831 let packed =
8832 crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
8833 let scores = crate::exact_knn::gemv(&packed, q_unit);
8834 let mut scored: Vec<(String, f64)> = packed
8835 .ids
8836 .iter()
8837 .zip(scores.iter())
8838 .filter_map(|(&id, &sim)| {
8839 if sim < min {
8840 return None;
8841 }
8842 let key = self.ids.key_of(id)?.to_string();
8843 Some((key, sim))
8844 })
8845 .collect();
8846 scored.sort_by(|a, b| {
8847 b.1.partial_cmp(&a.1)
8848 .unwrap_or(std::cmp::Ordering::Equal)
8849 .then_with(|| a.0.cmp(&b.0))
8850 });
8851 scored.truncate(k);
8852 scored
8853 }
8854
8855 /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
8856 ///
8857 /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
8858 /// same unit and inequality as `find_similar_vector`. Self-matches are
8859 /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
8860 /// embeddings are omitted as both query and candidate. Duplicate keys are
8861 /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
8862 /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
8863 #[allow(clippy::type_complexity)]
8864 pub fn pairwise_similar(
8865 &self,
8866 keys: &[&str],
8867 field: &str,
8868 k: usize,
8869 min: f64,
8870 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
8871 let mut seen = HashSet::new();
8872 let mut unique_ids = Vec::new();
8873 for key in keys {
8874 let Some(id) = self.ids.get(key) else {
8875 continue;
8876 };
8877 if seen.insert(id) {
8878 unique_ids.push(id);
8879 }
8880 }
8881 let max_n = crate::exact_knn::pairwise_max_n();
8882 if unique_ids.len() > max_n {
8883 return Err(GraphError::QueryError {
8884 detail: format!(
8885 "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
8886 unique_ids.len()
8887 ),
8888 });
8889 }
8890 if unique_ids.is_empty() {
8891 return Ok(Vec::new());
8892 }
8893
8894 let view = self.view();
8895 let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
8896 let mut counts: HashMap<usize, usize> = HashMap::new();
8897 for id in unique_ids {
8898 let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
8899 continue;
8900 };
8901 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
8902 if norm == 0.0 {
8903 continue;
8904 }
8905 *counts.entry(v.len()).or_default() += 1;
8906 rows.push((id, v));
8907 }
8908 if rows.is_empty() {
8909 return Ok(Vec::new());
8910 }
8911 let dim = counts
8912 .into_iter()
8913 .max_by_key(|&(d, c)| (c, d))
8914 .map(|(d, _)| d)
8915 .expect("rows non-empty");
8916 let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
8917 let n = packed.ids.len();
8918 if n == 0 {
8919 return Ok(Vec::new());
8920 }
8921 let src_keys: Vec<String> = packed
8922 .ids
8923 .iter()
8924 .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
8925 .collect();
8926
8927 let mut out = Vec::with_capacity(n);
8928 if n <= crate::exact_knn::pairwise_gram_max() {
8929 let sims = crate::exact_knn::gram(&packed);
8930 for i in 0..n {
8931 out.push(Self::topk_from_row(
8932 &src_keys,
8933 i,
8934 &sims[i * n..(i + 1) * n],
8935 k,
8936 min,
8937 ));
8938 }
8939 } else {
8940 for i in 0..n {
8941 let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
8942 let scores = crate::exact_knn::gemv(&packed, row);
8943 out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
8944 }
8945 }
8946 Ok(out)
8947 }
8948
8949 /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
8950 /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
8951 /// still appear as `(src, [])`.
8952 fn topk_from_row(
8953 src_keys: &[String],
8954 i: usize,
8955 scores: &[f64],
8956 k: usize,
8957 min: f64,
8958 ) -> (String, Vec<(String, f64)>) {
8959 let mut neigh: Vec<(String, f64)> = scores
8960 .iter()
8961 .enumerate()
8962 .filter_map(|(j, &sim)| {
8963 if i == j || sim < min {
8964 return None;
8965 }
8966 Some((src_keys[j].clone(), sim))
8967 })
8968 .collect();
8969 neigh.sort_by(|a, b| {
8970 b.1.partial_cmp(&a.1)
8971 .unwrap_or(std::cmp::Ordering::Equal)
8972 .then_with(|| a.0.cmp(&b.0))
8973 });
8974 neigh.truncate(k);
8975 (src_keys[i].clone(), neigh)
8976 }
8977
8978 /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
8979 /// hits, order by score then key. The index's own `f32` similarity is discarded.
8980 fn score_masked_hnsw_hits(
8981 &self,
8982 hits: &[(u32, f64)],
8983 field: &str,
8984 q_unit: &[f64],
8985 min: f64,
8986 mask: &crate::mask::NodeMask,
8987 ) -> Vec<(String, f64)> {
8988 let view = self.view_masked(mask);
8989 let mut out: Vec<(String, f64)> = hits
8990 .iter()
8991 .copied()
8992 .filter(|&(id, _)| mask.visible.contains(&id))
8993 .filter_map(|(id, _)| {
8994 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8995 if sim < min {
8996 return None;
8997 }
8998 Some((self.ids.key_of(id)?.to_string(), sim))
8999 })
9000 .collect();
9001 out.sort_by(|a, b| {
9002 b.1.partial_cmp(&a.1)
9003 .unwrap_or(std::cmp::Ordering::Equal)
9004 .then_with(|| a.0.cmp(&b.0))
9005 });
9006 out
9007 }
9008
9009 /// Read a single property from an edge.
9010 ///
9011 /// Returns `None` when the edge does not exist, the field is absent, or any
9012 /// of the string keys cannot be resolved to interned ids. Only edge props
9013 /// written by rules (weight fields) are accessible without a `set_edge_prop`
9014 /// binding; topology-only edges (no props set) return `None` for every field.
9015 pub fn get_edge_prop(
9016 &self,
9017 edge_type: &str,
9018 src_key: &str,
9019 dst_key: &str,
9020 field: &str,
9021 ) -> Option<Value> {
9022 let etype = self.syms.get(edge_type)?;
9023 let src = self.ids.get(src_key)?;
9024 let dst = self.ids.get(dst_key)?;
9025 self.edge_props_view().get(etype, src, dst, field)
9026 }
9027
9028 /// Lex → parse → plan → execute `cypher` over a read-only view.
9029 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
9030 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
9031 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
9032 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9033 detail: format!("lex: {e}"),
9034 })?;
9035 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9036 detail: format!("parse: {e}"),
9037 })?;
9038 let t0 = std::time::Instant::now();
9039 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
9040 GraphError::QueryError {
9041 detail: format!("execute: {e}"),
9042 }
9043 });
9044 let elapsed_ms = t0.elapsed().as_millis() as u64;
9045 let threshold = self.slow_query_threshold_ms;
9046 if threshold > 0 && elapsed_ms >= threshold {
9047 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
9048 let entry = SlowQueryEntry {
9049 ms: elapsed_ms,
9050 query: cypher.to_string(),
9051 at_commit: self.commit_seq,
9052 };
9053 if let Ok(mut log) = self.slow_queries.lock() {
9054 if log.entries.len() == SLOW_QUERY_RING_CAP {
9055 log.entries.pop_front();
9056 }
9057 log.entries.push_back(entry);
9058 log.total += 1;
9059 }
9060 }
9061 result
9062 }
9063
9064 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
9065 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
9066 /// calling [`GraphDb::query`].
9067 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
9068 let map: BTreeMap<String, Value> = params
9069 .iter()
9070 .map(|(k, v)| (k.to_string(), v.clone()))
9071 .collect();
9072 self.query(cypher, &map)
9073 }
9074
9075 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
9076 ///
9077 /// All mutations flow through the same `insert_node` / `set_prop` /
9078 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
9079 /// fires and the WAL captures everything with one fsync per statement.
9080 ///
9081 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
9082 /// and `deleted` matching the write-result contract.
9083 ///
9084 /// **Mutation routing**: mutations are collected into a single
9085 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
9086 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
9087 /// over `self.view()` — the borrow is dropped before the batch is opened.
9088 ///
9089 /// **Limitations (v1)**:
9090 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
9091 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
9092 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
9093 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
9094 /// - Deleting a derived edge → named error "cannot delete derived edge".
9095 pub fn query_write(
9096 &mut self,
9097 cypher: &str,
9098 params: &BTreeMap<String, Value>,
9099 ) -> Result<ResultSet> {
9100 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9101 detail: format!("lex: {e}"),
9102 })?;
9103 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9104 detail: format!("parse: {e}"),
9105 })?;
9106 self.exec_write_stmt(stmt, params)
9107 }
9108
9109 fn exec_write_stmt(
9110 &mut self,
9111 stmt: WriteStatement,
9112 params: &BTreeMap<String, Value>,
9113 ) -> Result<ResultSet> {
9114 match stmt {
9115 WriteStatement::Create(s) => self.exec_create(s, params),
9116 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
9117 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
9118 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
9119 WriteStatement::Merge(s) => self.exec_merge(s, params),
9120 }
9121 }
9122
9123 fn exec_create(
9124 &mut self,
9125 stmt: core_query::cypher::CreateStmt,
9126 params: &BTreeMap<String, Value>,
9127 ) -> Result<ResultSet> {
9128 // Extract the node key from props: require a string-valued `id` field.
9129 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
9130 for node in &stmt.nodes {
9131 let var = node.var.as_deref().unwrap_or("_cn0");
9132 let key = node
9133 .props
9134 .iter()
9135 .find(|(f, _)| f == "id")
9136 .and_then(|(_, v)| {
9137 if let Value::Str(s) = v {
9138 Some(s.clone())
9139 } else {
9140 None
9141 }
9142 })
9143 .ok_or_else(|| GraphError::QueryError {
9144 detail: format!(
9145 "CREATE node ({}:{}) requires a string 'id' property",
9146 var, node.label
9147 ),
9148 })?;
9149 var_to_key.insert(var.to_string(), key);
9150 }
9151
9152 let mut batch = self.batch();
9153 let mut created: usize = 0;
9154 for node in &stmt.nodes {
9155 let var = node.var.as_deref().unwrap_or("_cn0");
9156 let key = &var_to_key[var];
9157 batch.insert_node(&node.label, key, node.props.clone());
9158 created += 1;
9159 }
9160 for edge in &stmt.edges {
9161 let src_key = var_to_key
9162 .get(&edge.src_var)
9163 .ok_or_else(|| GraphError::QueryError {
9164 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
9165 })?;
9166 let dst_key = var_to_key
9167 .get(&edge.dst_var)
9168 .ok_or_else(|| GraphError::QueryError {
9169 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
9170 })?;
9171 batch.insert_edge(&edge.etype, src_key, dst_key);
9172 }
9173 batch.commit()?;
9174
9175 // Optional RETURN clause: project created bindings as a read result.
9176 if let Some(returns) = stmt.returns {
9177 // Each created node is looked up by its key via a separate MATCH pattern.
9178 // Multiple single-node patterns cross-join to produce 1 output row with
9179 // all variables bound (each pattern returns exactly 1 row).
9180 let patterns: Vec<Pattern> = stmt
9181 .nodes
9182 .iter()
9183 .map(|node| {
9184 let var = node.var.as_deref().unwrap_or("_cn0");
9185 let key = var_to_key[var].clone();
9186 Pattern {
9187 start: NodePat {
9188 var: Some(var.to_string()),
9189 label: Some(node.label.clone()),
9190 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
9191 },
9192 chain: vec![],
9193 shortest: false,
9194 }
9195 })
9196 .collect();
9197 let q = Query {
9198 matches: patterns,
9199 optional_clauses: vec![],
9200 where_expr: None,
9201 unwinds: vec![],
9202 post_unwind_where: None,
9203 stages: vec![],
9204 returns,
9205 distinct: false,
9206 order_by: vec![],
9207 skip: None,
9208 limit: None,
9209 };
9210 let ops = plan(&q).map_err(|e| GraphError::QueryError {
9211 detail: format!("plan: {e}"),
9212 })?;
9213 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
9214 GraphError::QueryError {
9215 detail: format!("execute: {e}"),
9216 }
9217 });
9218 }
9219
9220 let mut rs = write_result_set();
9221 rs.push_row(vec![
9222 Some(Value::Int(created as i64)),
9223 Some(Value::Int(0)),
9224 Some(Value::Int(0)),
9225 ]);
9226 Ok(rs)
9227 }
9228
9229 fn exec_match_set(
9230 &mut self,
9231 stmt: core_query::cypher::MatchSetStmt,
9232 params: &BTreeMap<String, Value>,
9233 ) -> Result<ResultSet> {
9234 let project_returns = stmt.returns.clone();
9235 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
9236 // so the post-write projection can look them up by key.
9237 let mut set_vars: Vec<String> = Vec::new();
9238 for s in &stmt.sets {
9239 if !set_vars.contains(&s.var) {
9240 set_vars.push(s.var.clone());
9241 }
9242 }
9243 let rel_vars = pattern_rel_vars(&stmt.matches);
9244 let mut lookup_vars = set_vars.clone();
9245 for v in pattern_node_vars(&stmt.matches) {
9246 add_var(&mut lookup_vars, &v);
9247 }
9248 if let Some(ref returns) = project_returns {
9249 for v in ret_node_vars(returns) {
9250 if !rel_vars.iter().any(|r| r == &v) {
9251 add_var(&mut lookup_vars, &v);
9252 }
9253 }
9254 }
9255
9256 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
9257 // SET values are projected as ScalarExpr items so that arithmetic expressions
9258 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
9259 let mut set_returns: Vec<RetItem> = lookup_vars
9260 .iter()
9261 .map(|v| RetItem {
9262 value: RetVal::Var(v.clone()),
9263 alias: None,
9264 })
9265 .collect();
9266 // One computed column per SET clause; alias is `__sv_<i>`.
9267 let set_val_cols: Vec<String> = stmt
9268 .sets
9269 .iter()
9270 .enumerate()
9271 .map(|(i, _)| format!("__sv_{i}"))
9272 .collect();
9273 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9274 set_returns.push(RetItem {
9275 value: RetVal::ScalarExpr(sc.value.clone()),
9276 alias: Some(col.clone()),
9277 });
9278 }
9279 // Capture relationship types while r is bound; SET does not change them.
9280 for r in &rel_vars {
9281 set_returns.push(RetItem {
9282 value: RetVal::FuncCall {
9283 name: "type".into(),
9284 args: vec![Operand::Var(r.clone())],
9285 },
9286 alias: Some(rel_type_alias(r)),
9287 });
9288 }
9289
9290 let read_q = Query {
9291 matches: stmt.matches.clone(),
9292 optional_clauses: vec![],
9293 where_expr: stmt.where_expr.clone(),
9294 unwinds: vec![],
9295 post_unwind_where: None,
9296 stages: vec![],
9297 returns: set_returns,
9298 distinct: false,
9299 order_by: vec![],
9300 skip: None,
9301 limit: None,
9302 };
9303 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9304 detail: format!("plan: {e}"),
9305 })?;
9306 // MATCH phase is read-only; borrow ends before batch opens.
9307 //
9308 // When a role-scoped write is in flight, run the MATCH read through
9309 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
9310 // zero-rows (no SetProp ops generated, no existence-oracle 403).
9311 // Full-authority writes (pending_write_authz=None) keep view().
9312 let match_rs = {
9313 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9314 if let Some(ref mask) = mask_opt {
9315 execute(&self.view_masked(mask), &ops, &Params(params))
9316 } else {
9317 execute(&self.view(), &ops, &Params(params))
9318 }
9319 }
9320 .map_err(|e| GraphError::QueryError {
9321 detail: format!("execute: {e}"),
9322 })?;
9323
9324 // Collect (key, field, value) for each matched row × each SET clause.
9325 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
9326 for row_i in 0..match_rs.len() {
9327 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9328 let key = match match_rs.get(row_i, &sc.var) {
9329 Some(Value::Str(k)) => k.clone(),
9330 _ => {
9331 return Err(GraphError::QueryError {
9332 detail: format!(
9333 "SET variable '{}' did not resolve to a node key",
9334 sc.var
9335 ),
9336 })
9337 }
9338 };
9339 // The SET value was already evaluated by the executor.
9340 let value = match match_rs.get(row_i, col) {
9341 Some(v) => v.clone(),
9342 None => {
9343 return Err(GraphError::QueryError {
9344 detail: format!(
9345 "SET value for {}.{} evaluated to null",
9346 sc.var, sc.field
9347 ),
9348 })
9349 }
9350 };
9351 set_ops.push((key, sc.field.clone(), value));
9352 }
9353 }
9354
9355 // Apply as one atomic batch.
9356 let props_set = set_ops.len();
9357 let mut batch = self.batch();
9358 for (key, field, value) in set_ops {
9359 batch.set_prop(&key, &field, value);
9360 }
9361 batch.commit()?;
9362
9363 if let Some(returns) = project_returns {
9364 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9365 }
9366
9367 let mut rs = write_result_set();
9368 rs.push_row(vec![
9369 Some(Value::Int(0)),
9370 Some(Value::Int(props_set as i64)),
9371 Some(Value::Int(0)),
9372 ]);
9373 Ok(rs)
9374 }
9375
9376 fn exec_match_delete(
9377 &mut self,
9378 stmt: core_query::cypher::MatchDeleteStmt,
9379 params: &BTreeMap<String, Value>,
9380 ) -> Result<ResultSet> {
9381 // Collect unique node vars needed to identify edge endpoints.
9382 let mut node_vars: Vec<String> = Vec::new();
9383 for ed in &stmt.deletes {
9384 if !node_vars.contains(&ed.src_var) {
9385 node_vars.push(ed.src_var.clone());
9386 }
9387 if !node_vars.contains(&ed.dst_var) {
9388 node_vars.push(ed.dst_var.clone());
9389 }
9390 }
9391
9392 // Synthesize read query.
9393 let returns: Vec<RetItem> = node_vars
9394 .iter()
9395 .map(|v| RetItem {
9396 value: RetVal::Var(v.clone()),
9397 alias: None,
9398 })
9399 .collect();
9400 let read_q = Query {
9401 matches: stmt.matches,
9402 optional_clauses: vec![],
9403 where_expr: stmt.where_expr,
9404 unwinds: vec![],
9405 post_unwind_where: None,
9406 stages: vec![],
9407 returns,
9408 distinct: false,
9409 order_by: vec![],
9410 skip: None,
9411 limit: None,
9412 };
9413 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9414 detail: format!("plan: {e}"),
9415 })?;
9416 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9417 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9418 let match_rs = {
9419 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9420 if let Some(ref mask) = mask_opt {
9421 execute(&self.view_masked(mask), &ops, &Params(params))
9422 } else {
9423 execute(&self.view(), &ops, &Params(params))
9424 }
9425 }
9426 .map_err(|e| GraphError::QueryError {
9427 detail: format!("execute: {e}"),
9428 })?;
9429
9430 // Collect (etype, src_key, dst_key) for each row × each delete target.
9431 let mut del_ops: Vec<(String, String, String)> = Vec::new();
9432 for row_i in 0..match_rs.len() {
9433 for ed in &stmt.deletes {
9434 let src_key = match match_rs.get(row_i, &ed.src_var) {
9435 Some(Value::Str(k)) => k.clone(),
9436 _ => {
9437 return Err(GraphError::QueryError {
9438 detail: format!(
9439 "DELETE src variable '{}' did not resolve to a node key",
9440 ed.src_var
9441 ),
9442 })
9443 }
9444 };
9445 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9446 Some(Value::Str(k)) => k.clone(),
9447 _ => {
9448 return Err(GraphError::QueryError {
9449 detail: format!(
9450 "DELETE dst variable '{}' did not resolve to a node key",
9451 ed.dst_var
9452 ),
9453 })
9454 }
9455 };
9456 del_ops.push((ed.etype.clone(), src_key, dst_key));
9457 }
9458 }
9459
9460 // Apply as one atomic batch.
9461 let deleted = del_ops.len();
9462 let mut batch = self.batch();
9463 for (etype, src_key, dst_key) in del_ops {
9464 batch.delete_edge(&etype, &src_key, &dst_key);
9465 }
9466 batch.commit().map_err(|e| match e {
9467 GraphError::RuleOwned { .. } => GraphError::QueryError {
9468 detail: "cannot delete derived edge; retract via the rule or change the property"
9469 .to_string(),
9470 },
9471 other => other,
9472 })?;
9473
9474 let mut rs = write_result_set();
9475 rs.push_row(vec![
9476 Some(Value::Int(0)),
9477 Some(Value::Int(0)),
9478 Some(Value::Int(deleted as i64)),
9479 ]);
9480 Ok(rs)
9481 }
9482
9483 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9484 ///
9485 /// Collects the matching node keys via an ephemeral read query, then calls
9486 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
9487 /// the executor first checks that the node has no incident edges; if any
9488 /// remain it returns a named error matching openCypher semantics.
9489 fn exec_match_delete_node(
9490 &mut self,
9491 stmt: MatchDeleteNodeStmt,
9492 params: &BTreeMap<String, Value>,
9493 ) -> Result<ResultSet> {
9494 // Build a read query returning only the node keys we need.
9495 let returns: Vec<RetItem> = stmt
9496 .node_vars
9497 .iter()
9498 .map(|v| RetItem {
9499 value: RetVal::Var(v.clone()),
9500 alias: None,
9501 })
9502 .collect();
9503 let read_q = Query {
9504 matches: stmt.matches,
9505 optional_clauses: vec![],
9506 where_expr: stmt.where_expr,
9507 unwinds: vec![],
9508 post_unwind_where: None,
9509 stages: vec![],
9510 returns,
9511 distinct: false,
9512 order_by: vec![],
9513 skip: None,
9514 limit: None,
9515 };
9516 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9517 detail: format!("plan: {e}"),
9518 })?;
9519 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9520 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9521 let match_rs = {
9522 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9523 if let Some(ref mask) = mask_opt {
9524 execute(&self.view_masked(mask), &ops, &Params(params))
9525 } else {
9526 execute(&self.view(), &ops, &Params(params))
9527 }
9528 }
9529 .map_err(|e| GraphError::QueryError {
9530 detail: format!("execute: {e}"),
9531 })?;
9532
9533 // Collect unique node keys to delete (deduplicate across rows × vars).
9534 let mut keys: Vec<String> = Vec::new();
9535 for row_i in 0..match_rs.len() {
9536 for var in &stmt.node_vars {
9537 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9538 if !keys.contains(k) {
9539 keys.push(k.clone());
9540 }
9541 }
9542 }
9543 }
9544
9545 if !stmt.detach {
9546 // openCypher bare DELETE: error if any matched node has incident edges.
9547 for key in &keys {
9548 if let Some(id) = self.ids.get(key) {
9549 let tv = self.topo_view();
9550 let has_edges = tv.etypes().any(|et| {
9551 !tv.neighbors(et, Direction::Out, id).is_empty()
9552 || !tv.neighbors(et, Direction::In, id).is_empty()
9553 });
9554 if has_edges {
9555 return Err(GraphError::QueryError {
9556 detail: format!(
9557 "Cannot delete node `{key}` because it still has incident edges. \
9558 Use DETACH DELETE to remove the node and all its edges."
9559 ),
9560 });
9561 }
9562 }
9563 }
9564 }
9565
9566 let mut nodes_deleted = 0i64;
9567 let mut edges_deleted = 0i64;
9568 for key in keys {
9569 match self.delete_node(&key) {
9570 Ok(report) => {
9571 nodes_deleted += 1;
9572 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9573 }
9574 Err(GraphError::KeyNotFound { .. }) => {
9575 // Node may have been deleted by an earlier iteration (e.g., via
9576 // multiple MATCH rows for the same node). Safe to skip.
9577 }
9578 Err(e) => return Err(e),
9579 }
9580 }
9581
9582 let mut rs = write_result_set();
9583 rs.push_row(vec![
9584 Some(Value::Int(0)),
9585 Some(Value::Int(0)),
9586 Some(Value::Int(nodes_deleted + edges_deleted)),
9587 ]);
9588 Ok(rs)
9589 }
9590
9591 /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
9592 /// the pattern named one, or the executing role's sole namespace when it
9593 /// did not. A role bound to two or more namespaces cannot choose, and is
9594 /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
9595 /// refuses a named `ns` the role cannot write.
9596 fn merge_create_props(
9597 &self,
9598 key_field: &str,
9599 key_value: &Value,
9600 named_ns: Option<&Value>,
9601 ) -> Result<Vec<(String, Value)>> {
9602 let mut props = vec![(key_field.to_string(), key_value.clone())];
9603 if let Some(ns) = named_ns {
9604 props.push((NS_PROP.to_string(), ns.clone()));
9605 return Ok(props);
9606 }
9607 if let Some(ns) = self.merge_create_stamp_ns()? {
9608 props.push((NS_PROP.to_string(), Value::Str(ns)));
9609 }
9610 Ok(props)
9611 }
9612
9613 /// The namespace a role-scoped MERGE create stamps when the pattern does
9614 /// not name `ns`. `None` = unscoped / full authority, so the node lands in
9615 /// `default`.
9616 fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
9617 let Some(authz) = self.pending_write_authz.as_ref() else {
9618 return Ok(None);
9619 };
9620 let Some(def) = self.role_def_for(&authz.role) else {
9621 return Ok(None);
9622 };
9623 match def.namespaces.as_deref() {
9624 Some([only]) => Ok(Some(only.clone())),
9625 Some(_) => Err(GraphError::RoleWriteDenied {
9626 reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
9627 }),
9628 None => Ok(None),
9629 }
9630 }
9631
9632 fn exec_merge(
9633 &mut self,
9634 stmt: core_query::cypher::MergeStmt,
9635 params: &BTreeMap<String, Value>,
9636 ) -> Result<ResultSet> {
9637 // MERGE: check if a node with the given key already exists.
9638 let key = match &stmt.key_value {
9639 Value::Str(s) => s.clone(),
9640 _ => {
9641 return Err(GraphError::QueryError {
9642 detail: format!(
9643 "MERGE key value must be a string (got {:?})",
9644 stmt.key_value
9645 ),
9646 })
9647 }
9648 };
9649
9650 if let Some(var) = stmt.var.as_deref() {
9651 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9652 if sc.var != var {
9653 return Err(GraphError::QueryError {
9654 detail: format!(
9655 "SET variable '{}' does not match MERGE variable '{var}'",
9656 sc.var
9657 ),
9658 });
9659 }
9660 }
9661 }
9662
9663 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9664 //
9665 // MERGE scope precondition: check create OR update scope for the
9666 // declared label BEFORE calling `has_node` (timing-oracle closure,
9667 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9668 // unscoped roles — the scope denial fires without touching the key store).
9669 //
9670 // Clone to avoid holding a borrow on `self.pending_write_authz` while
9671 // also calling `self.ids.get(key)`.
9672 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9673 let has_create = authz.scope.create_labels.contains(&stmt.label);
9674 let has_update = authz.scope.update_labels.contains(&stmt.label);
9675 if !has_create && !has_update {
9676 // Scope-before-lookup: 403 without has_node call (timing oracle
9677 // closure — see test_merge_unscoped_no_key_lookup).
9678 return Err(GraphError::RoleWriteDenied {
9679 reason: format!(
9680 "role-bound token: label '{}' not in write scope (create_labels)",
9681 stmt.label
9682 ),
9683 });
9684 }
9685 // Key lookup under mask.
9686 match self.ids.get(key.as_str()) {
9687 Some(id) if authz.mask.contains_id(id) => {
9688 // Visible: must have update scope to proceed to match arm.
9689 if !has_update {
9690 return Err(GraphError::RoleWriteDenied {
9691 reason: format!(
9692 "role-bound token: label '{}' not in write scope (update_labels)",
9693 stmt.label
9694 ),
9695 });
9696 }
9697 true // existed = true → match arm
9698 }
9699 Some(_) => {
9700 // Hidden: same error as absent to the role (spec §3.1/§3.3).
9701 return Err(GraphError::RoleWriteDenied {
9702 reason: "role-bound token: target node not visible".into(),
9703 });
9704 }
9705 None => {
9706 // Absent: must have create scope to proceed to the create arm.
9707 //
9708 // Update-only roles (create_labels empty, update_labels set):
9709 // return the SAME "not visible" error as the hidden-key branch
9710 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9711 // "confirm existence of hidden nodes: No").
9712 //
9713 // Create-scoped roles (has_create=true): absent → create arm
9714 // as before. The accepted structural key-existence disclosure
9715 // (§THREAT-MODEL) applies only when the role holds create scope.
9716 if !has_create {
9717 return Err(GraphError::RoleWriteDenied {
9718 reason: "role-bound token: target node not visible".into(),
9719 });
9720 }
9721 false // existed = false → create arm
9722 }
9723 }
9724 } else {
9725 // Full authority: use the existing non-masked has_node check.
9726 self.has_node(&key)
9727 };
9728
9729 let existed = merge_existed;
9730 let create_props = if existed {
9731 None
9732 } else {
9733 Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
9734 };
9735 let mut created = 0i64;
9736 if create_props.is_some() || !stmt.on_match.is_empty() {
9737 let mut batch = self.batch();
9738 if let Some(props) = create_props {
9739 batch.insert_node(&stmt.label, &key, props);
9740 for sc in &stmt.on_create {
9741 let value = resolve_merge_set_value(&sc.value, params)?;
9742 batch.set_prop(&key, &sc.field, value);
9743 }
9744 created = 1;
9745 } else {
9746 for sc in &stmt.on_match {
9747 let value = resolve_merge_set_value(&sc.value, params)?;
9748 batch.set_prop(&key, &sc.field, value);
9749 }
9750 }
9751 batch.commit()?;
9752 }
9753
9754 // Refresh the role mask so the just-created node is visible to this
9755 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9756 // (apply_schema subset rule), so the new node's label is already in the
9757 // role's read scope — this never widens beyond the role's declared labels.
9758 if !existed {
9759 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9760 let new_mask = self.mask_for_role(&role)?;
9761 if let Some(a) = self.pending_write_authz.as_mut() {
9762 a.mask = new_mask;
9763 }
9764 }
9765 }
9766
9767 // Optional RETURN clause: project the node (created or matched) as a read result.
9768 if let Some(returns) = stmt.returns {
9769 let var = stmt.var.as_deref().unwrap_or("_mn0");
9770 let q = Query {
9771 matches: vec![Pattern {
9772 start: NodePat {
9773 var: Some(var.to_string()),
9774 label: Some(stmt.label.clone()),
9775 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9776 },
9777 chain: vec![],
9778 shortest: false,
9779 }],
9780 optional_clauses: vec![],
9781 where_expr: None,
9782 unwinds: vec![],
9783 post_unwind_where: None,
9784 stages: vec![],
9785 returns,
9786 distinct: false,
9787 order_by: vec![],
9788 skip: None,
9789 limit: None,
9790 };
9791 let ops = plan(&q).map_err(|e| GraphError::QueryError {
9792 detail: format!("plan: {e}"),
9793 })?;
9794 // Use view_masked when a role-scoped write is in flight so the
9795 // post-merge projection is consistent with the masked read phase.
9796 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9797 return (if let Some(ref mask) = mask_opt {
9798 execute(&self.view_masked(mask), &ops, &Params(params))
9799 } else {
9800 execute(&self.view(), &ops, &Params(params))
9801 })
9802 .map_err(|e| GraphError::QueryError {
9803 detail: format!("execute: {e}"),
9804 });
9805 }
9806
9807 let mut rs = write_result_set();
9808 rs.push_row(vec![
9809 Some(Value::Int(created)),
9810 Some(Value::Int(0)),
9811 Some(Value::Int(0)),
9812 ]);
9813 Ok(rs)
9814 }
9815
9816 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9817 /// annotated with rule name, edge type, direction, and weight.
9818 /// Results are sorted by (rule, edge_type).
9819 /// Returns `Err(KeyNotFound)` if either key is unknown.
9820 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9821 self.ensure_v8_base_sections_loaded();
9822 let id_a = self
9823 .ids
9824 .get(key_a)
9825 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9826 let id_b = self
9827 .ids
9828 .get(key_b)
9829 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9830
9831 let mut results = Vec::new();
9832
9833 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9834 // rather than O(total provenance).
9835 let scan = if self.engine.provenance_touching_len(id_a)
9836 <= self.engine.provenance_touching_len(id_b)
9837 {
9838 id_a
9839 } else {
9840 id_b
9841 };
9842 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9843 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9844 continue;
9845 }
9846 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9847 continue;
9848 };
9849 let edge_type = match self.syms.resolve(etype) {
9850 Some(s) => s.to_string(),
9851 None => continue,
9852 };
9853 // Provenance (src, dst) ids come from the archived PROVENANCE section
9854 // (large, no eager CRC). A corrupt section can produce ids that are
9855 // out of range; return Corrupt rather than panic.
9856 let src_key = self
9857 .ids
9858 .key_of(src)
9859 .ok_or_else(|| GraphError::Corrupt {
9860 detail: format!("v8: provenance src id {src} not in id table"),
9861 })?
9862 .to_string();
9863 let dst_key = self
9864 .ids
9865 .key_of(dst)
9866 .ok_or_else(|| GraphError::Corrupt {
9867 detail: format!("v8: provenance dst id {dst} not in id table"),
9868 })?
9869 .to_string();
9870 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9871 self.edge_props_view()
9872 .get(etype, src, dst, prop)
9873 .and_then(|v| {
9874 if let Value::Float(f) = v {
9875 Some(f)
9876 } else {
9877 None
9878 }
9879 })
9880 });
9881 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9882 // still have a score: recompute it from the predicate so explain
9883 // never reports "no score" for an edge the engine scored. Via-hop
9884 // rules score over their via set, not over (src, dst), so leave
9885 // those None rather than report a number the rule did not produce.
9886 let weight = stored.or_else(|| {
9887 if rule_def.via_edge.is_some() {
9888 return None;
9889 }
9890 let props_view = build_props_view(&self.props, &self.base);
9891 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9892 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9893 let src_view = NodeView {
9894 key: &src_key,
9895 props: &src_get,
9896 };
9897 let dst_view = NodeView {
9898 key: &dst_key,
9899 props: &dst_get,
9900 };
9901 evaluate(&rule_def.predicate, &src_view, &dst_view)
9902 });
9903 results.push(Explanation {
9904 rule: rule_name.to_string(),
9905 edge_type,
9906 src_key,
9907 dst_key,
9908 weight,
9909 predicate: PredicateSummary {
9910 approximate: rule_def.approximate,
9911 ..PredicateSummary::from(&rule_def.predicate)
9912 },
9913 via_edge: rule_def.via_edge.clone(),
9914 });
9915 }
9916
9917 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
9918 Ok(results)
9919 }
9920
9921 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
9922 let id = self
9923 .ids
9924 .get(key)
9925 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9926 let Some(sym) = self.syms.get(edge_type) else {
9927 return Ok(Vec::new());
9928 };
9929 self.topo_view()
9930 .neighbors(sym, dir, id)
9931 .iter()
9932 .map(|&n| {
9933 self.ids
9934 .key_of(n)
9935 .map(|k| k.to_string())
9936 .ok_or_else(|| GraphError::Corrupt {
9937 detail: format!("topology id {n} has no key"),
9938 })
9939 })
9940 .collect::<Result<Vec<_>>>()
9941 }
9942
9943 /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
9944 /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
9945 pub fn degree(
9946 &self,
9947 key: &str,
9948 edge_type: Option<&str>,
9949 direction: crate::algo::AlgoDir,
9950 ) -> Result<u64> {
9951 let id = self
9952 .ids
9953 .get(key)
9954 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9955 let topo = self.topo_view();
9956 Ok(Self::unique_directed_degree(
9957 &topo, &self.syms, id, edge_type, direction,
9958 ))
9959 }
9960
9961 /// Unique directed degree for a subset or a label scan.
9962 ///
9963 /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
9964 /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
9965 /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
9966 #[allow(clippy::too_many_arguments)]
9967 pub fn degrees(
9968 &self,
9969 keys: Option<&[String]>,
9970 label: Option<&str>,
9971 where_: Option<&PropPredicate>,
9972 edge_type: Option<&str>,
9973 direction: crate::algo::AlgoDir,
9974 limit: Option<usize>,
9975 ) -> Result<Vec<(String, u64)>> {
9976 if let Some(pred) = where_ {
9977 pred.validate_named("where")
9978 .map_err(|detail| GraphError::QueryError { detail })?;
9979 }
9980 if matches!(keys, Some(ks) if ks.is_empty()) {
9981 return Ok(Vec::new());
9982 }
9983 let view = self.view();
9984 let ids: Vec<u32> = match keys {
9985 Some(ks) => {
9986 let mut seen = HashSet::new();
9987 let mut out = Vec::new();
9988 for k in ks {
9989 let Some(id) = view.ids.get(k) else {
9990 continue;
9991 };
9992 if !seen.insert(id) {
9993 continue;
9994 }
9995 if let Some(pred) = where_ {
9996 let holds = match view.prop(id, &pred.field) {
9997 None => pred.holds(None),
9998 Some(vr) => pred.holds(Some(vr.as_value())),
9999 };
10000 if !holds {
10001 continue;
10002 }
10003 }
10004 out.push(id);
10005 }
10006 out
10007 }
10008 None => Self::vector_candidates(&view, label, where_),
10009 };
10010 let mut out: Vec<(String, u64)> = ids
10011 .into_iter()
10012 .filter_map(|id| {
10013 let key = self.ids.key_of(id)?.to_string();
10014 let deg =
10015 Self::unique_directed_degree(&view.topo, view.syms, id, edge_type, direction);
10016 Some((key, deg))
10017 })
10018 .collect();
10019 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
10020 if let Some(lim) = limit {
10021 out.truncate(lim);
10022 }
10023 Ok(out)
10024 }
10025
10026 /// Unique neighbour count for `id` across `edge_type` (or all types) and
10027 /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
10028 fn unique_directed_degree(
10029 topo: &TopologyView<'_>,
10030 syms: &Interner,
10031 id: u32,
10032 edge_type: Option<&str>,
10033 direction: crate::algo::AlgoDir,
10034 ) -> u64 {
10035 let dirs: &[Direction] = match direction {
10036 crate::algo::AlgoDir::Out => &[Direction::Out],
10037 crate::algo::AlgoDir::In => &[Direction::In],
10038 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
10039 };
10040 match edge_type {
10041 Some(name) => {
10042 let Some(et) = syms.get(name) else {
10043 return 0;
10044 };
10045 dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
10046 }
10047 None => topo
10048 .etypes()
10049 .map(|et| {
10050 dirs.iter()
10051 .map(|&d| topo.degree(et, d, id) as u64)
10052 .sum::<u64>()
10053 })
10054 .sum(),
10055 }
10056 }
10057
10058 /// Return the last-change commit sequence for `key`, or `None` if the node
10059 /// does not exist or has never been mutated since the last V5-V7 snapshot
10060 /// (horizon-bounded for legacy stores).
10061 ///
10062 /// The returned sequence is a monotonically increasing counter that starts
10063 /// at 1 for the first commit after `open` and increments with every
10064 /// successful write. WAL replay at open also assigns sequences (1..N for N
10065 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
10066 ///
10067 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
10068 /// in the snapshot but not touched by any WAL frame will return `None`
10069 /// (horizon-bounded: CAS against such nodes is only safe after the first
10070 /// V8 snapshot or after the node is next mutated).
10071 pub fn last_changed(&self, key: &str) -> Option<u64> {
10072 let id = self.ids.get(key)?;
10073 self.last_change.get(&id).copied()
10074 }
10075
10076 /// The current commit sequence (number of successful commits since open,
10077 /// including WAL replay frames). Useful for recording a baseline before
10078 /// a read-modify-write cycle.
10079 pub fn commit_seq(&self) -> u64 {
10080 self.commit_seq
10081 }
10082
10083 /// Check that all `preconds` are satisfied against the current db state.
10084 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
10085 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
10086 for precond in preconds {
10087 match precond {
10088 Precondition::NodeUnchangedSince { key, expected } => {
10089 // Missing entry means the node predates the WAL window or
10090 // does not exist; treat as 0 (before any commit).
10091 let actual = self.last_changed(key).unwrap_or_default();
10092 if actual != *expected {
10093 return Err(GraphError::CasConflict {
10094 key: key.clone(),
10095 expected: *expected,
10096 actual,
10097 });
10098 }
10099 }
10100 Precondition::NodeAbsent { key } => {
10101 // Node must not exist (not live).
10102 if self.ids.get(key).is_some() {
10103 let actual = self.last_changed(key).unwrap_or(0);
10104 return Err(GraphError::CasConflict {
10105 key: key.clone(),
10106 expected: u64::MAX,
10107 actual,
10108 });
10109 }
10110 }
10111 }
10112 }
10113 Ok(())
10114 }
10115
10116 /// Apply a batch of mutations with compare-and-set preconditions.
10117 ///
10118 /// All preconditions are checked atomically before any operation is applied.
10119 /// If any precondition fails, the entire batch is rejected with
10120 /// [`GraphError::CasConflict`] and no WAL frame is written.
10121 ///
10122 /// # Returns
10123 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
10124 ///
10125 /// # Errors
10126 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
10127 /// - Any error that [`write_batch`] would return for the ops themselves.
10128 pub fn write_batch_cas(
10129 &mut self,
10130 preconds: Vec<Precondition>,
10131 ops: Vec<BatchOp>,
10132 ) -> Result<(usize, usize)> {
10133 self.check_preconditions(&preconds)?;
10134 self.commit_logged_batch(ops, None, None)
10135 }
10136
10137 /// Update the per-node last-change map for a WAL record at commit `seq`.
10138 ///
10139 /// Called after a successful apply to record which nodes were touched.
10140 /// For replay, called with the WAL-frame's replayed seq.
10141 ///
10142 /// Touch definition (see [`Precondition`] doc):
10143 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
10144 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
10145 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
10146 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
10147 /// - Batch → recurse into inner records.
10148 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
10149 match rec {
10150 WalRecord::InsertNode { key, .. }
10151 | WalRecord::SetProp { key, .. }
10152 | WalRecord::RemoveProp { key, .. } => {
10153 if let Some(id) = self.ids.get(key) {
10154 self.last_change.insert(id, seq);
10155 }
10156 }
10157 WalRecord::InsertNodeId { key, .. } => {
10158 if let Some(id) = self.ids.get(key) {
10159 self.last_change.insert(id, seq);
10160 }
10161 }
10162 WalRecord::SetPropId { id, .. } => {
10163 self.last_change.insert(*id, seq);
10164 }
10165 WalRecord::InsertEdge {
10166 src_key, dst_key, ..
10167 }
10168 | WalRecord::DeleteEdge {
10169 src_key, dst_key, ..
10170 } => {
10171 if let Some(src_id) = self.ids.get(src_key) {
10172 self.last_change.insert(src_id, seq);
10173 }
10174 if let Some(dst_id) = self.ids.get(dst_key) {
10175 self.last_change.insert(dst_id, seq);
10176 }
10177 }
10178 WalRecord::InsertEdgeId { src, dst, .. } => {
10179 self.last_change.insert(*src, seq);
10180 self.last_change.insert(*dst, seq);
10181 }
10182 // DeleteNode: node is tombstoned; last_changed(key) returns None for
10183 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
10184 // History markers: state no-ops; the underlying mutation already
10185 // touched the relevant nodes' last_change entries.
10186 WalRecord::DeleteNode { .. }
10187 | WalRecord::DerivedEdgeAdded { .. }
10188 | WalRecord::DerivedEdgeRetracted { .. }
10189 | WalRecord::Intern { .. }
10190 | WalRecord::CreateRule { .. }
10191 | WalRecord::DeleteRule { .. }
10192 | WalRecord::RebuildRule { .. }
10193 | WalRecord::CreateView { .. }
10194 | WalRecord::DeleteView { .. }
10195 | WalRecord::EnableFulltext { .. }
10196 | WalRecord::DisableFulltext { .. }
10197 | WalRecord::EnableIndex { .. }
10198 | WalRecord::DisableIndex { .. } => {}
10199 // RenameNode: node id is stable; update last_change via the new key.
10200 // Called after apply(), so ids already reflects new_key.
10201 WalRecord::RenameNode { new_key, .. } => {
10202 if let Some(id) = self.ids.get(new_key) {
10203 self.last_change.insert(id, seq);
10204 }
10205 }
10206 WalRecord::Batch(inner) => {
10207 for inner_rec in inner {
10208 self.update_last_change_from_rec(inner_rec, seq);
10209 }
10210 }
10211 }
10212 }
10213
10214 pub fn node_count(&self) -> usize {
10215 self.ids.len()
10216 }
10217
10218 /// Configure archive retention: keep the `N` newest WAL archives at each
10219 /// [`snapshot_with`] call when `archive_wal: true`.
10220 ///
10221 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
10222 /// `Some(0)` or `None` → unlimited (no pruning).
10223 ///
10224 /// Pruning only ever happens inside [`snapshot_with`]; this method only
10225 /// stores the policy. Archives below the retention limit are deleted
10226 /// oldest-first. The horizon floor is updated so that
10227 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
10228 /// in pruned archives rather than silently returning wrong data.
10229 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
10230 self.wal_archive_retention = keep;
10231 }
10232
10233 /// Delete any WAL archives that are fully below the current horizon floor.
10234 ///
10235 /// Orphaned archives arise when the floor is written first during retention
10236 /// pruning and then a crash interrupts the archive-delete sequence. The
10237 /// opening cleanup ensures no subsequent read path sees stale data.
10238 ///
10239 /// Under the monotonic naming scheme, the archive name N equals the
10240 /// cumulative end-frame index of the archive in global commit space (i.e.
10241 /// the archive covers global frames `[prev_n, N)`). An archive is
10242 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
10243 /// below the floor and have already been counted in it.
10244 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
10245 if self.wal_horizon_floor == 0 {
10246 // Floor at 0 means no pruning has ever occurred; nothing to clean.
10247 return Ok(());
10248 }
10249 let archive_ns = self.fs.list_archives()?;
10250 for n in archive_ns {
10251 if n <= self.wal_horizon_floor {
10252 // Archive N ends at global frame N; all its frames are below
10253 // the floor (floor already accounts for them) → orphaned.
10254 self.fs.delete_archive(n).map_err(GraphError::Io)?;
10255 } else {
10256 // Archives are sorted ascending; first one above floor stops scan.
10257 break;
10258 }
10259 }
10260 Ok(())
10261 }
10262
10263 /// Collect all WAL frames from surviving archives (oldest-first) then the
10264 /// live WAL into one flat list, and return the total along with the number
10265 /// of archive frames at the front of the list.
10266 ///
10267 /// Commit indices into the returned list are LOCAL (0 = first frame of
10268 /// oldest surviving archive). To obtain the GLOBAL index add
10269 /// `self.wal_horizon_floor`.
10270 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
10271 let archive_ns = self.fs.list_archives()?;
10272 let mut all: Vec<WalRecord> = Vec::new();
10273 for n in archive_ns {
10274 let bytes = self.fs.read_archive(n)?;
10275 let (frames, _) = decode_all(&bytes);
10276 all.extend(frames);
10277 }
10278 let archive_count = all.len() as u64;
10279 let live_bytes = self.fs.read(FileId::Wal)?;
10280 let (live_frames, _) = decode_all(&live_bytes);
10281 all.extend(live_frames);
10282 Ok((all, archive_count))
10283 }
10284
10285 /// Return the total number of committed WAL frames visible in the current
10286 /// horizon window, including frames in surviving WAL archives.
10287 ///
10288 /// This is the exclusive upper bound for valid `at_commit` indices in
10289 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
10290 ///
10291 /// Returns the horizon floor when all surviving history is empty.
10292 pub fn wal_total_commits(&self) -> Result<u64> {
10293 let (frames, _) = self.all_frames()?;
10294 Ok(self.wal_horizon_floor + frames.len() as u64)
10295 }
10296
10297 /// The global frame index of the first commit reachable through surviving
10298 /// archives (0 when no archives have been pruned).
10299 pub fn wal_horizon_floor(&self) -> u64 {
10300 self.wal_horizon_floor
10301 }
10302
10303 /// Return the per-node change history for `key` by scanning the on-disk WAL.
10304 ///
10305 /// ## Horizon
10306 ///
10307 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
10308 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
10309 /// zero-cost contract; a durable history log is out of scope.
10310 ///
10311 /// ## Derived edges
10312 ///
10313 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
10314 /// history. Only edges written directly by the application are recorded.
10315 ///
10316 /// ## Deleted nodes
10317 ///
10318 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
10319 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
10320 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
10321 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
10322 ///
10323 /// ## Dense-id edge entries and tombstoned partners
10324 ///
10325 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
10326 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
10327 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
10328 /// Build commit-bounded alias intervals for `queried_key`.
10329 ///
10330 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
10331 /// A record written under `key` at commit `c` matches the queried identity iff
10332 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
10333 ///
10334 /// Each alias entry carries both a lower and an upper bound so that key-reuse
10335 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
10336 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
10337 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
10338 /// only identity-2's events (commits 7–9 under "a") are in scope.
10339 ///
10340 /// Only **forward aliasing**: querying the *new* key surfaces events written
10341 /// under the *old* key. The reverse direction is not supported.
10342 fn build_key_alias_intervals(
10343 &self,
10344 frames: &[core_storage::wal::WalRecord],
10345 queried_key: &str,
10346 ) -> Vec<(String, u64, Option<u64>)> {
10347 use core_storage::wal::WalRecord;
10348
10349 // Pre-pass: build reverse_rename and key_starts maps.
10350 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
10351 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
10352
10353 for (local_i, frame) in frames.iter().enumerate() {
10354 let commit = self.wal_horizon_floor + local_i as u64;
10355 let records: &[WalRecord] = match frame {
10356 WalRecord::Batch(inner) => inner.as_slice(),
10357 single => std::slice::from_ref(single),
10358 };
10359 for rec in records {
10360 match rec {
10361 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
10362 key_starts.entry(key.clone()).or_default().push(commit);
10363 }
10364 WalRecord::RenameNode { old_key, new_key } => {
10365 // new_key came into existence at this commit.
10366 key_starts.entry(new_key.clone()).or_default().push(commit);
10367 // Record the reverse rename: new_key was introduced by renaming old_key.
10368 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
10369 }
10370 _ => {}
10371 }
10372 }
10373 }
10374
10375 // Build alias intervals by following the reverse rename chain.
10376 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
10377 let mut current_key = queried_key.to_string();
10378 let mut current_valid_until: Option<u64> = None;
10379
10380 loop {
10381 // valid_from: the most recent commit where current_key was assigned to this
10382 // identity. For aliases (valid_until = Some(vu)), find the last start event
10383 // for the key strictly before vu — this is where the alias's occupancy by
10384 // this identity began, correctly excluding prior identities that reused the key.
10385 let valid_from = if let Some(vu) = current_valid_until {
10386 key_starts
10387 .get(¤t_key)
10388 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
10389 .unwrap_or(self.wal_horizon_floor)
10390 } else {
10391 // Queried key — no upper bound; may have been introduced at any commit.
10392 self.wal_horizon_floor
10393 };
10394
10395 result.push((current_key.clone(), valid_from, current_valid_until));
10396
10397 match reverse_rename.get(¤t_key) {
10398 Some((old_key, rename_commit)) => {
10399 current_valid_until = Some(*rename_commit);
10400 current_key = old_key.clone();
10401 }
10402 None => break,
10403 }
10404 }
10405
10406 result
10407 }
10408
10409 /// Returns true if `record_key` matches any alias interval that covers `commit`.
10410 fn aliases_match(
10411 intervals: &[(String, u64, Option<u64>)],
10412 record_key: &str,
10413 commit: u64,
10414 ) -> bool {
10415 intervals
10416 .iter()
10417 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
10418 }
10419
10420 /// Return the change history of node `key` by scanning the on-disk WAL.
10421 ///
10422 /// ## Horizon
10423 ///
10424 /// History reaches back only as far as the retained WAL. The returned
10425 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
10426 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
10427 /// oldest commit still reachable). When `horizon > 0`, older events were
10428 /// pruned and are not in `items`.
10429 pub fn node_history(
10430 &self,
10431 key: &str,
10432 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
10433 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
10434 use core_storage::wal::WalRecord;
10435
10436 let (frames, _) = self.all_frames()?;
10437 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10438
10439 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
10440 let alias_intervals = self.build_key_alias_intervals(&frames, key);
10441
10442 let mut out: Vec<HistoryEntry> = Vec::new();
10443
10444 for (local_i, frame) in frames.iter().enumerate() {
10445 let commit = self.wal_horizon_floor + local_i as u64;
10446 // Collect the inner records to process — Batch is one commit, single records are one commit.
10447 let records: &[WalRecord] = match frame {
10448 WalRecord::Batch(inner) => inner.as_slice(),
10449 single => std::slice::from_ref(single),
10450 };
10451
10452 for rec in records {
10453 let change = match rec {
10454 WalRecord::InsertNode { label, key: k, .. }
10455 if Self::aliases_match(&alias_intervals, k, commit) =>
10456 {
10457 Some(HistoryChange::NodeInserted {
10458 label: label.clone(),
10459 })
10460 }
10461 WalRecord::InsertNodeId { label, key: k, .. }
10462 if Self::aliases_match(&alias_intervals, k, commit) =>
10463 {
10464 let label_str = match self.syms.resolve(*label) {
10465 Some(s) => s.to_string(),
10466 None => continue,
10467 };
10468 Some(HistoryChange::NodeInserted { label: label_str })
10469 }
10470 WalRecord::SetProp {
10471 key: k,
10472 field,
10473 value,
10474 } if Self::aliases_match(&alias_intervals, k, commit) => {
10475 Some(HistoryChange::PropSet {
10476 field: field.clone(),
10477 value: value.clone(),
10478 })
10479 }
10480 WalRecord::SetPropId { id, field, value } => {
10481 // Use key_of_historical (not key_of) so a node's prop_set
10482 // events remain visible after the node is later deleted:
10483 // key_of returns None for a tombstoned id, which would
10484 // silently drop every PropSet between insert and delete.
10485 // Mirrors the InsertEdgeId arm below and edge_history's
10486 // own id-keyed arms.
10487 match self.ids.key_of_historical(*id) {
10488 // key_of_historical returns the last-known (possibly
10489 // post-rename, possibly post-delete) key; compare to queried key.
10490 Some(resolved) if resolved == key => {
10491 let field_str = match self.syms.resolve(*field) {
10492 Some(s) => s.to_string(),
10493 None => continue,
10494 };
10495 Some(HistoryChange::PropSet {
10496 field: field_str,
10497 value: value.clone(),
10498 })
10499 }
10500 _ => None,
10501 }
10502 }
10503 WalRecord::RemoveProp { key: k, field }
10504 if Self::aliases_match(&alias_intervals, k, commit) =>
10505 {
10506 Some(HistoryChange::PropRemoved {
10507 field: field.clone(),
10508 })
10509 }
10510 WalRecord::InsertEdge {
10511 edge_type,
10512 src_key,
10513 dst_key,
10514 } => {
10515 if Self::aliases_match(&alias_intervals, src_key, commit) {
10516 Some(HistoryChange::EdgeAdded {
10517 edge_type: edge_type.clone(),
10518 other: dst_key.clone(),
10519 outgoing: true,
10520 })
10521 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10522 Some(HistoryChange::EdgeAdded {
10523 edge_type: edge_type.clone(),
10524 other: src_key.clone(),
10525 outgoing: false,
10526 })
10527 } else {
10528 None
10529 }
10530 }
10531 WalRecord::InsertEdgeId { etype, src, dst } => {
10532 let etype_str = match self.syms.resolve(*etype) {
10533 Some(s) => s.to_string(),
10534 None => continue,
10535 };
10536 // key_of_historical (not key_of): an edge added before
10537 // either endpoint was later deleted must still resolve —
10538 // see the SetPropId arm above and edge_history's
10539 // InsertEdgeId arm, which use the same lookup for the
10540 // same reason.
10541 let src_key = self.ids.key_of_historical(*src);
10542 let dst_key = self.ids.key_of_historical(*dst);
10543 if src_key == Some(key) {
10544 let other = match dst_key {
10545 Some(s) => s.to_string(),
10546 None => continue,
10547 };
10548 Some(HistoryChange::EdgeAdded {
10549 edge_type: etype_str,
10550 other,
10551 outgoing: true,
10552 })
10553 } else if dst_key == Some(key) {
10554 let other = match src_key {
10555 Some(s) => s.to_string(),
10556 None => continue,
10557 };
10558 Some(HistoryChange::EdgeAdded {
10559 edge_type: etype_str,
10560 other,
10561 outgoing: false,
10562 })
10563 } else {
10564 None
10565 }
10566 }
10567 WalRecord::DeleteEdge {
10568 edge_type,
10569 src_key,
10570 dst_key,
10571 } => {
10572 if Self::aliases_match(&alias_intervals, src_key, commit) {
10573 Some(HistoryChange::EdgeRemoved {
10574 edge_type: edge_type.clone(),
10575 other: dst_key.clone(),
10576 outgoing: true,
10577 })
10578 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10579 Some(HistoryChange::EdgeRemoved {
10580 edge_type: edge_type.clone(),
10581 other: src_key.clone(),
10582 outgoing: false,
10583 })
10584 } else {
10585 None
10586 }
10587 }
10588 WalRecord::DeleteNode { key: k }
10589 if Self::aliases_match(&alias_intervals, k, commit) =>
10590 {
10591 Some(HistoryChange::NodeDeleted)
10592 }
10593 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10594 _ => None,
10595 };
10596
10597 if let Some(change) = change {
10598 out.push(HistoryEntry { commit, change });
10599 }
10600 }
10601 }
10602
10603 Ok(HistoryResult {
10604 items: out,
10605 total_commits,
10606 horizon: self.wal_horizon_floor,
10607 })
10608 }
10609
10610 /// Return the per-edge change history between nodes `a` and `b` by scanning
10611 /// the on-disk WAL.
10612 ///
10613 /// ## Horizon
10614 ///
10615 /// History reaches back only to the last WAL-truncating snapshot, exactly
10616 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10617 /// `total_commits` (= number of WAL frames), which is the exclusive upper
10618 /// bound for valid commit indices.
10619 ///
10620 /// ## Derived edges
10621 ///
10622 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10623 /// WAL markers written by `log_then_apply_with` after each rule-firing
10624 /// mutation. The `rule` field of those events carries the rule name.
10625 ///
10626 /// ## DeleteNode
10627 ///
10628 /// When a node is deleted, its manual incident edges are swept inline without
10629 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10630 /// events for either endpoint and synthesises `Retracted(rule:None)` events
10631 /// for each manual edge that was active at that point. Derived edges active at
10632 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10633 /// the engine appends immediately after the `DeleteNode` record; those events
10634 /// carry correct rule attribution and are emitted by the marker arm, not the
10635 /// synthetic sweep.
10636 ///
10637 /// ## Masks
10638 ///
10639 /// Like `node_history`, this method has no mask parameter and returns WAL
10640 /// history regardless of any role mask. For masked history semantics, apply
10641 /// the mask at the caller level.
10642 pub fn edge_history(
10643 &self,
10644 a: &str,
10645 b: &str,
10646 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10647 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10648 use core_storage::wal::WalRecord;
10649
10650 let (frames, _) = self.all_frames()?;
10651 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10652
10653 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10654 // Intervals are commit-bounded so recycled keys don't contaminate histories.
10655 let alias_a = self.build_key_alias_intervals(&frames, a);
10656 let alias_b = self.build_key_alias_intervals(&frames, b);
10657
10658 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10659 // The is_derived flag is used by the DeleteNode sweep: manual edges are
10660 // swept with a synthetic Retracted(rule:None); derived edges are skipped
10661 // because the engine writes a DerivedEdgeRetracted marker immediately after
10662 // the DeleteNode record, which carries the correct rule attribution.
10663 let mut active: Vec<(String, String, String, bool)> = Vec::new();
10664 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10665
10666 for (local_i, frame) in frames.iter().enumerate() {
10667 let commit = self.wal_horizon_floor + local_i as u64;
10668 let records: &[WalRecord] = match frame {
10669 WalRecord::Batch(inner) => inner.as_slice(),
10670 single => std::slice::from_ref(single),
10671 };
10672
10673 for rec in records {
10674 match rec {
10675 WalRecord::InsertEdge {
10676 edge_type,
10677 src_key,
10678 dst_key,
10679 } => {
10680 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10681 && Self::aliases_match(&alias_b, dst_key, commit);
10682 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10683 && Self::aliases_match(&alias_a, dst_key, commit);
10684 if is_ab || is_ba {
10685 active.push((
10686 edge_type.clone(),
10687 src_key.clone(),
10688 dst_key.clone(),
10689 false,
10690 ));
10691 out.push(EdgeHistoryEvent {
10692 edge_type: edge_type.clone(),
10693 commit,
10694 event: EdgeEvent::Added,
10695 rule: None,
10696 });
10697 }
10698 }
10699 WalRecord::InsertEdgeId { etype, src, dst } => {
10700 let etype_str = match self.syms.resolve(*etype) {
10701 Some(s) => s.to_string(),
10702 None => continue,
10703 };
10704 // Use key_of_historical so tombstoned nodes (deleted
10705 // later in the WAL) still resolve during the scan.
10706 let src_key = self.ids.key_of_historical(*src);
10707 let dst_key = self.ids.key_of_historical(*dst);
10708 let is_ab = src_key == Some(a) && dst_key == Some(b);
10709 let is_ba = src_key == Some(b) && dst_key == Some(a);
10710 if is_ab || is_ba {
10711 let src_str = src_key.unwrap().to_string();
10712 let dst_str = dst_key.unwrap().to_string();
10713 active.push((etype_str.clone(), src_str, dst_str, false));
10714 out.push(EdgeHistoryEvent {
10715 edge_type: etype_str,
10716 commit,
10717 event: EdgeEvent::Added,
10718 rule: None,
10719 });
10720 }
10721 }
10722 WalRecord::DeleteEdge {
10723 edge_type,
10724 src_key,
10725 dst_key,
10726 } => {
10727 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10728 && Self::aliases_match(&alias_b, dst_key, commit);
10729 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10730 && Self::aliases_match(&alias_a, dst_key, commit);
10731 if is_ab || is_ba {
10732 // Remove the first matching active entry (flag ignored).
10733 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10734 et == edge_type && s == src_key && d == dst_key
10735 }) {
10736 active.remove(pos);
10737 }
10738 out.push(EdgeHistoryEvent {
10739 edge_type: edge_type.clone(),
10740 commit,
10741 event: EdgeEvent::Retracted,
10742 rule: None,
10743 });
10744 }
10745 }
10746 WalRecord::DeleteNode { key: k }
10747 if Self::aliases_match(&alias_a, k, commit)
10748 || Self::aliases_match(&alias_b, k, commit) =>
10749 {
10750 // Sweep: implicitly retract only MANUAL active edges.
10751 // Derived active edges are skipped here because the rule
10752 // engine appends a DerivedEdgeRetracted marker immediately
10753 // after this DeleteNode record; that marker produces the
10754 // single correctly-attributed Retracted event. Derived
10755 // entries are dropped from `active` (the marker arm's
10756 // idempotent retain finds nothing to remove).
10757 for (et, _, _, is_derived) in active.drain(..) {
10758 if !is_derived {
10759 out.push(EdgeHistoryEvent {
10760 edge_type: et,
10761 commit,
10762 event: EdgeEvent::Retracted,
10763 rule: None,
10764 });
10765 }
10766 // Derived: drop silently; marker carries the Retracted event.
10767 }
10768 }
10769 WalRecord::DerivedEdgeAdded {
10770 rule,
10771 edge_type: et,
10772 src_key,
10773 dst_key,
10774 } => {
10775 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10776 && Self::aliases_match(&alias_b, dst_key, commit);
10777 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10778 && Self::aliases_match(&alias_a, dst_key, commit);
10779 if is_ab || is_ba {
10780 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10781 out.push(EdgeHistoryEvent {
10782 edge_type: et.clone(),
10783 commit,
10784 event: EdgeEvent::Added,
10785 rule: Some(rule.clone()),
10786 });
10787 }
10788 }
10789 WalRecord::DerivedEdgeRetracted {
10790 rule,
10791 edge_type: et,
10792 src_key,
10793 dst_key,
10794 } => {
10795 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10796 && Self::aliases_match(&alias_b, dst_key, commit);
10797 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10798 && Self::aliases_match(&alias_a, dst_key, commit);
10799 if is_ab || is_ba {
10800 // Push unconditionally: a derived edge whose Added marker
10801 // predates the history horizon has no `active` entry, but
10802 // the retraction is still a real in-window event.
10803 // Remove from active idempotently if present.
10804 active.retain(|(aet, s, d, _)| {
10805 !(aet == et && s == src_key && d == dst_key)
10806 });
10807 out.push(EdgeHistoryEvent {
10808 edge_type: et.clone(),
10809 commit,
10810 event: EdgeEvent::Retracted,
10811 rule: Some(rule.clone()),
10812 });
10813 }
10814 }
10815 // All other records (InsertNode, SetProp, CreateRule, etc.)
10816 // do not affect edges between a and b.
10817 _ => {}
10818 }
10819 }
10820 }
10821
10822 Ok(HistoryResult {
10823 items: out,
10824 total_commits,
10825 horizon: self.wal_horizon_floor,
10826 })
10827 }
10828
10829 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10830 /// (in either direction) at the WAL commit `at_commit`.
10831 ///
10832 /// ## Horizon
10833 ///
10834 /// Valid commit indices are `0..total_commits` where `total_commits` is the
10835 /// number of WAL frames. An `at_commit >= total_commits` is outside the
10836 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10837 ///
10838 /// ## Derived edges
10839 ///
10840 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10841 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10842 /// and therefore includes derived edges in its point-in-time evaluation,
10843 /// matching `edge_history`'s fidelity.
10844 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10845 use core_storage::wal::WalRecord;
10846
10847 let (frames, _) = self.all_frames()?;
10848 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10849
10850 // Horizon floor: commits in pruned archives are unreachable.
10851 if at_commit < self.wal_horizon_floor {
10852 return Err(GraphError::CommitOutOfRange {
10853 commit: at_commit,
10854 total: total_commits,
10855 floor: self.wal_horizon_floor,
10856 });
10857 }
10858 if at_commit >= total_commits {
10859 return Err(GraphError::CommitOutOfRange {
10860 commit: at_commit,
10861 total: total_commits,
10862 floor: self.wal_horizon_floor,
10863 });
10864 }
10865
10866 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10867 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10868 let alias_a = self.build_key_alias_intervals(&frames, a);
10869 let alias_b = self.build_key_alias_intervals(&frames, b);
10870
10871 // Local index into surviving frames (0 = first frame of oldest archive).
10872 let local_commit = at_commit - self.wal_horizon_floor;
10873
10874 // Replay local frames 0..=local_commit, tracking active edges.
10875 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10876
10877 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10878 let commit = self.wal_horizon_floor + local_i as u64;
10879 let records: &[WalRecord] = match frame {
10880 WalRecord::Batch(inner) => inner.as_slice(),
10881 single => std::slice::from_ref(single),
10882 };
10883
10884 for rec in records {
10885 match rec {
10886 WalRecord::InsertEdge {
10887 edge_type: et,
10888 src_key,
10889 dst_key,
10890 } => {
10891 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10892 && Self::aliases_match(&alias_b, dst_key, commit);
10893 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10894 && Self::aliases_match(&alias_a, dst_key, commit);
10895 if is_ab || is_ba {
10896 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10897 }
10898 }
10899 WalRecord::InsertEdgeId { etype, src, dst } => {
10900 let etype_str = match self.syms.resolve(*etype) {
10901 Some(s) => s.to_string(),
10902 None => continue,
10903 };
10904 // Use key_of_historical so tombstoned nodes resolve.
10905 let src_key = self.ids.key_of_historical(*src);
10906 let dst_key = self.ids.key_of_historical(*dst);
10907 let is_ab = src_key == Some(a) && dst_key == Some(b);
10908 let is_ba = src_key == Some(b) && dst_key == Some(a);
10909 if is_ab || is_ba {
10910 active.insert((
10911 etype_str,
10912 src_key.unwrap().to_string(),
10913 dst_key.unwrap().to_string(),
10914 ));
10915 }
10916 }
10917 WalRecord::DeleteEdge {
10918 edge_type: et,
10919 src_key,
10920 dst_key,
10921 } => {
10922 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10923 && Self::aliases_match(&alias_b, dst_key, commit);
10924 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10925 && Self::aliases_match(&alias_a, dst_key, commit);
10926 if is_ab || is_ba {
10927 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10928 }
10929 }
10930 WalRecord::DeleteNode { key: k }
10931 if Self::aliases_match(&alias_a, k, commit)
10932 || Self::aliases_match(&alias_b, k, commit) =>
10933 {
10934 // All edges touching the deleted node are gone.
10935 active.retain(|(_, s, d)| s != k && d != k);
10936 }
10937 WalRecord::DerivedEdgeAdded {
10938 edge_type: et,
10939 src_key,
10940 dst_key,
10941 ..
10942 } => {
10943 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10944 && Self::aliases_match(&alias_b, dst_key, commit);
10945 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10946 && Self::aliases_match(&alias_a, dst_key, commit);
10947 if is_ab || is_ba {
10948 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10949 }
10950 }
10951 WalRecord::DerivedEdgeRetracted {
10952 edge_type: et,
10953 src_key,
10954 dst_key,
10955 ..
10956 } => {
10957 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10958 && Self::aliases_match(&alias_b, dst_key, commit);
10959 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10960 && Self::aliases_match(&alias_a, dst_key, commit);
10961 if is_ab || is_ba {
10962 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10963 }
10964 }
10965 _ => {}
10966 }
10967 }
10968 }
10969
10970 Ok(active.iter().any(|(et, _, _)| et == edge_type))
10971 }
10972
10973 /// Every edge incident to `key` — either endpoint — that existed at WAL
10974 /// commit `commit`, from ONE scan of the WAL.
10975 ///
10976 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
10977 /// "what did K's relationships look like at commit C" with one call instead
10978 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
10979 /// The two agree edge for edge.
10980 ///
10981 /// Results are sorted by `(edge_type, src_key, dst_key)`.
10982 ///
10983 /// ## Horizon
10984 ///
10985 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
10986 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
10987 /// `was_linked`. An unknown key is not an error — it simply had no edges.
10988 ///
10989 /// ## Derived edges
10990 ///
10991 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
10992 /// attribution, so a rule-owned edge comes back with `derived: true` and
10993 /// `rule: Some(name)`.
10994 ///
10995 /// ## Renames
10996 ///
10997 /// `key` is matched through the same commit-bounded alias intervals
10998 /// `edge_history` uses, so querying a node's *current* key surfaces edges
10999 /// written under an earlier name. Endpoint keys in the result are reported
11000 /// under the name the node carries today, so they can be fed straight back
11001 /// into `node_info`, `explain` or another `edges_at`.
11002 ///
11003 /// ## Masks
11004 ///
11005 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
11006 /// any role mask. Apply masking at the caller level.
11007 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
11008 use core_storage::wal::WalRecord;
11009
11010 let (frames, _) = self.all_frames()?;
11011 let total_commits = self.wal_horizon_floor + frames.len() as u64;
11012
11013 // Horizon floor: commits in pruned archives are unreachable.
11014 if commit < self.wal_horizon_floor || commit >= total_commits {
11015 return Err(GraphError::CommitOutOfRange {
11016 commit,
11017 total: total_commits,
11018 floor: self.wal_horizon_floor,
11019 });
11020 }
11021
11022 // Commit-bounded historical names of `key` (handles RenameNode).
11023 let alias = self.build_key_alias_intervals(&frames, key);
11024
11025 // Forward rename chain, for reporting endpoints under their current
11026 // names: old key → [(commit, new key)] in ascending commit order.
11027 // Built over the whole WAL, not just the prefix up to `commit`, because
11028 // a rename after `commit` still changes what the node is called today.
11029 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
11030 for (local_i, frame) in frames.iter().enumerate() {
11031 let c = self.wal_horizon_floor + local_i as u64;
11032 let records: &[WalRecord] = match frame {
11033 WalRecord::Batch(inner) => inner.as_slice(),
11034 single => std::slice::from_ref(single),
11035 };
11036 for rec in records {
11037 if let WalRecord::RenameNode { old_key, new_key } = rec {
11038 renames
11039 .entry(old_key.clone())
11040 .or_default()
11041 .push((c, new_key.clone()));
11042 }
11043 }
11044 }
11045
11046 // The name a node written as `k` at commit `from` carries today.
11047 // Follows the first rename at or after `from`, then keeps going. The
11048 // iteration cap bounds a rename cycle inside a single batch.
11049 let canon = |k: &str, from: u64| -> String {
11050 if renames.is_empty() {
11051 return k.to_string();
11052 }
11053 let mut cur = k.to_string();
11054 let mut at = from;
11055 for _ in 0..64 {
11056 match renames
11057 .get(&cur)
11058 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
11059 {
11060 Some((c, new)) => {
11061 at = *c;
11062 cur = new.clone();
11063 }
11064 None => break,
11065 }
11066 }
11067 cur
11068 };
11069
11070 let local_commit = commit - self.wal_horizon_floor;
11071 // (edge_type, src_key, dst_key) → (derived, rule)
11072 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
11073 BTreeMap::new();
11074
11075 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
11076 let c = self.wal_horizon_floor + local_i as u64;
11077 let records: &[WalRecord] = match frame {
11078 WalRecord::Batch(inner) => inner.as_slice(),
11079 single => std::slice::from_ref(single),
11080 };
11081
11082 for rec in records {
11083 match rec {
11084 WalRecord::InsertEdge {
11085 edge_type,
11086 src_key,
11087 dst_key,
11088 } => {
11089 if Self::aliases_match(&alias, src_key, c)
11090 || Self::aliases_match(&alias, dst_key, c)
11091 {
11092 active.insert(
11093 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11094 (false, None),
11095 );
11096 }
11097 }
11098 WalRecord::InsertEdgeId { etype, src, dst } => {
11099 let Some(etype_str) = self.syms.resolve(*etype) else {
11100 continue;
11101 };
11102 // `key_of_historical` resolves tombstoned ids too, and
11103 // already returns the node's current key — no rename
11104 // canonicalisation needed on this arm.
11105 let (Some(src_key), Some(dst_key)) = (
11106 self.ids.key_of_historical(*src),
11107 self.ids.key_of_historical(*dst),
11108 ) else {
11109 continue;
11110 };
11111 if src_key == key || dst_key == key {
11112 active.insert(
11113 (
11114 etype_str.to_string(),
11115 src_key.to_string(),
11116 dst_key.to_string(),
11117 ),
11118 (false, None),
11119 );
11120 }
11121 }
11122 WalRecord::DeleteEdge {
11123 edge_type,
11124 src_key,
11125 dst_key,
11126 } => {
11127 if Self::aliases_match(&alias, src_key, c)
11128 || Self::aliases_match(&alias, dst_key, c)
11129 {
11130 active.remove(&(
11131 edge_type.clone(),
11132 canon(src_key, c),
11133 canon(dst_key, c),
11134 ));
11135 }
11136 }
11137 WalRecord::DeleteNode { key: k } => {
11138 if active.is_empty() {
11139 continue;
11140 }
11141 if Self::aliases_match(&alias, k, c) {
11142 // Our node is gone; every incident edge goes with it.
11143 active.clear();
11144 } else {
11145 // A partner is gone; its edges to us go with it.
11146 let ck = canon(k, c);
11147 active.retain(|(_, s, d), _| *s != ck && *d != ck);
11148 }
11149 }
11150 WalRecord::DerivedEdgeAdded {
11151 rule,
11152 edge_type,
11153 src_key,
11154 dst_key,
11155 } => {
11156 if Self::aliases_match(&alias, src_key, c)
11157 || Self::aliases_match(&alias, dst_key, c)
11158 {
11159 active.insert(
11160 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11161 (true, Some(rule.clone())),
11162 );
11163 }
11164 }
11165 WalRecord::DerivedEdgeRetracted {
11166 edge_type,
11167 src_key,
11168 dst_key,
11169 ..
11170 } => {
11171 if Self::aliases_match(&alias, src_key, c)
11172 || Self::aliases_match(&alias, dst_key, c)
11173 {
11174 active.remove(&(
11175 edge_type.clone(),
11176 canon(src_key, c),
11177 canon(dst_key, c),
11178 ));
11179 }
11180 }
11181 // InsertNode, SetProp, CreateRule, … do not move edges.
11182 _ => {}
11183 }
11184 }
11185 }
11186
11187 // BTreeMap iteration is already (edge_type, src, dst) order.
11188 Ok(active
11189 .into_iter()
11190 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
11191 edge_type,
11192 src_key,
11193 dst_key,
11194 derived,
11195 rule,
11196 })
11197 .collect())
11198 }
11199
11200 /// The derived edges that would be retracted and derived if `key.field`
11201 /// were set to `value` — computed WITHOUT writing anything.
11202 ///
11203 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
11204 /// provenance, its candidate indexes, the topology and the property columns
11205 /// are all cloned first, the change is applied to the clone, and the real
11206 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
11207 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
11208 /// it emits are the answer, so rule semantics — predicates, top-k,
11209 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
11210 ///
11211 /// Works on a read-only handle.
11212 ///
11213 /// **While a rule's vector index is still building** (`RuleStats::building`)
11214 /// the clone carries no pending-build state, so this reports the edges that
11215 /// rule would derive — which the live store will not derive until its
11216 /// backfill runs. Right about the end state, early about the timing.
11217 ///
11218 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
11219 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
11220 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
11221 /// (the node already holds `value`, or no rule watches `field`) returns
11222 /// empty lists.
11223 ///
11224 /// ## Cost
11225 ///
11226 /// One clone of the property columns, the topology overlay, the symbol
11227 /// interner, the edge properties and the provenance map, plus one candidate
11228 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
11229 /// directory, but it is not free — this is an interactive "what if", not a
11230 /// hot path.
11231 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
11232 // The engine's provenance, HNSW and IVF state live in the mmap'd base
11233 // until something asks for them. On a store opened cold from a snapshot
11234 // this is the first ask, and without it the clone below starts from an
11235 // empty provenance map: nothing to retract, so `lost` comes back empty.
11236 self.ensure_v8_base_sections_loaded();
11237
11238 let empty = WhatIf {
11239 lost: Vec::new(),
11240 gained: Vec::new(),
11241 };
11242
11243 if let Some(view_name) = self.view_store.view_for_prop(field) {
11244 return Err(GraphError::ViewPropReadOnly {
11245 view_name: view_name.to_string(),
11246 });
11247 }
11248 MutPreview::new(self).check_live_key(key)?;
11249 let id = self
11250 .ids
11251 .get(key)
11252 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11253
11254 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
11255 if rules.is_empty() {
11256 return Ok(empty);
11257 }
11258
11259 // No rule watches this field → no derivation can change.
11260 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
11261 return Ok(empty);
11262 }
11263
11264 let old_value = build_props_view(&self.props, &self.base)
11265 .get(id, field)
11266 .map(|vr| vr.into_value());
11267 if old_value.as_ref() == Some(&value) {
11268 return Ok(empty);
11269 }
11270
11271 // --- Clone every piece of state the re-derivation writes to. ---
11272 let mut props = self.props.clone();
11273 let mut topo = self.topo.clone();
11274 let mut syms = self.syms.clone();
11275 let mut edge_props = self.edge_props.clone();
11276
11277 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
11278 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
11279 for r in &rules {
11280 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
11281 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
11282 }
11283 // `provenance()` decodes retained snapshot bytes on first use; the
11284 // engine clone needs the real map, not an empty one.
11285 let provenance = self.engine.provenance().clone();
11286 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
11287
11288 // Build the candidate indexes from the state BEFORE the change, exactly
11289 // as apply() sees them: `on_node_changed` withdraws the node under its
11290 // old value and refiles it under the new one, so the index must not
11291 // already reflect the change.
11292 engine.reindex_all_load_state(
11293 &self.ids,
11294 &syms,
11295 &self.labels,
11296 build_props_view(&self.props, &self.base),
11297 self.engine.export_ivf_state(),
11298 self.engine.export_hnsw_state_passthrough(),
11299 );
11300 engine.set_emit_deltas(true);
11301
11302 // --- Apply the hypothetical change and re-derive. ---
11303 props.set(id, field, value);
11304 {
11305 let mut gm = make_graph_mut(
11306 &self.ids,
11307 &mut syms,
11308 &self.labels,
11309 build_props_view(&props, &self.base),
11310 &mut topo,
11311 &self.base,
11312 &mut edge_props,
11313 );
11314 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
11315 }
11316
11317 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
11318 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
11319 for d in engine.drain_deltas() {
11320 let edge = EdgeAt {
11321 edge_type: d.edge_type,
11322 src_key: d.src_key,
11323 dst_key: d.dst_key,
11324 derived: true,
11325 rule: Some(d.rule),
11326 };
11327 if d.fired {
11328 gained.insert(edge);
11329 } else {
11330 lost.insert(edge);
11331 }
11332 }
11333 // An edge retracted and re-derived within the same re-derivation (top-k
11334 // churn) is not a change the caller would see.
11335 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
11336 for e in churn {
11337 lost.remove(&e);
11338 gained.remove(&e);
11339 }
11340
11341 Ok(WhatIf {
11342 lost: lost.into_iter().collect(),
11343 gained: gained.into_iter().collect(),
11344 })
11345 }
11346
11347 pub fn edge_count(&self) -> u64 {
11348 self.topo_view().edge_count()
11349 }
11350
11351 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
11352 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
11353 pub fn stats(&self) -> Stats {
11354 self.ensure_v8_base_sections_loaded();
11355 let building = self.engine.builds_in_progress();
11356 let rules: Vec<RuleStats> = self
11357 .engine
11358 .rules()
11359 .map(|r| RuleStats {
11360 name: r.name.clone(),
11361 edges: self
11362 .engine
11363 .provenance()
11364 .get(&r.name)
11365 .map(|s| s.len() as u64)
11366 .unwrap_or(0),
11367 tripped: self.engine.is_tripped(&r.name),
11368 fires: self.engine.fire_count(&r.name),
11369 approximate: r.approximate,
11370 building: building.iter().find(|b| b.rule == r.name).cloned(),
11371 })
11372 .collect();
11373 Stats {
11374 nodes_live: self.ids.live_len(),
11375 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
11376 edges: self.topo_view().edge_count(),
11377 rules,
11378 chain_truncations: self.engine.chain_truncations(),
11379 history_floor: self.wal_horizon_floor,
11380 namespaces: self.namespace_stats(),
11381 }
11382 }
11383
11384 /// On-disk size of the WAL file in bytes.
11385 ///
11386 /// Reads file metadata without loading WAL contents. Returns `Err` for
11387 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
11388 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
11389 let path = self.fs.wal_path().ok_or_else(|| {
11390 std::io::Error::new(
11391 std::io::ErrorKind::Unsupported,
11392 "wal_path not available for this Fs implementation",
11393 )
11394 })?;
11395 Ok(std::fs::metadata(path)?.len())
11396 }
11397
11398 /// Set the slow-query threshold. Queries whose execution time equals or
11399 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
11400 ///
11401 /// Use this setter in tests — the environment variable
11402 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
11403 /// threads.
11404 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
11405 self.slow_query_threshold_ms = ms;
11406 }
11407
11408 /// Snapshot of the slow-query ring buffer and lifetime counter.
11409 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
11410 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
11411 SlowQuerySnapshot {
11412 threshold_ms: self.slow_query_threshold_ms,
11413 count: log.total,
11414 last: log.entries.iter().cloned().collect(),
11415 }
11416 }
11417
11418 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
11419 /// to compute uptime.
11420 pub fn started_at(&self) -> std::time::Instant {
11421 self.started_at
11422 }
11423
11424 /// On-disk snapshot format version this binary writes and reads.
11425 pub fn format_version() -> u16 {
11426 core_storage::snapshot::VERSION
11427 }
11428
11429 /// Test-support: total bytes appended (SimFs only usage).
11430 pub fn fs_total_appended(&self) -> usize
11431 where
11432 F: FsIntrospect,
11433 {
11434 self.fs.total_appended()
11435 }
11436
11437 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
11438 pub fn fs_sync_count(&self) -> usize
11439 where
11440 F: FsIntrospect,
11441 {
11442 self.fs.sync_count()
11443 }
11444
11445 /// Consume the db, returning its fs (for crash simulation).
11446 pub fn into_fs(self) -> F {
11447 self.fs
11448 }
11449
11450 pub fn snapshot(&mut self) -> Result<()> {
11451 self.snapshot_with(SnapshotOptions::default())
11452 }
11453
11454 /// Snapshot with explicit options.
11455 ///
11456 /// # `keep_wal`
11457 ///
11458 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
11459 /// - The WAL is replaced with a minimal baseline containing one
11460 /// `EnableFulltext` record per active declaration. All pre-snapshot
11461 /// history is discarded; `open_at` can only reach post-snapshot commits.
11462 ///
11463 /// When `keep_wal` is `true`:
11464 /// - The WAL is left intact. All pre-snapshot commits remain reachable
11465 /// via `open_at`. The existing WAL already contains the original
11466 /// `EnableFulltext` records, so no baseline re-write is needed; the
11467 /// recovery guards in `apply()` silently skip any duplicate records on
11468 /// replay.
11469 /// - Crash window: a crash after the snapshot write but before the next
11470 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
11471 /// snapshot is loaded and the WAL replayed idempotently over it — safe
11472 /// because every `apply()` arm is idempotent when replayed over an
11473 /// already-current snapshot.
11474 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
11475 if self.read_only {
11476 return Err(GraphError::ReadOnly);
11477 }
11478 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
11479 // appending ends up holding a descriptor on an unlinked inode and loses
11480 // commits it believes durable. Snapshotting therefore requires the
11481 // cross-process write lock, exactly as appending does. Unlike the WAL
11482 // append path this does not go through `log_then_apply_with`, so both
11483 // guards are repeated here.
11484 if self.degraded {
11485 return Err(GraphError::Io(std::io::Error::other(
11486 "database degraded after group-commit fsync failure; reopen required",
11487 )));
11488 }
11489 if self.lock_denied {
11490 return Err(GraphError::Busy { holder: None });
11491 }
11492 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
11493 // Used by the archive path's conservative genesis-chain check: if a prior
11494 // snapshot exists but wal.truncated does not, we cannot distinguish a
11495 // legacy store (may have been truncated in an older code version) from a
11496 // new store that only used keep_wal=true. Conservative: refuse genesis in
11497 // both cases. Must be sampled here, before the snapshot write below.
11498 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
11499 self.ensure_v8_base_sections_loaded();
11500 // Ensure provenance is decoded before to_persist() clones it.
11501 self.engine.ensure_provenance_loaded_mut();
11502 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
11503 let rule_defs = rule_defs_typed
11504 .iter()
11505 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
11506 .collect();
11507 // Collect HNSW state and IVF state. When indexes are not yet
11508 // populated (clean open, no mutation since open), pass the retained
11509 // raw bytes through directly so that migrate/snapshot does not
11510 // silently discard fitted approximate-rule indexes.
11511 let hnsw_state = self.engine.export_hnsw_state_passthrough();
11512 let ivf_bytes = if !self.engine.indexes_populated() {
11513 // Pass retained IVF bytes through unchanged (no re-encode).
11514 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
11515 } else {
11516 // Indexes live: encode from current state.
11517 let raw_ivf = self.engine.export_ivf_state();
11518 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
11519 .into_iter()
11520 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
11521 (
11522 name,
11523 core_storage::snapshot::PerRuleIvfState {
11524 src: core_storage::snapshot::SideIvfState {
11525 centroids: sc,
11526 clusters: sa,
11527 drift: sd,
11528 },
11529 dst: core_storage::snapshot::SideIvfState {
11530 centroids: dc,
11531 clusters: da,
11532 drift: dd,
11533 },
11534 },
11535 )
11536 })
11537 .collect();
11538 if ivf_state_map.is_empty() {
11539 Vec::new()
11540 } else {
11541 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11542 }
11543 };
11544 let view_defs: Vec<Vec<u8>> = self
11545 .view_store
11546 .views()
11547 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11548 .collect();
11549 if self.base.is_some() {
11550 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11551 // write it atomically, remap it as the new base, then clear the overlay.
11552 let meta = V8Meta {
11553 labels: self.labels.clone(),
11554 edge_props: self.edge_props.clone(),
11555 rule_defs,
11556 provenance,
11557 rule_tripped,
11558 rule_fires,
11559 ivf_bytes,
11560 view_defs,
11561 wal_truncated: !opts.keep_wal,
11562 hnsw: hnsw_state,
11563 last_change: self.last_change.clone(),
11564 };
11565 let mut buf: Vec<u8> = Vec::new();
11566 {
11567 // Clone the Arc so the old base stays alive while we encode.
11568 // The borrow of archived_csr (into old_base's mmap) is released
11569 // at the end of this block, before we replace self.base.
11570 let old_base = self.base.clone().expect("is_some checked above");
11571 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11572 detail: format!("v8 snapshot: topology section: {e:?}"),
11573 })?;
11574 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11575 detail: format!("v8 snapshot: columns section: {e:?}"),
11576 })?;
11577 // `None` when the base predates V9 — the migration path: its
11578 // string columns still carry their own tables and this snapshot
11579 // is the rewrite that collapses them into section 12.
11580 let archived_strings =
11581 old_base
11582 .string_table()
11583 .transpose()
11584 .map_err(|e| GraphError::Corrupt {
11585 detail: format!("v8 snapshot: strings section: {e:?}"),
11586 })?;
11587 let archived_edge_props =
11588 old_base
11589 .edge_props_section()
11590 .map_err(|e| GraphError::Corrupt {
11591 detail: format!("v8 snapshot: edge_props section: {e:?}"),
11592 })?;
11593 let edge_props_raw =
11594 old_base
11595 .edge_props_raw_bytes()
11596 .map_err(|e| GraphError::Corrupt {
11597 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11598 })?;
11599 let prov_raw =
11600 old_base
11601 .provenance_raw_bytes()
11602 .map_err(|e| GraphError::Corrupt {
11603 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11604 })?;
11605 encode_v8(
11606 Some(archived_csr),
11607 Some(archived_cols),
11608 archived_strings,
11609 Some((archived_edge_props, edge_props_raw)),
11610 Some(prov_raw),
11611 &self.topo,
11612 &self.props,
11613 &self.ids,
11614 &self.syms,
11615 &meta,
11616 &mut buf,
11617 )?;
11618 }
11619 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11620 // Remap the freshly-written snapshot as the new base.
11621 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11622 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11623 core_storage::v8::MappedBase::map(&snap_path)
11624 } else {
11625 core_storage::v8::MappedBase::from_bytes(buf)
11626 }
11627 .map_err(|e| GraphError::Corrupt {
11628 detail: format!("v8 snapshot: remap new base: {e:?}"),
11629 })?;
11630 self.base = Some(Arc::new(new_base));
11631 // Clear the overlay and prop tombstones — all data is now in the new base.
11632 self.topo = Topology::new();
11633 self.props = core_storage::columns::ColumnStore::new();
11634 } else {
11635 // Legacy path (V5–V7 stores without a V8 base).
11636 //
11637 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11638 // clone and no encode_v8_from_state intermediate clones. The big
11639 // structures (self.topo, self.props) are borrowed, not cloned.
11640 // self.edge_props is moved (not cloned) because we immediately clear it
11641 // when we remap the new V8 snapshot as self.base (see below).
11642 //
11643 // Eliminates from peak RSS vs. the old SnapshotState path:
11644 // • self.topo.clone() (~topology HashMap footprint)
11645 // • self.props.clone() (~column-store footprint)
11646 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11647 let meta = V8Meta {
11648 labels: self.labels.clone(),
11649 wal_truncated: !opts.keep_wal,
11650 // Move edge_props out so the large overlay is freed when meta
11651 // drops at end of this block (self.edge_props is now empty; reads
11652 // after base assignment go through the mmap'd base section).
11653 edge_props: std::mem::take(&mut self.edge_props),
11654 rule_defs,
11655 provenance,
11656 rule_tripped,
11657 rule_fires,
11658 ivf_bytes,
11659 view_defs,
11660 hnsw: hnsw_state,
11661 last_change: self.last_change.clone(),
11662 };
11663 let mut buf = Vec::new();
11664 encode_v8(
11665 None,
11666 None,
11667 None,
11668 None,
11669 None,
11670 &self.topo,
11671 &self.props,
11672 &self.ids,
11673 &self.syms,
11674 &meta,
11675 &mut buf,
11676 )?;
11677 // meta (and the moved edge_props inside it) is no longer needed;
11678 // drop it before the write to keep the peak window narrow.
11679 drop(meta);
11680 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11681 // Remap the freshly-written V8 snapshot as self.base.
11682 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11683 // On SimFs (tests): pass buf to from_bytes.
11684 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11685 drop(buf);
11686 core_storage::v8::MappedBase::map(&snap_path)
11687 } else {
11688 core_storage::v8::MappedBase::from_bytes(buf)
11689 }
11690 .map_err(|e| GraphError::Corrupt {
11691 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11692 })?;
11693 self.base = Some(Arc::new(new_base));
11694 // Free the large heap-allocated decoded state — all data is now in the
11695 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
11696 // self.edge_props was already moved into meta and is effectively empty.
11697 self.topo = Topology::new();
11698 self.props = core_storage::columns::ColumnStore::new();
11699 }
11700
11701 if opts.archive_wal {
11702 // History-preserving snapshot (Task 4):
11703 // 1. Snapshot already written above (write_atomic → fsynced).
11704 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
11705 // Crash window B: crash here leaves archive present, WAL
11706 // absent. Reopen: snapshot loaded (full state), no WAL
11707 // replay. Archive is NOT replayed into live state — it is
11708 // pre-snapshot by construction. Safe.
11709 // 3. Optionally write genesis marker (first archive only, no
11710 // prior WAL truncation).
11711 // 4. Prune old archives (retention), update horizon floor.
11712 // Pruning invalidates the genesis chain; delete marker.
11713 // 5. Write new minimal baseline WAL (write_atomic).
11714 // Crash window C: crash here leaves new archive plus no live
11715 // WAL. Same as window B — handled above.
11716 //
11717 // Sample existing archives BEFORE the rename so we can detect
11718 // whether this is the first archive.
11719 let existing_archives = self.fs.list_archives()?;
11720 let is_first_archive = existing_archives.is_empty();
11721
11722 // Compute a globally-monotonic archive name: the name equals the
11723 // cumulative end-frame index of the archive in global commit space.
11724 //
11725 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11726 // commit_seq is seeded from max(last_change), which underestimates
11727 // the WAL depth when trailing commits (e.g. insert_edge) do not
11728 // update last_change. A session-2 archive could then receive a name
11729 // ≤ the session-1 archive, causing incorrect sort order or collision.
11730 //
11731 // Instead: read and decode the live WAL here (before the rename) to
11732 // get its exact frame count, then add it to the last known global
11733 // end-frame index (the name of the most recent existing archive, or
11734 // wal_horizon_floor if no archives exist). This is O(WAL size) but
11735 // snapshot is already serialising the full graph state, so the cost
11736 // is dominated.
11737 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11738 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11739 let archive_n = existing_archives
11740 .last()
11741 .copied()
11742 .unwrap_or(self.wal_horizon_floor)
11743 + live_frames_for_name.len() as u64;
11744 self.fs.archive_wal(archive_n)?;
11745
11746 // Genesis marker: written once when the first archive is taken
11747 // from a store that has never undergone a WAL-truncating snapshot.
11748 // When present, `open_at` may replay archive-resident commits from
11749 // empty state (the archive chain covers from global index 0).
11750 //
11751 // Two conditions must ALL hold:
11752 // 1. This is the first archive (existing_archives was empty).
11753 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11754 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11755 // before truncating the WAL, so if any prior truncating snapshot was taken
11756 // — even in a previous session — snapshot.bin is present and this condition
11757 // is false. This subsumes the cross-session truncation case without
11758 // requiring a separate wal.truncated sidecar file.
11759 // For legacy stores (snapshot.bin written by an older code version that
11760 // may have truncated the WAL), the same conservative refusal applies:
11761 // we cannot prove the chain is complete, so we refuse genesis (cost =
11762 // no as-of-through-archives; never silent wrong data).
11763 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11764 // so SimFs always passes this check.
11765 if is_first_archive && !had_prior_snapshot {
11766 self.fs.write_genesis_marker()?;
11767 self.archive_genesis_chain = true;
11768 }
11769
11770 // Retention pruning: keep newest `keep` archives; delete oldest.
11771 // Pruning is the ONLY deletion site for archives.
11772 //
11773 // Crash-safety ordering (C1 fix):
11774 // 1. Count frames in surplus archives (reads only — no mutation).
11775 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
11776 // rename (atomic). A crash after this point leaves orphaned
11777 // archives on disk, but the floor is correct. The opening
11778 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
11779 // the next open, so the store is always safe to reopen.
11780 // 3. Delete the genesis marker (floor > 0 already blocks open_at
11781 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
11782 // 4. Delete surplus archives. A crash between any two deletes
11783 // leaves the floor committed and orphaned archives cleaned at
11784 // next open — never a stale floor with a missing archive prefix.
11785 if let Some(keep) = self.wal_archive_retention {
11786 if keep > 0 {
11787 let archives = self.fs.list_archives()?;
11788 // archives is sorted ascending (oldest first)
11789 if archives.len() as u32 > keep {
11790 let surplus = archives.len() - keep as usize;
11791 // Step 1: count pruned frames (reads, no mutation).
11792 let mut pruned_frames = 0u64;
11793 for &n in &archives[..surplus] {
11794 let bytes = self.fs.read_archive(n)?;
11795 let (frames, _) = decode_all(&bytes);
11796 pruned_frames += frames.len() as u64;
11797 }
11798 // Step 2: advance and persist floor FIRST.
11799 self.wal_horizon_floor += pruned_frames;
11800 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11801 // Step 3: delete genesis marker (floor > 0 already
11802 // blocks open_at; this is belt-and-suspenders cleanup).
11803 if pruned_frames > 0 && self.archive_genesis_chain {
11804 self.fs.delete_genesis_marker()?;
11805 self.archive_genesis_chain = false;
11806 }
11807 // Step 4: delete surplus archives. Crash here →
11808 // orphaned archives; cleaned at next open.
11809 for &n in &archives[..surplus] {
11810 self.fs.delete_archive(n)?;
11811 }
11812 }
11813 }
11814 }
11815
11816 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11817 let mut baseline_wal: Vec<u8> = Vec::new();
11818 for (label, field) in self.fulltext.enabled_pairs() {
11819 let rec = WalRecord::EnableFulltext {
11820 label: label.clone(),
11821 field: field.clone(),
11822 };
11823 baseline_wal.extend_from_slice(&encode_record(&rec));
11824 }
11825 for (label, field) in self.prop_index.enabled_pairs() {
11826 let rec = WalRecord::EnableIndex {
11827 label: label.clone(),
11828 field: field.clone(),
11829 };
11830 baseline_wal.extend_from_slice(&encode_record(&rec));
11831 }
11832 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11833 } else if opts.keep_wal {
11834 // keep_wal=true: WAL is left untouched. The existing WAL already
11835 // contains the EnableFulltext records from the original enable calls;
11836 // replay is idempotent (guards in apply() skip already-live entries).
11837 // No baseline re-write is needed or safe here — the full WAL history
11838 // must remain intact for open_at to reach pre-snapshot commits.
11839 } else {
11840 // keep_wal=false (default): truncate by replacing the WAL with a
11841 // minimal baseline of one EnableFulltext record per active pair.
11842 //
11843 // Crash-ordering: write_atomic is atomic.
11844 // • Crash before snapshot write → WAL unchanged. Safe.
11845 // • Crash after snapshot write but before this WAL write → full
11846 // pre-snapshot WAL still present; open_with replays idempotently.
11847 // • Crash after both writes → normal post-snapshot state.
11848 //
11849 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11850 // for any archives taken AFTER this point (their WAL slices would
11851 // not start at genesis). Delete any existing genesis marker so that
11852 // open_at refuses archive-resident commits. Future sessions are
11853 // covered by had_prior_snapshot: snapshot.bin written here persists
11854 // across sessions and prevents a later archiving session from
11855 // incorrectly claiming a complete genesis chain.
11856 if self.archive_genesis_chain {
11857 self.fs.delete_genesis_marker()?;
11858 self.archive_genesis_chain = false;
11859 }
11860 let mut baseline_wal: Vec<u8> = Vec::new();
11861 for (label, field) in self.fulltext.enabled_pairs() {
11862 let rec = WalRecord::EnableFulltext {
11863 label: label.clone(),
11864 field: field.clone(),
11865 };
11866 baseline_wal.extend_from_slice(&encode_record(&rec));
11867 }
11868 for (label, field) in self.prop_index.enabled_pairs() {
11869 let rec = WalRecord::EnableIndex {
11870 label: label.clone(),
11871 field: field.clone(),
11872 };
11873 baseline_wal.extend_from_slice(&encode_record(&rec));
11874 }
11875 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11876 }
11877 // After snapshot the overlay may have changed (V8 merge path clears
11878 // self.topo and self.props). Refresh the MVCC fold so future readers
11879 // see the post-snapshot state rather than stale overlay data.
11880 self.fold_now();
11881 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11882 // markers this handle uses to detect other processes' work must be
11883 // re-taken from disk. Skipping this would make our own snapshot look
11884 // like a peer's on the next staleness check and force a needless
11885 // reload.
11886 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11887 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11888 Ok(())
11889 }
11890}
11891
11892/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11893///
11894/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11895/// callers can build a set of mutations without holding `&mut GraphDb` and
11896/// hand them off to the group-committing writer for durable, batched I/O.
11897pub enum BatchOp {
11898 InsertNode {
11899 label: String,
11900 key: String,
11901 props: Vec<(String, Value)>,
11902 },
11903 InsertEdge {
11904 edge_type: String,
11905 src_key: String,
11906 dst_key: String,
11907 },
11908 SetProp {
11909 key: String,
11910 field: String,
11911 value: Value,
11912 },
11913 RemoveProp {
11914 key: String,
11915 field: String,
11916 },
11917 DeleteEdge {
11918 edge_type: String,
11919 src_key: String,
11920 dst_key: String,
11921 },
11922 DeleteNode {
11923 key: String,
11924 },
11925 CreateRule(RuleDef),
11926 DeleteRule {
11927 name: String,
11928 },
11929 /// Rename a node's key. Validated: old must exist, new must not.
11930 RenameNode {
11931 old_key: String,
11932 new_key: String,
11933 },
11934 /// Insert an edge, auto-creating any missing endpoint as a plain node with
11935 /// `placeholder_label` and no props. Rules fire and last-change is updated
11936 /// for each created endpoint (normal InsertNode semantics in the batch frame).
11937 InsertEdgeUpsert {
11938 edge_type: String,
11939 src_key: String,
11940 dst_key: String,
11941 placeholder_label: String,
11942 },
11943}
11944
11945/// Three-way node visibility status used by `check_single_op_authz`.
11946enum NodeAuthzStatus {
11947 /// Node exists in the store and is in the role's read mask.
11948 Visible(String), // carries the node's label
11949 /// Node exists in the store but is NOT in the role's read mask.
11950 Hidden,
11951 /// Node does not exist in the store.
11952 Absent,
11953}
11954
11955/// Overlay of ops already accepted earlier in the same batch. Never written
11956/// back to the database — validation only.
11957#[derive(Default)]
11958struct Overlay {
11959 extra_keys: BTreeSet<String>,
11960 deleted_keys: BTreeSet<String>,
11961 extra_props: BTreeMap<(String, String), Value>,
11962 removed_props: BTreeSet<(String, String)>,
11963 extra_edges: BTreeSet<(String, String, String)>,
11964 deleted_edges: BTreeSet<(String, String, String)>,
11965 extra_rules: BTreeSet<String>,
11966 deleted_rules: BTreeSet<String>,
11967 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
11968 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
11969 /// sees only the rules already committed to the engine. Keyed by name so a
11970 /// later `DeleteRule` in the same batch drops the arc with the rule.
11971 extra_rule_arcs: BTreeMap<String, (String, String)>,
11972}
11973
11974/// Read-only view of live db state plus a batch overlay. Shared by single-op
11975/// public methods (empty overlay) and `commit_batch`.
11976struct MutPreview<'a, F: Fs> {
11977 db: &'a GraphDb<F>,
11978 overlay: Overlay,
11979}
11980
11981/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
11982/// `None` if `target` is unreachable.
11983///
11984/// Used for rule-chain cycle detection, where an arc is "a rule hops over
11985/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
11986/// reported path is stable for a given rule set, and iterative so a pathological
11987/// rule graph cannot overflow the stack.
11988fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
11989 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
11990 for (from, to) in arcs {
11991 adj.entry(from.as_str()).or_default().insert(to.as_str());
11992 }
11993 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
11994 let mut visited: BTreeSet<&str> = BTreeSet::new();
11995 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
11996 visited.insert(start);
11997 queue.push_back(start);
11998 while let Some(node) = queue.pop_front() {
11999 if node == target {
12000 let mut path = vec![node.to_string()];
12001 let mut cur = node;
12002 while let Some(&p) = parent.get(cur) {
12003 path.push(p.to_string());
12004 cur = p;
12005 }
12006 path.reverse();
12007 return Some(path);
12008 }
12009 for &next in adj.get(node).into_iter().flatten() {
12010 if visited.insert(next) {
12011 parent.insert(next, node);
12012 queue.push_back(next);
12013 }
12014 }
12015 }
12016 None
12017}
12018
12019impl<'a, F: Fs> MutPreview<'a, F> {
12020 fn new(db: &'a GraphDb<F>) -> Self {
12021 Self {
12022 db,
12023 overlay: Overlay::default(),
12024 }
12025 }
12026
12027 fn has_key(&self, key: &str) -> bool {
12028 if self.overlay.extra_keys.contains(key) {
12029 return true;
12030 }
12031 if self.overlay.deleted_keys.contains(key) {
12032 return false;
12033 }
12034 self.db.ids.get(key).is_some()
12035 }
12036
12037 fn has_prop(&self, key: &str, field: &str) -> bool {
12038 if !self.has_key(key) {
12039 return false;
12040 }
12041 let k = (key.to_string(), field.to_string());
12042 if self.overlay.removed_props.contains(&k) {
12043 return false;
12044 }
12045 if self.overlay.extra_props.contains_key(&k) {
12046 return true;
12047 }
12048 // Fresh identity (first insert in this batch, or delete+reinsert):
12049 // ignore props still sitting on the soon-to-be-tombstoned slot.
12050 if self.overlay.extra_keys.contains(key) {
12051 return false;
12052 }
12053 self.db.get_prop(key, field).is_some()
12054 }
12055
12056 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12057 let k = (
12058 edge_type.to_string(),
12059 src_key.to_string(),
12060 dst_key.to_string(),
12061 );
12062 if self.overlay.deleted_edges.contains(&k) {
12063 return false;
12064 }
12065 if self.overlay.extra_edges.contains(&k) {
12066 return true;
12067 }
12068 // A key created in this batch (including reinsert) has no db edges.
12069 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12070 return false;
12071 }
12072 if self.overlay.deleted_keys.contains(src_key)
12073 || self.overlay.deleted_keys.contains(dst_key)
12074 {
12075 return false;
12076 }
12077 let Some(src) = self.db.ids.get(src_key) else {
12078 return false;
12079 };
12080 let Some(dst) = self.db.ids.get(dst_key) else {
12081 return false;
12082 };
12083 let Some(sym) = self.db.syms.get(edge_type) else {
12084 return false;
12085 };
12086 self.db
12087 .topo_view()
12088 .neighbors(sym, Direction::Out, src)
12089 .binary_search(&dst)
12090 .is_ok()
12091 }
12092
12093 fn has_rule(&self, name: &str) -> bool {
12094 if self.overlay.extra_rules.contains(name) {
12095 return true;
12096 }
12097 if self.overlay.deleted_rules.contains(name) {
12098 return false;
12099 }
12100 self.db.engine.rules().any(|r| r.name == name)
12101 }
12102
12103 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12104 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12105 return false;
12106 }
12107 if self.overlay.deleted_keys.contains(src_key)
12108 || self.overlay.deleted_keys.contains(dst_key)
12109 {
12110 return false;
12111 }
12112 let Some(src) = self.db.ids.get(src_key) else {
12113 return false;
12114 };
12115 let Some(dst) = self.db.ids.get(dst_key) else {
12116 return false;
12117 };
12118 let Some(et) = self.db.syms.get(edge_type) else {
12119 return false;
12120 };
12121 // extra_rules is deliberately not consulted: a CreateRule earlier in
12122 // this batch has not fired, so it contributes no provenance. That is
12123 // the documented rule-window gap (see GraphDb::batch).
12124 if self.overlay.deleted_rules.is_empty() {
12125 return self.db.engine.is_owned(et, src, dst);
12126 }
12127 for (rule, triples) in self.db.engine.provenance() {
12128 if self.overlay.deleted_rules.contains(rule) {
12129 continue;
12130 }
12131 if triples.contains(&(et, src, dst)) {
12132 return true;
12133 }
12134 }
12135 false
12136 }
12137
12138 fn check_insert_node(&self, key: &str) -> Result<()> {
12139 if self.has_key(key) {
12140 Err(GraphError::DuplicateKey { key: key.into() })
12141 } else {
12142 Ok(())
12143 }
12144 }
12145
12146 fn check_live_key(&self, key: &str) -> Result<()> {
12147 if self.has_key(key) {
12148 Ok(())
12149 } else {
12150 Err(GraphError::KeyNotFound { key: key.into() })
12151 }
12152 }
12153
12154 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12155 for k in [src_key, dst_key] {
12156 if !self.has_key(k) {
12157 return Err(GraphError::KeyNotFound { key: k.into() });
12158 }
12159 }
12160 if self.is_rule_owned(edge_type, src_key, dst_key) {
12161 return Err(GraphError::RuleOwned {
12162 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
12163 });
12164 }
12165 // A user-written edge stays inside one namespace. Derived edges do not
12166 // come through here — the engine adds them directly — and the rule
12167 // scoping check is what keeps those pure.
12168 let src_ns = self.namespace_in_batch(src_key);
12169 let dst_ns = self.namespace_in_batch(dst_key);
12170 if src_ns != dst_ns {
12171 return Err(GraphError::CrossNamespace {
12172 src: src_key.to_string(),
12173 src_ns,
12174 dst: dst_key.to_string(),
12175 dst_ns,
12176 });
12177 }
12178 Ok(!self.has_edge(edge_type, src_key, dst_key))
12179 }
12180
12181 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
12182 self.check_live_key(key)?;
12183 // Removing `ns` is changing the namespace — to `default`, the namespace
12184 // an absent property names. It goes through this one choke-point and NOT
12185 // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
12186 // the immutability rule has to be stated here as well. Without it the
12187 // node silently lands in `default` on the next open: the cross-namespace
12188 // edge guard is defeated and a default-bound role reads a tenant's node.
12189 if field == NS_PROP {
12190 let from = self.namespace_in_batch(key);
12191 if from != NS_DEFAULT {
12192 return Err(GraphError::NamespaceImmutable {
12193 key: key.to_string(),
12194 from,
12195 to: NS_DEFAULT.to_string(),
12196 });
12197 }
12198 // Already in `default`: the removal changes no namespace. It is the
12199 // no-op `set_prop` to the current namespace is, not an error.
12200 return Ok(false);
12201 }
12202 Ok(self.has_prop(key, field))
12203 }
12204
12205 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12206 for k in [src_key, dst_key] {
12207 if !self.has_key(k) {
12208 return Err(GraphError::KeyNotFound { key: k.into() });
12209 }
12210 }
12211 // Provenance-owned OR a live rule would derive this pair. User-first
12212 // edges that a later rule matches are not in `owned`, but deleting
12213 // them would leave a hole `rebuild_rule` immediately fills.
12214 if self.is_rule_owned(edge_type, src_key, dst_key) {
12215 return Err(GraphError::RuleOwned {
12216 detail: format!(
12217 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12218 delete or change the owning rule"
12219 ),
12220 });
12221 }
12222 if self.would_derive(edge_type, src_key, dst_key) {
12223 return Err(GraphError::RuleOwned {
12224 detail: format!(
12225 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12226 delete or change the owning rule, or a live rule would re-derive it"
12227 ),
12228 });
12229 }
12230 Ok(self.has_edge(edge_type, src_key, dst_key))
12231 }
12232
12233 /// True if any live rule (minus overlay-deleted names) would derive
12234 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
12235 /// CreateRule names in `extra_rules` are ignored — same documented
12236 /// same-batch rule-window as [`Self::is_rule_owned`].
12237 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12238 if src_key == dst_key {
12239 return false;
12240 }
12241 let Some(src_label) = self.label_of(src_key) else {
12242 return false;
12243 };
12244 let Some(dst_label) = self.label_of(dst_key) else {
12245 return false;
12246 };
12247 for rule in self.db.engine.rules() {
12248 if self.overlay.deleted_rules.contains(&rule.name) {
12249 continue;
12250 }
12251 if rule.edge_type != edge_type {
12252 continue;
12253 }
12254 if rule.src_label != src_label || rule.dst_label != dst_label {
12255 continue;
12256 }
12257 let src_props = |f: &str| self.prop_value(src_key, f);
12258 let dst_props = |f: &str| self.prop_value(dst_key, f);
12259 let src_view = NodeView {
12260 key: src_key,
12261 props: &src_props,
12262 };
12263 let dst_view = NodeView {
12264 key: dst_key,
12265 props: &dst_props,
12266 };
12267 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
12268 return true;
12269 }
12270 }
12271 false
12272 }
12273
12274 fn label_of(&self, key: &str) -> Option<String> {
12275 if self.overlay.deleted_keys.contains(key) {
12276 return None;
12277 }
12278 // Fresh identities created in this batch have no stored label in the
12279 // overlay; they cannot be provenance-owned yet either.
12280 let id = self.db.ids.get(key)?;
12281 let sym = self.db.labels.get(id as usize).copied()?;
12282 if sym == u32::MAX {
12283 return None;
12284 }
12285 self.db.syms.resolve(sym).map(str::to_string)
12286 }
12287
12288 /// The namespace `key` is in as this batch sees it — including a node
12289 /// inserted earlier in the same batch, which the store does not have yet.
12290 fn namespace_in_batch(&self, key: &str) -> String {
12291 namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
12292 }
12293
12294 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
12295 if !self.has_key(key) {
12296 return None;
12297 }
12298 let k = (key.to_string(), field.to_string());
12299 if self.overlay.removed_props.contains(&k) {
12300 return None;
12301 }
12302 if let Some(v) = self.overlay.extra_props.get(&k) {
12303 return Some(v.clone());
12304 }
12305 if self.overlay.extra_keys.contains(key) {
12306 return None;
12307 }
12308 self.db.get_prop(key, field)
12309 }
12310
12311 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
12312 def.validate()
12313 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
12314 if self.has_rule(&def.name) {
12315 return Err(GraphError::RuleInvalid {
12316 detail: format!("rule {:?} already exists", def.name),
12317 });
12318 }
12319 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
12320 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
12321 // `edge_type`". A cycle in that graph is a rule set that would re-fire
12322 // itself forever; the engine's depth cap would silently truncate it
12323 // instead, leaving an arbitrary partial result. Reject it here, the one
12324 // place that sees the whole rule set.
12325 //
12326 // Rules accepted earlier in the same batch count too: the overlay
12327 // carries their arcs, so a cycle cannot be assembled one op at a time.
12328 if let Some(via) = def.via_edge.as_deref() {
12329 if via == def.edge_type {
12330 return Err(GraphError::RuleInvalid {
12331 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
12332 });
12333 }
12334 let mut arcs: Vec<(String, String)> = self
12335 .db
12336 .engine
12337 .rules()
12338 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
12339 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
12340 .collect();
12341 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
12342 arcs.push((via.to_string(), def.edge_type.clone()));
12343 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
12344 return Err(GraphError::RuleInvalid {
12345 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
12346 });
12347 }
12348 }
12349 Ok(())
12350 }
12351
12352 fn check_delete_rule(&self, name: &str) -> Result<()> {
12353 if self.has_rule(name) {
12354 Ok(())
12355 } else {
12356 Err(GraphError::RuleNotFound { name: name.into() })
12357 }
12358 }
12359
12360 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
12361 self.overlay.deleted_keys.remove(key);
12362 self.overlay.extra_keys.insert(key.to_string());
12363 self.overlay.extra_props.retain(|(k, _), _| k != key);
12364 self.overlay.removed_props.retain(|(k, _)| k != key);
12365 for (field, value) in props {
12366 self.overlay
12367 .extra_props
12368 .insert((key.to_string(), field.clone()), value.clone());
12369 }
12370 }
12371
12372 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12373 let k = (
12374 edge_type.to_string(),
12375 src_key.to_string(),
12376 dst_key.to_string(),
12377 );
12378 self.overlay.deleted_edges.remove(&k);
12379 self.overlay.extra_edges.insert(k);
12380 }
12381
12382 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
12383 let k = (key.to_string(), field.to_string());
12384 self.overlay.removed_props.remove(&k);
12385 self.overlay.extra_props.insert(k, value.clone());
12386 }
12387
12388 fn note_remove_prop(&mut self, key: &str, field: &str) {
12389 let k = (key.to_string(), field.to_string());
12390 self.overlay.extra_props.remove(&k);
12391 self.overlay.removed_props.insert(k);
12392 }
12393
12394 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12395 let k = (
12396 edge_type.to_string(),
12397 src_key.to_string(),
12398 dst_key.to_string(),
12399 );
12400 self.overlay.extra_edges.remove(&k);
12401 self.overlay.deleted_edges.insert(k);
12402 }
12403
12404 fn note_delete_node(&mut self, key: &str) {
12405 self.overlay.extra_keys.remove(key);
12406 self.overlay.deleted_keys.insert(key.to_string());
12407 self.overlay.extra_props.retain(|(k, _), _| k != key);
12408 self.overlay.removed_props.retain(|(k, _)| k != key);
12409 self.overlay
12410 .extra_edges
12411 .retain(|(_, s, d)| s != key && d != key);
12412 self.overlay
12413 .deleted_edges
12414 .retain(|(_, s, d)| s != key && d != key);
12415 }
12416
12417 fn note_create_rule(&mut self, def: &RuleDef) {
12418 self.overlay.deleted_rules.remove(&def.name);
12419 self.overlay.extra_rules.insert(def.name.clone());
12420 // Rules accepted earlier in this batch are not in the engine yet, so
12421 // the cycle check would not see their arcs. Keep the arc, not just the
12422 // name, so a batch cannot smuggle in a cycle one op at a time.
12423 if let Some(via) = def.via_edge.clone() {
12424 self.overlay
12425 .extra_rule_arcs
12426 .insert(def.name.clone(), (via, def.edge_type.clone()));
12427 }
12428 }
12429
12430 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
12431 if !self.has_key(old) {
12432 return Err(GraphError::KeyNotFound { key: old.into() });
12433 }
12434 if self.has_key(new) {
12435 return Err(GraphError::DuplicateKey { key: new.into() });
12436 }
12437 Ok(())
12438 }
12439
12440 fn note_rename_node(&mut self, old: &str, new: &str) {
12441 // Mark old as deleted so subsequent batch ops cannot reference it.
12442 self.overlay.extra_keys.remove(old);
12443 self.overlay.deleted_keys.insert(old.to_string());
12444 // Mark new as extra so subsequent batch ops can reference it.
12445 self.overlay.deleted_keys.remove(new);
12446 self.overlay.extra_keys.insert(new.to_string());
12447 // Migrate any overlay props from old key to new key.
12448 let new_str = new.to_string();
12449 let transferred: Vec<((String, String), Value)> = self
12450 .overlay
12451 .extra_props
12452 .iter()
12453 .filter(|((k, _), _)| k.as_str() == old)
12454 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
12455 .collect();
12456 self.overlay
12457 .extra_props
12458 .retain(|(k, _), _| k.as_str() != old);
12459 for (k, v) in transferred {
12460 self.overlay.extra_props.insert(k, v);
12461 }
12462 // Migrate removed_props.
12463 let transferred_removed: Vec<(String, String)> = self
12464 .overlay
12465 .removed_props
12466 .iter()
12467 .filter(|(k, _)| k.as_str() == old)
12468 .map(|(_, f)| (new_str.clone(), f.clone()))
12469 .collect();
12470 self.overlay
12471 .removed_props
12472 .retain(|(k, _)| k.as_str() != old);
12473 for k in transferred_removed {
12474 self.overlay.removed_props.insert(k);
12475 }
12476 }
12477
12478 fn note_delete_rule(&mut self, name: &str) {
12479 self.overlay.extra_rules.remove(name);
12480 // Drop its chain arc too: a rule created and then deleted in the same
12481 // batch must not make a later, legal rule look like a cycle.
12482 self.overlay.extra_rule_arcs.remove(name);
12483 self.overlay.deleted_rules.insert(name.to_string());
12484 // Treat the deleted rule's current provenance as gone so a later
12485 // delete_edge of those triples is a no-op (matches sequential).
12486 if let Some(triples) = self.db.engine.provenance().get(name) {
12487 for &(et, s, d) in triples {
12488 let Some(etype) = self.db.syms.resolve(et) else {
12489 continue;
12490 };
12491 let Some(src) = self.db.ids.key_of(s) else {
12492 continue;
12493 };
12494 let Some(dst) = self.db.ids.key_of(d) else {
12495 continue;
12496 };
12497 let k = (etype.to_string(), src.to_string(), dst.to_string());
12498 self.overlay.extra_edges.remove(&k);
12499 self.overlay.deleted_edges.insert(k);
12500 }
12501 }
12502 }
12503}
12504
12505/// Collects mutations and commits them as one WAL `Batch` frame.
12506///
12507/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
12508/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
12509/// See [`GraphDb::batch`] for validation and atomicity rules.
12510pub struct BatchBuilder<'a, F: Fs> {
12511 db: &'a mut GraphDb<F>,
12512 ops: Vec<BatchOp>,
12513}
12514
12515impl<'a, F: Fs> BatchBuilder<'a, F> {
12516 pub fn insert_node(
12517 &mut self,
12518 label: &str,
12519 key: &str,
12520 props: Vec<(String, Value)>,
12521 ) -> &mut Self {
12522 self.ops.push(BatchOp::InsertNode {
12523 label: label.into(),
12524 key: key.into(),
12525 props,
12526 });
12527 self
12528 }
12529
12530 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12531 self.ops.push(BatchOp::InsertEdge {
12532 edge_type: edge_type.into(),
12533 src_key: src_key.into(),
12534 dst_key: dst_key.into(),
12535 });
12536 self
12537 }
12538
12539 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12540 self.ops.push(BatchOp::SetProp {
12541 key: key.into(),
12542 field: field.into(),
12543 value,
12544 });
12545 self
12546 }
12547
12548 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12549 self.ops.push(BatchOp::RemoveProp {
12550 key: key.into(),
12551 field: field.into(),
12552 });
12553 self
12554 }
12555
12556 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12557 self.ops.push(BatchOp::DeleteEdge {
12558 edge_type: edge_type.into(),
12559 src_key: src_key.into(),
12560 dst_key: dst_key.into(),
12561 });
12562 self
12563 }
12564
12565 pub fn delete_node(&mut self, key: &str) -> &mut Self {
12566 self.ops.push(BatchOp::DeleteNode { key: key.into() });
12567 self
12568 }
12569
12570 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12571 self.ops.push(BatchOp::CreateRule(def));
12572 self
12573 }
12574
12575 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12576 self.ops.push(BatchOp::DeleteRule { name: name.into() });
12577 self
12578 }
12579
12580 /// Queue a node-rename in this batch.
12581 ///
12582 /// Validation (old exists, new not taken) runs at commit time.
12583 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12584 self.ops.push(BatchOp::RenameNode {
12585 old_key: old_key.into(),
12586 new_key: new_key.into(),
12587 });
12588 self
12589 }
12590
12591 /// Queue an edge insert with endpoint auto-creation.
12592 ///
12593 /// Any missing endpoint is created as a plain node `{key, label:
12594 /// placeholder_label, no props}` inside this batch frame. Rules fire and
12595 /// last-change is updated for each auto-created node.
12596 pub fn insert_edge_upsert(
12597 &mut self,
12598 edge_type: &str,
12599 src_key: &str,
12600 dst_key: &str,
12601 placeholder_label: &str,
12602 ) -> &mut Self {
12603 self.ops.push(BatchOp::InsertEdgeUpsert {
12604 edge_type: edge_type.into(),
12605 src_key: src_key.into(),
12606 dst_key: dst_key.into(),
12607 placeholder_label: placeholder_label.into(),
12608 });
12609 self
12610 }
12611
12612 /// Validate every queued op, then log one `Batch` frame and apply.
12613 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12614 /// A second `commit()` after a successful one is an empty-batch no-op
12615 /// (queued ops were taken).
12616 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12617 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12618 ///
12619 /// **Rule-window limitation:** batch validation cannot see edges that a
12620 /// rule created earlier in the *same* batch will derive at apply time, so
12621 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12622 /// where sequential calls would return `Err(RuleOwned)`. State integrity
12623 /// is unaffected (idempotent apply, provenance intact). Create rules in
12624 /// their own batch, or sequentially, when later ops may touch derived
12625 /// edges.
12626 /// Validate every queued op and commit atomically.
12627 ///
12628 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12629 /// WAL records actually written (duplicate edges are silent no-ops and are
12630 /// NOT counted). Both are 0 when the batch is empty or all-noop.
12631 pub fn commit(&mut self) -> Result<(usize, usize)> {
12632 let ops = std::mem::take(&mut self.ops);
12633 self.db.commit_batch(ops)
12634 }
12635
12636 /// Same as [`commit`](Self::commit) but tail the inner events with
12637 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12638 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12639 let ops = std::mem::take(&mut self.ops);
12640 self.db
12641 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12642 }
12643}
12644
12645pub struct NodeRef<'a, F: Fs> {
12646 db: &'a GraphDb<F>,
12647 id: u32,
12648}
12649
12650impl<'a, F: Fs> NodeRef<'a, F> {
12651 pub fn key(&self) -> &str {
12652 self.db.ids.key_of(self.id).expect("dense ids")
12653 }
12654
12655 pub fn label(&self) -> &str {
12656 let sym = self
12657 .db
12658 .labels
12659 .get(self.id as usize)
12660 .copied()
12661 .filter(|&s| s != u32::MAX)
12662 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12663 self.db.syms.resolve(sym).expect("interned label symbol")
12664 }
12665
12666 pub fn prop(&self, field: &str) -> Option<Value> {
12667 self.db
12668 .props_view()
12669 .get(self.id, field)
12670 .map(|vr| vr.into_value())
12671 }
12672
12673 /// All stored fields for this node, sorted by field name.
12674 ///
12675 /// Reads from the full base+overlay view so that props stored only in the
12676 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12677 pub fn props(&self) -> BTreeMap<String, Value> {
12678 let mut out = BTreeMap::new();
12679 let pv = self.db.props_view();
12680 for field in pv.field_names() {
12681 if let Some(vr) = pv.get(self.id, &field) {
12682 out.insert(field, vr.into_value());
12683 }
12684 }
12685 out
12686 }
12687
12688 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12689 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12690 let view = self.db.view();
12691 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12692 names
12693 .iter()
12694 .filter_map(|name| view.syms.get(name))
12695 .collect()
12696 });
12697 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12698 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12699 for (nid, d) in nb.nodes {
12700 let key = view.key_of(nid);
12701 let label = view
12702 .label_of(nid)
12703 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12704 rs.push_row(vec![
12705 Some(Value::Str(key.to_string())),
12706 Some(Value::Str(label.to_string())),
12707 Some(Value::Int(d as i64)),
12708 ]);
12709 }
12710 rs
12711 }
12712
12713 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12714 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12715 let view = self.db.view();
12716 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12717 for e in expand(&view, self.id, None, Dir::Both) {
12718 // Skip edges with unknown etypes (only possible from corrupt large
12719 // TOPOLOGY section; function returns BTreeMap not Result).
12720 let Some(etype) = view.syms.resolve(e.etype) else {
12721 continue;
12722 };
12723 let etype = etype.to_string();
12724 let nbr = if e.src == self.id { e.dst } else { e.src };
12725 groups
12726 .entry(etype)
12727 .or_default()
12728 .insert(view.key_of(nbr).to_string());
12729 }
12730 groups
12731 .into_iter()
12732 .map(|(k, v)| (k, v.into_iter().collect()))
12733 .collect()
12734 }
12735}
12736
12737#[cfg(test)]
12738mod tests {
12739 use super::*;
12740 use core_rules::Predicate;
12741
12742 fn tmp_dir(name: &str) -> std::path::PathBuf {
12743 let d =
12744 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12745 let _ = std::fs::remove_dir_all(&d);
12746 d
12747 }
12748
12749 fn fk_rule() -> RuleDef {
12750 RuleDef {
12751 name: "works_at".into(),
12752 src_label: "Person".into(),
12753 dst_label: "Org".into(),
12754 predicate: Predicate::KeyMatch {
12755 field: "org_id".into(),
12756 },
12757 edge_type: "WORKS_AT".into(),
12758 weight_prop: None,
12759 max_edges: None,
12760 approximate: false,
12761 via_label: None,
12762 via_edge: None,
12763 via_dir: None,
12764 namespace: None,
12765 }
12766 }
12767
12768 /// Regression guard for the no-views delta-copy fast path.
12769 ///
12770 /// When no views are defined, `pending_deltas_since().to_vec()` must never
12771 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12772 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12773 /// a count of 0 after the entire sequence proves the guard fires correctly.
12774 #[test]
12775 fn no_delta_copy_when_no_views() {
12776 DELTA_COPY_COUNT.with(|c| c.set(0));
12777 let dir = tmp_dir("no-delta-copy");
12778 {
12779 let mut db = GraphDb::open(&dir).unwrap();
12780 // Insert 50 Org + 50 Person nodes with FK links.
12781 for i in 0..50u32 {
12782 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12783 }
12784 for i in 0..50u32 {
12785 db.insert_node(
12786 "Person",
12787 &format!("p{i}"),
12788 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12789 )
12790 .unwrap();
12791 }
12792 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12793 db.create_rule(fk_rule()).unwrap();
12794
12795 // Counter must stay 0 — no views, no copies.
12796 let copies = DELTA_COPY_COUNT.with(|c| c.get());
12797 assert_eq!(
12798 copies, 0,
12799 "pending_deltas_since().to_vec() called despite no views"
12800 );
12801
12802 // Derived edges must still be correct (the guard skips only the
12803 // empty delta propagation loop, not the rule application itself).
12804 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12805 assert_eq!(
12806 nbrs,
12807 vec!["o0"],
12808 "rule must derive edges even with no views"
12809 );
12810 }
12811 let _ = std::fs::remove_dir_all(&dir);
12812 }
12813
12814 /// Gating regression: subscribe AFTER a backfill must see no stale events.
12815 /// subscribe BEFORE a backfill must see every edge-fire event.
12816 #[test]
12817 fn subscribe_after_backfill_no_stale_events() {
12818 let dir = tmp_dir("sub-after-backfill");
12819 {
12820 let mut db = GraphDb::open(&dir).unwrap();
12821 for i in 0..10u32 {
12822 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12823 db.insert_node(
12824 "Person",
12825 &format!("p{i}"),
12826 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12827 )
12828 .unwrap();
12829 }
12830 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12831 db.create_rule(fk_rule()).unwrap();
12832
12833 // Subscribe AFTER the backfill — queue must be empty (no stale events).
12834 let sub = db.subscribe_all_rules().unwrap();
12835 // No events should have queued for the prior backfill.
12836 assert!(
12837 sub.try_recv().is_none(),
12838 "subscribe after backfill must see no stale events"
12839 );
12840
12841 // Inserting a new node now should fire an event (emit_deltas is now true).
12842 db.insert_node("Org", "o_new", vec![]).unwrap();
12843 db.insert_node(
12844 "Person",
12845 "p_new",
12846 vec![("org_id".into(), Value::Str("o_new".into()))],
12847 )
12848 .unwrap();
12849 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12850 assert!(
12851 ev.is_some(),
12852 "edge-fire event must arrive after subscribe (emit_deltas=true)"
12853 );
12854 }
12855 let _ = std::fs::remove_dir_all(&dir);
12856 }
12857
12858 /// Gating regression: subscribe BEFORE a backfill → events flow.
12859 #[test]
12860 fn subscribe_before_backfill_events_flow() {
12861 let dir = tmp_dir("sub-before-backfill");
12862 {
12863 let mut db = GraphDb::open(&dir).unwrap();
12864 // Subscribe FIRST — emit_deltas becomes true.
12865 let sub = db.subscribe_all_rules().unwrap();
12866
12867 for i in 0..5u32 {
12868 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12869 db.insert_node(
12870 "Person",
12871 &format!("p{i}"),
12872 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12873 )
12874 .unwrap();
12875 }
12876 // Backfill fires with emit_deltas=true → events queued.
12877 db.create_rule(fk_rule()).unwrap();
12878
12879 // Should receive at least one edge-fired event from the backfill.
12880 let mut received = 0usize;
12881 while sub.try_recv().is_some() {
12882 received += 1;
12883 }
12884 assert!(
12885 received > 0,
12886 "subscribe before backfill must receive edge-fire events (got 0)"
12887 );
12888 }
12889 let _ = std::fs::remove_dir_all(&dir);
12890 }
12891
12892 /// Companion: when a view IS defined, the delta path fires and view values update.
12893 #[test]
12894 fn delta_copy_fires_when_view_exists() {
12895 use core_rules::ViewSource;
12896 DELTA_COPY_COUNT.with(|c| c.set(0));
12897 let dir = tmp_dir("delta-copy-with-view");
12898 {
12899 let mut db = GraphDb::open(&dir).unwrap();
12900 db.insert_node("Org", "o1", vec![]).unwrap();
12901 db.insert_node(
12902 "Person",
12903 "p1",
12904 vec![("org_id".into(), Value::Str("o1".into()))],
12905 )
12906 .unwrap();
12907 // Declare a Degree view so is_empty() returns false.
12908 db.create_view(ViewDef {
12909 name: "degree_out".into(),
12910 label: "Person".into(),
12911 view_prop: "degree_out".into(),
12912 source: ViewSource::Degree {
12913 edge_type: "WORKS_AT".into(),
12914 direction: Direction::Out,
12915 },
12916 })
12917 .unwrap();
12918 db.create_rule(fk_rule()).unwrap();
12919
12920 // At least one delta copy should have happened (CreateRule backfill).
12921 let copies = DELTA_COPY_COUNT.with(|c| c.get());
12922 assert!(
12923 copies > 0,
12924 "expected delta copy to fire when a view is defined"
12925 );
12926
12927 // View value should be computed: p1 has one WORKS_AT out-edge.
12928 let info = db.node_info("p1").unwrap();
12929 let degree = info.props.get("degree_out");
12930 assert!(
12931 degree.is_some(),
12932 "view prop should be written to node props"
12933 );
12934 }
12935 let _ = std::fs::remove_dir_all(&dir);
12936 }
12937
12938 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
12939 /// derived-edge-driven view values reflect the as-of state rather than just
12940 /// the initial backfill written at `CreateView` time.
12941 ///
12942 /// Base WAL frames (indices 0..=5 before history markers):
12943 /// 0: insert Org "o1"
12944 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
12945 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
12946 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
12947 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
12948 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
12949 ///
12950 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
12951 /// no-op), so the total commit count is higher than the base frame count.
12952 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
12953 ///
12954 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
12955 /// initial backfill value (0) instead of reflecting the replayed derived edges.
12956 #[test]
12957 fn open_at_derived_edge_view_values_correct() {
12958 use core_rules::ViewSource;
12959 let dir = tmp_dir("open-at-view-rebuild");
12960 {
12961 let mut db = GraphDb::open(&dir).unwrap();
12962 // frame 0
12963 db.insert_node("Org", "o1", vec![]).unwrap();
12964 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
12965 db.create_view(ViewDef {
12966 name: "employee_count".into(),
12967 label: "Org".into(),
12968 view_prop: "emp".into(),
12969 source: ViewSource::Degree {
12970 edge_type: "WORKS_AT".into(),
12971 direction: Direction::In,
12972 },
12973 })
12974 .unwrap();
12975 // frame 2: create rule — no Persons yet; backfill is a no-op
12976 db.create_rule(fk_rule()).unwrap();
12977 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
12978 db.insert_node(
12979 "Person",
12980 "p1",
12981 vec![("org_id".into(), Value::Str("o1".into()))],
12982 )
12983 .unwrap();
12984 // frame 4: p2 — degree = 2
12985 db.insert_node(
12986 "Person",
12987 "p2",
12988 vec![("org_id".into(), Value::Str("o1".into()))],
12989 )
12990 .unwrap();
12991 // frame 5: p3 — degree = 3
12992 db.insert_node(
12993 "Person",
12994 "p3",
12995 vec![("org_id".into(), Value::Str("o1".into()))],
12996 )
12997 .unwrap();
12998 // Sanity: normal open sees degree = 3.
12999 assert_eq!(
13000 db.get_view_prop("o1", "emp"),
13001 Some(Value::Int(3)),
13002 "normal db must show degree 3 after 3 derived edges"
13003 );
13004 } // WAL flushed
13005
13006 // Re-open normally to get the authoritative reference value.
13007 let normal_db = GraphDb::open(&dir).unwrap();
13008 let normal_emp = normal_db.get_view_prop("o1", "emp");
13009 assert_eq!(
13010 normal_emp,
13011 Some(Value::Int(3)),
13012 "re-opened normal db must show degree 3"
13013 );
13014
13015 // Latest as-of (last WAL commit): must match the normal open.
13016 // History-marker frames are appended after each rule-fire, so the total
13017 // commit count is computed dynamically rather than hardcoded.
13018 let total = crate::wal_commit_count_at(&dir).unwrap();
13019 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
13020 assert_eq!(
13021 aof_latest.get_view_prop("o1", "emp"),
13022 normal_emp,
13023 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
13024 );
13025
13026 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
13027 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
13028 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
13029 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
13030 assert_eq!(
13031 aof_mid.get_view_prop("o1", "emp"),
13032 Some(Value::Int(1)),
13033 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
13034 );
13035
13036 let _ = std::fs::remove_dir_all(&dir);
13037 }
13038
13039 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
13040 /// as-of instances never commit, so distribute_events never runs and any
13041 /// subscription would wait forever.
13042 #[test]
13043 fn subscribe_on_as_of_returns_read_only_error() {
13044 let dir = tmp_dir("sub-as-of-read-only");
13045 {
13046 let mut db = GraphDb::open(&dir).unwrap();
13047 db.insert_node("Org", "o1", vec![]).unwrap();
13048 db.create_rule(fk_rule()).unwrap();
13049 }
13050 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
13051
13052 assert!(
13053 matches!(
13054 aof.subscribe_all_rules(),
13055 Err(core_storage::GraphError::ReadOnly)
13056 ),
13057 "subscribe_all_rules on as-of must return ReadOnly"
13058 );
13059 assert!(
13060 matches!(
13061 aof.subscribe_writes(),
13062 Err(core_storage::GraphError::ReadOnly)
13063 ),
13064 "subscribe_writes on as-of must return ReadOnly"
13065 );
13066 assert!(
13067 matches!(
13068 aof.subscribe_rule("works_at"),
13069 Err(core_storage::GraphError::ReadOnly)
13070 ),
13071 "subscribe_rule on as-of must return ReadOnly"
13072 );
13073 let _ = std::fs::remove_dir_all(&dir);
13074 }
13075
13076 /// Regression: a failed dense WAL rewrite must not leave speculative
13077 /// interns in `syms`. If it does, the next successful mutation logs an
13078 /// `Intern` record with an inflated id; replay (which never saw the
13079 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
13080 #[test]
13081 fn dense_rewrite_error_rolls_back_speculative_interns() {
13082 let dir = tmp_dir("dense-rewrite-rollback");
13083 {
13084 let mut db = GraphDb::open(&dir).unwrap();
13085 db.insert_node("Person", "a", vec![]).unwrap();
13086
13087 // Bypass MutPreview validation to hit the rewrite's own error path
13088 // (same shape as an id-exhaustion failure mid-rewrite). The
13089 // InsertEdge arm interns the edge type before it resolves keys.
13090 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
13091 edge_type: "ORPHAN_TYPE".into(),
13092 src_key: "missing".into(),
13093 dst_key: "a".into(),
13094 }]);
13095 assert!(err.is_err(), "rewrite of a missing src key must fail");
13096 assert_eq!(
13097 db.syms.get("ORPHAN_TYPE"),
13098 None,
13099 "failed rewrite must roll back speculative interns"
13100 );
13101
13102 // A later successful mutation must produce a replayable WAL.
13103 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
13104 }
13105 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
13106 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
13107 let _ = std::fs::remove_dir_all(&dir);
13108 }
13109}