core_api/db.rs
1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{PropPredicate, RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4 event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::{ret_val_label, ArithOp};
7use core_query::cypher::{
8 execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9 plan, Expr, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem,
10 RetVal, WriteStatement,
11};
12use core_query::{eval_cmp, eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, ef_max, evaluate, BuildProgress, EngineEdgeDelta, GraphMut, NodeView,
15 Predicate, RuleDef, RuleEngine, ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21 archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22 archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28 namespace_of_value, ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result,
29 Topology, Value,
30};
31pub use core_storage::{valid_namespace, NS_DEFAULT, NS_MAX_LEN, NS_PROP};
32
33/// Index of [`NS_DEFAULT`] in `GraphDb::ns_names` — always zero, so the
34/// open-time pass over a store with no `ns` column fills `node_ns` with one
35/// constant and allocates no names.
36const NS_DEFAULT_IDX: u32 = 0;
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 if let Some(v) = db.get_prop(key, field) {
808 return Ok(Some(v));
809 }
810 // Same stored-wins identity fallback as the read path:
811 // n.key / n.id / n.label, not only get_prop.
812 Ok(match field.as_str() {
813 "key" | "id" => Some(Value::Str(key.clone())),
814 "label" => db
815 .node_ref(key)
816 .map(|n| Value::Str(n.label().to_owned())),
817 _ => None,
818 })
819 }
820 Operand::FuncCall { name, args } => {
821 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
822 }
823 Operand::BinArith { op, left, right } => {
824 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
825 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
826 eval_set_return_arith(op, lv, rv)
827 }
828 Operand::Case { branches, default } => {
829 for (cond, value) in branches {
830 if eval_set_return_expr(db, match_rs, row, rel_vars, cond, params, 0)? {
831 return eval_set_return_operand(db, match_rs, row, rel_vars, value, params);
832 }
833 }
834 match default {
835 Some(d) => eval_set_return_operand(db, match_rs, row, rel_vars, d, params),
836 None => Ok(None),
837 }
838 }
839 Operand::Index { base, index } => {
840 let base_val = eval_set_return_operand(db, match_rs, row, rel_vars, base, params)?;
841 let idx_val = eval_set_return_operand(db, match_rs, row, rel_vars, index, params)?;
842 Ok(core_query::value_ops::index_list(base_val, idx_val))
843 }
844 }
845}
846
847fn eval_set_return_expr<F: Fs>(
848 db: &GraphDb<F>,
849 match_rs: &ResultSet,
850 row: usize,
851 rel_vars: &[String],
852 expr: &Expr,
853 params: &BTreeMap<String, Value>,
854 depth: u32,
855) -> Result<bool> {
856 if depth > 256 {
857 return Err(GraphError::QueryError {
858 detail: "expression nesting too deep".into(),
859 });
860 }
861 match expr {
862 Expr::And(lhs, rhs) => {
863 let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
864 let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
865 Ok(l && r)
866 }
867 Expr::Or(lhs, rhs) => {
868 let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
869 let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
870 Ok(l || r)
871 }
872 Expr::Not(inner) => Ok(!eval_set_return_expr(
873 db,
874 match_rs,
875 row,
876 rel_vars,
877 inner,
878 params,
879 depth + 1,
880 )?),
881 Expr::Cmp { lhs, op, rhs } => {
882 let l = eval_set_return_operand(db, match_rs, row, rel_vars, lhs, params)?;
883 let r = eval_set_return_operand(db, match_rs, row, rel_vars, rhs, params)?;
884 match (l, r) {
885 (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
886 _ => Ok(false),
887 }
888 }
889 Expr::Truthy(op) => {
890 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
891 Ok(match val {
892 None => false,
893 Some(Value::Bool(b)) => b,
894 Some(Value::Int(n)) => n != 0,
895 Some(Value::Float(f)) => f != 0.0,
896 Some(Value::Str(s)) => !s.is_empty(),
897 Some(Value::List(v)) => !v.is_empty(),
898 Some(Value::Map(m)) => !m.is_empty(),
899 })
900 }
901 Expr::IsNull(op) => {
902 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
903 Ok(val.is_none())
904 }
905 Expr::IsNotNull(op) => {
906 let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
907 Ok(val.is_some())
908 }
909 Expr::In { expr, list } => {
910 let Some(needle) = eval_set_return_operand(db, match_rs, row, rel_vars, expr, params)?
911 else {
912 return Ok(false);
913 };
914 for item_op in list {
915 match eval_set_return_operand(db, match_rs, row, rel_vars, item_op, params)? {
916 None => {}
917 Some(Value::List(items)) => {
918 for item in items {
919 if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) {
920 return Ok(true);
921 }
922 }
923 }
924 Some(item) if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) => {
925 return Ok(true);
926 }
927 Some(_) => {}
928 }
929 }
930 Ok(false)
931 }
932 }
933}
934
935fn eval_set_return_arith(
936 op: &ArithOp,
937 lv: Option<Value>,
938 rv: Option<Value>,
939) -> Result<Option<Value>> {
940 match (lv, rv) {
941 (None, _) | (_, None) => Ok(None),
942 (Some(Value::Int(a)), Some(Value::Int(b))) => {
943 let result = match op {
944 ArithOp::Sub => a.saturating_sub(b),
945 ArithOp::Mul => a.saturating_mul(b),
946 ArithOp::Add => a.saturating_add(b),
947 ArithOp::Div => {
948 if b == 0 {
949 return Err(GraphError::QueryError {
950 detail: "division by zero".into(),
951 });
952 }
953 a.checked_div(b).unwrap_or(i64::MAX)
954 }
955 };
956 Ok(Some(Value::Int(result)))
957 }
958 (Some(lv), Some(rv)) => {
959 let a = match &lv {
960 Value::Float(f) => *f,
961 Value::Int(i) => *i as f64,
962 _ => {
963 return Err(GraphError::QueryError {
964 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
965 })
966 }
967 };
968 let b = match &rv {
969 Value::Float(f) => *f,
970 Value::Int(i) => *i as f64,
971 _ => {
972 return Err(GraphError::QueryError {
973 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
974 })
975 }
976 };
977 let result = match op {
978 ArithOp::Sub => a - b,
979 ArithOp::Mul => a * b,
980 ArithOp::Add => a + b,
981 ArithOp::Div => {
982 if b == 0.0 {
983 return Err(GraphError::QueryError {
984 detail: "division by zero".into(),
985 });
986 }
987 a / b
988 }
989 };
990 Ok(Some(Value::Float(result)))
991 }
992 }
993}
994
995fn eval_set_return_func<F: Fs>(
996 db: &GraphDb<F>,
997 match_rs: &ResultSet,
998 row: usize,
999 rel_vars: &[String],
1000 name: &str,
1001 args: &[Operand],
1002 params: &BTreeMap<String, Value>,
1003) -> Result<Option<Value>> {
1004 let norm = name.to_ascii_lowercase();
1005 if norm == "type" {
1006 if args.len() != 1 {
1007 return Err(GraphError::QueryError {
1008 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
1009 });
1010 }
1011 let Operand::Var(rel) = &args[0] else {
1012 return Err(GraphError::QueryError {
1013 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
1014 });
1015 };
1016 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
1017 }
1018 if norm == "key" || norm == "id" {
1019 let fname = if norm == "id" { "id" } else { "key" };
1020 if args.len() != 1 {
1021 return Err(GraphError::QueryError {
1022 detail: format!("{fname}() requires exactly 1 argument, got {}", args.len()),
1023 });
1024 }
1025 let Operand::Var(var) = &args[0] else {
1026 return Err(GraphError::QueryError {
1027 detail: format!("{fname}() argument must be a node variable (e.g. {fname}(n))"),
1028 });
1029 };
1030 if rel_vars.iter().any(|r| r == var) {
1031 return Err(GraphError::QueryError {
1032 detail: format!("{fname}() argument `{var}` is a relationship, not a node"),
1033 });
1034 }
1035 // MATCH rows bind node variables to their key string, so the column
1036 // value *is* the key. `id()` aliases `key()`.
1037 return Ok(match_rs.get(row, var).cloned());
1038 }
1039 let mut vals = Vec::with_capacity(args.len());
1040 for arg in args {
1041 vals.push(eval_set_return_operand(
1042 db, match_rs, row, rel_vars, arg, params,
1043 )?);
1044 }
1045 match norm.as_str() {
1046 "tolower" => {
1047 if vals.len() != 1 {
1048 return Err(GraphError::QueryError {
1049 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
1050 });
1051 }
1052 Ok(vals[0].clone().map(|val| match val {
1053 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1054 other => other,
1055 }))
1056 }
1057 "toupper" => {
1058 if vals.len() != 1 {
1059 return Err(GraphError::QueryError {
1060 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
1061 });
1062 }
1063 Ok(vals[0].clone().map(|val| match val {
1064 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1065 other => other,
1066 }))
1067 }
1068 "size" => match vals.first().cloned().flatten() {
1069 None => Ok(None),
1070 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1071 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1072 Some(_) => Ok(None),
1073 },
1074 "coalesce" => Ok(vals.into_iter().flatten().next()),
1075 "abs" => match vals.first().cloned().flatten() {
1076 None => Ok(None),
1077 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
1078 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1079 Some(_) => Ok(None),
1080 },
1081 "round" => match vals.first().cloned().flatten() {
1082 None => Ok(None),
1083 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1084 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
1085 Some(_) => Ok(None),
1086 },
1087 "decay" => {
1088 if vals.len() != 3 {
1089 return Err(GraphError::QueryError {
1090 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
1091 });
1092 }
1093 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
1094 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
1095 (Some(b), Some(a), Some(h)) => {
1096 let numeric = |v: Value| -> Result<f64> {
1097 match v {
1098 Value::Int(n) => Ok(n as f64),
1099 Value::Float(f) => Ok(f),
1100 other => Err(GraphError::QueryError {
1101 detail: format!(
1102 "decay() requires numeric arguments, got {other:?}"
1103 ),
1104 }),
1105 }
1106 };
1107 let b = numeric(b)?;
1108 let a = numeric(a)?;
1109 let h = numeric(h)?;
1110 if h <= 0.0 {
1111 return Err(GraphError::QueryError {
1112 detail: "decay() requires halflife > 0".into(),
1113 });
1114 }
1115 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1116 }
1117 }
1118 }
1119 _ => Err(GraphError::QueryError {
1120 detail: format!(
1121 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key, id"
1122 ),
1123 }),
1124 }
1125}
1126
1127fn eval_set_return_item<F: Fs>(
1128 db: &GraphDb<F>,
1129 match_rs: &ResultSet,
1130 row: usize,
1131 rel_vars: &[String],
1132 item: &RetItem,
1133 params: &BTreeMap<String, Value>,
1134) -> Result<Option<Value>> {
1135 match &item.value {
1136 RetVal::Var(v) => eval_set_return_operand(
1137 db,
1138 match_rs,
1139 row,
1140 rel_vars,
1141 &Operand::Var(v.clone()),
1142 params,
1143 ),
1144 RetVal::Prop { var, field } => eval_set_return_operand(
1145 db,
1146 match_rs,
1147 row,
1148 rel_vars,
1149 &Operand::Prop {
1150 var: var.clone(),
1151 field: field.clone(),
1152 },
1153 params,
1154 ),
1155 RetVal::FuncCall { name, args } => {
1156 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1157 }
1158 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1159 RetVal::Agg { .. } => Err(GraphError::QueryError {
1160 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1161 }),
1162 }
1163}
1164
1165/// Project user RETURN from original MATCH rows after SET. No rematch.
1166fn project_set_return_rows<F: Fs>(
1167 db: &GraphDb<F>,
1168 rel_vars: &[String],
1169 match_rs: &ResultSet,
1170 returns: &[RetItem],
1171 params: &BTreeMap<String, Value>,
1172) -> Result<ResultSet> {
1173 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1174 let mut out = ResultSet::new(columns);
1175 for row in 0..match_rs.len() {
1176 let mut cells = Vec::with_capacity(returns.len());
1177 for item in returns {
1178 cells.push(eval_set_return_item(
1179 db, match_rs, row, rel_vars, item, params,
1180 )?);
1181 }
1182 out.push_row(cells);
1183 }
1184 Ok(out)
1185}
1186
1187/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1188/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1189/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1190/// Returns `None` for non-list values or lists with non-numeric elements.
1191/// Extra candidates pulled from an approximate index before re-scoring, over and
1192/// above the `k` asked for.
1193///
1194/// The index orders candidates by `f32` distances, which agree with the exact
1195/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1196/// candidates inside a band that narrow — it cannot move a hit past one that is
1197/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1198/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1199/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1200/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1201/// then the members are interchangeable anyway. `min` is applied to the exact
1202/// score, never to the index's, so a hit sitting on the threshold is decided
1203/// exactly.
1204const VECTOR_RESCORE_MARGIN: usize = 16;
1205
1206/// Cosine similarity between an already-unit query and node `id`'s `field`
1207/// vector, read from the **`f64`** properties. `None` when the node has no
1208/// numeric-list vector there, or its norm is zero.
1209///
1210/// The single definition of the score this API reports. Both the brute-force
1211/// scan and the re-scoring step that follows an index lookup go through it, so
1212/// the two paths cannot disagree — which is the property
1213/// `index_and_brute_force_agree_on_scores` pins.
1214fn exact_vector_similarity(
1215 view: &GraphView<'_>,
1216 id: u32,
1217 field: &str,
1218 q_unit: &[f64],
1219) -> Option<f64> {
1220 let v = view.prop(id, field)?;
1221 let xs = value_as_float_list(&v.into_value())?;
1222 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1223 if v_norm == 0.0 {
1224 return None;
1225 }
1226 Some(
1227 q_unit
1228 .iter()
1229 .zip(xs.iter())
1230 .map(|(a, b)| a * (b / v_norm))
1231 .sum(),
1232 )
1233}
1234
1235fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1236 match v {
1237 Value::List(items) => items
1238 .iter()
1239 .map(|item| match item {
1240 Value::Float(f) => Some(*f),
1241 Value::Int(i) => Some(*i as f64),
1242 _ => None,
1243 })
1244 .collect(),
1245 _ => None,
1246 }
1247}
1248
1249fn make_graph_mut<'a>(
1250 ids: &'a IdMap,
1251 syms: &'a mut Interner,
1252 labels: &'a [u32],
1253 props: core_storage::v8::seam::ColumnsView<'a>,
1254 topo: &'a mut Topology,
1255 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1256 edge_props: &'a mut EdgeProps,
1257) -> GraphMut<'a> {
1258 GraphMut {
1259 ids,
1260 syms,
1261 labels,
1262 props,
1263 topo,
1264 base_topo: base_csr(base),
1265 edge_props,
1266 }
1267}
1268
1269/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1270///
1271/// A store opened from a snapshot keeps its edges in the mapping and its
1272/// overlay empty, so a rule that reads the graph's shape has to see both.
1273fn base_csr(
1274 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1275) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1276 base.as_ref().map(|b| {
1277 b.topology()
1278 .expect("base topology section bounds validated at open")
1279 })
1280}
1281
1282/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1283///
1284/// Takes explicit field references rather than `&self` so the caller can hold
1285/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1286fn build_props_view<'a>(
1287 props: &'a ColumnStore,
1288 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1289) -> core_storage::v8::seam::ColumnsView<'a> {
1290 match base {
1291 None => core_storage::v8::seam::ColumnsView::owned(props),
1292 Some(b) => {
1293 let archived = b
1294 .columns()
1295 .expect("base columns section bounds validated at open");
1296 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1297 .with_shared_strings(base_string_table(b))
1298 }
1299 }
1300}
1301
1302/// The base columns section paired with the string table that resolves its
1303/// string ids — what `ViewStore` needs to read a neighbour's string property
1304/// out of a V9 snapshot.
1305fn base_columns(
1306 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1307) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1308 base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1309 cols: b
1310 .columns()
1311 .expect("base columns section bounds validated at open"),
1312 strings: base_string_table(b),
1313 })
1314}
1315
1316/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1317///
1318/// Every `ColumnsView` built over a base must carry it: without it a V9
1319/// snapshot's string columns, whose own tables are empty, read back as absent.
1320fn base_string_table(
1321 base: &core_storage::v8::MappedBase,
1322) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1323 base.string_table()
1324 .transpose()
1325 .expect("base strings section bounds validated at open")
1326}
1327
1328fn build_topo_view<'a>(
1329 overlay: &'a Topology,
1330 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1331) -> core_storage::v8::seam::TopologyView<'a> {
1332 match base {
1333 None => core_storage::v8::seam::TopologyView::owned(overlay),
1334 Some(b) => {
1335 let archived_csr = b
1336 .topology()
1337 .expect("base topology section bounds validated at open");
1338 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1339 }
1340 }
1341}
1342
1343/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1344///
1345/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1346/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1347/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1348/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1349/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1350#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1351pub enum FsyncPolicy {
1352 /// Every WAL commit calls `fs.sync` (today's behavior).
1353 #[default]
1354 Strict,
1355 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1356 /// this policy is set on the database.
1357 Batched,
1358 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1359 Relaxed,
1360}
1361
1362/// A precondition for a compare-and-set batch write.
1363///
1364/// All preconditions in a [`GraphDb::write_batch_cas`] or
1365/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1366/// any operation in the batch is applied. If any precondition fails, the
1367/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1368/// is written.
1369///
1370/// # Touch definition
1371///
1372/// A node's last-change commit (`last_changed`) is updated when any of the
1373/// following state-changing WAL records touch it:
1374///
1375/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1376/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1377/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1378/// endpoints (an edge change touches both sides).
1379/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1380/// for deleted keys so the pre-deletion entry is never observed.
1381///
1382/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1383/// state no-ops. The underlying mutation that triggered rule firing already
1384/// updated the relevant nodes' last-change entries. Rule-management records
1385/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1386/// do not touch any node's last-change.
1387#[derive(Debug, Clone, PartialEq, Eq)]
1388pub enum Precondition {
1389 /// The node's last-change commit must equal `expected`.
1390 ///
1391 /// Fails with [`GraphError::CasConflict`] when:
1392 /// - The node does not exist (`last_changed` returns `None`), or
1393 /// - The recorded commit seq does not match `expected`.
1394 NodeUnchangedSince { key: String, expected: u64 },
1395 /// The node must not exist (not inserted, or already deleted).
1396 ///
1397 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1398 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1399 NodeAbsent { key: String },
1400}
1401
1402pub struct GraphDb<F: Fs> {
1403 fs: F,
1404 ids: IdMap,
1405 syms: Interner,
1406 topo: Topology,
1407 props: ColumnStore,
1408 labels: Vec<u32>, // node id -> label symbol
1409 /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1410 /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1411 ///
1412 /// A private table rather than the shared [`Interner`]: interning
1413 /// `"default"` at open would add a symbol to the store's symbol table and
1414 /// change the bytes of the next snapshot of a store that has no namespaces
1415 /// at all.
1416 ns_names: Vec<String>,
1417 /// Namespace index per dense node id, into [`Self::ns_names`];
1418 /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1419 ///
1420 /// Derived: built by one pass over the `ns` column at open (which reads
1421 /// nothing when the column does not exist) and maintained at every node
1422 /// insert. Never written to a snapshot or the WAL, because the property it
1423 /// mirrors already is. A namespace cannot change, so no other record shape
1424 /// can move a node between namespaces.
1425 node_ns: Vec<u32>,
1426 edge_props: EdgeProps,
1427 engine: RuleEngine,
1428 view_store: ViewStore,
1429 /// Incremental inverted index for full-text-lite search.
1430 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1431 fulltext: FulltextIndex,
1432 /// Opt-in equality index over scalar node properties.
1433 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1434 /// open end (mirrors `fulltext`).
1435 prop_index: PropertyIndex,
1436 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1437 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1438 fsync: FsyncPolicy,
1439 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1440 /// call increments this once; all events emitted from that call share the same
1441 /// `commit_seq` value.
1442 commit_seq: u64,
1443 /// RBAC role definitions loaded from `roles.json` at open.
1444 ///
1445 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1446 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1447 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1448 roles: Option<Vec<RoleDef>>,
1449 /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1450 /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1451 ///
1452 /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1453 /// taken from this handle. Replaced (not cleared) whenever the role
1454 /// definitions change or the store is reloaded, which `commit_seq` does not
1455 /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1456 role_masks: Arc<crate::mask::RoleMaskCache>,
1457 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1458 /// distribute_events call.
1459 subscriptions: Vec<SubEntry>,
1460 /// Live query subscriptions. Re-executed on every commit when non-empty.
1461 /// Dead `Weak` entries are pruned inside `distribute_events`.
1462 query_subscriptions: Vec<QuerySubEntry>,
1463 /// Queue capacity for new subscriptions created by this db. Default is
1464 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1465 /// to test Lagged behaviour with small queues.
1466 sub_capacity: usize,
1467 /// True for as-of instances opened via [`GraphDb::open_at`].
1468 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1469 /// when this flag is set.
1470 read_only: bool,
1471 /// Total WAL commit count at the time [`open_at`] was called.
1472 /// 0 for normal (non-as-of) instances.
1473 total_wal_commits: u64,
1474 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1475 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1476 /// reads go through a merged `TopologyView`. `self.props` is always
1477 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1478 base: Option<Arc<core_storage::v8::MappedBase>>,
1479 // ── MVCC epoch reader state ───────────────────────────────────────────────
1480 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1481 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1482 /// `None` only between struct creation and the first fold.
1483 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1484 /// Per-commit deltas accumulated since the last fold.
1485 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1486 /// How many commits have occurred since the last fold.
1487 commits_since_fold: usize,
1488 /// When true, `log_then_apply_with` buffers event notifications instead of
1489 /// firing them immediately. Used by the group-commit drain thread to defer
1490 /// events until after the group fsync (R2: durability before notification).
1491 /// Cleared to false once the drain thread flushes or discards the buffer.
1492 defer_events: bool,
1493 /// Buffered events accumulated while `defer_events` is true.
1494 deferred_events: Vec<DeferredEvent>,
1495 /// Set to true by the group-commit drain thread when a group fsync fails
1496 /// after WAL truncation. All subsequent mutation attempts return an IO
1497 /// error until the database is reopened.
1498 degraded: bool,
1499 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1500 /// HNSW, and IVF sections from the mmap base into the engine's retained
1501 /// fields. `false` on all opens until first use; always `true` for non-V8
1502 /// opens (base is None, fast-path sets flag immediately).
1503 v8_sections_loaded: std::sync::atomic::AtomicBool,
1504 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1505 v8_sections_mutex: std::sync::Mutex<()>,
1506 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1507 /// the node was last modified by commit `seq`.
1508 ///
1509 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1510 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1511 /// nodes return `None` from `last_changed` until they are next mutated.
1512 ///
1513 /// See [`Precondition`] for the full touch definition.
1514 last_change: HashMap<u32, u64>,
1515 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1516 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1517 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1518 wal_archive_retention: Option<u32>,
1519 /// Global frame index of the first commit that is still reachable through
1520 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1521 /// Default 0 = all history reachable.
1522 wal_horizon_floor: u64,
1523 /// True when the surviving archive chain forms a continuous WAL history
1524 /// starting from the store's first commit (the genesis chain).
1525 ///
1526 /// `open_at` may replay archive-resident commits from empty state only when
1527 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1528 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1529 /// already exist (breaks the chain for subsequent archives), or
1530 /// - any archive is pruned (floor advances past zero).
1531 ///
1532 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1533 archive_genesis_chain: bool,
1534 /// Transient write-authz context set by `write_batch_authz` /
1535 /// `query_write_authz` for the duration of ONE mutation call.
1536 /// Always `None` at rest. Never serialized, never WAL-replayed.
1537 pending_write_authz: Option<WriteAuthz>,
1538 /// Slow-query threshold in milliseconds. 0 = disabled.
1539 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1540 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1541 /// — env vars are process-global and race parallel test threads).
1542 slow_query_threshold_ms: u64,
1543 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1544 /// can record entries without requiring `&mut self`).
1545 slow_queries: std::sync::Mutex<SlowQueryLog>,
1546 /// Instant at which the database was opened (used by `/metrics` uptime).
1547 started_at: std::time::Instant,
1548 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1549 /// Byte offset of the WAL prefix already applied to in-memory state.
1550 ///
1551 /// Advanced by exactly the encoded length of every frame this handle
1552 /// appends, and by the decoded byte count of every tail
1553 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1554 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1555 /// drain thread truncates a failed group. Compared against the WAL's
1556 /// on-disk length to decide staleness.
1557 wal_consumed: u64,
1558 /// Identity of the snapshot this handle's base state came from, as
1559 /// `(len, mtime_nanos)`. A different value means another process replaced
1560 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1561 snapshot_ident: Option<(u64, u64)>,
1562 /// The options this handle was opened with. Replayed verbatim when
1563 /// `refresh` has to rebuild from disk.
1564 open_opts: OpenOptions,
1565 /// True when this handle holds the cross-process write lock for its whole
1566 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1567 /// no-op on such a handle, and never releases the lock.
1568 holds_lifetime_lock: bool,
1569 /// True between a failed lock acquisition and the end of the write scope
1570 /// that failed. Makes every WAL-appending mutation in that scope return
1571 /// [`GraphError::Busy`] instead of writing.
1572 lock_denied: bool,
1573 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1574 /// pinned to one commit, so it is never stale and never refreshes — later
1575 /// commits by any process are deliberately invisible to it.
1576 pinned: bool,
1577}
1578
1579/// One group of deferred event notifications, held until the group fsync
1580/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1581struct DeferredEvent {
1582 rec: core_storage::WalRecord,
1583 engine_deltas: Vec<EngineEdgeDelta>,
1584 seq: u64,
1585 ingest: Option<(String, usize)>,
1586}
1587
1588/// Options for [`GraphDb::open_with_options`].
1589#[derive(Clone, Copy, Debug)]
1590pub struct OpenOptions {
1591 /// Rewrite an old-format snapshot to the current VERSION after a
1592 /// successful load (default `true`). The old snapshot is kept as
1593 /// `snapshot.bin.bak` until the next clean open at the current version,
1594 /// at which point the `.bak` is deleted.
1595 ///
1596 /// Set to `false` to open a store without touching any on-disk files
1597 /// (useful for read-only inspection of a store at an older format).
1598 pub auto_migrate: bool,
1599
1600 /// Write the valid WAL prefix back over a torn tail on open (default
1601 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1602 ///
1603 /// Set to `false` for an unattended reader. The valid prefix is still
1604 /// decoded and replayed in memory, but nothing is written: a reader that
1605 /// opens while another process is mid-append would otherwise discard a
1606 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1607 /// every prompt, passes `false` for exactly this reason.
1608 pub repair_wal: bool,
1609
1610 /// Open without ever writing to the store (default `false`).
1611 ///
1612 /// A read-only handle:
1613 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1614 /// `snapshot()`;
1615 /// - performs no disk write at open — no WAL repair write-back and no
1616 /// auto-migration rewrite, whatever the other two flags say;
1617 /// - never takes the cross-process write lock, so it opens immediately even
1618 /// while another process is writing, and never makes a writer wait.
1619 ///
1620 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1621 /// normally, so a read-only handle can follow another process's commits.
1622 pub read_only: bool,
1623}
1624
1625impl Default for OpenOptions {
1626 fn default() -> Self {
1627 Self {
1628 auto_migrate: true,
1629 repair_wal: true,
1630 read_only: false,
1631 }
1632 }
1633}
1634
1635/// How long a writer polls for the cross-process write lock before giving up
1636/// with [`GraphError::Busy`].
1637///
1638/// Long enough to ride out another process's commit (a batch apply plus one
1639/// fsync), short enough that a stuck peer surfaces as an error rather than a
1640/// hang.
1641pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1642
1643/// Refusal when a `MERGE` create cannot choose a namespace.
1644///
1645/// A role bound to two or more namespaces cannot have its create arm land in
1646/// `default`, and the statement did not name `ns`. The role must name one.
1647pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1648 "role-bound token: MERGE create requires the role to name one namespace";
1649
1650/// Interval between poll attempts while waiting for the cross-process lock.
1651pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1652
1653/// Why `load_from_disk` is running, which decides whether it may repair.
1654#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1655enum LoadOrigin {
1656 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1657 /// the signature of a crash and truncating it is correct, and archives
1658 /// orphaned by an interrupted prune can be swept.
1659 Open,
1660 /// A reload driven by [`GraphDb::refresh`], because another process
1661 /// replaced the snapshot. Nothing here is crash recovery — the store is
1662 /// live and someone else is writing it — so this origin writes nothing.
1663 Reload,
1664}
1665
1666/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1667///
1668/// `None` at the call site = full authority (today's zero-cost behavior).
1669/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1670/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1671/// record is built. A denial returns an error with no WAL frame written.
1672///
1673/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1674/// hidden-node existence to callers.
1675#[derive(Clone, Debug)]
1676pub struct WriteAuthz {
1677 pub role: String,
1678 pub scope: WriteScope,
1679 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1680 /// Always `Omit`-mode — never `Stub`.
1681 pub mask: crate::mask::NodeMask,
1682}
1683
1684/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1685///
1686/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1687/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1688/// syncs the directory entry. This is the only correct path for writing the
1689/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1690/// the directory sync.
1691pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1692 use core_storage::fs::{FileId, Fs as _};
1693 RealFs::new(dir)
1694 .map_err(core_storage::GraphError::Io)?
1695 .write_atomic(FileId::SnapshotBak, bytes)
1696 .map_err(core_storage::GraphError::Io)
1697}
1698
1699/// Return the on-disk snapshot format version without decoding the full snapshot.
1700///
1701/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1702/// snapshot file exists (WAL-only store). Returns an error if the header is
1703/// malformed.
1704pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1705 use std::io::Read as _;
1706 let path = dir.join("snapshot.bin");
1707 let mut header = [0u8; 6];
1708 let n = match std::fs::File::open(&path) {
1709 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1710 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1711 Err(e) => return Err(core_storage::GraphError::Io(e)),
1712 };
1713 core_storage::snapshot::peek_version(&header[..n])
1714}
1715
1716/// Options for [`GraphDb::snapshot_with`].
1717#[derive(Debug, Clone, Default)]
1718pub struct SnapshotOptions {
1719 /// When `true`, the WAL is preserved after the snapshot write.
1720 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1721 /// When `false` (the default), the WAL is truncated to a minimal
1722 /// baseline so cold-start replay stays fast.
1723 pub keep_wal: bool,
1724 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1725 /// before a fresh WAL baseline is written (history-preserving snapshot).
1726 ///
1727 /// This is the feature opt-in: `false` (the default) leaves the existing
1728 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1729 /// precedence over `keep_wal` when both are set.
1730 ///
1731 /// Archives can be scanned by [`GraphDb::node_history`],
1732 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1733 /// [`GraphDb::open_at`], extending the reachable history horizon across
1734 /// snapshot boundaries.
1735 pub archive_wal: bool,
1736}
1737
1738/// Derive the scan-label sym for the commit-skip fast-path.
1739///
1740/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1741/// or `IndexIntersect`) with a concrete label string, then interns it.
1742///
1743/// Returns `None` in all cases where skipping is unsafe:
1744/// - Any `Expand` op is present (edge traversal; edges change results regardless
1745/// of node labels).
1746/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1747/// - No recognizable leading scan op is found.
1748///
1749/// This is the conservative v0.4.3 boundary. The caller stores the result in
1750/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1751fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1752 // Any Expand → must always re-execute (edges can change join results).
1753 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1754 return None;
1755 }
1756 for op in ops {
1757 match op {
1758 PlanOp::ScanLabel {
1759 label: Some(label), ..
1760 } => return Some(syms.intern(label)),
1761 PlanOp::IndexScan {
1762 label: Some(label), ..
1763 } => return Some(syms.intern(label)),
1764 PlanOp::IndexIntersect {
1765 label: Some(label), ..
1766 } => return Some(syms.intern(label)),
1767 _ => {}
1768 }
1769 }
1770 None
1771}
1772
1773/// How an as-of read is restricted — the argument to
1774/// [`GraphDb::query_at_scoped`].
1775///
1776/// Every variant is resolved against the graph **as it was at the requested
1777/// commit**, not against the current graph.
1778#[derive(Debug, Clone, Copy)]
1779pub enum AsOfScope<'a> {
1780 /// Everything the named role may see. The role *definition* is the current
1781 /// one — `roles.json` is a sidecar and has no past version — but its
1782 /// `keys` and `labels` are resolved against the as-of graph.
1783 Role(&'a str),
1784 /// An explicit node-key allow-list. Keys that did not exist at that commit
1785 /// resolve to nothing.
1786 Keys(&'a [String]),
1787 /// A role intersected with a client-supplied allow-list. The intersection
1788 /// is the never-widen rule: a client mask can only narrow a role.
1789 RoleAndKeys(&'a str, &'a [String]),
1790 /// Every live node in one namespace, as the graph was at that commit.
1791 ///
1792 /// A namespace cannot change — it is set at insert and immutable — so the
1793 /// answer is simply "the nodes that existed then and are in this
1794 /// namespace". A name no node uses resolves to nothing, never to
1795 /// everything.
1796 Namespace(&'a str),
1797}
1798
1799impl GraphDb<RealFs> {
1800 /// Open the database at `dir` with default options.
1801 ///
1802 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1803 /// Old-format snapshots (V5, V6) are automatically migrated to the
1804 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1805 pub fn open(dir: &std::path::Path) -> Result<Self> {
1806 Self::open_with_options(dir, OpenOptions::default())
1807 }
1808
1809 /// Open the database at `dir` with explicit options.
1810 ///
1811 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1812 /// snapshot is an older format version, this function:
1813 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1814 /// + fsynced) before any modification.
1815 /// 2. Rewrites `snapshot.bin` at the current format version via
1816 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1817 ///
1818 /// If migration fails the error is returned and the original files are
1819 /// intact (the `.bak` was written before the new snapshot was attempted).
1820 ///
1821 /// A clean open that finds the snapshot already at the current version
1822 /// deletes any leftover `.bak` file.
1823 ///
1824 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1825 ///
1826 /// `opts.repair_wal` controls the other write this function can make; see
1827 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1828 /// no file on disk.
1829 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1830 Self::open_dir(dir, opts, true)
1831 }
1832
1833 /// Open without taking the cross-process write lock for the handle's
1834 /// lifetime.
1835 ///
1836 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1837 /// its handle open indefinitely, so it takes the lock per write instead of
1838 /// keeping every other process out of the store for as long as it runs.
1839 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1840 Self::open_dir(dir, OpenOptions::default(), false)
1841 }
1842
1843 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1844 // Header-only peek — 6 bytes, no full decode.
1845 let snap_version = snapshot_version_at(dir)?;
1846
1847 // Full load: decode snapshot + replay WAL + rebuild indexes.
1848 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1849
1850 // A read-only handle writes nothing at open, so it never migrates —
1851 // the old-format snapshot is loaded and left exactly as it is.
1852 if opts.auto_migrate && !opts.read_only {
1853 match snap_version {
1854 Some(ver) if ver < core_storage::snapshot::VERSION => {
1855 let _tm = std::time::Instant::now();
1856 // Copy the original snapshot to .bak at OS level — no in-memory
1857 // buffer required for a 2+ GiB file.
1858 //
1859 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1860 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1861 // A torn .bak on crash is acceptable because the original
1862 // snapshot.bin is the authoritative source until after the rename.
1863 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1864 .map_err(core_storage::GraphError::Io)?;
1865 trace_migrate!("bak copy done", _tm);
1866 // Rewrite snapshot at current version; keep WAL intact.
1867 db.snapshot_with(SnapshotOptions {
1868 keep_wal: true,
1869 ..SnapshotOptions::default()
1870 })?;
1871 trace_migrate!("snapshot_with done", _tm);
1872 }
1873 Some(_) => {
1874 // Already current version: remove any leftover .bak.
1875 let bak = dir.join("snapshot.bin.bak");
1876 if bak.exists() {
1877 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1878 }
1879 }
1880 None => {
1881 // WAL-only store — nothing to migrate on open.
1882 }
1883 }
1884 }
1885
1886 Ok(db)
1887 }
1888
1889 /// Open a read-only view of the database as it existed after `commit`.
1890 ///
1891 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1892 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1893 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1894 ///
1895 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1896 /// so as-of can only reach commits recorded in the current WAL (those
1897 /// written after the most recent snapshot, or all commits if no snapshot
1898 /// was ever taken). Commit 0 in `open_at` always refers to the first
1899 /// frame in the WAL that exists on disk, not the first ever write to the
1900 /// database. When the on-disk snapshot recorded that it truncated the
1901 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1902 /// before frame replay, so the as-of view includes all pre-snapshot data.
1903 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1904 /// are ignored and replay is WAL-only, as before.
1905 ///
1906 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1907 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1908 /// `stats()` work normally.
1909 ///
1910 /// # Errors
1911 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1912 /// when the WAL is empty after a snapshot).
1913 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1914 Self::open_at_with(RealFs::new(dir)?, commit)
1915 }
1916
1917 /// Run a **read-only** Cypher query against the graph as it existed at
1918 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1919 /// of this store's directory at that commit and executes the read there.
1920 ///
1921 /// The current instance is unaffected. Write statements are rejected (the
1922 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1923 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1924 /// state. Prefer this over holding many historical instances open.
1925 ///
1926 /// # Errors
1927 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1928 /// - A query error for a malformed or write query.
1929 pub fn query_at(
1930 &self,
1931 commit: u64,
1932 cypher: &str,
1933 params: &std::collections::BTreeMap<String, Value>,
1934 ) -> Result<ResultSet> {
1935 let temporal = self.open_at_for_read(commit, cypher)?;
1936 temporal.query(cypher, params)
1937 }
1938
1939 /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1940 ///
1941 /// The **graph** is as of `commit`; the **role definition** is as it is
1942 /// now, because `roles.json` is a sidecar and is never a WAL record — it
1943 /// has no past version to read. A role's `keys` and `labels` are resolved
1944 /// against the commit-`commit` graph, so a role that may see a label sees
1945 /// exactly the nodes that carried it then, and an explicit key that did
1946 /// not exist yet resolves to nothing.
1947 ///
1948 /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1949 /// only narrow what a role may see, never widen it.
1950 ///
1951 /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1952 /// them.
1953 ///
1954 /// # Errors
1955 /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1956 /// range; the error carries that range.
1957 /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1958 /// or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1959 /// - A query error for a malformed or write query.
1960 pub fn query_at_scoped(
1961 &self,
1962 commit: u64,
1963 cypher: &str,
1964 params: &std::collections::BTreeMap<String, Value>,
1965 scope: AsOfScope<'_>,
1966 ) -> Result<ResultSet> {
1967 let temporal = self.open_at_for_read(commit, cypher)?;
1968 let mask = temporal.mask_at_scope(scope)?;
1969 temporal.query_masked(cypher, params, &mask)
1970 }
1971
1972 /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1973 /// whatever `scope` resolves to.
1974 ///
1975 /// This is what a surface needs when a caller passes `namespace` beside a
1976 /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1977 /// restriction, and the namespace is a second one that composes with it
1978 /// rather than replacing it. The intersection is the never-widen rule — a
1979 /// namespace can only narrow what the scope already allows — and both legs
1980 /// are resolved against the graph as it was at `commit`.
1981 ///
1982 /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1983 pub fn query_at_scoped_in_namespace(
1984 &self,
1985 commit: u64,
1986 cypher: &str,
1987 params: &std::collections::BTreeMap<String, Value>,
1988 scope: AsOfScope<'_>,
1989 namespace: &str,
1990 ) -> Result<ResultSet> {
1991 let temporal = self.open_at_for_read(commit, cypher)?;
1992 let mask = temporal
1993 .mask_at_scope(scope)?
1994 .intersect(&temporal.mask_for_namespace(namespace));
1995 temporal.query_masked(cypher, params, &mask)
1996 }
1997
1998 /// Open the temporal view for a time-travel read and refuse write Cypher.
1999 ///
2000 /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2001 /// resolve the commit and reject writes identically.
2002 fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2003 let dir = self.fs.dir().to_path_buf();
2004 let temporal = Self::open_at(&dir, commit)?;
2005 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2006 detail: format!("lex: {e}"),
2007 })?) {
2008 return Err(GraphError::QueryError {
2009 detail: "query_at is read-only: write statements are not permitted in a \
2010 time-travel query"
2011 .into(),
2012 });
2013 }
2014 Ok(temporal)
2015 }
2016}
2017
2018impl<F: Fs> GraphDb<F> {
2019 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2020 pub fn open_with(fs: F) -> Result<Self> {
2021 Self::open_with_repair(fs, true)
2022 }
2023
2024 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2025 /// prefix without writing the truncation back. See
2026 /// [`OpenOptions::repair_wal`].
2027 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2028 Self::open_generic(
2029 fs,
2030 OpenOptions {
2031 repair_wal,
2032 ..OpenOptions::default()
2033 },
2034 true,
2035 )
2036 }
2037
2038 /// Shared open path.
2039 ///
2040 /// `hold_lock` requests the cross-process write lock for the whole handle
2041 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2042 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2043 /// `false` and takes the lock per write instead, so that a long-lived
2044 /// server does not keep every other process out of the store.
2045 ///
2046 /// A read-only open never takes the lock regardless of `hold_lock`.
2047 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2048 let mut db = Self::new_empty(fs, opts);
2049 db.read_only = opts.read_only;
2050 if hold_lock && !opts.read_only {
2051 if !db.poll_lock(WRITE_LOCK_WAIT)? {
2052 return Err(GraphError::Busy { holder: None });
2053 }
2054 db.holds_lifetime_lock = true;
2055 }
2056 db.load_from_disk(LoadOrigin::Open)?;
2057 Ok(db)
2058 }
2059
2060 /// A handle with no state loaded: every field at its empty value, the
2061 /// filesystem and options in place. Only [`load_from_disk`] makes it
2062 /// usable.
2063 fn new_empty(fs: F, opts: OpenOptions) -> Self {
2064 Self {
2065 fs,
2066 ids: IdMap::new(),
2067 syms: Interner::new(),
2068 topo: Topology::new(),
2069 props: ColumnStore::new(),
2070 labels: Vec::new(),
2071 ns_names: vec![NS_DEFAULT.to_string()],
2072 node_ns: Vec::new(),
2073 edge_props: EdgeProps::new(),
2074 engine: RuleEngine::new(),
2075 view_store: ViewStore::new(),
2076 fulltext: FulltextIndex::new(),
2077 prop_index: PropertyIndex::new(),
2078 event_sink: None,
2079 fsync: FsyncPolicy::Strict,
2080 commit_seq: 0,
2081 roles: Some(vec![]),
2082 role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2083 subscriptions: Vec::new(),
2084 query_subscriptions: Vec::new(),
2085 sub_capacity: DEFAULT_SUB_CAPACITY,
2086 read_only: false,
2087 total_wal_commits: 0,
2088 base: None,
2089 fold_overlay: None,
2090 delta_tail: Vec::new(),
2091 commits_since_fold: 0,
2092 defer_events: false,
2093 deferred_events: Vec::new(),
2094 degraded: false,
2095 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2096 v8_sections_mutex: std::sync::Mutex::new(()),
2097 last_change: HashMap::new(),
2098 wal_archive_retention: None,
2099 wal_horizon_floor: 0,
2100 archive_genesis_chain: false,
2101 pending_write_authz: None,
2102 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2103 .ok()
2104 .and_then(|v| v.parse().ok())
2105 .unwrap_or(100),
2106 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2107 entries: std::collections::VecDeque::new(),
2108 total: 0,
2109 }),
2110 started_at: std::time::Instant::now(),
2111 wal_consumed: 0,
2112 snapshot_ident: None,
2113 open_opts: opts,
2114 holds_lifetime_lock: false,
2115 lock_denied: false,
2116 pinned: false,
2117 }
2118 }
2119
2120 /// Return every field describing stored graph state to its empty value,
2121 /// leaving this handle's own identity alone.
2122 ///
2123 /// Preserved on purpose: the filesystem, open options, lock ownership, the
2124 /// event sink and subscriptions, fsync policy, degraded flag, and the
2125 /// slow-query configuration and log. A caller that registered a sink or a
2126 /// subscription keeps it across a reload.
2127 fn reset_for_reload(&mut self) {
2128 self.ids = IdMap::new();
2129 self.syms = Interner::new();
2130 self.topo = Topology::new();
2131 self.props = ColumnStore::new();
2132 self.labels = Vec::new();
2133 self.ns_names = vec![NS_DEFAULT.to_string()];
2134 self.node_ns = Vec::new();
2135 self.edge_props = EdgeProps::new();
2136 self.engine = RuleEngine::new();
2137 self.view_store = ViewStore::new();
2138 self.fulltext = FulltextIndex::new();
2139 self.prop_index = PropertyIndex::new();
2140 self.commit_seq = 0;
2141 self.roles = Some(vec![]);
2142 // A fresh cache, not a cleared one: any reader snapshot still holding
2143 // the old `Arc` keeps it to itself, so nothing it memoised against the
2144 // pre-reload store can be read back through this handle.
2145 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2146 self.total_wal_commits = 0;
2147 self.base = None;
2148 self.fold_overlay = None;
2149 self.delta_tail = Vec::new();
2150 self.commits_since_fold = 0;
2151 self.deferred_events = Vec::new();
2152 self.v8_sections_loaded
2153 .store(false, std::sync::atomic::Ordering::Release);
2154 self.last_change = HashMap::new();
2155 self.wal_horizon_floor = 0;
2156 self.archive_genesis_chain = false;
2157 self.pending_write_authz = None;
2158 self.wal_consumed = 0;
2159 self.snapshot_ident = None;
2160 }
2161
2162 /// Load the snapshot base and replay the WAL into an empty handle — the
2163 /// whole of what opening a store does after the struct exists.
2164 ///
2165 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2166 /// rebuild a handle in place, without ownership of `F`, when another
2167 /// process replaces the snapshot underneath it.
2168 ///
2169 /// `origin` decides whether the two repair writes this function can make
2170 /// are appropriate; see [`LoadOrigin`].
2171 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2172 // Both writes below are crash recovery, and only an open is entitled to
2173 // perform them. A read-only handle promises to touch nothing, and a
2174 // reload driven by `refresh` is looking at a store another process is
2175 // actively writing: what looks like a torn tail there is a peer
2176 // mid-append, and what looks like an orphaned archive may be one that
2177 // peer is about to reference.
2178 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2179 let repair_wal = self.open_opts.repair_wal && may_repair;
2180 let db = self;
2181 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2182 db.archive_genesis_chain = db.fs.has_genesis_marker();
2183 // Opening cleanup: remove orphaned archives — archives whose frames all
2184 // fall below the horizon floor. Orphans arise when a crash interrupted
2185 // the retention-prune sequence after the floor was written but before
2186 // all surplus archives were deleted. Safe to delete: floor already
2187 // accounts for their frames.
2188 if may_repair {
2189 db.cleanup_orphaned_archives()?;
2190 }
2191 let _t0 = std::time::Instant::now();
2192 // Peek 6 bytes to determine snapshot version without reading the full
2193 // file. For RealFs this is a true partial read (O(1)); for SimFs the
2194 // default impl reads all bytes and truncates (still correct).
2195 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2196 // V8 and V9 share the mmap-able container; V9 only adds section 12.
2197 let is_v8 = snap_header.len() >= 6
2198 && &snap_header[0..4] == b"GDB1"
2199 && matches!(
2200 u16::from_le_bytes([snap_header[4], snap_header[5]]),
2201 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2202 );
2203 if is_v8 {
2204 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2205 // No 2.4GB heap Vec is allocated on RealFs.
2206 let mapped = Arc::new(
2207 if let Some(snap_path) = db.fs.snapshot_path() {
2208 core_storage::v8::MappedBase::map(&snap_path)
2209 } else {
2210 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2211 core_storage::v8::MappedBase::from_bytes(snap_bytes)
2212 }
2213 .map_err(|e| GraphError::Corrupt {
2214 detail: format!("v8: mmap open: {e:?}"),
2215 })?,
2216 );
2217 db.restore_v8_base(Arc::clone(&mapped))?;
2218 trace_open!("restore_v8_base", _t0);
2219 db.base = Some(mapped);
2220 trace_open!("base assigned", _t0);
2221 } else if !snap_header.is_empty() {
2222 // Legacy V5-V7: full read required for decode.
2223 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2224 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2225 db.restore_snapshot_state(state)?;
2226 }
2227 }
2228 // else: snap_header is empty = no snapshot file, fresh store.
2229 //
2230 // Seed commit_seq from the highest seq persisted in last_change so that
2231 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2232 // already stored in the snapshot. Without this, a db with one snapshot
2233 // commit would save last_change["a"]=1, then on reopen the first WAL
2234 // frame would replay at seq=1 again — colliding and making WAL-tail
2235 // mutations indistinguishable from the snapshot baseline.
2236 //
2237 // Safety invariant (seq-recycling):
2238 // Recycled seqs (those below the seeded baseline) were NEVER stored in
2239 // last_change because they belonged to a previous db lifetime — a new
2240 // db starts at commit_seq=0 with an empty last_change. Therefore no
2241 // CAS precondition can carry a recycled seq as its `expected` value
2242 // and accidentally match a live node's last_change entry.
2243 //
2244 // `expected:0` on a deleted-then-reinserted node:
2245 // After deletion, last_changed() returns None; callers that call
2246 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2247 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
2248 // expected=0 correctly conflicts. The only way to observe actual=0 in
2249 // a CasConflict would be a caller that invented expected=0 without ever
2250 // calling last_changed() — unreachable via the documented API contract.
2251 if let Some(&max_seq) = db.last_change.values().max() {
2252 db.commit_seq = db.commit_seq.max(max_seq);
2253 }
2254 let bytes = db.fs.read(FileId::Wal)?;
2255 let (records, valid_len) = decode_all(&bytes);
2256 // The valid prefix is replayed either way; `repair_wal` only decides
2257 // whether the truncation is written back. A reader that races a live
2258 // appender must not persist a truncation the writer never asked for.
2259 if valid_len < bytes.len() && repair_wal {
2260 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2261 }
2262 // WAL-present path: build indexes eagerly BEFORE replay so that the
2263 // first replayed record does not trigger the lazy-init guard (which
2264 // would call reindex_all_load_state on an empty graph, defeating the
2265 // point of restoring IVF/HNSW blobs from the snapshot).
2266 if !records.is_empty() {
2267 db.ensure_v8_base_sections_loaded();
2268 trace_open!("lazy sections loaded (WAL path)", _t0);
2269 }
2270 let replayed = db.apply_frames(records)?;
2271 // The cursor sits at the end of the valid prefix, not the end of the
2272 // file: a torn or still-being-written tail is unconsumed by definition
2273 // and stays visible to `is_stale` until it decodes.
2274 db.wal_consumed = valid_len as u64;
2275 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2276 trace_open!("wal replay done", _t0);
2277 // Rebuild view values after WAL replay only when there is no V8 base.
2278 // With a V8 base, view values are correct in the snapshot and are updated
2279 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2280 // A full rebuild would read overlay-only props (empty after restore_v8_base)
2281 // and overwrite correct base values with wrong results (e.g. NeighborAgg
2282 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2283 // base value).
2284 if db.base.is_none() {
2285 let topo_view = TopologyView::owned(&db.topo);
2286 db.view_store
2287 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2288 }
2289 // Rebuild full-text index after WAL replay. Corrects drift from
2290 // per-record incremental apply during replay.
2291 db.fulltext.rebuild_all(
2292 &db.ids,
2293 &db.labels,
2294 &db.syms,
2295 build_props_view(&db.props, &db.base),
2296 );
2297 db.prop_index.rebuild_all(
2298 &db.ids,
2299 &db.labels,
2300 &db.syms,
2301 build_props_view(&db.props, &db.base),
2302 );
2303 // Namespaces: one pass over the `ns` column, after the snapshot is
2304 // restored and the WAL replayed. Replay maintains `node_ns` record by
2305 // record as well; this pass is what makes a snapshot-only open right,
2306 // and it reads nothing on a store with no `ns` column.
2307 db.rebuild_node_ns();
2308 // A mid-build snapshot's HNSW blob carries `complete == false`.
2309 // Register it so `serve`'s ticker sees work without waiting for a write.
2310 db.register_outstanding_index_builds();
2311 // Load roles sidecar. Missing file = no roles (Some(vec![])).
2312 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2313 db.roles = Self::load_roles_from_fs(&db.fs)?;
2314 // Capture the initial MVCC fold so reader() is ready immediately.
2315 db.fold_now();
2316 trace_open!("open_with complete", _t0);
2317 Ok(replayed)
2318 }
2319
2320 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2321 /// replay does — same `apply` calls, same per-frame delta drain, same
2322 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2323 /// edges appear identically whether a frame arrives at open, from a local
2324 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2325 ///
2326 /// Returns the number of frames applied.
2327 ///
2328 /// Deltas are drained and discarded per frame: replayed frames are already
2329 /// reflected on disk, so they are not news to a subscriber, and draining
2330 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2331 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2332 if records.is_empty() {
2333 return Ok(0);
2334 }
2335 // Materialize any state retained in the mmap base before the first
2336 // frame lands, so a replayed record cannot trip the lazy-init guard and
2337 // rebuild indexes from an empty graph. Both calls are idempotent.
2338 self.ensure_v8_base_sections_loaded();
2339 self.engine.consume_retained_state_eager(
2340 &self.ids,
2341 &self.syms,
2342 &self.labels,
2343 build_props_view(&self.props, &self.base),
2344 );
2345 let applied = records.len();
2346 for rec in records {
2347 self.apply(&rec)?;
2348 let _ = self.engine.drain_deltas();
2349 // Track commit_seq during replay so last_change entries are
2350 // consistent with the seqs assigned by log_then_apply_with on
2351 // subsequent live commits. After N replayed frames, commit_seq=N;
2352 // live commits begin at N+1.
2353 self.commit_seq += 1;
2354 let replay_seq = self.commit_seq;
2355 self.update_last_change_from_rec(&rec, replay_seq);
2356 }
2357 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2358 // this assert catches the regression in debug builds immediately.
2359 debug_assert_eq!(
2360 self.engine.pending_delta_count(),
2361 0,
2362 "pending_deltas non-empty after replay — \
2363 per-frame drain must run inside the loop to keep memory O(1)"
2364 );
2365 // T2 note: the per-frame drain IS the suppression seam for replay.
2366 // Any future as-of replay path (Plan-15 T2) must drain here to feed
2367 // replaying subscribers; the mechanism is already in place.
2368 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2369 Ok(applied)
2370 }
2371
2372 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2373 //
2374 // mushroomdb is many-readers / one-writer across processes. Writers take an
2375 // advisory exclusive lock on the store's `LOCK` file; readers never do.
2376 // Every handle tracks how much of the WAL it has consumed, so it can pick
2377 // up another process's commits by decoding only the new tail rather than
2378 // reopening. See `docs/site/concurrency.md`.
2379
2380 /// Whether the store on disk has moved ahead of (or out from under) this
2381 /// handle's in-memory state.
2382 ///
2383 /// True when the WAL's length differs from this handle's cursor — another
2384 /// process committed, or is mid-append — or when the snapshot file's
2385 /// identity changed. Costs two metadata lookups and reads no file contents,
2386 /// so it is cheap enough for a read path to call.
2387 ///
2388 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2389 /// pinned to one commit and later commits are deliberately invisible to it.
2390 pub fn is_stale(&self) -> Result<bool> {
2391 if self.pinned {
2392 return Ok(false);
2393 }
2394 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2395 return Ok(true);
2396 }
2397 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2398 }
2399
2400 /// Bring this handle up to date with every commit other processes have made,
2401 /// and return how many frames were applied.
2402 ///
2403 /// The WAL tail is decoded from this handle's cursor and applied through the
2404 /// same path the open replay uses, so rules fire and derived edges appear
2405 /// exactly as they would on a fresh open. Interners, id maps and indexes
2406 /// stay valid for the same reason.
2407 ///
2408 /// A frame another process is still writing is left alone: a trailing
2409 /// partial frame is a wait, not a corruption, and the handle stays stale
2410 /// until that frame is complete. Nothing is written to disk, so a read-only
2411 /// handle can refresh freely.
2412 ///
2413 /// When the snapshot file's identity changed, or the WAL is shorter than
2414 /// this handle's cursor, the WAL no longer continues our state — another
2415 /// process snapshotted or archived. The handle is then rebuilt from disk
2416 /// with the options it was opened with, and the return value is the number
2417 /// of frames in the new WAL.
2418 ///
2419 /// Returns 0 for an as-of view, which never follows later commits.
2420 ///
2421 /// # Errors
2422 ///
2423 /// An error here leaves the handle **degraded**: it got partway through
2424 /// applying the tail, or partway through a reload, so its in-memory state
2425 /// no longer matches any point on disk. Further mutations are refused and
2426 /// the handle must be reopened. Nothing on disk was damaged — the store
2427 /// itself is fine, and a fresh open recovers it.
2428 pub fn refresh(&mut self) -> Result<u64> {
2429 if self.pinned {
2430 return Ok(0);
2431 }
2432 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2433 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2434 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2435 // The WAL no longer continues our state: rebuild from disk. State
2436 // is cleared first, so a failed load leaves an empty handle — mark
2437 // it degraded rather than let a caller read an empty graph as if
2438 // it were the store's contents.
2439 self.reset_for_reload();
2440 return match self.load_from_disk(LoadOrigin::Reload) {
2441 Ok(frames) => Ok(frames as u64),
2442 Err(e) => {
2443 self.degraded = true;
2444 Err(e)
2445 }
2446 };
2447 }
2448 if wal_len == self.wal_consumed {
2449 return Ok(0);
2450 }
2451 let tail = self
2452 .fs
2453 .read_range(FileId::Wal, self.wal_consumed)
2454 .map_err(GraphError::Io)?;
2455 let (records, valid_len) = decode_all(&tail);
2456 let applied = match self.apply_frames(records) {
2457 Ok(n) => n,
2458 Err(e) => {
2459 // Some frames landed and some did not, and the cursor cannot
2460 // say how many. Advancing it would skip the rest; leaving it
2461 // would replay what already applied. Neither is recoverable in
2462 // place, so refuse further writes and require a reopen.
2463 self.degraded = true;
2464 return Err(e);
2465 }
2466 };
2467 // Advance by the bytes actually decoded, never by the file length: an
2468 // incomplete trailing frame stays unconsumed for the next refresh.
2469 self.wal_consumed += valid_len as u64;
2470 if applied > 0 {
2471 // Peer commits must reach `reader()` snapshots taken from here on.
2472 // A full fold is what open does; refresh does not build per-commit
2473 // deltas, so there is nothing cheaper that stays correct.
2474 self.fold_now();
2475 }
2476 Ok(applied as u64)
2477 }
2478
2479 /// Byte offset of the WAL prefix this handle has applied.
2480 ///
2481 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2482 #[doc(hidden)]
2483 pub fn wal_consumed(&self) -> u64 {
2484 self.wal_consumed
2485 }
2486
2487 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2488 /// failed group off the tail, so the cursor still describes the file.
2489 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2490 self.wal_consumed = len;
2491 }
2492
2493 /// One non-blocking attempt at the cross-process write lock.
2494 ///
2495 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2496 /// in-process write guard. That ordering is what keeps a busy peer in
2497 /// another process from stalling this process's readers.
2498 ///
2499 /// A handle that owns the lock for its lifetime always succeeds.
2500 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2501 if self.holds_lifetime_lock {
2502 return Ok(true);
2503 }
2504 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2505 }
2506
2507 /// Poll for the cross-process write lock until `wait` elapses.
2508 ///
2509 /// One attempt is always made, so a zero wait is a single try. Returns
2510 /// `false` when the lock is still held elsewhere at the deadline; nothing
2511 /// has been written and retrying later is safe.
2512 ///
2513 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2514 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2515 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2516 /// that it holds no in-process guard while it waits.
2517 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2518 let deadline = std::time::Instant::now() + wait;
2519 loop {
2520 if self.try_cross_process_lock()? {
2521 return Ok(true);
2522 }
2523 let now = std::time::Instant::now();
2524 if now >= deadline {
2525 return Ok(false);
2526 }
2527 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2528 }
2529 }
2530
2531 /// Open a cross-process write scope, given the outcome of an already-made
2532 /// lock attempt.
2533 ///
2534 /// The caller polls for the lock first — outside any in-process guard — and
2535 /// passes what it got. On success this refreshes, so the writes about to
2536 /// happen land on top of every other process's commits. On failure the
2537 /// handle refuses WAL-appending mutations and `snapshot()` with
2538 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2539 /// closes the scope, so a caller holding a guard cannot write behind
2540 /// another process's back.
2541 ///
2542 /// A handle that already owns the lock for its lifetime skips the refresh:
2543 /// no other process can have written, so there is nothing to pick up.
2544 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2545 self.lock_denied = !acquired;
2546 if !acquired || self.holds_lifetime_lock {
2547 return Ok(());
2548 }
2549 if let Err(e) = self.refresh() {
2550 // Do not hold a lock we cannot use: release it and let the caller
2551 // see the underlying failure.
2552 let _ = self.fs.unlock();
2553 self.lock_denied = true;
2554 return Err(e);
2555 }
2556 Ok(())
2557 }
2558
2559 /// Close a cross-process write scope opened by
2560 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2561 /// clear the Busy latch. Safe to call when the lock was never taken.
2562 pub(crate) fn end_write_lock(&mut self) {
2563 self.lock_denied = false;
2564 if !self.holds_lifetime_lock {
2565 // Releasing a lock we do not hold is a no-op; a failure to release
2566 // is reported by the OS closing the descriptor at handle drop.
2567 let _ = self.fs.unlock();
2568 }
2569 }
2570
2571 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2572 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2573 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2574 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2575 /// Restore all persisted state from a decoded snapshot. Shared by
2576 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2577 fn restore_snapshot_state(
2578 &mut self,
2579 state: core_storage::snapshot::SnapshotState,
2580 ) -> Result<()> {
2581 self.ids = state.ids;
2582 self.syms = state.syms;
2583 self.topo = state.topo;
2584 self.props = state.props;
2585 self.labels = state.labels;
2586 self.edge_props = state.edge_props;
2587 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2588 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2589 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2590 // open successfully and panic later in `NodeRef::label()` or
2591 // `neighborhood_masked()`. Catching it here turns those into typed
2592 // `GraphError::Corrupt` at open time.
2593 {
2594 let ids_len = self.ids.len();
2595 if self.labels.len() != ids_len {
2596 return Err(GraphError::Corrupt {
2597 detail: format!(
2598 "snapshot: labels vec has {} entries but id table has {} total slots",
2599 self.labels.len(),
2600 ids_len,
2601 ),
2602 });
2603 }
2604 let syms_len = self.syms.len() as u32;
2605 for (i, &sym) in self.labels.iter().enumerate() {
2606 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2607 if sym == u32::MAX {
2608 if !is_tombstoned {
2609 return Err(GraphError::Corrupt {
2610 detail: format!(
2611 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2612 ),
2613 });
2614 }
2615 } else if sym >= syms_len {
2616 return Err(GraphError::Corrupt {
2617 detail: format!(
2618 "snapshot: label at id slot {i} references sym {sym} \
2619 which is out of interner range ({syms_len})"
2620 ),
2621 });
2622 }
2623 }
2624 }
2625 let defs: Vec<RuleDef> = state
2626 .rule_defs
2627 .iter()
2628 .map(|b| {
2629 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2630 detail: format!("snapshot rule_def deserialize: {e}"),
2631 })
2632 })
2633 .collect::<Result<Vec<_>>>()?;
2634 self.engine =
2635 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2636 // Candidate indexes are rebuilt lazily on the first mutation (see
2637 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2638 // snapshot are retained without deserializing so that:
2639 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2640 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2641 // lazy-init guard which calls reindex_all_load_state (the scan
2642 // skips the HNSW build for every side the blob supplies).
2643 // - WAL-present: open_with calls consume_retained_state_eager before
2644 // replay so HNSW/IVF are live before any record fires the hooks.
2645 let ivf_bytes = if state.ivf_state.is_empty() {
2646 Vec::new()
2647 } else {
2648 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2649 };
2650 // Store blobs without eagerly deserializing them.
2651 // `self.ids` is the snapshot's id table at this point — WAL replay has
2652 // not run — so its length is the line an interrupted build is detected
2653 // against.
2654 let snapshot_ids = self.ids.len() as u32;
2655 self.engine
2656 .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2657 // Restore view defs from snapshot (V5).
2658 // The ColumnStore already contains view values from the snapshot;
2659 // use restore_view (no collision check, no backfill) so the store
2660 // is aware of the definitions. rebuild_all runs after WAL replay.
2661 for def_bytes in &state.view_defs {
2662 let def: ViewDef =
2663 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2664 detail: format!("snapshot view_def deserialize: {e}"),
2665 })?;
2666 self.view_store
2667 .restore_view(def)
2668 .map_err(|e| GraphError::Corrupt {
2669 detail: format!("snapshot view restore: {e}"),
2670 })?;
2671 }
2672 Ok(())
2673 }
2674
2675 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2676 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2677 ///
2678 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2679 /// deserialization and view rebuild have access to all column data.
2680 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2681 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2682 detail: format!("v8: ids section: {e:?}"),
2683 })?);
2684 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2685 detail: format!("v8: syms section: {e:?}"),
2686 })?);
2687
2688 // C1: self.props is left as an empty overlay. Column reads go through
2689 // props_view() (ColumnsView::with_base), which consults the archived base
2690 // section zero-copy. This avoids the O(columns) heap copy at every open.
2691
2692 // self.topo deliberately left as Topology::new() — overlay path.
2693
2694 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2695 detail: format!("v8: meta section: {e:?}"),
2696 })?)
2697 .map_err(|e| GraphError::Corrupt {
2698 detail: format!("v8: meta decode: {e:?}"),
2699 })?;
2700 self.labels = meta.labels;
2701 // Cross-section label integrity: labels must cover every id slot (live
2702 // and tombstoned), every non-sentinel sym must be within the interner's
2703 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2704 // sentinel label. Without this check, a crafted snapshot where the META
2705 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2706 // sym ids, or a sentinel label on a live node, would open successfully
2707 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2708 // related read paths. Catching the inconsistency here converts those
2709 // panics into typed `GraphError::Corrupt` at open time.
2710 {
2711 let ids_len = self.ids.len();
2712 if self.labels.len() != ids_len {
2713 return Err(GraphError::Corrupt {
2714 detail: format!(
2715 "v8: labels section has {} entries but id table has {} total slots",
2716 self.labels.len(),
2717 ids_len,
2718 ),
2719 });
2720 }
2721 let syms_len = self.syms.len() as u32;
2722 for (i, &sym) in self.labels.iter().enumerate() {
2723 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2724 if sym == u32::MAX {
2725 // Sentinel is only valid for tombstoned slots.
2726 if !is_tombstoned {
2727 return Err(GraphError::Corrupt {
2728 detail: format!(
2729 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2730 ),
2731 });
2732 }
2733 } else if sym >= syms_len {
2734 return Err(GraphError::Corrupt {
2735 detail: format!(
2736 "v8: label at id slot {i} references sym {sym} \
2737 which is out of interner range ({syms_len})"
2738 ),
2739 });
2740 }
2741 }
2742 }
2743 // C3: self.edge_props stays as an empty overlay. Reads go through
2744 // edge_props_view() which consults the mmap'd base section zero-copy
2745 // via EdgePropsView::with_base. No heap decode at open time.
2746
2747 // Restore rule engine.
2748 let (rule_def_bytes, rule_tripped, rule_fires) =
2749 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2750 GraphError::Corrupt {
2751 detail: format!("v8: rules_meta section: {e:?}"),
2752 }
2753 })?);
2754 let defs: Vec<RuleDef> = rule_def_bytes
2755 .iter()
2756 .map(|b| {
2757 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2758 detail: format!("v8: rule_def deserialize: {e}"),
2759 })
2760 })
2761 .collect::<Result<Vec<_>>>()?;
2762 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2763 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2764 // `ensure_v8_base_sections_loaded` reads them on first use from
2765 // `self.base` (set by the caller immediately after this returns).
2766 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2767
2768 // Restore view definitions.
2769 let view_defs =
2770 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2771 detail: format!("v8: views section: {e:?}"),
2772 })?);
2773 for def_bytes in &view_defs {
2774 let def: ViewDef =
2775 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2776 detail: format!("v8: view_def deserialize: {e}"),
2777 })?;
2778 self.view_store
2779 .restore_view(def)
2780 .map_err(|e| GraphError::Corrupt {
2781 detail: format!("v8: view restore: {e}"),
2782 })?;
2783 }
2784 // Load the last-change map from section 11 (small section; load eagerly).
2785 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2786 // in that case and `decode_last_change_bytes` returns an empty map.
2787 let last_change_raw = mapped
2788 .last_change_bytes()
2789 .map_err(|e| GraphError::Corrupt {
2790 detail: format!("v8: last_change section: {e:?}"),
2791 })?;
2792 self.last_change = decode_last_change_bytes(last_change_raw);
2793
2794 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2795 // the file. Pure bounds check — no bytes read, no page faults triggered.
2796 // Catches truncated snapshots at open time before the lazy deferred reads.
2797 mapped.validate_section_bounds().map_err(|e| match e {
2798 GraphError::Corrupt { detail } => GraphError::Corrupt {
2799 detail: format!("v8: section bounds: {detail}"),
2800 },
2801 other => other,
2802 })?;
2803 Ok(())
2804 }
2805
2806 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2807 /// engine's retained fields on first call. Subsequent calls are a no-op
2808 /// (AtomicBool fast-path).
2809 ///
2810 /// Must be called before any code path that reads or mutates engine
2811 /// provenance, HNSW, or IVF state:
2812 /// - WAL replay (before `consume_retained_state_eager`)
2813 /// - First mutation (`log_then_apply_with`)
2814 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2815 /// - Snapshot (`snapshot_with`)
2816 ///
2817 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2818 fn ensure_v8_base_sections_loaded(&self) {
2819 use std::sync::atomic::Ordering;
2820 if self.v8_sections_loaded.load(Ordering::Acquire) {
2821 return;
2822 }
2823 let _guard = self
2824 .v8_sections_mutex
2825 .lock()
2826 .expect("v8 sections mutex poisoned");
2827 if self.v8_sections_loaded.load(Ordering::Acquire) {
2828 return; // another caller populated while we waited
2829 }
2830 let _t = std::time::Instant::now();
2831 if let Some(base) = &self.base {
2832 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2833 // Bounds are already validated at open time (restore_v8_base →
2834 // validate_section_bounds) — unreachable post-validate_section_bounds;
2835 // unwrap_or_default is a safety belt against impossible errors.
2836 let prov_bytes = base
2837 .provenance_raw_bytes()
2838 .map(|b| b.to_vec())
2839 .unwrap_or_default();
2840 self.engine.store_provenance_bytes(prov_bytes);
2841 // HNSW: decode rkyv blobs into owned map.
2842 let hnsw_state = base
2843 .hnsw_section()
2844 .map(archived_hnsw_to_owned)
2845 .unwrap_or_default();
2846 // IVF: raw bincode bytes; deserialized on first mutation/query.
2847 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2848 // Called before WAL replay on a WAL-present open (`open_with`) and
2849 // before any write on a clean one, so this is the snapshot's count.
2850 let snapshot_ids = self.ids.len() as u32;
2851 self.engine
2852 .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2853 }
2854 self.v8_sections_loaded.store(true, Ordering::Release);
2855 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2856 eprintln!(
2857 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2858 _t.elapsed()
2859 );
2860 }
2861 }
2862
2863 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2864 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2865 /// the full merged topology without going through `self.view()`.
2866 fn topo_view(&self) -> TopologyView<'_> {
2867 match self.base {
2868 None => TopologyView::owned(&self.topo),
2869 Some(ref base) => {
2870 // SAFETY: base lives as long as self; section bounds validated at open.
2871 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2872 let archived = base
2873 .topology()
2874 .expect("base topology section bounds validated at open");
2875 TopologyView::with_base(&self.topo, archived)
2876 }
2877 }
2878 }
2879
2880 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2881 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2882 /// overlay first, then fall through to the archived base section zero-copy.
2883 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2884 match self.base {
2885 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2886 Some(ref base) => {
2887 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2888 let archived = base
2889 .columns()
2890 .expect("base columns section bounds validated at open");
2891 core_storage::v8::seam::ColumnsView::with_base_cached(
2892 &self.props,
2893 archived,
2894 base.mixed_cache(),
2895 )
2896 .with_shared_strings(base_string_table(base))
2897 }
2898 }
2899 }
2900
2901 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2902 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2903 ///
2904 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2905 /// through to the archived base section zero-copy. Tombstones in the
2906 /// overlay mask deleted-from-base entries.
2907 fn edge_props_view(&self) -> EdgePropsView<'_> {
2908 match self.base {
2909 None => EdgePropsView::owned(&self.edge_props),
2910 Some(ref base) => {
2911 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2912 let archived = base
2913 .edge_props_section()
2914 .expect("base edge_props section bounds validated at open");
2915 EdgePropsView::with_base(&self.edge_props, archived)
2916 }
2917 }
2918 }
2919
2920 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2921 // An as-of view never writes and is pinned to one commit: it takes no
2922 // cross-process lock and does not follow later commits.
2923 let mut db = Self::new_empty(
2924 fs,
2925 OpenOptions {
2926 repair_wal: false,
2927 auto_migrate: false,
2928 read_only: true,
2929 },
2930 );
2931 db.pinned = true; // read_only is set after replay, but pinning is immediate
2932 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2933 db.archive_genesis_chain = db.fs.has_genesis_marker();
2934 // Same orphaned-archive cleanup as open_with: floor was written first
2935 // during pruning, so a crash may have left stale archives below floor.
2936 db.cleanup_orphaned_archives()?;
2937 // Collect archive frames (oldest-first) and live WAL frames.
2938 // Archives represent pre-snapshot history; the snapshot captures the
2939 // cumulative state at the time of archiving. Crash-window guarantee:
2940 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2941 // B: crash after rename, before new WAL → archive present, WAL
2942 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2943 // C: crash after new baseline WAL written → normal post-archive.
2944 let archive_ns = db.fs.list_archives()?;
2945 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2946 for n in &archive_ns {
2947 let arc_bytes = db.fs.read_archive(*n)?;
2948 let (arc_frames, _) = decode_all(&arc_bytes);
2949 archive_frames_all.extend(arc_frames);
2950 }
2951 let total_archive_frames = archive_frames_all.len() as u64;
2952
2953 let live_bytes = db.fs.read(FileId::Wal)?;
2954 let (live_records, _valid_len) = decode_all(&live_bytes);
2955 let total_surviving = total_archive_frames + live_records.len() as u64;
2956 // Global total including any pruned history below the horizon floor.
2957 let total = db.wal_horizon_floor + total_surviving;
2958
2959 // Horizon and range check.
2960 if commit < db.wal_horizon_floor {
2961 return Err(GraphError::CommitOutOfRange {
2962 commit,
2963 total,
2964 floor: db.wal_horizon_floor,
2965 });
2966 }
2967 if commit >= total {
2968 return Err(GraphError::CommitOutOfRange {
2969 commit,
2970 total,
2971 floor: db.wal_horizon_floor,
2972 });
2973 }
2974
2975 // Local index into surviving frames (0 = first frame of oldest archive).
2976 let local = commit - db.wal_horizon_floor;
2977
2978 if local < total_archive_frames {
2979 // Target commit is in an archive. Correct replay from empty state
2980 // is only possible when the archive chain is an uninterrupted
2981 // genesis chain (first archive taken from a fresh store, no prior
2982 // WAL truncation) and no archives have been pruned (floor == 0).
2983 //
2984 // If either condition is violated the prefix needed to reconstruct
2985 // the requested state is gone; refuse rather than return wrong data.
2986 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2987 return Err(GraphError::CommitOutOfRange {
2988 commit,
2989 total,
2990 floor: db.wal_horizon_floor,
2991 });
2992 }
2993 // Replay all archive frames up to and including the target commit
2994 // from an empty database state. Archives must be replayed in order
2995 // so that dense-id intern tables are built up correctly.
2996 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2997 db.apply(&rec)?;
2998 let _ = db.engine.drain_deltas();
2999 }
3000 } else {
3001 // Target commit is in the live WAL: load snapshot as base, then
3002 // replay the needed live WAL prefix.
3003 //
3004 // Base state: a truncating snapshot (wal_truncated=true) compacts
3005 // all pre-truncation / pre-archive commits. Dense-id records in
3006 // the live WAL reference ids/interns that the snapshot provides.
3007 // Peek 6 bytes (same pattern as open_with).
3008 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3009 let is_v8 = snap_header.len() >= 6
3010 && &snap_header[0..4] == b"GDB1"
3011 && matches!(
3012 u16::from_le_bytes([snap_header[4], snap_header[5]]),
3013 core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
3014 );
3015 if is_v8 {
3016 let state = if let Some(snap_path) = db.fs.snapshot_path() {
3017 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3018 GraphError::Corrupt {
3019 detail: format!("v8: open_at mmap: {e:?}"),
3020 }
3021 })?;
3022 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3023 } else {
3024 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3025 core_storage::snapshot::decode(&snap_bytes)?
3026 };
3027 if let Some(state) = state {
3028 if state.wal_truncated {
3029 db.restore_snapshot_state(state)?;
3030 }
3031 }
3032 } else if !snap_header.is_empty() {
3033 let snap_bytes = db.fs.read(FileId::Snapshot)?;
3034 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3035 if state.wal_truncated {
3036 db.restore_snapshot_state(state)?;
3037 }
3038 }
3039 }
3040 // else: snap_header empty = no snapshot file.
3041 let live_local = local - total_archive_frames;
3042 for rec in live_records.into_iter().take((live_local + 1) as usize) {
3043 db.apply(&rec)?;
3044 let _ = db.engine.drain_deltas();
3045 }
3046 }
3047 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3048 // post-loop assert in open_with.
3049 debug_assert_eq!(
3050 db.engine.pending_delta_count(),
3051 0,
3052 "pending_deltas non-empty after open_at replay — \
3053 per-frame drain must run inside the loop to keep memory O(1)"
3054 );
3055 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3056 // Rebuild view values after WAL replay so derived-edge-driven views
3057 // reflect the as-of state. open_at always uses the legacy path (no V8
3058 // base), so topo_view is always owned.
3059 {
3060 let topo_view = TopologyView::owned(&db.topo);
3061 db.view_store
3062 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
3063 }
3064 // Rebuild full-text index for as-of view (mirrors open_with pattern).
3065 db.fulltext.rebuild_all(
3066 &db.ids,
3067 &db.labels,
3068 &db.syms,
3069 build_props_view(&db.props, &db.base),
3070 );
3071 db.prop_index.rebuild_all(
3072 &db.ids,
3073 &db.labels,
3074 &db.syms,
3075 build_props_view(&db.props, &db.base),
3076 );
3077 // Namespaces on the temporal handle, built by the same pass the live
3078 // open uses, so an as-of mask narrows by the namespaces of that commit.
3079 db.rebuild_node_ns();
3080 // Load roles sidecar (current roles, not point-in-time).
3081 db.roles = Self::load_roles_from_fs(&db.fs)?;
3082 db.read_only = true;
3083 db.total_wal_commits = total;
3084 // Capture initial fold so reader() is immediately usable.
3085 db.fold_now();
3086 Ok(db)
3087 }
3088
3089 /// Whether this instance is a read-only as-of view.
3090 pub fn is_read_only(&self) -> bool {
3091 self.read_only
3092 }
3093
3094 // ── MVCC epoch reader ─────────────────────────────────────────────────────
3095
3096 /// Clone the current overlay state into a new `FrozenOverlay` and reset
3097 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3098 /// the end of `open_with` / `open_at_with` to prime the reader.
3099 fn fold_now(&mut self) {
3100 let frozen = crate::reader::FrozenOverlay {
3101 ids: self.ids.clone(),
3102 syms: self.syms.clone(),
3103 topo: self.topo.clone(),
3104 props: self.props.clone(),
3105 labels: self.labels.clone(),
3106 edge_props: self.edge_props.clone(),
3107 roles: self.roles.clone(),
3108 fulltext: self.fulltext.clone(),
3109 };
3110 self.fold_overlay = Some(Arc::new(frozen));
3111 self.delta_tail.clear();
3112 self.commits_since_fold = 0;
3113 }
3114
3115 /// Capture a lock-free reader snapshot of the current db state.
3116 ///
3117 /// The read lock is held only for the duration of this call (to clone a
3118 /// handful of `Arc` handles). Subsequent query operations run without any
3119 /// lock.
3120 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3121 crate::reader::ReaderSnapshot::new(
3122 self.fold_overlay
3123 .clone()
3124 .expect("fold_overlay is always Some after open_with; call reader() after open"),
3125 self.base.clone(),
3126 self.delta_tail.clone(),
3127 // The snapshot's effective state is exactly this handle's state at
3128 // this commit, so it shares the memo and its version key.
3129 self.commit_seq,
3130 Arc::clone(&self.role_masks),
3131 )
3132 }
3133
3134 /// Total number of WAL commits at the time [`open_at`] was called.
3135 /// Returns 0 for normal (non-as-of) instances.
3136 pub fn total_wal_commits(&self) -> u64 {
3137 self.total_wal_commits
3138 }
3139
3140 /// Apply a record to in-memory state. Used by both live writes and replay,
3141 /// so replay is definitionally identical to the original execution.
3142 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3143 // Before the record mutates anything: a store restored from a snapshot
3144 // defers building its candidate indexes until the first write, and that
3145 // build is a full node scan. Left where it used to fire — inside the
3146 // engine hook, after `props.set` and the label assignment — the scan
3147 // read the half-applied record and took the in-flight node's vector for
3148 // one the snapshot should have carried, which read as an interrupted
3149 // vector-index build and cost a full `RebuildRule` on the first
3150 // embedded write after every reopen. Hoisted here the scan sees exactly
3151 // the persisted state; the record's own hook then files its vector
3152 // through the ordinary insert path a line later.
3153 self.populate_indexes_before_write();
3154 match rec {
3155 WalRecord::InsertNode { label, key, props } => {
3156 let id = self.ids.try_insert(key)?;
3157 let sym = self.syms.intern(label);
3158 if self.labels.len() <= id as usize {
3159 // gap slots are sentinels, never valid label symbols
3160 self.labels.resize(id as usize + 1, u32::MAX);
3161 }
3162 self.labels[id as usize] = sym;
3163 let mut ns_name = NS_DEFAULT.to_string();
3164 for (field, value) in props {
3165 if field == NS_PROP {
3166 ns_name = namespace_of_value(Some(value)).to_string();
3167 }
3168 self.props.set(id, field, value.clone());
3169 }
3170 self.set_node_ns(id, &ns_name);
3171 // Initialize view values for the new node before the engine runs so
3172 // delta-based increments start from a known zero baseline.
3173 self.view_store
3174 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3175 // Fire rules for the newly inserted node.
3176 let cursor = self.engine.pending_delta_count();
3177 let mut eng = std::mem::take(&mut self.engine);
3178 {
3179 let mut gm = make_graph_mut(
3180 &self.ids,
3181 &mut self.syms,
3182 &self.labels,
3183 build_props_view(&self.props, &self.base),
3184 &mut self.topo,
3185 &self.base,
3186 &mut self.edge_props,
3187 );
3188 eng.on_node_changed(id, None, &mut gm);
3189 }
3190 self.engine = eng;
3191 // Process derived-edge deltas for view maintenance.
3192 // Fast path: skip the O(delta_count) allocation when no views exist.
3193 if !self.view_store.is_empty() {
3194 #[cfg(test)]
3195 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3196 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3197 for d in &new_deltas {
3198 self.view_store.on_edge_changed(
3199 d.etype_sym,
3200 d.src_id,
3201 d.dst_id,
3202 d.fired,
3203 &mut self.props,
3204 &build_topo_view(&self.topo, &self.base),
3205 &self.ids,
3206 &self.syms,
3207 &self.labels,
3208 base_columns(&self.base),
3209 );
3210 }
3211 }
3212 // Full-text index maintenance: index enabled fields for this label.
3213 if self.fulltext.has_label(label) {
3214 for (field, value) in props {
3215 if self.fulltext.is_enabled(label, field) {
3216 self.fulltext.add_tokens(id, field, value);
3217 }
3218 }
3219 }
3220 // Property (equality) index maintenance.
3221 if self.prop_index.has_label(label) {
3222 for (field, value) in props {
3223 self.prop_index.set(label, field, id, value);
3224 }
3225 }
3226 }
3227 WalRecord::InsertEdge {
3228 edge_type,
3229 src_key,
3230 dst_key,
3231 } => {
3232 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3233 detail: format!("wal replay references unknown key {src_key}"),
3234 })?;
3235 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3236 detail: format!("wal replay references unknown key {dst_key}"),
3237 })?;
3238 let etype = self.syms.intern(edge_type);
3239 // Skip if the edge is already visible in the merged base+overlay
3240 // view. This keeps WAL replay idempotent when the WAL contains
3241 // pre-snapshot records that are already encoded in a V8 base
3242 // (keep_wal=true opens and crash-before-truncation scenarios).
3243 if self.base.is_some()
3244 && self
3245 .topo_view()
3246 .neighbors(etype, Direction::Out, src)
3247 .contains(&dst)
3248 {
3249 return Ok(());
3250 }
3251 self.topo.add_edge(etype, src, dst);
3252 // View maintenance for manual edge insert.
3253 self.view_store.on_edge_changed(
3254 etype,
3255 src,
3256 dst,
3257 true,
3258 &mut self.props,
3259 &build_topo_view(&self.topo, &self.base),
3260 &self.ids,
3261 &self.syms,
3262 &self.labels,
3263 base_columns(&self.base),
3264 );
3265 // Rule engine: via-hop rules must update when user edges change.
3266 let cursor = self.engine.pending_delta_count();
3267 let mut eng = std::mem::take(&mut self.engine);
3268 {
3269 let mut gm = make_graph_mut(
3270 &self.ids,
3271 &mut self.syms,
3272 &self.labels,
3273 build_props_view(&self.props, &self.base),
3274 &mut self.topo,
3275 &self.base,
3276 &mut self.edge_props,
3277 );
3278 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3279 }
3280 self.engine = eng;
3281 if !self.view_store.is_empty() {
3282 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3283 for d in &new_deltas {
3284 self.view_store.on_edge_changed(
3285 d.etype_sym,
3286 d.src_id,
3287 d.dst_id,
3288 d.fired,
3289 &mut self.props,
3290 &build_topo_view(&self.topo, &self.base),
3291 &self.ids,
3292 &self.syms,
3293 &self.labels,
3294 base_columns(&self.base),
3295 );
3296 }
3297 }
3298 }
3299 WalRecord::SetProp { key, field, value } => {
3300 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3301 detail: format!("wal replay references unknown key {key}"),
3302 })?;
3303 let old_value = build_props_view(&self.props, &self.base)
3304 .get(id, field)
3305 .map(|vr| vr.into_value());
3306 self.props.set(id, field, value.clone());
3307 // Fire rules for the changed field.
3308 let cursor = self.engine.pending_delta_count();
3309 let mut eng = std::mem::take(&mut self.engine);
3310 {
3311 let mut gm = make_graph_mut(
3312 &self.ids,
3313 &mut self.syms,
3314 &self.labels,
3315 build_props_view(&self.props, &self.base),
3316 &mut self.topo,
3317 &self.base,
3318 &mut self.edge_props,
3319 );
3320 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3321 }
3322 self.engine = eng;
3323 // Derived-edge deltas → view updates.
3324 if !self.view_store.is_empty() {
3325 #[cfg(test)]
3326 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3327 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3328 for d in &new_deltas {
3329 self.view_store.on_edge_changed(
3330 d.etype_sym,
3331 d.src_id,
3332 d.dst_id,
3333 d.fired,
3334 &mut self.props,
3335 &build_topo_view(&self.topo, &self.base),
3336 &self.ids,
3337 &self.syms,
3338 &self.labels,
3339 base_columns(&self.base),
3340 );
3341 }
3342 }
3343 // Neighbor-aggregate views that read `field` must also update.
3344 self.view_store.on_prop_changed(
3345 id,
3346 field,
3347 &mut self.props,
3348 &build_topo_view(&self.topo, &self.base),
3349 &self.ids,
3350 &self.syms,
3351 &self.labels,
3352 base_columns(&self.base),
3353 );
3354 // Full-text index maintenance: update tokens for this field if indexed.
3355 if self.fulltext.field_indexed(field) {
3356 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3357 if sym == u32::MAX {
3358 None
3359 } else {
3360 self.syms.resolve(sym)
3361 }
3362 });
3363 if let Some(label) = label_opt {
3364 if self.fulltext.is_enabled(label, field) {
3365 self.fulltext.remove_node_field(id, field);
3366 self.fulltext.add_tokens(id, field, value);
3367 }
3368 }
3369 }
3370 // Property (equality) index maintenance: re-key this node's value.
3371 if self.prop_index.field_indexed(field) {
3372 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3373 if sym == u32::MAX {
3374 None
3375 } else {
3376 self.syms.resolve(sym)
3377 }
3378 });
3379 if let Some(label) = label_opt {
3380 self.prop_index.set(label, field, id, value);
3381 }
3382 }
3383 }
3384 WalRecord::Intern { id, text } => {
3385 if let Some(existing) = self.syms.get(text) {
3386 if existing != *id {
3387 return Err(GraphError::Corrupt {
3388 detail: format!(
3389 "wal intern mismatch for {text:?}: have {existing}, record {id}"
3390 ),
3391 });
3392 }
3393 } else {
3394 let got = self.syms.intern(text);
3395 if got != *id {
3396 return Err(GraphError::Corrupt {
3397 detail: format!(
3398 "wal intern assigned {got} for {text:?}, record wanted {id}"
3399 ),
3400 });
3401 }
3402 }
3403 }
3404 WalRecord::InsertNodeId { label, key, props } => {
3405 let id = self.ids.try_insert(key)?;
3406 if self.labels.len() <= id as usize {
3407 self.labels.resize(id as usize + 1, u32::MAX);
3408 }
3409 self.labels[id as usize] = *label;
3410 let label_str = self
3411 .syms
3412 .resolve(*label)
3413 .ok_or_else(|| GraphError::Corrupt {
3414 detail: format!("wal InsertNodeId unknown label intern {label}"),
3415 })?
3416 .to_string();
3417 let mut ns_name = NS_DEFAULT.to_string();
3418 for (field_sym, value) in props {
3419 let field =
3420 self.syms
3421 .resolve(*field_sym)
3422 .ok_or_else(|| GraphError::Corrupt {
3423 detail: format!(
3424 "wal InsertNodeId unknown field intern {field_sym}"
3425 ),
3426 })?;
3427 if field == NS_PROP {
3428 ns_name = namespace_of_value(Some(value)).to_string();
3429 }
3430 self.props.set(id, field, value.clone());
3431 }
3432 self.set_node_ns(id, &ns_name);
3433 self.view_store
3434 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3435 let cursor = self.engine.pending_delta_count();
3436 let mut eng = std::mem::take(&mut self.engine);
3437 {
3438 let mut gm = make_graph_mut(
3439 &self.ids,
3440 &mut self.syms,
3441 &self.labels,
3442 build_props_view(&self.props, &self.base),
3443 &mut self.topo,
3444 &self.base,
3445 &mut self.edge_props,
3446 );
3447 eng.on_node_changed(id, None, &mut gm);
3448 }
3449 self.engine = eng;
3450 if !self.view_store.is_empty() {
3451 #[cfg(test)]
3452 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3453 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3454 for d in &new_deltas {
3455 self.view_store.on_edge_changed(
3456 d.etype_sym,
3457 d.src_id,
3458 d.dst_id,
3459 d.fired,
3460 &mut self.props,
3461 &build_topo_view(&self.topo, &self.base),
3462 &self.ids,
3463 &self.syms,
3464 &self.labels,
3465 base_columns(&self.base),
3466 );
3467 }
3468 }
3469 if self.fulltext.has_label(&label_str) {
3470 for (field_sym, value) in props {
3471 let Some(field) = self.syms.resolve(*field_sym) else {
3472 continue;
3473 };
3474 if self.fulltext.is_enabled(&label_str, field) {
3475 self.fulltext.add_tokens(id, field, value);
3476 }
3477 }
3478 }
3479 if self.prop_index.has_label(&label_str) {
3480 for (field_sym, value) in props {
3481 let Some(field) = self.syms.resolve(*field_sym) else {
3482 continue;
3483 };
3484 self.prop_index.set(&label_str, field, id, value);
3485 }
3486 }
3487 }
3488 WalRecord::InsertEdgeId { etype, src, dst } => {
3489 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3490 // already be tombstoned. Skip rather than attaching edges to
3491 // dead ids (DeleteNode keys the live re-insert, not the old id).
3492 if self.ids.is_tombstoned(*src)
3493 || self.ids.is_tombstoned(*dst)
3494 || self.ids.key_of(*src).is_none()
3495 || self.ids.key_of(*dst).is_none()
3496 {
3497 return Ok(());
3498 }
3499 // Skip if already visible in the merged view (same idempotency
3500 // guard as InsertEdge above: prevents double-counting when
3501 // pre-snapshot WAL records are replayed over a V8 base).
3502 if self.base.is_some()
3503 && self
3504 .topo_view()
3505 .neighbors(*etype, Direction::Out, *src)
3506 .contains(dst)
3507 {
3508 return Ok(());
3509 }
3510 self.topo.add_edge(*etype, *src, *dst);
3511 self.view_store.on_edge_changed(
3512 *etype,
3513 *src,
3514 *dst,
3515 true,
3516 &mut self.props,
3517 &build_topo_view(&self.topo, &self.base),
3518 &self.ids,
3519 &self.syms,
3520 &self.labels,
3521 base_columns(&self.base),
3522 );
3523 // Rule engine: via-hop rules fire when user via-edges are inserted.
3524 // Resolve etype back to string so on_edge_changed can match rules by name.
3525 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3526 let cursor = self.engine.pending_delta_count();
3527 let mut eng = std::mem::take(&mut self.engine);
3528 {
3529 let mut gm = make_graph_mut(
3530 &self.ids,
3531 &mut self.syms,
3532 &self.labels,
3533 build_props_view(&self.props, &self.base),
3534 &mut self.topo,
3535 &self.base,
3536 &mut self.edge_props,
3537 );
3538 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3539 }
3540 self.engine = eng;
3541 if !self.view_store.is_empty() {
3542 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3543 for d in &new_deltas {
3544 self.view_store.on_edge_changed(
3545 d.etype_sym,
3546 d.src_id,
3547 d.dst_id,
3548 d.fired,
3549 &mut self.props,
3550 &build_topo_view(&self.topo, &self.base),
3551 &self.ids,
3552 &self.syms,
3553 &self.labels,
3554 base_columns(&self.base),
3555 );
3556 }
3557 }
3558 }
3559 }
3560 WalRecord::SetPropId { id, field, value } => {
3561 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3562 return Ok(());
3563 }
3564 let field_str = self
3565 .syms
3566 .resolve(*field)
3567 .ok_or_else(|| GraphError::Corrupt {
3568 detail: format!("wal SetPropId unknown field intern {field}"),
3569 })?
3570 .to_string();
3571 let old_value = build_props_view(&self.props, &self.base)
3572 .get(*id, &field_str)
3573 .map(|vr| vr.into_value());
3574 self.props.set(*id, &field_str, value.clone());
3575 let cursor = self.engine.pending_delta_count();
3576 let mut eng = std::mem::take(&mut self.engine);
3577 {
3578 let mut gm = make_graph_mut(
3579 &self.ids,
3580 &mut self.syms,
3581 &self.labels,
3582 build_props_view(&self.props, &self.base),
3583 &mut self.topo,
3584 &self.base,
3585 &mut self.edge_props,
3586 );
3587 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3588 }
3589 self.engine = eng;
3590 if !self.view_store.is_empty() {
3591 #[cfg(test)]
3592 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3593 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3594 for d in &new_deltas {
3595 self.view_store.on_edge_changed(
3596 d.etype_sym,
3597 d.src_id,
3598 d.dst_id,
3599 d.fired,
3600 &mut self.props,
3601 &build_topo_view(&self.topo, &self.base),
3602 &self.ids,
3603 &self.syms,
3604 &self.labels,
3605 base_columns(&self.base),
3606 );
3607 }
3608 }
3609 self.view_store.on_prop_changed(
3610 *id,
3611 &field_str,
3612 &mut self.props,
3613 &build_topo_view(&self.topo, &self.base),
3614 &self.ids,
3615 &self.syms,
3616 &self.labels,
3617 base_columns(&self.base),
3618 );
3619 if self.fulltext.field_indexed(&field_str) {
3620 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3621 if sym == u32::MAX {
3622 None
3623 } else {
3624 self.syms.resolve(sym)
3625 }
3626 });
3627 if let Some(label) = label_opt {
3628 if self.fulltext.is_enabled(label, &field_str) {
3629 self.fulltext.remove_node_field(*id, &field_str);
3630 self.fulltext.add_tokens(*id, &field_str, value);
3631 }
3632 }
3633 }
3634 if self.prop_index.field_indexed(&field_str) {
3635 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3636 if sym == u32::MAX {
3637 None
3638 } else {
3639 self.syms.resolve(sym)
3640 }
3641 });
3642 if let Some(label) = label_opt {
3643 self.prop_index.set(label, &field_str, *id, value);
3644 }
3645 }
3646 }
3647 WalRecord::CreateRule { def_bytes } => {
3648 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3649 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3650 })?;
3651 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3652 // so the engine already has it; silently skip to avoid a spurious
3653 // RuleInvalid error in the crash window between snapshot write and WAL
3654 // truncation.
3655 if self.engine.rules().any(|r| r.name == def.name) {
3656 return Ok(());
3657 }
3658 let cursor = self.engine.pending_delta_count();
3659 let mut eng = std::mem::take(&mut self.engine);
3660 let result = {
3661 let mut gm = make_graph_mut(
3662 &self.ids,
3663 &mut self.syms,
3664 &self.labels,
3665 build_props_view(&self.props, &self.base),
3666 &mut self.topo,
3667 &self.base,
3668 &mut self.edge_props,
3669 );
3670 eng.create_rule(def, &mut gm)
3671 };
3672 self.engine = eng;
3673 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3674 // Derived-edge fires from backfill → view updates.
3675 // Fast path: skip O(edge_count) allocation when no views exist.
3676 if !self.view_store.is_empty() {
3677 #[cfg(test)]
3678 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3679 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3680 for d in &new_deltas {
3681 self.view_store.on_edge_changed(
3682 d.etype_sym,
3683 d.src_id,
3684 d.dst_id,
3685 d.fired,
3686 &mut self.props,
3687 &build_topo_view(&self.topo, &self.base),
3688 &self.ids,
3689 &self.syms,
3690 &self.labels,
3691 base_columns(&self.base),
3692 );
3693 }
3694 }
3695 }
3696 WalRecord::DeleteRule { name } => {
3697 // Replay-over-snapshot idempotency: the snapshot already captured the
3698 // post-delete state so the rule is absent; silently skip to avoid a
3699 // spurious RuleNotFound error in the crash window between snapshot write
3700 // and WAL truncation.
3701 if !self.engine.rules().any(|r| r.name == *name) {
3702 return Ok(());
3703 }
3704 let cursor = self.engine.pending_delta_count();
3705 let mut eng = std::mem::take(&mut self.engine);
3706 let result = {
3707 let mut gm = make_graph_mut(
3708 &self.ids,
3709 &mut self.syms,
3710 &self.labels,
3711 build_props_view(&self.props, &self.base),
3712 &mut self.topo,
3713 &self.base,
3714 &mut self.edge_props,
3715 );
3716 eng.delete_rule(name, &mut gm)
3717 };
3718 self.engine = eng;
3719 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3720 // Derived-edge retractions → view updates.
3721 if !self.view_store.is_empty() {
3722 #[cfg(test)]
3723 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3724 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3725 for d in &new_deltas {
3726 self.view_store.on_edge_changed(
3727 d.etype_sym,
3728 d.src_id,
3729 d.dst_id,
3730 d.fired,
3731 &mut self.props,
3732 &build_topo_view(&self.topo, &self.base),
3733 &self.ids,
3734 &self.syms,
3735 &self.labels,
3736 base_columns(&self.base),
3737 );
3738 }
3739 }
3740 }
3741 WalRecord::RemoveProp { key, field } => {
3742 // Recovery-safe: unknown key or already-absent field is a
3743 // clean no-op. Crash-window replay over a snapshot that
3744 // already applied this record must not Err.
3745 let Some(id) = self.ids.get(key) else {
3746 return Ok(());
3747 };
3748 // Read old value through the seam for rule retraction.
3749 let old = build_props_view(&self.props, &self.base)
3750 .get(id, field)
3751 .map(|vr| vr.into_value());
3752 self.props.remove(id, field);
3753 // If the base still supplies the value after the overlay removal,
3754 // record a tombstone so ColumnsView::get does not resurrect it.
3755 // This covers both the base-only case AND the both-resident case:
3756 // base-only (in_overlay=false): old prop was only in base, remove
3757 // is a no-op on overlay, base still visible → tombstone needed.
3758 // both-resident (in_overlay=true): overlay had v2, base has v1;
3759 // removing overlay uncovers v1 → tombstone needed.
3760 // Idempotent on double-replay: second pass sees the tombstone →
3761 // get() returns None → condition is false → no duplicate tombstone.
3762 if build_props_view(&self.props, &self.base)
3763 .get(id, field)
3764 .is_some()
3765 {
3766 self.props.record_prop_tombstone(id, field);
3767 }
3768 let cursor = self.engine.pending_delta_count();
3769 let mut eng = std::mem::take(&mut self.engine);
3770 {
3771 let mut gm = make_graph_mut(
3772 &self.ids,
3773 &mut self.syms,
3774 &self.labels,
3775 build_props_view(&self.props, &self.base),
3776 &mut self.topo,
3777 &self.base,
3778 &mut self.edge_props,
3779 );
3780 eng.on_node_changed(id, Some((field, old)), &mut gm);
3781 }
3782 self.engine = eng;
3783 // Derived-edge deltas → view updates.
3784 if !self.view_store.is_empty() {
3785 #[cfg(test)]
3786 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3787 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3788 for d in &new_deltas {
3789 self.view_store.on_edge_changed(
3790 d.etype_sym,
3791 d.src_id,
3792 d.dst_id,
3793 d.fired,
3794 &mut self.props,
3795 &build_topo_view(&self.topo, &self.base),
3796 &self.ids,
3797 &self.syms,
3798 &self.labels,
3799 base_columns(&self.base),
3800 );
3801 }
3802 }
3803 // Neighbor-aggregate views that read `field` must also update.
3804 self.view_store.on_prop_changed(
3805 id,
3806 field,
3807 &mut self.props,
3808 &build_topo_view(&self.topo, &self.base),
3809 &self.ids,
3810 &self.syms,
3811 &self.labels,
3812 base_columns(&self.base),
3813 );
3814 // Full-text index maintenance: remove tokens for this field.
3815 if self.fulltext.field_indexed(field) {
3816 self.fulltext.remove_node_field(id, field);
3817 }
3818 // Property (equality) index maintenance: drop this node's entry.
3819 if self.prop_index.field_indexed(field) {
3820 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3821 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3822 }) {
3823 self.prop_index.remove_node(label, field, id);
3824 }
3825 }
3826 }
3827 WalRecord::DeleteEdge {
3828 edge_type,
3829 src_key,
3830 dst_key,
3831 } => {
3832 // Recovery-safe: unknown keys, unknown etype, or already-
3833 // absent edge is a clean no-op (remove_edge returns false).
3834 let Some(src) = self.ids.get(src_key) else {
3835 return Ok(());
3836 };
3837 let Some(dst) = self.ids.get(dst_key) else {
3838 return Ok(());
3839 };
3840 let Some(etype) = self.syms.get(edge_type) else {
3841 return Ok(());
3842 };
3843 // I3: phantom-tombstone guard. When a V8 base is present, a
3844 // DeleteEdge WAL record for an edge that was already absorbed into
3845 // the new base (i.e. neither in overlay nor in base) must be skipped.
3846 // Without this guard, remove_edge records a tombstone for an edge
3847 // that no longer exists, incorrectly understating edge_count.
3848 if self.base.is_some()
3849 && !self
3850 .topo_view()
3851 .neighbors(etype, core_storage::topology::Direction::Out, src)
3852 .contains(&dst)
3853 {
3854 return Ok(());
3855 }
3856 self.topo.remove_edge(etype, src, dst);
3857 self.edge_props.remove_edge(etype, src, dst);
3858 // View maintenance for manual edge delete (topo already updated above).
3859 self.view_store.on_edge_changed(
3860 etype,
3861 src,
3862 dst,
3863 false,
3864 &mut self.props,
3865 &build_topo_view(&self.topo, &self.base),
3866 &self.ids,
3867 &self.syms,
3868 &self.labels,
3869 base_columns(&self.base),
3870 );
3871 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3872 let cursor = self.engine.pending_delta_count();
3873 let mut eng = std::mem::take(&mut self.engine);
3874 {
3875 let mut gm = make_graph_mut(
3876 &self.ids,
3877 &mut self.syms,
3878 &self.labels,
3879 build_props_view(&self.props, &self.base),
3880 &mut self.topo,
3881 &self.base,
3882 &mut self.edge_props,
3883 );
3884 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3885 }
3886 self.engine = eng;
3887 if !self.view_store.is_empty() {
3888 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3889 for d in &new_deltas {
3890 self.view_store.on_edge_changed(
3891 d.etype_sym,
3892 d.src_id,
3893 d.dst_id,
3894 d.fired,
3895 &mut self.props,
3896 &build_topo_view(&self.topo, &self.base),
3897 &self.ids,
3898 &self.syms,
3899 &self.labels,
3900 base_columns(&self.base),
3901 );
3902 }
3903 }
3904 }
3905 WalRecord::DeleteNode { key } => {
3906 // Recovery-safe: already-tombstoned / unknown key is a clean
3907 // no-op. Crash-window replay over a snapshot that already
3908 // applied this record cannot recover the retired id from the
3909 // key (`IdMap::get` is None), so every subsequent step is
3910 // skipped. Each step is independently idempotent if invoked
3911 // twice on a still-live id: retraction is a no-op on empty
3912 // provenance, `remove_edge` returns false, `remove_all` is a
3913 // no-op, `ids.delete` returns None, label sentinel is sticky.
3914 let Some(n) = self.ids.get(key) else {
3915 return Ok(());
3916 };
3917
3918 // (1) Retract derived edges + de-index while props/labels live.
3919 let cursor = self.engine.pending_delta_count();
3920 let mut eng = std::mem::take(&mut self.engine);
3921 {
3922 let mut gm = make_graph_mut(
3923 &self.ids,
3924 &mut self.syms,
3925 &self.labels,
3926 build_props_view(&self.props, &self.base),
3927 &mut self.topo,
3928 &self.base,
3929 &mut self.edge_props,
3930 );
3931 eng.on_node_removed(n, &mut gm);
3932 }
3933 self.engine = eng;
3934 // Derived-edge retractions → view updates for neighbors.
3935 if !self.view_store.is_empty() {
3936 #[cfg(test)]
3937 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3938 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3939 for d in &new_deltas {
3940 self.view_store.on_edge_changed(
3941 d.etype_sym,
3942 d.src_id,
3943 d.dst_id,
3944 d.fired,
3945 &mut self.props,
3946 &build_topo_view(&self.topo, &self.base),
3947 &self.ids,
3948 &self.syms,
3949 &self.labels,
3950 base_columns(&self.base),
3951 );
3952 }
3953 }
3954
3955 // (2) Sweep ALL remaining edges incident to n, both directions,
3956 // every etype. This cascade is intentionally mask-independent:
3957 // topology integrity requires removing every edge touching the
3958 // deleted node regardless of the caller's visibility scope.
3959 // (The mask limits which nodes a role's read phase can return;
3960 // the WAL delete always executes with full storage authority.)
3961 // Collect then remove so neighbor slices stay valid during
3962 // iteration. Remove from topo first, then call view maintenance
3963 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3964 let etypes: Vec<u32> = self.topo.etypes().collect();
3965 let mut doomed = Vec::new();
3966 for et in &etypes {
3967 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3968 doomed.push((*et, n, dst));
3969 }
3970 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3971 doomed.push((*et, src, n));
3972 }
3973 }
3974 for (et, s, d) in doomed {
3975 self.topo.remove_edge(et, s, d);
3976 self.edge_props.remove_edge(et, s, d);
3977 // View maintenance: n's own view values will be cleared by
3978 // remove_all below; only update surviving neighbors.
3979 self.view_store.on_edge_changed(
3980 et,
3981 s,
3982 d,
3983 false,
3984 &mut self.props,
3985 &build_topo_view(&self.topo, &self.base),
3986 &self.ids,
3987 &self.syms,
3988 &self.labels,
3989 base_columns(&self.base),
3990 );
3991 }
3992
3993 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3994 self.props.remove_all(n);
3995 // Full-text index maintenance: remove all tokens for this node.
3996 self.fulltext.remove_node(n);
3997 // Property (equality) index maintenance: drop all entries for n.
3998 self.prop_index.remove_node_all(n);
3999
4000 // (4) Retire the dense id and stamp the label sentinel.
4001 self.ids.delete(key);
4002 if let Some(slot) = self.labels.get_mut(n as usize) {
4003 *slot = u32::MAX;
4004 }
4005 }
4006 WalRecord::Batch(inner) => {
4007 // Apply each inner record in order through the same apply path.
4008 // Inner records are validated free of nested Batch by encode_record.
4009 for rec in inner {
4010 self.apply(rec)?;
4011 }
4012 }
4013 WalRecord::RebuildRule { name } => {
4014 // Replay-over-snapshot idempotency: the snapshot may already
4015 // reflect a later delete_rule, so the rule is absent; skip.
4016 if !self.engine.rules().any(|r| r.name == *name) {
4017 return Ok(());
4018 }
4019 let cursor = self.engine.pending_delta_count();
4020 let mut eng = std::mem::take(&mut self.engine);
4021 let result = {
4022 let mut gm = make_graph_mut(
4023 &self.ids,
4024 &mut self.syms,
4025 &self.labels,
4026 build_props_view(&self.props, &self.base),
4027 &mut self.topo,
4028 &self.base,
4029 &mut self.edge_props,
4030 );
4031 eng.rebuild(name, &mut gm)
4032 };
4033 self.engine = eng;
4034 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4035 // Derived-edge delta changes → view updates.
4036 if !self.view_store.is_empty() {
4037 #[cfg(test)]
4038 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4039 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4040 for d in &new_deltas {
4041 self.view_store.on_edge_changed(
4042 d.etype_sym,
4043 d.src_id,
4044 d.dst_id,
4045 d.fired,
4046 &mut self.props,
4047 &build_topo_view(&self.topo, &self.base),
4048 &self.ids,
4049 &self.syms,
4050 &self.labels,
4051 base_columns(&self.base),
4052 );
4053 }
4054 }
4055 }
4056 WalRecord::CreateView { def_bytes } => {
4057 let def: ViewDef =
4058 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4059 detail: format!("CreateView def_bytes deserialize failed: {e}"),
4060 })?;
4061 // Replay-over-snapshot idempotency: view already present → skip.
4062 if self.view_store.has_view(&def.name) {
4063 return Ok(());
4064 }
4065 self.view_store
4066 .create_view(
4067 def,
4068 &mut self.props,
4069 &build_topo_view(&self.topo, &self.base),
4070 &self.ids,
4071 &self.syms,
4072 &self.labels,
4073 )
4074 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4075 }
4076 WalRecord::DeleteView { name } => {
4077 // Replay-over-snapshot idempotency: view already absent → skip.
4078 if !self.view_store.has_view(name) {
4079 return Ok(());
4080 }
4081 self.view_store
4082 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
4083 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4084 }
4085 WalRecord::EnableFulltext { label, field } => {
4086 // Replay-over-snapshot idempotency: already enabled → skip.
4087 if self.fulltext.is_enabled(label, field) {
4088 return Ok(());
4089 }
4090 self.fulltext.enable(label, field);
4091 // Backfill: index all live nodes of this label that have the field.
4092 let n = self.ids.len() as u32;
4093 for id in 0..n {
4094 let Some(&sym) = self.labels.get(id as usize) else {
4095 continue;
4096 };
4097 if sym == u32::MAX {
4098 continue; // tombstoned
4099 }
4100 let Some(lbl) = self.syms.resolve(sym) else {
4101 continue;
4102 };
4103 if lbl != label {
4104 continue;
4105 }
4106 if let Some(value) = build_props_view(&self.props, &self.base)
4107 .get(id, field)
4108 .map(|vr| vr.into_value())
4109 {
4110 self.fulltext.add_tokens(id, field, &value);
4111 }
4112 }
4113 }
4114 WalRecord::DisableFulltext { label, field } => {
4115 // Replay-over-snapshot idempotency: already disabled → skip.
4116 if !self.fulltext.is_enabled(label, field) {
4117 return Ok(());
4118 }
4119 // If another label still indexes this field, the postings column
4120 // is kept — but it must not contain node_ids from the now-disabled
4121 // label. Remove them before calling disable() so the field_indexed
4122 // guard inside disable() sees the correct post-removal state.
4123 if self.fulltext.field_indexed_by_other(label, field) {
4124 if let Some(label_sym) = self.syms.get(label) {
4125 for (node_id, &lsym) in self.labels.iter().enumerate() {
4126 if lsym == label_sym {
4127 self.fulltext.remove_node_field(node_id as u32, field);
4128 }
4129 }
4130 }
4131 }
4132 self.fulltext.disable(label, field);
4133 }
4134 WalRecord::EnableIndex { label, field } => {
4135 // Replay-over-snapshot idempotency: already enabled → skip.
4136 if self.prop_index.is_enabled(label, field) {
4137 return Ok(());
4138 }
4139 self.prop_index.enable(label, field);
4140 // Backfill: index all live nodes of this label that have the field.
4141 let n = self.ids.len() as u32;
4142 for id in 0..n {
4143 let Some(&sym) = self.labels.get(id as usize) else {
4144 continue;
4145 };
4146 if sym == u32::MAX {
4147 continue; // tombstoned
4148 }
4149 let Some(lbl) = self.syms.resolve(sym) else {
4150 continue;
4151 };
4152 if lbl != label {
4153 continue;
4154 }
4155 if let Some(value) = build_props_view(&self.props, &self.base)
4156 .get(id, field)
4157 .map(|vr| vr.into_value())
4158 {
4159 self.prop_index.set(label, field, id, &value);
4160 }
4161 }
4162 }
4163 WalRecord::DisableIndex { label, field } => {
4164 self.prop_index.disable(label, field);
4165 }
4166 // History markers carry no replay state — rules re-derive edges
4167 // deterministically on open/replay. Skip unconditionally.
4168 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4169 // ── rename_node ──────────────────────────────────────────────────
4170 WalRecord::RenameNode { old_key, new_key } => {
4171 // Recovery-safe: if old_key is already gone (key was renamed
4172 // by a snapshot or a prior replay frame), skip cleanly.
4173 if self.ids.get(old_key).is_none() {
4174 return Ok(());
4175 }
4176 // The rename only updates the key-table; the dense id, all
4177 // topo edges, props, labels, and rule state are id-indexed and
4178 // require no change.
4179 self.ids
4180 .rename(old_key, new_key)
4181 .map_err(|e| GraphError::Corrupt {
4182 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4183 })?;
4184 }
4185 }
4186 Ok(())
4187 }
4188
4189 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4190 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4191 /// idempotent when the string is already bound. Always emit: after
4192 /// `snapshot()` the WAL is truncated and live intern is not on disk.
4193 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4194 let id = if let Some(id) = self.syms.get(s) {
4195 id
4196 } else {
4197 self.syms.intern(s)
4198 };
4199 (
4200 id,
4201 WalRecord::Intern {
4202 id,
4203 text: s.to_string(),
4204 },
4205 )
4206 }
4207
4208 /// Rewrite user-facing records into dense-id records. On `Err`, no live
4209 /// state is left mutated: speculative interns made while building the
4210 /// output are rolled back, so a later successful mutation cannot log an
4211 /// `Intern` record whose id replay would never reproduce.
4212 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4213 let syms_checkpoint = self.syms.len();
4214 let result = self.rewrite_wal_dense_inner(recs);
4215 if result.is_err() {
4216 self.syms.truncate(syms_checkpoint);
4217 }
4218 result
4219 }
4220
4221 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4222 let mut out = Vec::with_capacity(recs.len());
4223 // Node ids allocated by later apply(InsertNodeId) in this same batch.
4224 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4225 // Namespace of each node inserted earlier in this same frame, so a SET
4226 // on a node this frame created is measured against the namespace it was
4227 // created in rather than against the store, where it does not exist yet.
4228 let mut pending_ns: std::collections::HashMap<String, String> =
4229 std::collections::HashMap::new();
4230 let mut interned = std::collections::HashSet::<u32>::new();
4231 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4232 detail: "id space exhausted".into(),
4233 })?;
4234 let lookup = |ids: &IdMap,
4235 pending: &std::collections::HashMap<String, u32>,
4236 key: &str|
4237 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4238 for rec in recs {
4239 match rec {
4240 WalRecord::InsertNode { label, key, props } => {
4241 // Namespace validation and normalisation, on the one seam
4242 // every user-visible node insert passes through: insert_node,
4243 // a batch, ingest, Cypher CREATE and MERGE all arrive here
4244 // before the WAL append, and replay never does.
4245 let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4246 pending_ns.insert(key.clone(), ns_name);
4247 let (label_id, intern) = self.intern_wal(&label);
4248 if interned.insert(label_id) {
4249 out.push(intern);
4250 }
4251 let mut props_id = Vec::with_capacity(props.len());
4252 for (field, value) in props {
4253 let (field_id, intern) = self.intern_wal(&field);
4254 if interned.insert(field_id) {
4255 out.push(intern);
4256 }
4257 props_id.push((field_id, value));
4258 }
4259 if lookup(&self.ids, &pending, &key).is_none() {
4260 pending.insert(key.clone(), next);
4261 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4262 detail: "id space exhausted".into(),
4263 })?;
4264 }
4265 out.push(WalRecord::InsertNodeId {
4266 label: label_id,
4267 key,
4268 props: props_id,
4269 });
4270 }
4271 WalRecord::SetProp { key, field, value } => {
4272 // A namespace is set at insert and fixed after: the write is
4273 // refused when it would move the node, and dropped when it
4274 // names the namespace the node is already in. Checked here
4275 // so set_prop, a batch, Cypher SET/MERGE and every upsert
4276 // that merges props get the same answer.
4277 if field == NS_PROP {
4278 let Value::Str(ref to) = value else {
4279 return Err(GraphError::RuleInvalid {
4280 detail: format!(
4281 "node {key}: {NS_PROP} must be a string naming a namespace, \
4282 got {value:?}"
4283 ),
4284 });
4285 };
4286 let from = pending_ns
4287 .get(&key)
4288 .cloned()
4289 .or_else(|| self.namespace_of(&key))
4290 .unwrap_or_else(|| NS_DEFAULT.to_string());
4291 let to = to.clone();
4292 if to != from {
4293 return Err(GraphError::NamespaceImmutable {
4294 key: key.clone(),
4295 from,
4296 to,
4297 });
4298 }
4299 continue;
4300 }
4301 let id =
4302 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4303 detail: format!("dense WAL rewrite missing key {key}"),
4304 })?;
4305 let (field_id, intern) = self.intern_wal(&field);
4306 if interned.insert(field_id) {
4307 out.push(intern);
4308 }
4309 out.push(WalRecord::SetPropId {
4310 id,
4311 field: field_id,
4312 value,
4313 });
4314 }
4315 WalRecord::InsertEdge {
4316 edge_type,
4317 src_key,
4318 dst_key,
4319 } => {
4320 let (etype, intern) = self.intern_wal(&edge_type);
4321 if interned.insert(etype) {
4322 out.push(intern);
4323 }
4324 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4325 GraphError::Corrupt {
4326 detail: format!("dense WAL rewrite missing src {src_key}"),
4327 }
4328 })?;
4329 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4330 GraphError::Corrupt {
4331 detail: format!("dense WAL rewrite missing dst {dst_key}"),
4332 }
4333 })?;
4334 out.push(WalRecord::InsertEdgeId { etype, src, dst });
4335 }
4336 WalRecord::RenameNode {
4337 ref old_key,
4338 ref new_key,
4339 } => {
4340 // Track the rename in `pending` so subsequent InsertEdge /
4341 // SetProp records in this batch can resolve the new key.
4342 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4343 GraphError::Corrupt {
4344 detail: format!(
4345 "dense WAL rewrite: RenameNode old key {old_key} not found"
4346 ),
4347 }
4348 })?;
4349 pending.remove(old_key.as_str());
4350 pending.insert(new_key.clone(), id);
4351 out.push(rec);
4352 }
4353 // # Symbol-order invariant (load-bearing)
4354 //
4355 // Write-time and replay-time symbol assignment must agree: every
4356 // symbol in a `Batch` frame has to receive the same dense id when
4357 // the frame's records are replayed in order as it received when
4358 // the frame was written.
4359 //
4360 // A rule's backfill interns its `edge_type` lazily
4361 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4362 // site), and that backfill runs from `apply` — during the
4363 // `CreateRule` record itself, and again from any later
4364 // `InsertNodeId` in the same frame that makes the rule fire. At
4365 // write time the whole batch is rewritten before any of it is
4366 // applied, so a later `InsertEdge` in the same batch would win the
4367 // lower id for its edge type; on replay the rule's lazy intern
4368 // gets there first and steals it, and the `Intern` record fails at
4369 // the `wal intern assigned …` check in `apply`.
4370 //
4371 // Pre-interning the rule's `edge_type` here, and emitting its
4372 // `Intern` record ahead of the `CreateRule` record, makes both
4373 // orders identical. `weight_prop` needs no pre-intern:
4374 // `EdgeProps::set` keys props by `String`, never through the
4375 // interner. `via_edge` needs none either: via-hop rules resolve it
4376 // with `syms.get` and skip when it is absent.
4377 //
4378 // `RebuildRule` and `DeleteRule` need no such handling here:
4379 // `RebuildRule` has no `BatchOp` variant, so it never appears
4380 // inside a `Batch` today — it is only ever issued as its own
4381 // standalone commit (`rebuild_rule`, or the auto-rebuild path
4382 // that logs it as a second commit after the triggering op).
4383 // `DeleteRule` does have a `BatchOp` variant and can appear
4384 // inside a `Batch`, but it carries only a rule `name` — no
4385 // `edge_type` or other symbol that needs pre-interning — so
4386 // only `CreateRule` needs this arm.
4387 WalRecord::CreateRule { ref def_bytes } => {
4388 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4389 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4390 })?;
4391 let (etype, intern) = self.intern_wal(&def.edge_type);
4392 if interned.insert(etype) {
4393 out.push(intern);
4394 }
4395 out.push(rec);
4396 }
4397 other => out.push(other),
4398 }
4399 }
4400 Ok(out)
4401 }
4402
4403 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4404 let recs = self.rewrite_wal_dense(recs)?;
4405 match recs.len() {
4406 0 => Ok(()),
4407 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4408 _ => self.log_then_apply(WalRecord::Batch(recs)),
4409 }
4410 }
4411
4412 /// Durable write, then notify the event sink. Replay (`apply` during
4413 /// `open`) never enters this function, so it is the replay-silent seam.
4414 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4415 self.log_then_apply_with(rec, None, self.fsync)
4416 }
4417
4418 /// Whether this frame must fsync under `policy`.
4419 ///
4420 /// Batched contract: user-visible batches (>1 mutation) fsync; single
4421 /// mutations do not. The dense rewrite wraps a single mutation in a
4422 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4423 /// from the count — removing that filter would make every single-op write
4424 /// fsync under Batched (or, if the threshold were raised instead, skip a
4425 /// needed fsync for real two-op batches).
4426 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4427 match policy {
4428 FsyncPolicy::Relaxed => false,
4429 FsyncPolicy::Strict => true,
4430 FsyncPolicy::Batched => match rec {
4431 // Intern + one mutation is the single-op rewrite, not a user batch.
4432 WalRecord::Batch(inner) => {
4433 inner
4434 .iter()
4435 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4436 .count()
4437 > 1
4438 }
4439 _ => false,
4440 },
4441 }
4442 }
4443
4444 /// # Apply-infallibility invariant (load-bearing)
4445 ///
4446 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4447 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4448 /// the full frame while in-memory state would reflect only the ops before
4449 /// the failure. On reopen, WAL replay would then apply the entire batch —
4450 /// diverging permanently from what the pre-crash process had in memory.
4451 ///
4452 /// For `Batch` frames this situation cannot arise because:
4453 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4454 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4455 /// use, with no concurrent mutation between validation exit and apply entry.
4456 /// - Every `apply` arm for a validated op is either infallible by construction
4457 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4458 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4459 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4460 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4461 ///
4462 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4463 /// `Err` for a `Batch` frame, making any future regression immediately visible
4464 /// in tests rather than silently diverging crash-recovery behaviour.
4465 fn log_then_apply_with(
4466 &mut self,
4467 rec: WalRecord,
4468 ingest: Option<(String, usize)>,
4469 policy: FsyncPolicy,
4470 ) -> Result<()> {
4471 // Read-only guard: as-of instances must never write the WAL.
4472 if self.read_only {
4473 return Err(GraphError::ReadOnly);
4474 }
4475 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4476 // partway; in-memory state is ahead of (or out of step with) the
4477 // on-disk WAL, so further mutations would deepen the divergence.
4478 // Reopen the database to recover. Checked before the lock guard: this
4479 // is the more serious condition and the more useful error.
4480 if self.degraded {
4481 return Err(GraphError::Io(std::io::Error::other(
4482 "database degraded after group-commit fsync failure; reopen required",
4483 )));
4484 }
4485 // Cross-process guard: this write scope asked for the store's write
4486 // lock and did not get it. Writing anyway would append frames on top of
4487 // a WAL another process is extending, so refuse instead.
4488 if self.lock_denied {
4489 return Err(GraphError::Busy { holder: None });
4490 }
4491 // Ensure retained provenance bytes are decoded into the live mutable
4492 // fields before any mutation touches self.engine.provenance. This is a
4493 // no-op if provenance was never stored (fresh store) or has already been
4494 // consumed (subsequent mutations). WAL replay calls apply() directly
4495 // and is covered by consume_retained_state_eager before replay.
4496 self.ensure_v8_base_sections_loaded();
4497 self.engine.ensure_provenance_loaded_mut();
4498 // Invariant (I-1): no stale deltas may enter from a previous apply.
4499 // If any engine method ever accumulates deltas before erroring, they would
4500 // contaminate the *next* commit's event stream. This assert fires in debug
4501 // builds, making any future regression visible at the earliest point.
4502 debug_assert_eq!(
4503 self.engine.pending_delta_count(),
4504 0,
4505 "stale engine deltas at log_then_apply_with entry — \
4506 a previous apply arm may have accumulated deltas before erroring; \
4507 the caller must drain_deltas() on any error path before returning"
4508 );
4509 let frame = encode_record(&rec);
4510 self.fs.append(FileId::Wal, &frame)?;
4511 // The cursor advances by exactly the bytes appended: these frames are
4512 // ours and already applied, so a later refresh must not replay them.
4513 self.wal_consumed += frame.len() as u64;
4514 if Self::wal_needs_sync(policy, &rec) {
4515 self.fs.sync(FileId::Wal)?;
4516 }
4517 // Marker writing always needs the engine deltas, but the engine only
4518 // accumulates them when emit_deltas is true (normally gated on subscribers
4519 // or views being present). Enable emission for this apply if it is
4520 // currently off, then restore the original state unconditionally via an
4521 // RAII guard — this prevents a panic in apply() from leaking the flag.
4522 // The same guard resets the engine's transient chaining state. A panic
4523 // unwinding out of a rule hook would otherwise leave `chain_depth`
4524 // non-zero, which makes every later `begin_chain` decide chaining is
4525 // already running and silently switch it off for good.
4526 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4527 impl Drop for RestoreEmitDeltas {
4528 fn drop(&mut self) {
4529 // SAFETY: pointer into self (GraphDb); guard is dropped within
4530 // this frame before log_then_apply_with returns.
4531 unsafe {
4532 (*self.0).set_emit_deltas(self.1);
4533 (*self.0).reset_chain_state();
4534 }
4535 }
4536 }
4537 let original_emit = self.engine.emit_deltas();
4538 if !original_emit {
4539 self.engine.set_emit_deltas(true);
4540 }
4541 // SAFETY: raw pointer into self; guard dropped within this frame.
4542 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4543
4544 let apply_result = self.apply(&rec);
4545 // For Batch frames, post-validation apply must be infallible (see above).
4546 // A debug_assert here catches any future change that makes apply fallible
4547 // before the caller notices via silent WAL/memory divergence.
4548 if matches!(&rec, WalRecord::Batch(_)) {
4549 debug_assert!(
4550 apply_result.is_ok(),
4551 "Batch apply returned Err after successful WAL write — \
4552 the validate-then-apply invariant has been violated; \
4553 see log_then_apply_with invariant doc"
4554 );
4555 }
4556 if apply_result.is_err() {
4557 // Discard any partial deltas accumulated by the failed apply.
4558 // They must not ride the next commit's event stream (I-1).
4559 // _emit_guard restores emit_deltas on drop automatically.
4560 let _ = self.engine.drain_deltas();
4561 let _ = self.engine.take_rebuild_needed();
4562 apply_result?;
4563 }
4564 self.commit_seq += 1;
4565 let seq = self.commit_seq;
4566 // Update per-node last-change map for the committed record.
4567 // Must happen after commit_seq is incremented so the seq is correct.
4568 self.update_last_change_from_rec(&rec, seq);
4569 // Drain engine deltas and distribute to subscribers before the existing
4570 // MutationEvent sink fires — both happen post-fsync, post-apply.
4571 // _emit_guard restores emit_deltas after this line when it drops.
4572 let engine_deltas = self.engine.drain_deltas();
4573
4574 // Append history-marker WAL records for any derived-edge changes so
4575 // that `edge_history` and `was_linked` can surface rule-attributed
4576 // events. Markers are STATE NO-OPS during replay; they are written
4577 // without an additional fsync (the triggering commit's sync already
4578 // happened; the next commit's sync covers these lazily).
4579 if !engine_deltas.is_empty() {
4580 let markers: Vec<WalRecord> = engine_deltas
4581 .iter()
4582 .map(|d| {
4583 if d.fired {
4584 WalRecord::DerivedEdgeAdded {
4585 rule: d.rule.clone(),
4586 edge_type: d.edge_type.clone(),
4587 src_key: d.src_key.clone(),
4588 dst_key: d.dst_key.clone(),
4589 }
4590 } else {
4591 WalRecord::DerivedEdgeRetracted {
4592 rule: d.rule.clone(),
4593 edge_type: d.edge_type.clone(),
4594 src_key: d.src_key.clone(),
4595 dst_key: d.dst_key.clone(),
4596 }
4597 }
4598 })
4599 .collect();
4600 let marker_frame = if markers.len() == 1 {
4601 markers.into_iter().next().unwrap()
4602 } else {
4603 WalRecord::Batch(markers)
4604 };
4605 // Ignore append errors: markers are best-effort history
4606 // annotations. Losing them does not affect state correctness.
4607 // The cursor only advances when the bytes actually landed.
4608 let marker_bytes = encode_record(&marker_frame);
4609 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4610 self.wal_consumed += marker_bytes.len() as u64;
4611 }
4612 }
4613
4614 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4615 // stored as-is (including any nested Batch / Intern records); the
4616 // ReaderSnapshot's apply_one function handles all variants.
4617 {
4618 let derived_inserts = engine_deltas
4619 .iter()
4620 .filter(|d| d.fired)
4621 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4622 .collect();
4623 let derived_deletes = engine_deltas
4624 .iter()
4625 .filter(|d| !d.fired)
4626 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4627 .collect();
4628 let delta = Arc::new(crate::reader::CommitDelta {
4629 records: vec![rec.clone()],
4630 derived_inserts,
4631 derived_deletes,
4632 });
4633 self.delta_tail.push(delta);
4634 self.commits_since_fold += 1;
4635 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4636 self.fold_now();
4637 }
4638 }
4639
4640 if self.defer_events {
4641 // Group-commit drain thread: hold events until after the group
4642 // fsync so subscribers only observe durable data (R2).
4643 self.deferred_events.push(DeferredEvent {
4644 rec: rec.clone(),
4645 engine_deltas,
4646 seq,
4647 ingest,
4648 });
4649 } else {
4650 self.distribute_events(&rec, &engine_deltas, seq);
4651 self.emit_committed(&rec, ingest);
4652 }
4653 // Drift is only known after apply, so auto-rebuild cannot join the
4654 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4655 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4656 // retrigger loop is impossible if the fit succeeded, but we still
4657 // drain the flag so a leftover cannot re-enter.
4658 // One slice of any outstanding vector-index build rides here too, so a
4659 // store that is being written to finishes its build without anyone
4660 // calling `pump_index_build`. A rule that becomes whole joins the same
4661 // RebuildRule loop below.
4662 let mut rebuilds = self.engine.take_rebuild_needed();
4663 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4664 // Not after `CreateRule`: that record's own apply already did the
4665 // rule's first slice, and pumping again here would make one
4666 // `create_rule` call do two slices' work under one lock.
4667 // Nothing pending is the overwhelmingly common case and must cost
4668 // a map lookup, not an engine swap: a store being written to has
4669 // long since populated its indexes, so the `pump_index_build`
4670 // entry point owns the not-yet-populated case on its own.
4671 if !matches!(&rec, WalRecord::CreateRule { .. })
4672 && !self.engine.builds_in_progress().is_empty()
4673 {
4674 rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4675 }
4676 let mut failed = Vec::new();
4677 for name in rebuilds {
4678 if self.engine.rules().any(|r| r.name == name) {
4679 // User op is already durable. A failed second commit must
4680 // not surface as the caller's error.
4681 if let Err(e) =
4682 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4683 {
4684 eprintln!(
4685 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4686 );
4687 failed.push(name);
4688 }
4689 }
4690 }
4691 for name in failed {
4692 self.engine.queue_rebuild_needed(name);
4693 }
4694 }
4695 Ok(())
4696 }
4697
4698 /// Install a post-commit hook. Replaces any previous sink.
4699 ///
4700 /// The sink runs inside `log_then_apply` after a successful
4701 /// durable commit, while the caller still holds `&mut self`. When this
4702 /// database is behind a [`crate::SharedDb`], that means the **write
4703 /// guard is held**. The sink must never call `read` / `write` (or any
4704 /// other method) on the same `SharedDb` — the `RwLock` is not
4705 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4706 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4707 /// Intended examples: `std::sync::mpsc::SyncSender`,
4708 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4709 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4710 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4711 self.event_sink = Some(sink);
4712 }
4713
4714 /// Whether a post-commit event sink is currently installed.
4715 pub fn has_event_sink(&self) -> bool {
4716 self.event_sink.is_some()
4717 }
4718
4719 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4720 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4721 self.fsync = p;
4722 }
4723
4724 /// Return the current WAL fsync cadence.
4725 pub fn fsync_policy(&self) -> FsyncPolicy {
4726 self.fsync
4727 }
4728
4729 // ── Group-commit event deferral ───────────────────────────────────────────
4730
4731 /// Enable or disable deferred event mode.
4732 ///
4733 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4734 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4735 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4736 /// or [`discard_deferred_events`] if the fsync failed and the group must
4737 /// be treated as lost.
4738 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4739 self.defer_events = defer;
4740 }
4741
4742 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4743 /// was set to true. Clears the buffer.
4744 ///
4745 /// Called by the drain thread AFTER a successful group fsync, so
4746 /// subscribers observe only data that is durably on disk.
4747 pub fn flush_deferred_events(&mut self) {
4748 let events = std::mem::take(&mut self.deferred_events);
4749 for de in events {
4750 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4751 self.emit_committed(&de.rec, de.ingest);
4752 }
4753 }
4754
4755 /// Discard all buffered events without firing them.
4756 ///
4757 /// Called by the drain thread when a group fsync fails: the WAL has been
4758 /// truncated back to the pre-group offset, so the committed-but-unsynced
4759 /// ops must not be observable to subscribers.
4760 pub fn discard_deferred_events(&mut self) {
4761 self.deferred_events.clear();
4762 }
4763
4764 // ── Degraded state ────────────────────────────────────────────────────────
4765
4766 /// Mark this database as degraded.
4767 ///
4768 /// Called by the group-commit drain thread after a group fsync failure and
4769 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4770 /// further mutations would deepen the divergence. All subsequent calls to
4771 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4772 pub fn set_degraded(&mut self) {
4773 self.degraded = true;
4774 }
4775
4776 fn emit(&self, ev: MutationEvent) {
4777 if let Some(sink) = &self.event_sink {
4778 sink(ev);
4779 }
4780 }
4781
4782 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4783 match rec {
4784 WalRecord::Batch(inner) => {
4785 for r in inner {
4786 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4787 self.emit(ev);
4788 }
4789 }
4790 match ingest {
4791 Some((label, inserted)) => {
4792 self.emit(MutationEvent::Ingested { label, inserted })
4793 }
4794 None => {
4795 let ops = inner
4796 .iter()
4797 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4798 .count();
4799 if ops > 1 {
4800 self.emit(MutationEvent::BatchApplied { ops });
4801 }
4802 }
4803 }
4804 }
4805 other => {
4806 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4807 self.emit(ev);
4808 }
4809 }
4810 }
4811 }
4812
4813 // -----------------------------------------------------------------------
4814 // Subscription API
4815 // -----------------------------------------------------------------------
4816
4817 /// Distribute post-commit events to all live subscribers.
4818 ///
4819 /// Build a row-key → row-data map from a [`ResultSet`].
4820 ///
4821 /// Each row is serialized to JSON to form its key; a debug fallback is used
4822 /// if serialization fails. Used by both the initial-seed path in
4823 /// [`Self::subscribe_query`] and the per-commit diff path in
4824 /// [`Self::distribute_events`] to keep the two in sync.
4825 fn result_to_row_map(
4826 result: &core_query::ResultSet,
4827 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4828 (0..result.len())
4829 .map(|i| {
4830 let row = result.row(i).to_vec();
4831 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4832 (key, row)
4833 })
4834 .collect()
4835 }
4836
4837 /// Collect the set of label syms touched by a WAL record.
4838 ///
4839 /// Returns `Some(set)` when every record in this commit can be attributed to
4840 /// a known label sym. Returns `None` when the commit must not be skipped:
4841 /// edge records, unresolvable key→label lookups, or any record type not in
4842 /// the explicit handled set.
4843 ///
4844 /// Handled record types and their actions:
4845 /// - `InsertNode` → look up label in interner (fails → None)
4846 /// - `InsertNodeId` → label sym is carried directly
4847 /// - `SetProp` → resolve key→id→label (fails → None)
4848 /// - `DeleteNode` → resolve key→id→label (fails → None)
4849 /// - `Batch` → recurse into every inner record
4850 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4851 /// - everything else → None (conservative)
4852 fn commit_touched_labels(
4853 rec: &WalRecord,
4854 syms: &Interner,
4855 ids: &IdMap,
4856 labels: &[u32],
4857 ) -> Option<BTreeSet<u32>> {
4858 let mut out = BTreeSet::new();
4859 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4860 Some(out)
4861 } else {
4862 None
4863 }
4864 }
4865
4866 fn collect_touched_labels(
4867 rec: &WalRecord,
4868 syms: &Interner,
4869 ids: &IdMap,
4870 labels: &[u32],
4871 out: &mut BTreeSet<u32>,
4872 ) -> bool {
4873 match rec {
4874 // String-key insert: the dense rewrite converts this to
4875 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4876 // records written before the dense path was added.
4877 WalRecord::InsertNode { label, .. } => {
4878 if let Some(sym) = syms.get(label) {
4879 out.insert(sym);
4880 true
4881 } else {
4882 false
4883 }
4884 }
4885 // Dense-id insert (produced by rewrite_wal_dense for every
4886 // insert_node call in the current codebase).
4887 WalRecord::InsertNodeId { label, .. } => {
4888 out.insert(*label);
4889 true
4890 }
4891 // String-key prop set: dense path converts to [Intern, SetPropId].
4892 WalRecord::SetProp { key, .. } => {
4893 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4894 out.insert(sym);
4895 true
4896 } else {
4897 false
4898 }
4899 }
4900 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4901 WalRecord::SetPropId { id, .. } => {
4902 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4903 out.insert(sym);
4904 true
4905 } else {
4906 false
4907 }
4908 }
4909 WalRecord::DeleteNode { key } => {
4910 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4911 out.insert(sym);
4912 true
4913 } else {
4914 false
4915 }
4916 }
4917 WalRecord::Batch(inner) => inner
4918 .iter()
4919 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4920 // Intern is a pure metadata record — it does not touch any node's
4921 // label and is safe to skip for the label-skip predicate.
4922 WalRecord::Intern { .. } => true,
4923 // Edge records: always re-execute (edges can change join results).
4924 WalRecord::InsertEdge { .. }
4925 | WalRecord::DeleteEdge { .. }
4926 | WalRecord::InsertEdgeId { .. } => false,
4927 _ => false,
4928 }
4929 }
4930
4931 /// Resolve a node key to its label sym via the dense id table.
4932 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4933 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4934 let id = ids.get(key)?;
4935 let sym = labels.get(id as usize).copied()?;
4936 (sym != u32::MAX).then_some(sym)
4937 }
4938
4939 /// Distribute post-commit events to all live subscribers.
4940 ///
4941 /// Called from `log_then_apply_with` after apply + fsync, before the
4942 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4943 ///
4944 /// Query subscriptions (subscribe_query) re-execute their plan on every
4945 /// call and diff the result against the previous run. Zero overhead when
4946 /// no query subscriptions are active.
4947 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4948 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4949 return;
4950 }
4951
4952 if !self.subscriptions.is_empty() {
4953 // Build write events from the WAL record.
4954 let write_events: Vec<DbEvent> =
4955 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4956
4957 // Build edge events from engine deltas. Weight is looked up from
4958 // edge_props at distribution time (after apply), so it's always fresh.
4959 let edge_events: Vec<DbEvent> = engine_deltas
4960 .iter()
4961 .map(|d| {
4962 if d.fired {
4963 // The score lives under the rule's declared weight_prop,
4964 // which is not always the literal "weight".
4965 let prop = self
4966 .engine
4967 .rules()
4968 .find(|r| r.name == d.rule)
4969 .and_then(|r| r.weight_prop.as_deref());
4970 let weight = prop.and_then(|p| {
4971 self.edge_props
4972 .get(d.etype_sym, d.src_id, d.dst_id, p)
4973 .and_then(|v| {
4974 if let core_storage::Value::Float(f) = v {
4975 Some(*f)
4976 } else {
4977 None
4978 }
4979 })
4980 });
4981 DbEvent::EdgeFired {
4982 rule: d.rule.clone(),
4983 src_key: d.src_key.clone(),
4984 dst_key: d.dst_key.clone(),
4985 edge_type: d.edge_type.clone(),
4986 weight,
4987 commit_seq: seq,
4988 }
4989 } else {
4990 DbEvent::EdgeRetracted {
4991 rule: d.rule.clone(),
4992 src_key: d.src_key.clone(),
4993 dst_key: d.dst_key.clone(),
4994 edge_type: d.edge_type.clone(),
4995 commit_seq: seq,
4996 }
4997 }
4998 })
4999 .collect();
5000
5001 // Prune dead entries; push matching events to live ones.
5002 self.subscriptions.retain(|entry| {
5003 let Some(inner) = entry.inner.upgrade() else {
5004 return false;
5005 };
5006 for ev in &write_events {
5007 if event_matches(ev, &entry.filter) {
5008 inner.push(ev.clone());
5009 }
5010 }
5011 for ev in &edge_events {
5012 if event_matches(ev, &entry.filter) {
5013 inner.push(ev.clone());
5014 }
5015 }
5016 true
5017 });
5018
5019 // Turn off delta accumulation if all subscribers dropped and no views remain.
5020 if self.subscriptions.is_empty() && self.view_store.is_empty() {
5021 self.engine.set_emit_deltas(false);
5022 }
5023 }
5024
5025 // Query subscriptions: full re-run per commit, then diff rows.
5026 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
5027 // Differential evaluation is roadmap / Phase 5.
5028 if !self.query_subscriptions.is_empty() {
5029 // Take the list out so we can call self.view() without borrow conflict.
5030 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
5031 let empty_params = BTreeMap::new();
5032 query_subs.retain_mut(|entry| {
5033 let Some(inner) = entry.inner.upgrade() else {
5034 return false; // subscriber dropped — prune
5035 };
5036 // Label-skip: if the plan has a known scan label and this commit
5037 // can be proven to touch only different labels (and no rule-derived
5038 // edge deltas fired), the result set cannot have changed — skip.
5039 if let Some(scan_sym) = entry.scan_label {
5040 if engine_deltas.is_empty() {
5041 let touched =
5042 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
5043 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
5044 return true; // safe to skip — result set unchanged
5045 }
5046 }
5047 }
5048 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
5049 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
5050 Ok(r) => r,
5051 Err(e) => {
5052 // Keep the subscription alive; skip the diff for this commit.
5053 // Re-run errors are transient (e.g., planner change) and
5054 // self-heal when the next commit succeeds.
5055 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
5056 return true;
5057 }
5058 };
5059 // Build new row map: serialized-key → row data.
5060 let new_row_map = Self::result_to_row_map(&result);
5061 // Removed rows: in prev but not in new.
5062 for (key, row) in &entry.prev_row_map {
5063 if !new_row_map.contains_key(key) {
5064 inner.push(DbEvent::QueryRowRemoved {
5065 columns: entry.columns.clone(),
5066 row: row.clone(),
5067 });
5068 }
5069 }
5070 // Added rows: in new but not in prev.
5071 for (key, row) in &new_row_map {
5072 if !entry.prev_row_map.contains_key(key) {
5073 inner.push(DbEvent::QueryRowAdded {
5074 columns: entry.columns.clone(),
5075 row: row.clone(),
5076 });
5077 }
5078 }
5079 entry.prev_row_map = new_row_map;
5080 true
5081 });
5082 self.query_subscriptions = query_subs;
5083 }
5084 }
5085
5086 /// Returns `true` if any live subscriber or view definition requires delta
5087 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
5088 fn needs_emit_deltas(&self) -> bool {
5089 !self.view_store.is_empty()
5090 || self
5091 .subscriptions
5092 .iter()
5093 .any(|e| e.inner.upgrade().is_some())
5094 }
5095
5096 /// Convert a WAL record into `DbEvent` write events with the given seq.
5097 fn write_events_from_record(
5098 rec: &WalRecord,
5099 seq: u64,
5100 intern: &Interner,
5101 ids: &IdMap,
5102 ) -> Vec<DbEvent> {
5103 match rec {
5104 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
5105 label: label.clone(),
5106 key: key.clone(),
5107 commit_seq: seq,
5108 }],
5109 // *Id arms run after a successful apply, so resolution can only
5110 // fail on a programming error. Skip the event rather than emit a
5111 // fabricated "" that clients can't tell from a real empty value
5112 // (mirrors event_from_record returning None).
5113 WalRecord::InsertNodeId { label, key, .. } => intern
5114 .resolve(*label)
5115 .map(|label| DbEvent::NodeInserted {
5116 label: label.to_string(),
5117 key: key.clone(),
5118 commit_seq: seq,
5119 })
5120 .into_iter()
5121 .collect(),
5122 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5123 key: key.clone(),
5124 field: field.clone(),
5125 commit_seq: seq,
5126 }],
5127 WalRecord::SetPropId { id, field, .. } => ids
5128 .key_of(*id)
5129 .zip(intern.resolve(*field))
5130 .map(|(key, field)| DbEvent::PropSet {
5131 key: key.to_string(),
5132 field: field.to_string(),
5133 commit_seq: seq,
5134 })
5135 .into_iter()
5136 .collect(),
5137 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5138 key: key.clone(),
5139 field: field.clone(),
5140 commit_seq: seq,
5141 }],
5142 WalRecord::InsertEdge {
5143 edge_type,
5144 src_key,
5145 dst_key,
5146 } => vec![DbEvent::EdgeInserted {
5147 edge_type: edge_type.clone(),
5148 src: src_key.clone(),
5149 dst: dst_key.clone(),
5150 commit_seq: seq,
5151 }],
5152 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5153 Some(DbEvent::EdgeInserted {
5154 edge_type: intern.resolve(*etype)?.to_string(),
5155 src: ids.key_of(*src)?.to_string(),
5156 dst: ids.key_of(*dst)?.to_string(),
5157 commit_seq: seq,
5158 })
5159 })()
5160 .into_iter()
5161 .collect(),
5162 WalRecord::DeleteEdge {
5163 edge_type,
5164 src_key,
5165 dst_key,
5166 } => vec![DbEvent::EdgeDeleted {
5167 edge_type: edge_type.clone(),
5168 src: src_key.clone(),
5169 dst: dst_key.clone(),
5170 commit_seq: seq,
5171 }],
5172 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5173 key: key.clone(),
5174 commit_seq: seq,
5175 }],
5176 WalRecord::Batch(inner) => inner
5177 .iter()
5178 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5179 .collect(),
5180 WalRecord::CreateRule { .. }
5181 | WalRecord::DeleteRule { .. }
5182 | WalRecord::RebuildRule { .. }
5183 | WalRecord::CreateView { .. }
5184 | WalRecord::DeleteView { .. }
5185 | WalRecord::EnableFulltext { .. }
5186 | WalRecord::DisableFulltext { .. }
5187 | WalRecord::EnableIndex { .. }
5188 | WalRecord::DisableIndex { .. }
5189 | WalRecord::Intern { .. }
5190 // History markers produce no DbEvent — the engine delta already
5191 // fired the EdgeFired/EdgeRetracted subscription events.
5192 | WalRecord::DerivedEdgeAdded { .. }
5193 | WalRecord::DerivedEdgeRetracted { .. }
5194 | WalRecord::RenameNode { .. } => vec![],
5195 }
5196 }
5197
5198 /// Subscribe to edge-fire and edge-retract events for one named rule.
5199 ///
5200 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5201 /// currently registered. Dropping the returned [`Subscription`] handle
5202 /// unregisters the subscriber — no further events are queued, no
5203 /// resources leak.
5204 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5205 if self.read_only {
5206 return Err(core_storage::GraphError::ReadOnly);
5207 }
5208 if !self.engine.rules().any(|r| r.name == rule_name) {
5209 return Err(core_storage::GraphError::RuleNotFound {
5210 name: rule_name.to_string(),
5211 });
5212 }
5213 let inner = SubInner::new(self.sub_capacity());
5214 self.subscriptions.push(SubEntry {
5215 filter: SubFilter::Rule(rule_name.to_string()),
5216 inner: std::sync::Arc::downgrade(&inner),
5217 });
5218 self.engine.set_emit_deltas(true);
5219 Ok(Subscription(inner))
5220 }
5221
5222 /// Subscribe to edge-fire and edge-retract events for **all** rules.
5223 ///
5224 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5225 /// as-of instances never commit, so `distribute_events` never runs and the
5226 /// subscription would never deliver events.
5227 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5228 if self.read_only {
5229 return Err(core_storage::GraphError::ReadOnly);
5230 }
5231 let inner = SubInner::new(self.sub_capacity());
5232 self.subscriptions.push(SubEntry {
5233 filter: SubFilter::AllRules,
5234 inner: std::sync::Arc::downgrade(&inner),
5235 });
5236 self.engine.set_emit_deltas(true);
5237 Ok(Subscription(inner))
5238 }
5239
5240 /// Subscribe to write events: node insert/delete, prop set/remove.
5241 ///
5242 /// Does not include edge-fire / edge-retract (rule-derived edge events).
5243 ///
5244 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5245 /// as-of instances never commit, so `distribute_events` never runs and the
5246 /// subscription would never deliver events.
5247 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5248 if self.read_only {
5249 return Err(core_storage::GraphError::ReadOnly);
5250 }
5251 let inner = SubInner::new(self.sub_capacity());
5252 self.subscriptions.push(SubEntry {
5253 filter: SubFilter::Writes,
5254 inner: std::sync::Arc::downgrade(&inner),
5255 });
5256 self.engine.set_emit_deltas(true);
5257 Ok(Subscription(inner))
5258 }
5259
5260 /// Subscribe to incremental Cypher query results.
5261 ///
5262 /// Parses and plans `cypher`; rejects the query if the plan is not in the
5263 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5264 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5265 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
5266 ///
5267 /// SKIP is not supported — it shifts the result window on every commit,
5268 /// causing spurious Added/Removed churn for rows whose data never changed.
5269 /// Multi-hop Expand chains are not supported; each additional MATCH clause
5270 /// widens scope beyond the documented single-scan / single-hop subset.
5271 ///
5272 /// After each successful commit, the plan is **fully re-executed** and the
5273 /// result is diffed against the previous run. Added rows produce
5274 /// [`DbEvent::QueryRowAdded`]; removed rows produce
5275 /// [`DbEvent::QueryRowRemoved`].
5276 ///
5277 /// **Full re-run per commit; use LIMIT to bound execution cost.**
5278 /// The existing 1 M intermediate-row cap applies. Differential evaluation
5279 /// is roadmap / Phase 5.
5280 ///
5281 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5282 /// as-of instances never commit, so `distribute_events` never runs and the
5283 /// subscription would never deliver events.
5284 ///
5285 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5286 /// or if the plan shape is not in the allowlist.
5287 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5288 if self.read_only {
5289 return Err(GraphError::ReadOnly);
5290 }
5291 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5292 detail: format!("lex: {e}"),
5293 })?;
5294 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5295 detail: format!("parse: {e}"),
5296 })?;
5297 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5298 detail: format!("plan: {e}"),
5299 })?;
5300 if !is_subscribable(&ops) {
5301 return Err(GraphError::QueryError {
5302 detail: "subscribe_query only supports allowlisted plan shapes: \
5303 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5304 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5305 Not supported: multi-hop Expand chains, SKIP (creates \
5306 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5307 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5308 Use LIMIT to bound re-execution cost."
5309 .to_string(),
5310 });
5311 }
5312 // Execute once to capture initial state (initial rows are not emitted as
5313 // events — the subscriber learns the baseline via the first query call).
5314 let empty_params = BTreeMap::new();
5315 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5316 GraphError::QueryError {
5317 detail: format!("execute: {e}"),
5318 }
5319 })?;
5320 let columns = initial.columns().to_vec();
5321 let prev_row_map = Self::result_to_row_map(&initial);
5322 let inner = SubInner::new(self.sub_capacity());
5323 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
5324 // or unrecognized leading scan → None (always re-execute).
5325 let scan_label = extract_scan_label(&ops, &mut self.syms);
5326 self.query_subscriptions.push(QuerySubEntry {
5327 ops,
5328 columns,
5329 prev_row_map,
5330 inner: std::sync::Arc::downgrade(&inner),
5331 scan_label,
5332 });
5333 Ok(Subscription(inner))
5334 }
5335
5336 /// Queue capacity used for new subscriptions.
5337 fn sub_capacity(&self) -> usize {
5338 self.sub_capacity
5339 }
5340
5341 /// Override per-subscriber queue capacity for subsequently created
5342 /// subscriptions on this db instance.
5343 ///
5344 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5345 /// value in tests to exercise the [`DbEvent::Lagged`] path without
5346 /// generating tens of thousands of events.
5347 ///
5348 /// This is a test-support escape hatch. Calling it in production reduces
5349 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5350 /// to discourage accidental production use.
5351 #[doc(hidden)]
5352 pub fn set_sub_capacity(&mut self, capacity: usize) {
5353 self.sub_capacity = capacity;
5354 }
5355
5356 // -----------------------------------------------------------------------
5357
5358 /// Start an atomic batch.
5359 ///
5360 /// The returned [`BatchBuilder`] borrows `self` mutably until
5361 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5362 /// validation, no WAL I/O. `commit` validates every queued op against
5363 /// live state plus preceding ops in this batch (duplicate key inside
5364 /// the batch is `Err`; an edge between two nodes created earlier in
5365 /// the batch is valid; `delete_node` then insert of the same key is a
5366 /// fresh identity). Validation never mutates the database. Any failure
5367 /// leaves WAL bytes and in-memory state identical to before `commit`.
5368 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5369 /// and each inner record is applied in order so rules fire per record.
5370 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5371 ///
5372 /// **Rule-window limitation:** batch validation cannot see edges that a
5373 /// rule created earlier in the *same* batch will derive at apply time, so
5374 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5375 /// where sequential calls would return `Err(RuleOwned)`. State integrity
5376 /// is unaffected (idempotent apply, provenance intact). Create rules in
5377 /// their own batch, or sequentially, when later ops may touch derived
5378 /// edges.
5379 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5380 BatchBuilder {
5381 db: self,
5382 ops: Vec::new(),
5383 }
5384 }
5385
5386 /// Closure-style atomic write batch.
5387 ///
5388 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5389 /// then committing. All ops queued inside `build` are validated in order and
5390 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5391 /// once per inner record, in order, after commit — semantically identical to
5392 /// sequential single-op writes.
5393 ///
5394 /// **Error semantics — validate-then-apply.** `build` queues ops without
5395 /// touching the database. [`BatchBuilder::commit`] validates every op against
5396 /// live state plus earlier ops in this batch before writing anything. If op N
5397 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5398 /// entire batch is rejected: no WAL bytes are written and no in-memory state
5399 /// changes. The database is identical to its state before `write_batch` was
5400 /// called.
5401 ///
5402 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5403 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5404 /// either fully applied or not at all. However, while applying a committed
5405 /// batch, concurrent readers may observe intermediate states as ops are applied
5406 /// sequentially in memory. There is no interactive transaction isolation in v1.
5407 /// This is documented as "crash-atomic write batches; no interactive
5408 /// transactions or read isolation."
5409 ///
5410 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5411 /// writes zero WAL bytes and returns `(0, 0)`.
5412 ///
5413 /// # Example
5414 ///
5415 /// ```rust,ignore
5416 /// let (nodes, edges) = db.write_batch(|b| {
5417 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5418 /// b.insert_node("Person", "bob", vec![]);
5419 /// b.insert_edge("KNOWS", "alice", "bob");
5420 /// b.set_prop("alice", "role", Value::Str("admin".into()));
5421 /// b.delete_node("old_key");
5422 /// })?;
5423 /// // One fsync; on crash replay: all five ops land or none do.
5424 /// ```
5425 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5426 where
5427 C: FnOnce(&mut BatchBuilder<'_, F>),
5428 {
5429 let mut b = self.batch();
5430 build(&mut b);
5431 b.commit()
5432 }
5433
5434 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5435 /// auto-declared KeyMatch rules (if any) first, then the accepted node
5436 /// inserts, so incremental fire sees the new rules. Per-row key problems
5437 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5438 /// `Err` means nothing was applied.
5439 ///
5440 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5441 /// distinct source labels sharing an FK field each get their own rule.
5442 pub fn ingest(
5443 &mut self,
5444 label: &str,
5445 rows: Vec<BTreeMap<String, Value>>,
5446 opts: &IngestOptions,
5447 ) -> Result<IngestReport> {
5448 self.ingest_with_edges(label, rows, opts, &[])
5449 }
5450
5451 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5452 /// A failing edge rejects the whole request; nothing is applied.
5453 pub fn ingest_with_edges(
5454 &mut self,
5455 label: &str,
5456 rows: Vec<BTreeMap<String, Value>>,
5457 opts: &IngestOptions,
5458 edges: &[(String, String, String)],
5459 ) -> Result<IngestReport> {
5460 crate::ingest::run(self, label, rows, opts, edges)
5461 }
5462
5463 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5464 ///
5465 /// JSON `null` fields are silently omitted (not stored, not a row error).
5466 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5467 /// Parse failures and a top-level value that is not an array of objects
5468 /// return [`GraphError::IngestError`].
5469 pub fn ingest_json(
5470 &mut self,
5471 label: &str,
5472 json: &str,
5473 opts: &IngestOptions,
5474 ) -> Result<IngestReport> {
5475 crate::ingest::run_json(self, label, json, opts)
5476 }
5477
5478 fn commit_logged_batch(
5479 &mut self,
5480 ops: Vec<BatchOp>,
5481 ingest: Option<(String, usize)>,
5482 // Two-source rule: write_batch_authz threads authz here directly (never
5483 // touches pending_write_authz); query_write_authz sets the field instead
5484 // and passes None. Only one source is non-None per call.
5485 param_authz: Option<WriteAuthz>,
5486 ) -> Result<(usize, usize)> {
5487 // Read-only guard: catches empty-batch calls before the early-return
5488 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5489 if self.read_only {
5490 return Err(GraphError::ReadOnly);
5491 }
5492 // Ensure provenance is decoded before MutPreview accesses it
5493 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5494 self.engine.ensure_provenance_loaded_mut();
5495
5496 // ── Authz pre-check ──────────────────────────────────────────────────
5497 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5498 // produces no WAL frame (all-or-nothing at the authz boundary extends
5499 // the existing validate-then-apply contract to role-scope checks).
5500 //
5501 // `batch_created` tracks key→label for nodes created by earlier ops in
5502 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5503 // as visible without needing to call `self.ids.get` on not-yet-committed
5504 // keys (they won't be there yet).
5505 //
5506 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5507 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5508 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5509 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5510 if let Some(ref authz) = authz_opt {
5511 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5512 for op in &ops {
5513 self.check_single_op_authz(authz, op, &batch_created)?;
5514 // Update batch_created after a passing authz check so that
5515 // subsequent ops in this batch see the nodes as "about to exist".
5516 match op {
5517 BatchOp::InsertNode { label, key, .. } => {
5518 // Only track genuinely new nodes (absent from the
5519 // snapshot at authz-check time). A pre-existing visible
5520 // key would be a DuplicateKey — not a real creation —
5521 // so MutPreview handles it. Letting it into batch_created
5522 // would allow a later SetProp to bypass update_labels
5523 // via the "batch-created → always updatable" ruling
5524 // (delete+recreate exploit, fix for I1 review round 2).
5525 //
5526 // Accepted edge: for a delete+recreate-with-different-
5527 // label batch, node_status resolves the pre-delete
5528 // (store) label for any subsequent update checks. This
5529 // grants no net-new capability — a role that can delete+
5530 // create can already place arbitrary props via
5531 // InsertNode's own props field.
5532 if self.ids.get(key.as_str()).is_none() {
5533 batch_created.insert(key.clone(), label.clone());
5534 }
5535 }
5536 BatchOp::InsertEdgeUpsert {
5537 placeholder_label,
5538 src_key,
5539 dst_key,
5540 ..
5541 } => {
5542 // Both endpoints will be created if not already in store.
5543 for ep_key in [src_key, dst_key] {
5544 if self.ids.get(ep_key.as_str()).is_none()
5545 && !batch_created.contains_key(ep_key.as_str())
5546 {
5547 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5548 }
5549 }
5550 }
5551 _ => {}
5552 }
5553 }
5554 }
5555
5556 let recs = {
5557 let mut preview = MutPreview::new(self);
5558 let mut recs = Vec::with_capacity(ops.len());
5559 for op in ops {
5560 match op {
5561 BatchOp::InsertNode { label, key, props } => {
5562 preview.check_insert_node(&key)?;
5563 preview.note_insert_node(&key, &props);
5564 recs.push(WalRecord::InsertNode { label, key, props });
5565 }
5566 BatchOp::InsertEdge {
5567 edge_type,
5568 src_key,
5569 dst_key,
5570 } => {
5571 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5572 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5573 recs.push(WalRecord::InsertEdge {
5574 edge_type,
5575 src_key,
5576 dst_key,
5577 });
5578 }
5579 }
5580 BatchOp::SetProp { key, field, value } => {
5581 if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
5582 return Err(GraphError::ViewPropReadOnly {
5583 view_name: view_name.to_string(),
5584 });
5585 }
5586 preview.check_live_key(&key)?;
5587 preview.note_set_prop(&key, &field, &value);
5588 recs.push(WalRecord::SetProp { key, field, value });
5589 }
5590 BatchOp::RemoveProp { key, field } => {
5591 if preview.prepare_remove_prop(&key, &field)? {
5592 preview.note_remove_prop(&key, &field);
5593 recs.push(WalRecord::RemoveProp { key, field });
5594 }
5595 }
5596 BatchOp::DeleteEdge {
5597 edge_type,
5598 src_key,
5599 dst_key,
5600 } => {
5601 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5602 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5603 recs.push(WalRecord::DeleteEdge {
5604 edge_type,
5605 src_key,
5606 dst_key,
5607 });
5608 }
5609 }
5610 BatchOp::DeleteNode { key } => {
5611 preview.check_live_key(&key)?;
5612 preview.note_delete_node(&key);
5613 recs.push(WalRecord::DeleteNode { key });
5614 }
5615 BatchOp::CreateRule(def) => {
5616 preview.check_create_rule(&def)?;
5617 let def_bytes =
5618 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5619 detail: format!("serialize rule: {e}"),
5620 })?;
5621 preview.note_create_rule(&def);
5622 recs.push(WalRecord::CreateRule { def_bytes });
5623 }
5624 BatchOp::DeleteRule { name } => {
5625 preview.check_delete_rule(&name)?;
5626 preview.note_delete_rule(&name);
5627 recs.push(WalRecord::DeleteRule { name });
5628 }
5629 BatchOp::RenameNode { old_key, new_key } => {
5630 preview.check_rename_node(&old_key, &new_key)?;
5631 preview.note_rename_node(&old_key, &new_key);
5632 recs.push(WalRecord::RenameNode { old_key, new_key });
5633 }
5634 BatchOp::InsertEdgeUpsert {
5635 edge_type,
5636 src_key,
5637 dst_key,
5638 placeholder_label,
5639 } => {
5640 // Auto-create any missing endpoints as plain InsertNode ops.
5641 // Rules fire and last-change is updated for each created node.
5642 for key in [&src_key, &dst_key] {
5643 if !preview.has_key(key) {
5644 preview.check_insert_node(key)?;
5645 preview.note_insert_node(key, &[]);
5646 recs.push(WalRecord::InsertNode {
5647 label: placeholder_label.clone(),
5648 key: key.clone(),
5649 props: vec![],
5650 });
5651 }
5652 }
5653 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5654 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5655 recs.push(WalRecord::InsertEdge {
5656 edge_type,
5657 src_key,
5658 dst_key,
5659 });
5660 }
5661 }
5662 }
5663 }
5664 recs
5665 };
5666 if recs.is_empty() {
5667 return Ok((0, 0));
5668 }
5669 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5670 // *Id form, so only the dense variants can appear in `recs` here.
5671 let recs = self.rewrite_wal_dense(recs)?;
5672 // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5673 // namespace the node is already in is a no-op and is dropped there. An
5674 // empty `Batch` frame would still take a commit sequence and a WAL
5675 // record, so a batch that turns out to be nothing writes nothing.
5676 if recs.is_empty() {
5677 return Ok((0, 0));
5678 }
5679 let nodes_inserted = recs
5680 .iter()
5681 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5682 .count();
5683 let edges_inserted = recs
5684 .iter()
5685 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5686 .count();
5687 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5688 // under Strict. Pass self.fsync directly so Strict stays Strict —
5689 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5690 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5691 // short-circuit on single-op batches and silently skip the fsync.
5692 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5693 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5694 Ok((nodes_inserted, edges_inserted))
5695 }
5696
5697 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5698 self.commit_logged_batch(ops, None, None)
5699 }
5700
5701 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5702 /// and the group-commit drain thread, which do a single group fsync later.
5703 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5704 // Restore fsync policy even on panic via a raw-pointer drop guard.
5705 // A panic here would poison the RwLock anyway, but the correct policy
5706 // must be in place if the guard is ever unwrapped.
5707 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5708 impl Drop for RestoreFsync {
5709 fn drop(&mut self) {
5710 // SAFETY: the pointer is valid for the full duration of
5711 // commit_batch_nosync; the guard is dropped before the frame
5712 // returns, and GraphDb outlives this frame.
5713 unsafe {
5714 *self.0 = self.1;
5715 }
5716 }
5717 }
5718 let saved = self.fsync;
5719 // SAFETY: raw pointer into self; guard dropped within this frame.
5720 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5721 self.fsync = FsyncPolicy::Relaxed;
5722 self.commit_logged_batch(ops, None, None)
5723 }
5724
5725 /// Commit multiple op-batches as a **group**: each submission gets its own
5726 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5727 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5728 ///
5729 /// # Durability semantics
5730 ///
5731 /// A crash before the group fsync may lose **all** submissions in the group.
5732 /// A crash after the group fsync preserves all of them. No submission is
5733 /// ever torn: each WAL frame is either fully applied on replay or dropped
5734 /// in its entirety (CRC-protected frame boundaries).
5735 ///
5736 /// Events and subscription notifications fire per-submission immediately
5737 /// after apply, which may be before the group fsync. From a subscriber's
5738 /// perspective this is equivalent to the `Relaxed` durability window.
5739 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5740 /// fsync, so from their perspective durability is fully guaranteed.
5741 ///
5742 /// # MVCC interplay
5743 ///
5744 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5745 /// increments per submission (not per group), preserving existing reader
5746 /// snapshot semantics.
5747 ///
5748 /// # Returns
5749 ///
5750 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5751 /// in order. Failures are per-submission (validation errors); the group
5752 /// fsync error (if any) is returned as the second tuple element.
5753 pub fn commit_group(
5754 &mut self,
5755 groups: Vec<Vec<BatchOp>>,
5756 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5757 let mut results = Vec::with_capacity(groups.len());
5758 for ops in groups {
5759 results.push(self.commit_batch_nosync(ops));
5760 }
5761 let any_ok = results.iter().any(|r| r.is_ok());
5762 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5763 self.fs
5764 .sync(core_storage::fs::FileId::Wal)
5765 .map_err(GraphError::Io)
5766 .err()
5767 } else {
5768 None
5769 };
5770 (results, sync_err)
5771 }
5772
5773 /// Like [`commit_group`] but skips the group fsync entirely.
5774 ///
5775 /// Used by the drain thread to apply submissions under the write lock and
5776 /// then perform the single fsync OUTSIDE the lock (via
5777 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5778 /// to concurrent readers.
5779 pub fn commit_group_nosync(
5780 &mut self,
5781 groups: Vec<Vec<BatchOp>>,
5782 ) -> Vec<Result<(usize, usize)>> {
5783 let mut results = Vec::with_capacity(groups.len());
5784 for ops in groups {
5785 results.push(self.commit_batch_nosync(ops));
5786 }
5787 results
5788 }
5789
5790 pub fn insert_node(
5791 &mut self,
5792 label: &str,
5793 key: &str,
5794 props: Vec<(String, Value)>,
5795 ) -> Result<()> {
5796 if self.read_only {
5797 return Err(GraphError::ReadOnly);
5798 }
5799 MutPreview::new(self).check_insert_node(key)?;
5800 self.log_dense(vec![WalRecord::InsertNode {
5801 label: label.into(),
5802 key: key.into(),
5803 props,
5804 }])
5805 }
5806
5807 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5808 if self.read_only {
5809 return Err(GraphError::ReadOnly);
5810 }
5811 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5812 return Ok(false);
5813 }
5814 self.log_dense(vec![WalRecord::InsertEdge {
5815 edge_type: edge_type.into(),
5816 src_key: src_key.into(),
5817 dst_key: dst_key.into(),
5818 }])?;
5819 Ok(true)
5820 }
5821
5822 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5823 if self.read_only {
5824 return Err(GraphError::ReadOnly);
5825 }
5826 if let Some(view_name) = self.view_store.view_for_prop(field) {
5827 return Err(GraphError::ViewPropReadOnly {
5828 view_name: view_name.to_string(),
5829 });
5830 }
5831 MutPreview::new(self).check_live_key(key)?;
5832 self.log_dense(vec![WalRecord::SetProp {
5833 key: key.into(),
5834 field: field.into(),
5835 value,
5836 }])
5837 }
5838
5839 /// Set several properties on one live node in a single WAL commit.
5840 ///
5841 /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
5842 /// names, live key, the `ns` immutability rule and its type — is evaluated
5843 /// for the whole list before any record is logged. The first refusal
5844 /// returns and the node is unchanged. An empty list writes nothing.
5845 pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
5846 if self.read_only {
5847 return Err(GraphError::ReadOnly);
5848 }
5849 MutPreview::new(self).check_live_key(key)?;
5850 for (field, _) in &props {
5851 if let Some(view_name) = self.view_store.view_for_prop(field) {
5852 return Err(GraphError::ViewPropReadOnly {
5853 view_name: view_name.to_string(),
5854 });
5855 }
5856 }
5857 if props.is_empty() {
5858 return Ok(());
5859 }
5860 self.write_batch(|b| {
5861 for (field, value) in props {
5862 b.set_prop(key, &field, value);
5863 }
5864 })
5865 .map(|_| ())
5866 }
5867
5868 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5869 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5870 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5871 if self.read_only {
5872 return Err(GraphError::ReadOnly);
5873 }
5874 if let Some(view_name) = self.view_store.view_for_prop(field) {
5875 return Err(GraphError::ViewPropReadOnly {
5876 view_name: view_name.to_string(),
5877 });
5878 }
5879 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5880 return Ok(false);
5881 }
5882 self.log_then_apply(WalRecord::RemoveProp {
5883 key: key.into(),
5884 field: field.into(),
5885 })?;
5886 Ok(true)
5887 }
5888
5889 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5890 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5891 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5892 /// (the rule would just put the edge back; delete or change the rule).
5893 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5894 if self.read_only {
5895 return Err(GraphError::ReadOnly);
5896 }
5897 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5898 return Ok(false);
5899 }
5900 self.log_then_apply(WalRecord::DeleteEdge {
5901 edge_type: edge_type.into(),
5902 src_key: src_key.into(),
5903 dst_key: dst_key.into(),
5904 })?;
5905 Ok(true)
5906 }
5907
5908 /// Delete a live node. Unknown or already-tombstoned keys are
5909 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5910 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5911 /// (crash window) is a clean no-op.
5912 ///
5913 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5914 /// removed (computed from live state before the deletion is applied).
5915 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5916 if self.read_only {
5917 return Err(GraphError::ReadOnly);
5918 }
5919 // Provenance must be loaded before we query provenance_touching.
5920 self.engine.ensure_provenance_loaded_mut();
5921 let id = self
5922 .ids
5923 .get(key)
5924 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5925
5926 // Count edges before the delete is applied so we can report counts.
5927 let derived_set: BTreeSet<(u32, u32, u32)> = self
5928 .engine
5929 .provenance_touching(id)
5930 .map(|(_, etype, src, dst)| (etype, src, dst))
5931 .collect();
5932 let derived_edges = derived_set.len() as u64;
5933
5934 let mut total_topo = 0u64;
5935 let tv = self.topo_view();
5936 for et in tv.etypes() {
5937 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5938 + tv.neighbors(et, Direction::In, id).len() as u64;
5939 }
5940 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5941 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5942 // The subtraction remains correct because both counts include both directions.
5943 let manual_edges = total_topo.saturating_sub(derived_edges);
5944
5945 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5946 Ok(DeleteReport {
5947 manual_edges,
5948 derived_edges,
5949 })
5950 }
5951
5952 /// Rename a live node's key. The dense id (and therefore all edges,
5953 /// props, history, and last-change tracking) is unaffected.
5954 ///
5955 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5956 /// Returns `Err(DuplicateKey)` if `new` is already live.
5957 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5958 if self.read_only {
5959 return Err(GraphError::ReadOnly);
5960 }
5961 MutPreview::new(self).check_rename_node(old, new)?;
5962 self.log_then_apply(WalRecord::RenameNode {
5963 old_key: old.into(),
5964 new_key: new.into(),
5965 })
5966 }
5967
5968 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5969 /// `None` if the rule does not exist or is not approximate.
5970 ///
5971 /// The drift counter increments on IVF insert/remove after the last fit.
5972 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5973 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5974 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5975 // SideIvfExport = (centroids, node→cluster, drift)
5976 self.engine
5977 .export_ivf_state()
5978 .remove(rule)
5979 .map(|(_src, dst)| dst.2)
5980 }
5981
5982 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5983 /// Validation and duplicate-name check run before logging so invalid rules
5984 /// never enter the WAL.
5985 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5986 if self.read_only {
5987 return Err(GraphError::ReadOnly);
5988 }
5989 MutPreview::new(self).check_create_rule(&def)?;
5990 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5991 detail: format!("serialize rule: {e}"),
5992 })?;
5993 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5994 }
5995
5996 /// Override this handle's HNSW build-slice size, or `None` to restore
5997 /// [`core_rules::HNSW_BUILD_BATCH`].
5998 ///
5999 /// Exposed for tests that need a small slice without a large corpus; not
6000 /// part of the stable surface.
6001 #[doc(hidden)]
6002 pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
6003 self.engine.set_hnsw_build_batch(batch);
6004 }
6005
6006 /// Rules whose vector index is still being built, in name order.
6007 ///
6008 /// The same list [`GraphDb::stats`] reports per rule in `building`.
6009 /// After a clean open this includes a build a snapshot cut short, so
6010 /// `serve`'s ticker can pump it without a write.
6011 pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
6012 self.engine.builds_in_progress()
6013 }
6014
6015 /// Advance any vector index still building and backfill each rule that
6016 /// finishes. Returns what is still outstanding.
6017 ///
6018 /// A map lookup when nothing is pending, so it is cheap to call on a timer.
6019 /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
6020 /// inserts per pending rule per call, so a caller can drive a large build
6021 /// to completion without ever holding the lock for more than a slice.
6022 ///
6023 /// A rule that finishes here is backfilled through the same
6024 /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
6025 /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
6026 /// and appear all at once.
6027 ///
6028 /// Every ordinary write pumps one slice on its own (see the post-commit
6029 /// hook in `log_then_apply_with`), so this is for quiescent stores and for
6030 /// operators who want the build finished before traffic arrives.
6031 pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
6032 Ok(self.pump_index_build_reporting()?.1)
6033 }
6034
6035 /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
6036 /// call finished, so a progress display can say so.
6037 ///
6038 /// A build can be registered and completed inside a single call — that is
6039 /// what a mid-build snapshot looks like on reopen, where the index scan
6040 /// finishes the graph and only the backfill is outstanding — and the
6041 /// outstanding list alone cannot show that anything happened.
6042 pub fn pump_index_build_reporting(
6043 &mut self,
6044 ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
6045 // A read-only handle cannot issue the `RebuildRule` a finished build
6046 // needs, so it would advance the index and then silently fail to
6047 // produce the edges. Refusing is the honest answer.
6048 if self.read_only {
6049 return Err(GraphError::ReadOnly);
6050 }
6051 let finished = self.pump_one_slice();
6052 for done in &finished {
6053 // The index is whole but the rule still owns no edges. A failed
6054 // second commit must leave the rule re-pumpable rather than
6055 // silently edge-less, so the error is surfaced here — unlike the
6056 // post-commit hook, this call is not riding someone else's commit.
6057 self.log_then_apply(WalRecord::RebuildRule {
6058 name: done.rule.clone(),
6059 })?;
6060 }
6061 Ok((finished, self.engine.builds_in_progress()))
6062 }
6063
6064 /// Run the deferred candidate-index build, if it is still owed, against the
6065 /// graph as it stands *now* — before the caller applies anything.
6066 ///
6067 /// A no-op bool test once the indexes are populated, which is after the
6068 /// first write of the handle's life, and for a store with no rules at all.
6069 fn populate_indexes_before_write(&mut self) {
6070 if !self.engine.needs_index_population() {
6071 return;
6072 }
6073 // The retained snapshot blobs arrive with the V8 base sections; without
6074 // them the scan would rebuild every graph the snapshot already holds.
6075 self.ensure_v8_base_sections_loaded();
6076 if !self.engine.needs_index_population() {
6077 return;
6078 }
6079 let mut eng = std::mem::take(&mut self.engine);
6080 {
6081 let gm = make_graph_mut(
6082 &self.ids,
6083 &mut self.syms,
6084 &self.labels,
6085 build_props_view(&self.props, &self.base),
6086 &mut self.topo,
6087 &self.base,
6088 &mut self.edge_props,
6089 );
6090 eng.populate_indexes(&gm);
6091 }
6092 self.engine = eng;
6093 }
6094
6095 /// One slice of build work for every pending rule. Returns the rules whose
6096 /// index just became whole, which the caller must `RebuildRule`.
6097 ///
6098 /// Goes through the engine even with nothing pending when the indexes have
6099 /// not been populated yet: that call adopts the persisted graphs and, for
6100 /// an incomplete blob already registered at open, leaves the remainder to
6101 /// this slice rather than inserting it inline.
6102 fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
6103 // The retained snapshot blobs — and the id count an interrupted build
6104 // is recognised against — arrive with the V8 base sections, which a
6105 // clean open reads lazily. Without this a freshly opened handle pumps
6106 // against empty retained state and concludes there is nothing to do,
6107 // which is precisely the store `build-index` exists for.
6108 self.ensure_v8_base_sections_loaded();
6109 let mut eng = std::mem::take(&mut self.engine);
6110 let finished = {
6111 let mut gm = make_graph_mut(
6112 &self.ids,
6113 &mut self.syms,
6114 &self.labels,
6115 build_props_view(&self.props, &self.base),
6116 &mut self.topo,
6117 &self.base,
6118 &mut self.edge_props,
6119 );
6120 eng.pump_index_build(&mut gm)
6121 };
6122 self.engine = eng;
6123 finished
6124 }
6125
6126 /// Register a sliced build a snapshot cut short, from blobs with
6127 /// `complete == false`.
6128 ///
6129 /// Peeks the V8 mmap for incomplete entries without copying complete
6130 /// graphs. V5–V7 already hold the blobs in the engine from restore.
6131 fn register_outstanding_index_builds(&mut self) {
6132 if self.engine.indexes_populated() {
6133 return;
6134 }
6135 let extra = self.collect_incomplete_hnsw_blobs();
6136 let mut eng = std::mem::take(&mut self.engine);
6137 {
6138 let gm = make_graph_mut(
6139 &self.ids,
6140 &mut self.syms,
6141 &self.labels,
6142 build_props_view(&self.props, &self.base),
6143 &mut self.topo,
6144 &self.base,
6145 &mut self.edge_props,
6146 );
6147 eng.register_incomplete_hnsw_builds(&extra, &gm);
6148 }
6149 self.engine = eng;
6150 }
6151
6152 /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
6153 /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
6154 /// engine's retained map instead).
6155 fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
6156 let Some(base) = &self.base else {
6157 return BTreeMap::new();
6158 };
6159 let Ok(archived) = base.hnsw_section() else {
6160 return BTreeMap::new();
6161 };
6162 archived
6163 .rules
6164 .iter()
6165 .filter_map(|e| {
6166 let src = e.src_blob.as_slice();
6167 let dst = e.dst_blob.as_slice();
6168 if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
6169 || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
6170 {
6171 Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
6172 } else {
6173 None
6174 }
6175 })
6176 .collect()
6177 }
6178
6179 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
6180 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
6181 if self.read_only {
6182 return Err(GraphError::ReadOnly);
6183 }
6184 MutPreview::new(self).check_delete_rule(name)?;
6185 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
6186 }
6187
6188 /// Return a snapshot of all registered rules.
6189 pub fn rules(&self) -> Vec<RuleDef> {
6190 self.engine.rules().cloned().collect()
6191 }
6192
6193 // -----------------------------------------------------------------------
6194 // Rule suggestion API
6195 // -----------------------------------------------------------------------
6196
6197 /// Profile the database and suggest linking rules with previewed edge counts.
6198 ///
6199 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
6200 /// sampling. Suggestions are sorted by estimated edge count (descending).
6201 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6202 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6203 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6204 }
6205
6206 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6207 /// reproducibility. Same seed + same data = identical output.
6208 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6209 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6210 .suggestions
6211 }
6212
6213 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6214 ///
6215 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6216 /// and a `truncated` flag indicating whether the global budget fired before all
6217 /// candidates were evaluated.
6218 pub fn suggest_rules_with_config(
6219 &self,
6220 config: &core_rules::suggest::SuggestConfig,
6221 seed: u64,
6222 ) -> core_rules::SuggestReport {
6223 use std::collections::BTreeMap;
6224
6225 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6226 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6227 for id in 0..self.ids.len() as u32 {
6228 let Some(key) = self.ids.key_of(id) else {
6229 continue;
6230 };
6231 let Some(&sym) = self.labels.get(id as usize) else {
6232 continue;
6233 };
6234 if sym == u32::MAX {
6235 continue; // tombstoned
6236 }
6237 let Some(label) = self.syms.resolve(sym) else {
6238 continue;
6239 };
6240 label_nodes
6241 .entry(label.to_string())
6242 .or_default()
6243 .push((id, key.to_string()));
6244 }
6245
6246 let existing = self.rules();
6247 let pv = build_props_view(&self.props, &self.base);
6248 let all_fields: Vec<String> = pv.field_names();
6249
6250 core_rules::suggest::suggest_rules(
6251 &label_nodes,
6252 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6253 &all_fields,
6254 &existing,
6255 config,
6256 seed,
6257 )
6258 }
6259
6260 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6261 /// plus later mutations replay identically (rebuild is a pure function
6262 /// of state).
6263 ///
6264 /// Only exit from the tripped latch: if the full desired set fits the
6265 /// budget, it is applied completely and `tripped` clears; if it still
6266 /// exceeds the budget, provenance is left untouched and `tripped` stays
6267 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6268 /// Unknown rule → `RuleNotFound`, nothing logged.
6269 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6270 if self.read_only {
6271 return Err(GraphError::ReadOnly);
6272 }
6273 if !self.engine.rules().any(|r| r.name == name) {
6274 return Err(GraphError::RuleNotFound { name: name.into() });
6275 }
6276 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6277 }
6278
6279 // -----------------------------------------------------------------------
6280 // Materialized view API
6281 // -----------------------------------------------------------------------
6282
6283 /// Register a new materialized property view, backfill its values for all
6284 /// existing nodes, and WAL-log the definition.
6285 ///
6286 /// # Errors
6287 /// - `ReadOnly`: called on an as-of instance.
6288 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6289 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6290 if self.read_only {
6291 return Err(GraphError::ReadOnly);
6292 }
6293 // Pre-validate before WAL write.
6294 def.validate()
6295 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6296 if self.view_store.has_view(&def.name) {
6297 return Err(GraphError::RuleInvalid {
6298 detail: format!("view {:?} already exists", def.name),
6299 });
6300 }
6301 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6302 return Err(GraphError::RuleInvalid {
6303 detail: format!(
6304 "view_prop {:?} is already used by view {:?}",
6305 def.view_prop, existing
6306 ),
6307 });
6308 }
6309 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6310 detail: format!("serialize view: {e}"),
6311 })?;
6312 // Enable delta accumulation before the view is registered so subsequent
6313 // incremental edge events reach view maintenance from this point onward.
6314 // (The backfill inside create_view reads topo directly; it does not rely
6315 // on pending deltas.)
6316 self.engine.set_emit_deltas(true);
6317 self.log_then_apply(WalRecord::CreateView { def_bytes })
6318 }
6319
6320 /// Remove a named view and delete its values from every node.
6321 ///
6322 /// # Errors
6323 /// - `ReadOnly`: called on an as-of instance.
6324 /// - `RuleNotFound`: view does not exist.
6325 pub fn delete_view(&mut self, name: &str) -> Result<()> {
6326 if self.read_only {
6327 return Err(GraphError::ReadOnly);
6328 }
6329 if !self.view_store.has_view(name) {
6330 return Err(GraphError::RuleNotFound { name: name.into() });
6331 }
6332 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6333 // After deletion, disable accumulation if no listeners remain.
6334 if !self.needs_emit_deltas() {
6335 self.engine.set_emit_deltas(false);
6336 }
6337 result
6338 }
6339
6340 /// Snapshot of all registered view definitions.
6341 pub fn views(&self) -> Vec<ViewDef> {
6342 self.view_store.views().cloned().collect()
6343 }
6344
6345 // -----------------------------------------------------------------------
6346 // Full-text-lite API
6347 // -----------------------------------------------------------------------
6348
6349 /// Enable full-text indexing for all nodes of `label` on property `field`.
6350 ///
6351 /// After this call, every subsequent write to `(label, field)` is reflected
6352 /// in the index incrementally. Existing nodes are backfilled immediately.
6353 /// The declaration is persisted as a WAL record; the index itself is rebuilt
6354 /// from scratch on re-open (no snapshot format changes).
6355 ///
6356 /// # Errors
6357 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6358 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6359 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6360 if self.read_only {
6361 return Err(GraphError::ReadOnly);
6362 }
6363 if self.fulltext.is_enabled(label, field) {
6364 return Err(GraphError::RuleInvalid {
6365 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6366 });
6367 }
6368 self.log_then_apply(WalRecord::EnableFulltext {
6369 label: label.into(),
6370 field: field.into(),
6371 })
6372 }
6373
6374 /// Disable full-text indexing for `(label, field)` and drop its postings.
6375 ///
6376 /// # Errors
6377 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6378 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6379 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6380 if self.read_only {
6381 return Err(GraphError::ReadOnly);
6382 }
6383 if !self.fulltext.is_enabled(label, field) {
6384 return Err(GraphError::RuleNotFound {
6385 name: format!("fulltext({label},{field})"),
6386 });
6387 }
6388 self.log_then_apply(WalRecord::DisableFulltext {
6389 label: label.into(),
6390 field: field.into(),
6391 })
6392 }
6393
6394 /// Whether `(label, field)` is currently indexed for full-text search.
6395 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6396 self.fulltext.is_enabled(label, field)
6397 }
6398
6399 /// Every `(label, field)` pair with a live full-text index, sorted.
6400 ///
6401 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6402 /// declares which nodes are *indexed*, so callers that want to search
6403 /// everything indexed should query each distinct field once.
6404 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6405 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6406 v.sort();
6407 v
6408 }
6409
6410 /// Enable an equality index for all nodes of `label` on scalar property
6411 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6412 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6413 /// declaration persists via WAL and the postings rebuild on re-open.
6414 ///
6415 /// # Errors
6416 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6417 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6418 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6419 if self.read_only {
6420 return Err(GraphError::ReadOnly);
6421 }
6422 if self.prop_index.is_enabled(label, field) {
6423 return Err(GraphError::RuleInvalid {
6424 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6425 });
6426 }
6427 self.log_then_apply(WalRecord::EnableIndex {
6428 label: label.into(),
6429 field: field.into(),
6430 })
6431 }
6432
6433 /// Disable the equality index for `(label, field)` and drop its postings.
6434 ///
6435 /// # Errors
6436 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6437 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6438 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6439 if self.read_only {
6440 return Err(GraphError::ReadOnly);
6441 }
6442 if !self.prop_index.is_enabled(label, field) {
6443 return Err(GraphError::RuleNotFound {
6444 name: format!("index({label},{field})"),
6445 });
6446 }
6447 self.log_then_apply(WalRecord::DisableIndex {
6448 label: label.into(),
6449 field: field.into(),
6450 })
6451 }
6452
6453 /// Whether `(label, field)` currently has an equality index.
6454 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6455 self.prop_index.is_enabled(label, field)
6456 }
6457
6458 /// Search a full-text-indexed field.
6459 ///
6460 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6461 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
6462 ///
6463 /// **Query syntax:**
6464 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6465 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6466 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6467 /// - `AND` keyword is accepted explicitly and is the default.
6468 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6469 ///
6470 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6471 /// Pin: this is the documented, tested, stable behavior for v1.
6472 ///
6473 /// **Memory / performance:** O(postings) lookup; no scan. The index is
6474 /// in-memory and proportional to total indexed text across all enabled fields.
6475 ///
6476 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6477 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6478 /// key ascending for deterministic tiebreaking.
6479 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6480 // Resolve node_ids to keys (excluding tombstones) then re-sort by
6481 // (score DESC, key ASC) to give a deterministic, key-lexicographic
6482 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
6483 // which diverges from key order when nodes were not inserted in key-lex order.
6484 let mut results: Vec<(String, f64)> = self
6485 .fulltext
6486 .search(field, query, 0)
6487 .into_iter()
6488 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6489 .collect();
6490 results.sort_by(|a, b| {
6491 b.1.partial_cmp(&a.1)
6492 .unwrap_or(std::cmp::Ordering::Equal)
6493 .then(a.0.cmp(&b.0))
6494 });
6495 results
6496 }
6497
6498 /// [`search`](Self::search), stopping at the `k` best hits.
6499 ///
6500 /// Same ranking and the same deterministic tiebreak, but the index drops
6501 /// everything past `k` before any key is resolved, so a caller that wants
6502 /// the top few out of a field that matched thousands does not pay to
6503 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6504 /// [`search`](Self::search) behaves.
6505 ///
6506 /// The BM25 scoring itself is not bounded by `k` — every candidate is
6507 /// scored either way — so this trims the resolve and the sort, not the
6508 /// search.
6509 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6510 // A tombstoned id resolves to nothing, so asking the index for exactly
6511 // `k` could return fewer. Over-fetching a little and truncating after
6512 // the filter keeps the count right without unbounding the call.
6513 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6514 let mut results: Vec<(String, f64)> = self
6515 .fulltext
6516 .search(field, query, want)
6517 .into_iter()
6518 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6519 .collect();
6520 results.sort_by(|a, b| {
6521 b.1.partial_cmp(&a.1)
6522 .unwrap_or(std::cmp::Ordering::Equal)
6523 .then(a.0.cmp(&b.0))
6524 });
6525 if k > 0 {
6526 results.truncate(k);
6527 }
6528 results
6529 }
6530
6531 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6532 ///
6533 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6534 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6535 /// them with RRF using a fixed constant of 60.
6536 ///
6537 /// ```text
6538 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
6539 /// ```
6540 ///
6541 /// Returns the top `k` nodes by fused score, ties broken by node key
6542 /// ascending (deterministic).
6543 ///
6544 /// # Vector leg fallback
6545 ///
6546 /// When `query_vec` is empty the vector leg is skipped entirely and
6547 /// results are ranked by the text list alone through the same RRF path
6548 /// (each text result scores `1/(60 + rank)` from that single list).
6549 ///
6550 /// When `label` is `None`, the vector leg **always** returns empty results.
6551 /// Internally `label` is mapped to `""`, which does not match any rule-created
6552 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6553 /// the brute-force fallback finds no nodes with an empty label. The fused
6554 /// ranking is therefore text-only in this case.
6555 pub fn search_hybrid(
6556 &self,
6557 text_field: &str,
6558 query_text: &str,
6559 vector_field: &str,
6560 query_vec: &[f64],
6561 label: Option<&str>,
6562 k: usize,
6563 ) -> Vec<(String, f64)> {
6564 use std::collections::HashMap;
6565
6566 const RRF_K: f64 = 60.0;
6567 let pool = 4 * k;
6568
6569 // Accumulate per-node RRF scores.
6570 let mut scores: HashMap<String, f64> = HashMap::new();
6571
6572 // Text leg.
6573 let text_hits = self.search(text_field, query_text);
6574 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6575 let rank = (rank0 + 1) as f64;
6576 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6577 }
6578
6579 // Vector leg (skipped when query_vec is empty).
6580 if !query_vec.is_empty() {
6581 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6582 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6583 let rank = (rank0 + 1) as f64;
6584 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6585 }
6586 }
6587
6588 // Sort: score DESC, then key ASC for deterministic tie-breaking.
6589 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6590 ranked.sort_by(|a, b| {
6591 b.1.partial_cmp(&a.1)
6592 .unwrap_or(std::cmp::Ordering::Equal)
6593 .then(a.0.cmp(&b.0))
6594 });
6595 ranked.truncate(k);
6596 ranked
6597 }
6598
6599 /// For DST/testing: scratch BM25 search over live nodes without the index.
6600 /// Walks every live node, re-stems field tokens, computes corpus stats, and
6601 /// returns BM25-ranked results.
6602 ///
6603 /// The oracle: the ordered key list of `search(field, q)` must equal that of
6604 /// `scratch_search(field, q)` at every quiescent state.
6605 #[doc(hidden)]
6606 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6607 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6608 use std::collections::BTreeMap;
6609
6610 let groups = parse_query(query);
6611 if groups.is_empty() {
6612 return vec![];
6613 }
6614
6615 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6616 struct NodeData {
6617 key: String,
6618 /// stemmed_token → positions (sorted)
6619 tokens: BTreeMap<String, Vec<u32>>,
6620 dl: u32,
6621 }
6622
6623 let mut nodes: Vec<NodeData> = Vec::new();
6624 for id in 0..self.ids.len() as u32 {
6625 let Some(key) = self.ids.key_of(id) else {
6626 continue;
6627 };
6628 let Some(&sym) = self.labels.get(id as usize) else {
6629 continue;
6630 };
6631 if sym == u32::MAX {
6632 continue;
6633 }
6634 let label = match self.syms.resolve(sym) {
6635 Some(l) => l,
6636 None => continue,
6637 };
6638 if !self.fulltext.is_enabled(label, field) {
6639 continue;
6640 }
6641 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6642 continue;
6643 };
6644 // Use value_tokens_stemmed_with_positions so list elements are
6645 // separated by POSITION_GAP — identical to the index path, which
6646 // prevents phrase queries from matching across element boundaries.
6647 let stemmed_with_pos = match &value {
6648 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6649 _ => continue,
6650 };
6651 let dl = stemmed_with_pos.len() as u32;
6652 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6653 for (tok, pos) in stemmed_with_pos {
6654 tok_map.entry(tok).or_default().push(pos);
6655 }
6656 nodes.push(NodeData {
6657 key: key.to_string(),
6658 tokens: tok_map,
6659 dl,
6660 });
6661 }
6662
6663 if nodes.is_empty() {
6664 return vec![];
6665 }
6666
6667 // --- BM25 corpus stats ---
6668 let n = nodes.len() as f64;
6669 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6670 // df per stemmed token across all live indexed nodes.
6671 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6672 for nd in &nodes {
6673 for tok in nd.tokens.keys() {
6674 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6675 }
6676 }
6677
6678 const K1: f64 = 1.2;
6679 const B: f64 = 0.75;
6680
6681 // --- Pass 2: score each node against each OR-group ---
6682 let mut results: Vec<(String, f64)> = Vec::new();
6683 for nd in &nodes {
6684 let dl = nd.dl as f64;
6685 let mut total_score = 0.0f64;
6686
6687 'group: for group in &groups {
6688 let mut group_score = 0.0f64;
6689
6690 for term in group {
6691 if term.negated {
6692 // Negated: if doc has this stemmed token → group fails.
6693 let present = if term.prefix {
6694 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6695 } else {
6696 nd.tokens.contains_key(term.token.as_str())
6697 };
6698 if present {
6699 continue 'group;
6700 }
6701 continue;
6702 }
6703 if term.prefix {
6704 // Prefix: sum BM25 for all matching stemmed tokens.
6705 let mut prefix_matched = false;
6706 for (tok, positions) in &nd.tokens {
6707 if tok.starts_with(term.token.as_str()) {
6708 let tf = positions.len() as f64;
6709 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6710 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6711 let tf_norm =
6712 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6713 group_score += idf * tf_norm;
6714 prefix_matched = true;
6715 }
6716 }
6717 if !prefix_matched {
6718 continue 'group;
6719 }
6720 } else {
6721 // term.token is already stemmed by parse_query; use directly.
6722 match nd.tokens.get(term.token.as_str()) {
6723 None => continue 'group,
6724 Some(positions) => {
6725 let tf = positions.len() as f64;
6726 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6727 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6728 let tf_norm =
6729 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6730 group_score += idf * tf_norm;
6731 }
6732 }
6733 }
6734 }
6735
6736 if group_score > 0.0 {
6737 total_score += group_score;
6738 }
6739 }
6740
6741 if total_score > 0.0 {
6742 results.push((nd.key.clone(), total_score));
6743 }
6744 }
6745
6746 results.sort_by(|a, b| {
6747 b.1.partial_cmp(&a.1)
6748 .unwrap_or(std::cmp::Ordering::Equal)
6749 .then(a.0.cmp(&b.0))
6750 });
6751 results
6752 }
6753
6754 /// Return the current view-maintained value of `view_prop` for node `key`.
6755 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6756 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6757 let id = self.ids.get(key)?;
6758 self.props_view()
6759 .get(id, view_prop)
6760 .map(|vr| vr.into_value())
6761 }
6762
6763 /// For testing / DST oracle: scratch recompute of a view value for one node.
6764 ///
6765 /// Returns `None` if the node does not exist, the view does not exist, or
6766 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6767 #[doc(hidden)]
6768 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6769 let node = self.ids.get(key)?;
6770 let def = self.view_store.views().find(|v| v.name == view_name)?;
6771 // Use TopologyView so that NeighborAgg sees base + overlay edges
6772 // without materialising a temporary Topology (I1).
6773 let topo_view = self.topo_view();
6774 core_rules::views::compute_view_value(
6775 def,
6776 node,
6777 self.props_view(),
6778 &topo_view,
6779 &self.ids,
6780 &self.syms,
6781 &self.labels,
6782 )
6783 }
6784
6785 // -----------------------------------------------------------------------
6786 // Graph algorithm API
6787 // -----------------------------------------------------------------------
6788
6789 /// Run PageRank over the unified topology (manual + derived edges).
6790 ///
6791 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6792 /// ascending). Set `config.edge_type` to restrict to one edge type.
6793 /// `config.converged` is `true` only when the power iteration converged
6794 /// within `config.max_iters` and within any time budget.
6795 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6796 let topo = build_topo_view(&self.topo, &self.base);
6797 let edge_props = self.edge_props_view();
6798 crate::algo::pagerank(
6799 &topo,
6800 &self.ids,
6801 &self.syms,
6802 &self.labels,
6803 &edge_props,
6804 config,
6805 )
6806 }
6807
6808 /// Weakly-connected components over the unified topology (treated as
6809 /// undirected regardless of how edges were inserted).
6810 ///
6811 /// Component IDs are the key of the smallest member in the component
6812 /// (deterministic). Result sorted by (component_id, key).
6813 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6814 let topo = build_topo_view(&self.topo, &self.base);
6815 let edge_props = self.edge_props_view();
6816 crate::algo::wcc(
6817 &topo,
6818 &self.ids,
6819 &self.syms,
6820 &self.labels,
6821 &edge_props,
6822 config,
6823 )
6824 }
6825
6826 /// Degree centrality for every live node.
6827 ///
6828 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6829 /// `AlgoDir::Both` = out + in (total directed degree).
6830 ///
6831 /// For one-shot ranking use this; for a live property updated on every
6832 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6833 pub fn degree_centrality(
6834 &self,
6835 config: &crate::algo::DegreeConfig,
6836 ) -> crate::algo::DegreeReport {
6837 let topo = build_topo_view(&self.topo, &self.base);
6838 let edge_props = self.edge_props_view();
6839 crate::algo::degree_centrality(
6840 &topo,
6841 &self.ids,
6842 &self.syms,
6843 &self.labels,
6844 &edge_props,
6845 config,
6846 )
6847 }
6848
6849 /// Louvain community detection over the unified topology (undirected).
6850 ///
6851 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6852 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6853 /// result (communities sorted size-desc, then smallest member key asc).
6854 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6855 let topo = build_topo_view(&self.topo, &self.base);
6856 let edge_props = self.edge_props_view();
6857 crate::algo::louvain(
6858 &topo,
6859 &self.ids,
6860 &self.syms,
6861 &self.labels,
6862 &edge_props,
6863 config,
6864 )
6865 }
6866
6867 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6868 /// atomically via a single write-batch (one WAL frame, one fsync).
6869 ///
6870 /// # Errors
6871 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6872 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6873 /// (collision check mirrors `create_view`).
6874 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6875 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6876 if self.read_only {
6877 return Err(GraphError::ReadOnly);
6878 }
6879 // Collision check: refuse if prop_name is view-managed.
6880 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6881 return Err(GraphError::RuleInvalid {
6882 detail: format!(
6883 "prop {:?} is managed by view {:?} and cannot be written as scores",
6884 prop_name, view_name
6885 ),
6886 });
6887 }
6888 // Refuse if prop_name is a view name itself (confusing namespace collision).
6889 if self.view_store.has_view(prop_name) {
6890 return Err(GraphError::RuleInvalid {
6891 detail: format!(
6892 "prop_name {:?} collides with an existing view name",
6893 prop_name
6894 ),
6895 });
6896 }
6897 // Write all scores in a single crash-atomic batch.
6898 self.write_batch(|b| {
6899 for (key, score) in scores {
6900 b.set_prop(key, prop_name, Value::Float(*score));
6901 }
6902 })?;
6903 Ok(())
6904 }
6905
6906 /// Return the value of `field` for the node with key `key`, or `None` if
6907 /// the node or field is absent. Reads through the overlay-over-base
6908 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6909 /// overlay hits; one clone per base hit).
6910 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6911 let id = self.ids.get(key)?;
6912 self.props_view().get(id, field).map(|vr| vr.into_value())
6913 }
6914
6915 pub fn has_node(&self, key: &str) -> bool {
6916 self.ids.get(key).is_some()
6917 }
6918
6919 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6920 pub(crate) fn ids(&self) -> &IdMap {
6921 &self.ids
6922 }
6923
6924 // -----------------------------------------------------------------------
6925 // Namespaces
6926 // -----------------------------------------------------------------------
6927
6928 /// The index `name` already has in `ns_names`, if any.
6929 fn ns_index_of(&self, name: &str) -> Option<u32> {
6930 self.ns_names
6931 .iter()
6932 .position(|n| n == name)
6933 .map(|i| i as u32)
6934 }
6935
6936 /// The index for `name`, appending it to `ns_names` when it is new.
6937 ///
6938 /// The table holds one entry per distinct namespace in the store — a
6939 /// tenant count, not a node count — so the linear scan is cheaper than a
6940 /// map and keeps `namespaces()` allocation-free of a second index.
6941 fn ns_index_for(&mut self, name: &str) -> u32 {
6942 match self.ns_index_of(name) {
6943 Some(i) => i,
6944 None => {
6945 self.ns_names.push(name.to_string());
6946 (self.ns_names.len() - 1) as u32
6947 }
6948 }
6949 }
6950
6951 /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6952 /// does not know (unreachable; the default is the narrowing answer).
6953 fn ns_name(&self, idx: u32) -> &str {
6954 self.ns_names
6955 .get(idx as usize)
6956 .map(String::as_str)
6957 .unwrap_or(NS_DEFAULT)
6958 }
6959
6960 /// The namespace index of dense node `id`, defaulting for an id with no
6961 /// entry (a node inserted before this handle rebuilt the array cannot
6962 /// exist: every insert path maintains it).
6963 fn node_ns_idx(&self, id: u32) -> u32 {
6964 self.node_ns
6965 .get(id as usize)
6966 .copied()
6967 .unwrap_or(NS_DEFAULT_IDX)
6968 }
6969
6970 /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6971 /// grows. Called from `apply` for every node insert, live and replayed.
6972 fn set_node_ns(&mut self, id: u32, name: &str) {
6973 let idx = if name == NS_DEFAULT {
6974 NS_DEFAULT_IDX
6975 } else {
6976 self.ns_index_for(name)
6977 };
6978 if self.node_ns.len() <= id as usize {
6979 self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6980 }
6981 self.node_ns[id as usize] = idx;
6982 }
6983
6984 /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6985 /// open or a reload, after the snapshot is restored and the WAL replayed.
6986 ///
6987 /// A store with no `ns` column reads nothing: the column-name check fails
6988 /// and the vector is filled with one constant.
6989 fn rebuild_node_ns(&mut self) {
6990 let total = self.ids.len();
6991 self.ns_names.truncate(1);
6992 self.node_ns.clear();
6993 self.node_ns.resize(total, NS_DEFAULT_IDX);
6994 let has_ns_column = {
6995 let cv = self.props_view();
6996 cv.field_names().iter().any(|f| f == NS_PROP)
6997 };
6998 if !has_ns_column {
6999 return;
7000 }
7001 // Collected first so the props view is released before `ns_index_for`
7002 // takes `&mut self`.
7003 let named: Vec<(u32, String)> = {
7004 let cv = self.props_view();
7005 (0..total as u32)
7006 .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
7007 Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
7008 _ => None,
7009 })
7010 .collect()
7011 };
7012 for (id, name) in named {
7013 let idx = self.ns_index_for(&name);
7014 self.node_ns[id as usize] = idx;
7015 }
7016 }
7017
7018 /// Every namespace with at least one live node, in name order.
7019 ///
7020 /// `["default"]` on any store that has never named a namespace, including
7021 /// an empty one: a store is always at least its default namespace.
7022 pub fn namespaces(&self) -> Vec<String> {
7023 let mut out: BTreeSet<&str> = BTreeSet::new();
7024 out.insert(NS_DEFAULT);
7025 for (id, &idx) in self.node_ns.iter().enumerate() {
7026 if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
7027 continue;
7028 }
7029 out.insert(self.ns_name(idx));
7030 }
7031 out.into_iter().map(str::to_string).collect()
7032 }
7033
7034 /// The namespace of `key`, or `None` when the key names no live node.
7035 pub fn namespace_of(&self, key: &str) -> Option<String> {
7036 let id = self.ids.get(key)?;
7037 if !self.is_live_node(id) {
7038 return None;
7039 }
7040 Some(self.ns_name(self.node_ns_idx(id)).to_string())
7041 }
7042
7043 /// Every live node in `namespace`, as a visibility mask.
7044 ///
7045 /// Built off `node_ns` on whichever handle this is, so on a temporal handle
7046 /// it is the namespace's membership at that commit. A name no node uses
7047 /// gives an empty mask — a namespace scope never widens.
7048 pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
7049 let Some(idx) = self.ns_index_of(namespace) else {
7050 return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
7051 };
7052 let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
7053 .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
7054 .collect();
7055 crate::mask::NodeMask::from_ids(visible)
7056 }
7057
7058 /// Live-node test used by the namespace accessors: a deleted node keeps its
7059 /// dense id and its `node_ns` slot, and the label sentinel is what marks it
7060 /// gone — the same test `mask_for_role`'s label leg applies implicitly.
7061 fn is_live_node(&self, id: u32) -> bool {
7062 self.labels
7063 .get(id as usize)
7064 .is_some_and(|&sym| sym != u32::MAX)
7065 && self.ids.key_of(id).is_some()
7066 }
7067
7068 /// Per-namespace live node counts for [`Stats`], in name order.
7069 fn namespace_stats(&self) -> Vec<NamespaceStats> {
7070 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
7071 counts.insert(NS_DEFAULT, 0);
7072 for id in 0..self.ids.len() as u32 {
7073 if !self.is_live_node(id) {
7074 continue;
7075 }
7076 *counts
7077 .entry(self.ns_name(self.node_ns_idx(id)))
7078 .or_insert(0) += 1;
7079 }
7080 counts
7081 .into_iter()
7082 .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
7083 .map(|(name, nodes_live)| NamespaceStats {
7084 name: name.to_string(),
7085 nodes_live,
7086 })
7087 .collect()
7088 }
7089
7090 /// The namespace a create-class op would put its node in: the `ns` entry of
7091 /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
7092 fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
7093 Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
7094 }
7095
7096 /// The one `ns` entry in a node's props, or `None` when it carries none.
7097 ///
7098 /// A props list naming `ns` twice is refused. Without that refusal the
7099 /// write path and the authorisation path can read the same list
7100 /// differently — one taking the first entry, the other the last — and
7101 /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
7102 /// the role was checked against the other of. One entry is the only shape
7103 /// where "the node's namespace" is a single fact, so it is the only shape
7104 /// accepted, and every reader of it agrees by construction.
7105 fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
7106 let mut found: Option<&'a Value> = None;
7107 for (field, value) in props {
7108 if field != NS_PROP {
7109 continue;
7110 }
7111 if found.is_some() {
7112 return Err(GraphError::RuleInvalid {
7113 detail: format!(
7114 "node {key}: {NS_PROP} is given more than once; a node has exactly \
7115 one namespace"
7116 ),
7117 });
7118 }
7119 found = Some(value);
7120 }
7121 Ok(found)
7122 }
7123
7124 /// The definition of the role a write authorisation names.
7125 ///
7126 /// `None` when `roles.json` was corrupt at open or the role has since been
7127 /// removed — neither can reach a write, because the authorisation carries a
7128 /// mask `mask_for_role` already resolved for that name.
7129 fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
7130 self.roles.as_ref()?.iter().find(|r| r.name == role)
7131 }
7132
7133 /// Validate the `ns` entry of a node's props and drop an explicit default.
7134 ///
7135 /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
7136 /// a record that reached the WAL was already accepted here.
7137 fn normalise_insert_ns(
7138 key: &str,
7139 props: Vec<(String, Value)>,
7140 ) -> Result<(Vec<(String, Value)>, String)> {
7141 // One `ns` or none: this is where that is enforced, so every later
7142 // reader of the list — the authorisation gate, the two `apply` arms,
7143 // `node_ns` — is looking at a single entry and cannot disagree about
7144 // which one counts.
7145 Self::sole_ns_entry(key, &props)?;
7146 let mut name = NS_DEFAULT.to_string();
7147 let mut out = Vec::with_capacity(props.len());
7148 for (field, value) in props {
7149 if field != NS_PROP {
7150 out.push((field, value));
7151 continue;
7152 }
7153 let Value::Str(ref s) = value else {
7154 return Err(GraphError::RuleInvalid {
7155 detail: format!(
7156 "node {key}: {NS_PROP} must be a string naming a namespace, \
7157 got {value:?}"
7158 ),
7159 });
7160 };
7161 if !valid_namespace(s) {
7162 return Err(GraphError::RuleInvalid {
7163 detail: format!(
7164 "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
7165 characters of [A-Za-z0-9_.-]"
7166 ),
7167 });
7168 }
7169 name = s.clone();
7170 // An explicit default stores nothing, so a single-tenant store
7171 // never grows an `ns` column.
7172 if name != NS_DEFAULT {
7173 out.push((field, value));
7174 }
7175 }
7176 Ok((out, name))
7177 }
7178
7179 // -----------------------------------------------------------------------
7180 // RBAC role resolution
7181 // -----------------------------------------------------------------------
7182
7183 /// Parse `roles.json` bytes from `fs`.
7184 ///
7185 /// Return values:
7186 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
7187 /// and valid; in both cases `mask_for_role` uses the
7188 /// list normally (an absent file means no roles defined).
7189 /// `Ok(None)` — file present but corrupt or unrecognised version
7190 /// → poisoned state; `mask_for_role` returns `Err` for
7191 /// any role name until the file is fixed and the DB
7192 /// re-opened (or `apply_schema` is called to repair it).
7193 ///
7194 /// Note: `None` signals corruption, not absence — the opposite of what an
7195 /// optional "file missing" convention would suggest. The open path stores
7196 /// this result on `db.roles` directly.
7197 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
7198 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
7199 if bytes.is_empty() {
7200 // Empty bytes means either the file is absent or zero-byte — both
7201 // are treated identically as "no roles defined". A zero-byte
7202 // roles.json does NOT widen access: an absent file and a zero-byte
7203 // file both resolve to an empty role list (sees nothing by default).
7204 return Ok(Some(vec![]));
7205 }
7206 match serde_json::from_slice::<RolesFile>(&bytes) {
7207 Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7208 // Corrupt or unrecognised version (>4): poison the roles state.
7209 // Never widen: a version this binary does not know may carry a
7210 // narrowing this binary would not apply.
7211 _ => Ok(None),
7212 }
7213 }
7214
7215 /// Resolve a role to a node-visibility mask against the current graph state.
7216 ///
7217 /// Returns `Err` when:
7218 /// - `roles.json` was present but corrupt at open (poisoned state), or
7219 /// - `role` does not match any defined role name.
7220 ///
7221 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7222 /// all live nodes carrying any label in `labels` that also pass the role's
7223 /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7224 /// has one. Label resolution is live — new nodes of an allowed label are
7225 /// visible without re-applying the schema, and a property edited out of the
7226 /// predicate takes its node out of the mask on the next read. An empty
7227 /// union = empty mask = sees nothing.
7228 ///
7229 /// This is the one resolver every read path calls, live and as-of alike, so
7230 /// the predicate applies everywhere at once. On an as-of handle the role
7231 /// *definition* is the current one and the graph is the historical one: the
7232 /// predicate is evaluated against the property values at the commit being
7233 /// read.
7234 ///
7235 /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7236 /// between two writes resolves the role once. See
7237 /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7238 /// stale.
7239 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7240 self.role_masks
7241 .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7242 .map(|m| (*m).clone())
7243 }
7244
7245 /// The mask an [`AsOfScope`] names, resolved against this handle.
7246 ///
7247 /// Shared by [`GraphDb::query_at_scoped`] and
7248 /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7249 /// however the namespace leg is added.
7250 fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7251 // One resolver answers "what may this role see" — `mask_for_role` — and
7252 // it runs against this handle, so on a temporal one the answer is the
7253 // as-of one.
7254 Ok(match scope {
7255 AsOfScope::Role(role) => self.mask_for_role(role)?,
7256 AsOfScope::Keys(keys) => {
7257 crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7258 }
7259 AsOfScope::RoleAndKeys(role, keys) => {
7260 self.mask_for_role(role)?
7261 .intersect(&crate::mask::NodeMask::from_keys(
7262 self,
7263 keys.iter().map(String::as_str),
7264 ))
7265 }
7266 AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7267 })
7268 }
7269
7270 /// Resolve `role` against the current graph, ignoring the memo.
7271 fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7272 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7273 detail:
7274 "roles.json was corrupt at open; fix the file and re-open to restore role access"
7275 .into(),
7276 })?;
7277 let def = roles
7278 .iter()
7279 .find(|r| r.name == role)
7280 .ok_or_else(|| GraphError::KeyNotFound {
7281 key: format!("role:{role}"),
7282 })?;
7283
7284 let mut visible = std::collections::HashSet::new();
7285
7286 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7287 // An administrative grant, never narrowed by the predicate.
7288 for key in &def.keys {
7289 if let Some(id) = self.ids.get(key) {
7290 visible.insert(id);
7291 }
7292 }
7293
7294 // Label leg: live scan — iterate labels vec for matching symbol, and
7295 // when the role carries a predicate, test the property as well. The
7296 // property comes from the store's own merged view (overlay over the
7297 // mmap'd base), so an as-of handle reads the values of its own commit.
7298 let props = def.visible_where.as_ref().map(|_| self.props_view());
7299 for label_name in &def.labels {
7300 if let Some(sym) = self.syms.get(label_name) {
7301 for (i, &s) in self.labels.iter().enumerate() {
7302 if s != sym {
7303 continue;
7304 }
7305 let id = i as u32;
7306 match (&def.visible_where, &props) {
7307 (Some(pred), Some(view)) => {
7308 let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7309 if pred.holds(value.as_ref()) {
7310 visible.insert(id);
7311 }
7312 }
7313 _ => {
7314 visible.insert(id);
7315 }
7316 }
7317 }
7318 }
7319 }
7320
7321 // Namespace leg: an intersection over the whole union, the key leg
7322 // included. A namespace is a tenancy boundary, so a key naming a node in
7323 // another tenant's namespace is not an administrative grant — and
7324 // `apply_schema` has already refused that role, so this only has to be
7325 // right about the node that moved into existence afterwards.
7326 if def.namespaces.is_some() {
7327 visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7328 }
7329
7330 Ok(crate::mask::NodeMask::from_ids(visible))
7331 }
7332
7333 /// Return the current list of role definitions.
7334 ///
7335 /// Returns an empty list when no roles are defined or when `roles.json`
7336 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7337 /// the fail-loud error in that case).
7338 pub fn roles(&self) -> Vec<RoleDef> {
7339 self.roles.as_deref().unwrap_or(&[]).to_vec()
7340 }
7341
7342 // ── Role-scoped write authz ───────────────────────────────────────────────
7343
7344 /// Execute `ops` with optional role-scoped write authorization.
7345 ///
7346 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7347 /// (zero-cost bypass of all authz checks).
7348 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7349 /// record is built. A denial returns an error with no WAL frame written
7350 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7351 ///
7352 /// See the plan's "authz decision table" section for the full semantics.
7353 pub fn write_batch_authz(
7354 &mut self,
7355 authz: Option<&WriteAuthz>,
7356 ops: Vec<BatchOp>,
7357 ) -> Result<(usize, usize)> {
7358 // Thread authz as a direct parameter — never touches pending_write_authz.
7359 self.commit_logged_batch(ops, None, authz.cloned())
7360 }
7361
7362 /// Execute a Cypher write statement with role-scoped write authorization.
7363 ///
7364 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7365 /// lifetime as execution, satisfying §5 lock discipline). The resolved
7366 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7367 /// call so that all inner `batch.commit()` calls are authz-checked.
7368 ///
7369 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7370 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7371 /// timing-oracle item (hidden ≡ absent for unscoped roles).
7372 ///
7373 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7374 /// "this endpoint is not permitted".
7375 pub fn query_write_authz(
7376 &mut self,
7377 role: &str,
7378 cypher: &str,
7379 params: &BTreeMap<String, Value>,
7380 ) -> Result<ResultSet> {
7381 // Resolve scope (fails fast if role has no write scope).
7382 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7383 let scope =
7384 {
7385 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7386 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7387 })?;
7388 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7389 GraphError::KeyNotFound {
7390 key: format!("role:{role}"),
7391 }
7392 })?;
7393 def.write
7394 .clone()
7395 .ok_or_else(|| GraphError::RoleWriteDenied {
7396 reason: "role-bound token: writes are not permitted".into(),
7397 })?
7398 };
7399 // Resolve mask inside the call (same guard, §5 coherence).
7400 let mask = self.mask_for_role(role)?;
7401 self.pending_write_authz = Some(WriteAuthz {
7402 role: role.into(),
7403 scope,
7404 mask,
7405 });
7406 // RAII guard: always clears pending_write_authz on scope exit, including
7407 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7408 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7409 impl Drop for ClearPendingAuthzOnDrop {
7410 fn drop(&mut self) {
7411 // SAFETY: pointer into the owning GraphDb; guard is dropped
7412 // within this function's frame before it returns.
7413 unsafe { *self.0 = None };
7414 }
7415 }
7416 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7417 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7418 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7419 detail: format!("lex: {e}"),
7420 })?;
7421 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7422 detail: format!("parse: {e}"),
7423 })?;
7424 self.exec_write_stmt(stmt, params)
7425 }
7426
7427 /// Execute `ops` with optional role-scoped write authorization, suppressing
7428 /// fsync (for use inside the group-commit drain thread, which performs one
7429 /// group fsync after releasing the write lock).
7430 ///
7431 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7432 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7433 /// contract established by [`commit_batch_nosync`].
7434 pub(crate) fn write_batch_authz_nosync(
7435 &mut self,
7436 authz: Option<&WriteAuthz>,
7437 ops: Vec<BatchOp>,
7438 ) -> Result<(usize, usize)> {
7439 let saved = self.fsync;
7440 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7441 impl Drop for RestoreFsync {
7442 fn drop(&mut self) {
7443 // SAFETY: pointer into the owning GraphDb; guard is dropped
7444 // within the enclosing function's frame before it returns.
7445 unsafe { *self.0 = self.1 };
7446 }
7447 }
7448 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7449 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7450 self.fsync = FsyncPolicy::Relaxed;
7451 self.commit_logged_batch(ops, None, authz.cloned())
7452 }
7453
7454 /// Execute a `/ingest` request with role-scoped write authorization.
7455 ///
7456 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7457 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7458 /// Sets `pending_write_authz` for the duration of the call so that the
7459 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7460 /// and evaluates the decision table per-op before any WAL write.
7461 ///
7462 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7463 /// denied by the decision table with the appropriate §4.3 scope reason;
7464 /// no special HTTP-layer check is needed.
7465 ///
7466 /// Roles with `write: None` return `RoleWriteDenied` with
7467 /// "writes are not permitted" (byte-identical to v1 blanket 403).
7468 pub fn ingest_with_edges_authz(
7469 &mut self,
7470 role: &str,
7471 label: &str,
7472 rows: Vec<std::collections::BTreeMap<String, Value>>,
7473 opts: &crate::ingest::IngestOptions,
7474 edges: &[(String, String, String)],
7475 ) -> Result<crate::ingest::IngestReport> {
7476 // Resolve scope (fails fast if role has no write scope).
7477 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7478 let scope =
7479 {
7480 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7481 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7482 })?;
7483 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7484 GraphError::KeyNotFound {
7485 key: format!("role:{role}"),
7486 }
7487 })?;
7488 def.write
7489 .clone()
7490 .ok_or_else(|| GraphError::RoleWriteDenied {
7491 reason: "role-bound token: writes are not permitted".into(),
7492 })?
7493 };
7494 let mask = self.mask_for_role(role)?;
7495 self.pending_write_authz = Some(WriteAuthz {
7496 role: role.into(),
7497 scope,
7498 mask,
7499 });
7500 // RAII guard: always clears pending_write_authz on scope exit, including
7501 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7502 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7503 impl Drop for ClearPendingAuthzOnDrop {
7504 fn drop(&mut self) {
7505 // SAFETY: pointer into the owning GraphDb; guard is dropped
7506 // within this function's frame before it returns.
7507 unsafe { *self.0 = None };
7508 }
7509 }
7510 // SAFETY: raw pointer into self; guard dropped before this fn returns.
7511 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7512 self.ingest_with_edges(label, rows, opts, edges)
7513 }
7514
7515 /// Evaluate the write-authz decision table for one `BatchOp`.
7516 ///
7517 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7518 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
7519 /// the remaining ops are not evaluated and no WAL frame is written.
7520 ///
7521 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7522 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
7523 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7524 /// batch creates counts as visible if its label passed the create-class gate").
7525 fn check_single_op_authz(
7526 &self,
7527 authz: &WriteAuthz,
7528 op: &BatchOp,
7529 batch_created: &BTreeMap<String, String>,
7530 ) -> Result<()> {
7531 // Helper: 3-way node status under the authz mask.
7532 //
7533 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7534 // as Visible with their recorded label — their create gate already passed
7535 // and they are not yet in self.ids (not committed). This fixes the
7536 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7537 // the SetProp must not see the node as Absent.
7538 let node_status = |key: &str| -> NodeAuthzStatus {
7539 if let Some(label) = batch_created.get(key) {
7540 return NodeAuthzStatus::Visible(label.clone());
7541 }
7542 match self.ids.get(key) {
7543 None => NodeAuthzStatus::Absent,
7544 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7545 Some(id) => {
7546 let label = self
7547 .labels
7548 .get(id as usize)
7549 .and_then(|&sym| {
7550 if sym == u32::MAX {
7551 None
7552 } else {
7553 self.syms.resolve(sym).map(str::to_string)
7554 }
7555 })
7556 .unwrap_or_default();
7557 NodeAuthzStatus::Visible(label)
7558 }
7559 }
7560 };
7561
7562 // Helper: is an InsertEdgeUpsert endpoint visible?
7563 // A same-batch placeholder counts as visible if its label passed
7564 // the create-class gate (spec "upsert placeholder-counts-as-visible").
7565 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7566 // In store and visible?
7567 if let Some(id) = self.ids.get(ep_key) {
7568 return authz.mask.contains_id(id);
7569 }
7570 // Created by an earlier op in this batch?
7571 if let Some(created_label) = batch_created.get(ep_key) {
7572 return authz.scope.create_labels.contains(created_label);
7573 }
7574 // Will be created by THIS InsertEdgeUpsert: placeholder_label
7575 // must pass the create-class gate.
7576 authz
7577 .scope
7578 .create_labels
7579 .contains(&placeholder_label.to_string())
7580 };
7581
7582 match op {
7583 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7584 // These ops are never routed to role-scoped paths by the HTTP layer,
7585 // but we 403 them here to close any future bypass route.
7586 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7587 return Err(GraphError::RoleWriteDenied {
7588 reason: "role-bound token: this endpoint is not permitted".into(),
7589 });
7590 }
7591
7592 // ── CREATE-class: InsertNode ─────────────────────────────────────
7593 //
7594 // Decision table row 1 (scope-before-lookup): check label in
7595 // create_labels BEFORE any key lookup. This is the structural
7596 // closure of the §6.2 timing-oracle item — the denial fires even
7597 // when the store is EMPTY (see test_create_scope_denied_empty_store).
7598 BatchOp::InsertNode { label, key, props } => {
7599 if !authz.scope.create_labels.contains(label) {
7600 return Err(GraphError::RoleWriteDenied {
7601 reason: format!(
7602 "role-bound token: label '{}' not in write scope (create_labels)",
7603 label
7604 ),
7605 });
7606 }
7607 // A role bound to namespaces may only create inside them. The
7608 // never-widen rule is about what a write makes visible to *any*
7609 // party, not only to the writer: a node this role could never
7610 // read back is a write into somebody else's tenancy. Also a
7611 // scope check, so it runs before the key lookup — it discloses
7612 // nothing about the store. Covers Cypher `CREATE` and the node
7613 // `MERGE` creates, both of which arrive as this op.
7614 // Resolved before the role lookup so a props list naming `ns`
7615 // twice is refused for every role, scoped or not: it is the same
7616 // malformed write the seam refuses, and leaving it to the seam
7617 // would mean the gate had already read one of the two.
7618 let target = Self::created_namespace(key, props)?;
7619 if let Some(def) = self.role_def_for(&authz.role) {
7620 if !def.sees_namespace(target) {
7621 return Err(GraphError::RoleWriteDenied {
7622 reason: format!(
7623 "role-bound token: namespace '{target}' not in the role's \
7624 namespaces"
7625 ),
7626 });
7627 }
7628 }
7629 // Row 2/3: key lookup.
7630 match self.ids.get(key.as_str()) {
7631 Some(id) if authz.mask.contains_id(id) => {
7632 // Visible: DuplicateKey — let MutPreview handle this.
7633 }
7634 Some(_) => {
7635 // Hidden: indistinguishable from absent to the role.
7636 return Err(GraphError::RoleWriteDenied {
7637 reason: "role-bound token: target node not visible".into(),
7638 });
7639 }
7640 None => {
7641 // Absent: proceed (create).
7642 }
7643 }
7644 }
7645
7646 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7647 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7648 if batch_created.contains_key(key.as_str()) {
7649 // Batch-created node: create gate already passed this batch.
7650 // Updating it in the same batch is always allowed, regardless
7651 // of update_labels (ruling §3.5: "writer just created it").
7652 } else {
7653 let label = match node_status(key) {
7654 NodeAuthzStatus::Visible(lbl) => lbl,
7655 _ => {
7656 return Err(GraphError::RoleWriteDenied {
7657 reason: "role-bound token: target node not visible".into(),
7658 });
7659 }
7660 };
7661 if !authz.scope.update_labels.contains(&label) {
7662 return Err(GraphError::RoleWriteDenied {
7663 reason: format!(
7664 "role-bound token: label '{}' not in write scope (update_labels)",
7665 label
7666 ),
7667 });
7668 }
7669 }
7670 }
7671
7672 // ── DELETE-class: DeleteNode ─────────────────────────────────────
7673 BatchOp::DeleteNode { key } => {
7674 let label = match node_status(key) {
7675 NodeAuthzStatus::Visible(lbl) => lbl,
7676 _ => {
7677 return Err(GraphError::RoleWriteDenied {
7678 reason: "role-bound token: target node not visible".into(),
7679 });
7680 }
7681 };
7682 if !authz.scope.delete_labels.contains(&label) {
7683 return Err(GraphError::RoleWriteDenied {
7684 reason: format!(
7685 "role-bound token: label '{}' not in write scope (delete_labels)",
7686 label
7687 ),
7688 });
7689 }
7690 }
7691
7692 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7693 //
7694 // Derived-edge rejection runs BEFORE the delete_edge_types scope
7695 // check (spec §3.5: "existing derived-edge rejection precedes
7696 // delete_edge_types check").
7697 BatchOp::DeleteEdge {
7698 edge_type,
7699 src_key,
7700 dst_key,
7701 } => {
7702 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7703 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7704 self.ids.get(src_key.as_str()),
7705 self.ids.get(dst_key.as_str()),
7706 self.syms.get(edge_type.as_str()),
7707 ) {
7708 if self.engine.is_owned(et_sym, src_id, dst_id) {
7709 return Err(GraphError::RuleOwned {
7710 detail: format!(
7711 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7712 delete or change the owning rule"
7713 ),
7714 });
7715 }
7716 // Also check would_derive via MutPreview (empty overlay, pre-batch).
7717 let preview = MutPreview::new(self);
7718 if preview.would_derive(edge_type, src_key, dst_key) {
7719 return Err(GraphError::RuleOwned {
7720 detail: format!(
7721 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7722 delete or change the owning rule, or a live rule would \
7723 re-derive it"
7724 ),
7725 });
7726 }
7727 }
7728 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7729 if !authz.scope.delete_edge_types.contains(edge_type) {
7730 return Err(GraphError::RoleWriteDenied {
7731 reason: format!(
7732 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7733 edge_type
7734 ),
7735 });
7736 }
7737 // Both endpoints must be visible.
7738 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7739 match self.ids.get(ep_key) {
7740 None => {
7741 return Err(GraphError::RoleWriteDenied {
7742 reason: "role-bound token: edge endpoint not visible".into(),
7743 });
7744 }
7745 Some(id) if !authz.mask.contains_id(id) => {
7746 return Err(GraphError::RoleWriteDenied {
7747 reason: "role-bound token: edge endpoint not visible".into(),
7748 });
7749 }
7750 _ => {}
7751 }
7752 }
7753 }
7754
7755 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7756 //
7757 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7758 BatchOp::InsertEdge {
7759 edge_type,
7760 src_key,
7761 dst_key,
7762 } => {
7763 if !authz.scope.create_edge_types.contains(edge_type) {
7764 return Err(GraphError::RoleWriteDenied {
7765 reason: format!(
7766 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7767 edge_type
7768 ),
7769 });
7770 }
7771 // Both endpoints must be visible. A node created by an earlier
7772 // InsertNode in the same batch (tracked in batch_created) counts
7773 // as visible if its label passed the create-class gate.
7774 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7775 if batch_created.contains_key(ep_key) {
7776 // Created earlier this batch — already scope-checked.
7777 continue;
7778 }
7779 match self.ids.get(ep_key) {
7780 None => {
7781 return Err(GraphError::RoleWriteDenied {
7782 reason: "role-bound token: edge endpoint not visible".into(),
7783 });
7784 }
7785 Some(id) if !authz.mask.contains_id(id) => {
7786 return Err(GraphError::RoleWriteDenied {
7787 reason: "role-bound token: edge endpoint not visible".into(),
7788 });
7789 }
7790 _ => {}
7791 }
7792 }
7793 }
7794
7795 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7796 //
7797 // Scope check first; then endpoint visibility using same-batch
7798 // placeholder awareness (spec: "a placeholder endpoint the SAME
7799 // batch creates counts as visible if its label passed the
7800 // create-class gate").
7801 BatchOp::InsertEdgeUpsert {
7802 edge_type,
7803 src_key,
7804 dst_key,
7805 placeholder_label,
7806 } => {
7807 if !authz.scope.create_edge_types.contains(edge_type) {
7808 return Err(GraphError::RoleWriteDenied {
7809 reason: format!(
7810 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7811 edge_type
7812 ),
7813 });
7814 }
7815 // Check placeholder label against create_labels (create-class gate).
7816 // This ensures the auto-created endpoints are scope-allowed.
7817 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7818 if !upsert_ep_visible(ep_key, placeholder_label) {
7819 return Err(GraphError::RoleWriteDenied {
7820 reason: "role-bound token: edge endpoint not visible".into(),
7821 });
7822 }
7823 }
7824 // A placeholder is created with no props, so it lands in the
7825 // default namespace. A role that cannot read `default` must not
7826 // create one there, for the same reason it may not create a node
7827 // there outright.
7828 //
7829 // The refusal is byte-identical to the hidden-endpoint one above,
7830 // and deliberately so: this arm fires only for an endpoint that
7831 // does **not** exist, and the one above only for an endpoint that
7832 // does. Two different strings would make the pair an existence
7833 // oracle — ask for an upsert and read off whether the key is
7834 // taken. Hidden ≡ absent is the rule everywhere else in this
7835 // table and it holds here too.
7836 if let Some(def) = self.role_def_for(&authz.role) {
7837 if !def.sees_namespace(NS_DEFAULT) {
7838 for ep_key in [src_key.as_str(), dst_key.as_str()] {
7839 if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7840 {
7841 return Err(GraphError::RoleWriteDenied {
7842 reason: "role-bound token: edge endpoint not visible".into(),
7843 });
7844 }
7845 }
7846 }
7847 }
7848 }
7849 }
7850 Ok(())
7851 }
7852
7853 /// Write `roles` to `roles.json` atomically and update the in-memory list.
7854 ///
7855 /// Called by `apply_schema` when roles change. Never called on unchanged
7856 /// re-apply — this preserves byte-identical idempotency.
7857 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7858 let file = RolesFile::new_versioned(roles.clone());
7859 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7860 detail: format!("roles serialization: {e}"),
7861 })?;
7862 self.fs
7863 .write_atomic(FileId::Roles, &bytes)
7864 .map_err(GraphError::Io)?;
7865 self.roles = Some(roles);
7866 // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7867 // and a memoised mask would still match its version. Install a fresh
7868 // cache instead of clearing the shared one: a reader snapshot frozen
7869 // against the old definitions keeps the old `Arc` to itself and can
7870 // never publish an answer this handle would read back.
7871 self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7872 // Refresh the MVCC frozen overlay so that reader() immediately sees the
7873 // updated role definitions without waiting for the next K-commit fold.
7874 self.fold_now();
7875 Ok(())
7876 }
7877
7878 fn view(&self) -> GraphView<'_> {
7879 GraphView {
7880 ids: &self.ids,
7881 syms: &self.syms,
7882 labels: &self.labels,
7883 props: self.props_view(),
7884 topo: self.topo_view(),
7885 edge_props: self.edge_props_view(),
7886 mask: None,
7887 prop_index: Some(&self.prop_index),
7888 }
7889 }
7890
7891 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7892 GraphView {
7893 ids: &self.ids,
7894 syms: &self.syms,
7895 labels: &self.labels,
7896 props: self.props_view(),
7897 topo: self.topo_view(),
7898 edge_props: self.edge_props_view(),
7899 mask: Some(&mask.visible),
7900 prop_index: Some(&self.prop_index),
7901 }
7902 }
7903
7904 /// Execute a read-only Cypher query with a node visibility mask.
7905 ///
7906 /// Only nodes whose key is in `mask` are accessible: label scans, key
7907 /// lookups, and neighbor expansions all respect the mask. Edges where
7908 /// either endpoint is hidden are silently dropped.
7909 ///
7910 /// Returns `Err` with a "masked queries are read-only" message when
7911 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7912 pub fn query_masked(
7913 &self,
7914 cypher: &str,
7915 params: &std::collections::BTreeMap<String, Value>,
7916 mask: &crate::mask::NodeMask,
7917 ) -> Result<ResultSet> {
7918 // Reject write statements up front.
7919 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7920 detail: format!("lex: {e}"),
7921 })?;
7922 if is_write_tokens(&tokens) {
7923 return Err(GraphError::MaskedReadOnly);
7924 }
7925 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7926 detail: format!("parse: {e}"),
7927 })?;
7928 // Each UNION part executes against the same masked view, so the mask
7929 // applies uniformly across the chain.
7930 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7931 GraphError::QueryError {
7932 detail: format!("execute: {e}"),
7933 }
7934 })
7935 }
7936
7937 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7938 let id = self.ids.get(key)?;
7939 Some(NodeRef { db: self, id })
7940 }
7941
7942 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7943 ///
7944 /// Hidden nodes are never used as traversal intermediaries in either
7945 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7946 /// only through a hidden node will not appear in results.
7947 ///
7948 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7949 /// a visited visible node are appended to the result as stub rows
7950 /// (`label` column is `null`, same key+depth columns as visible rows).
7951 /// They are NOT added to the BFS frontier.
7952 ///
7953 /// Returns `None` when `key` does not exist (caller should 404).
7954 ///
7955 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7956 /// stub rows are never produced on the role path.
7957 pub fn neighborhood_masked(
7958 &self,
7959 key: &str,
7960 depth: u32,
7961 edge_types: Option<&[&str]>,
7962 dir: Dir,
7963 mask: &crate::mask::NodeMask,
7964 ) -> Option<ResultSet> {
7965 let start_id = self.ids.get(key)?;
7966 let view = self.view_masked(mask);
7967 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7968 names
7969 .iter()
7970 .filter_map(|name| view.syms.get(name))
7971 .collect()
7972 });
7973 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7974 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7975 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7976 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7977 visited.push((start_id, 0));
7978 for (nid, d) in &nb.nodes {
7979 let k = view.key_of(*nid);
7980 let label = view
7981 .label_of(*nid)
7982 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7983 rs.push_row(vec![
7984 Some(Value::Str(k.to_string())),
7985 Some(Value::Str(label.to_string())),
7986 Some(Value::Int(*d as i64)),
7987 ]);
7988 visited.push((*nid, *d));
7989 }
7990 // Stub mode: add hidden direct neighbours of each visited node as stubs.
7991 // Hidden nodes are edge-endpoints only — they are not added to the BFS
7992 // frontier, so the BFS never expands through them.
7993 if mask.mode() == crate::mask::MaskMode::Stub {
7994 let raw_view = self.view();
7995 let mut seen: std::collections::HashSet<u32> =
7996 visited.iter().map(|(id, _)| *id).collect();
7997 for (node_id, node_depth) in &visited {
7998 if *node_depth >= depth {
7999 continue;
8000 }
8001 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
8002 let nbr = if e.src == *node_id { e.dst } else { e.src };
8003 if !mask.contains_id(nbr) && seen.insert(nbr) {
8004 if let Some(k) = self.ids.key_of(nbr) {
8005 rs.push_row(vec![
8006 Some(Value::Str(k.to_string())),
8007 None,
8008 Some(Value::Int((*node_depth + 1) as i64)),
8009 ]);
8010 }
8011 }
8012 }
8013 }
8014 }
8015 Some(rs)
8016 }
8017
8018 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
8019 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
8020 let n = self.node_ref(key)?;
8021 Some(NodeInfo {
8022 key: n.key().to_string(),
8023 label: n.label().to_string(),
8024 props: n.props(),
8025 })
8026 }
8027
8028 /// Look up a node with mask awareness.
8029 ///
8030 /// | Key state | Omit mode | Stub mode |
8031 /// |-------------------|-----------------|------------------------|
8032 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
8033 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
8034 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
8035 ///
8036 /// **SECURITY**: only call from client-mask (full-token) paths.
8037 /// Role-token paths must use [`node_info`] after an explicit visibility check.
8038 pub fn node_info_masked(
8039 &self,
8040 key: &str,
8041 mask: &crate::mask::NodeMask,
8042 ) -> Option<MaskedNodeResult> {
8043 let id = self.ids.get(key)?;
8044 if mask.contains_id(id) {
8045 Some(MaskedNodeResult::Visible(self.node_info(key)?))
8046 } else {
8047 match mask.mode() {
8048 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
8049 crate::mask::MaskMode::Omit => None,
8050 }
8051 }
8052 }
8053
8054 /// Get edges for `key` with mask-aware hidden-endpoint handling.
8055 ///
8056 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
8057 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
8058 /// is `true` for each hidden endpoint.
8059 ///
8060 /// Unknown key → [`GraphError::KeyNotFound`].
8061 ///
8062 /// **SECURITY**: only call from client-mask (full-token) paths.
8063 pub fn node_edges_masked(
8064 &self,
8065 key: &str,
8066 mask: &crate::mask::NodeMask,
8067 ) -> Result<Vec<MaskedEdge>> {
8068 self.ensure_v8_base_sections_loaded();
8069 let id = self
8070 .ids
8071 .get(key)
8072 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8073 let derived: BTreeSet<(u32, u32, u32)> = self
8074 .engine
8075 .provenance_touching(id)
8076 .map(|(_rule, etype, src, dst)| (etype, src, dst))
8077 .collect();
8078 let mut edges = Vec::new();
8079 let tv = self.topo_view();
8080 for etype in tv.etypes() {
8081 // etype comes from the archived CSR (access_unchecked, no eager CRC).
8082 // A bit-flip in the large TOPOLOGY section can produce an etype id
8083 // that is not in the interner. Return Corrupt rather than panic.
8084 let edge_type = self
8085 .syms
8086 .resolve(etype)
8087 .ok_or_else(|| GraphError::Corrupt {
8088 detail: format!("v8: topology etype {etype} not in interner"),
8089 })?
8090 .to_string();
8091 for dir in [Direction::Out, Direction::In] {
8092 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8093 let nbr_restricted = !mask.contains_id(nbr);
8094 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
8095 continue;
8096 }
8097 let nbr_key = self
8098 .ids
8099 .key_of(nbr)
8100 .ok_or_else(|| GraphError::Corrupt {
8101 detail: format!("topology id {nbr} has no key"),
8102 })?
8103 .to_string();
8104 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
8105 match dir {
8106 Direction::Out => {
8107 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
8108 }
8109 Direction::In => {
8110 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
8111 }
8112 };
8113 edges.push(MaskedEdge {
8114 edge_type: edge_type.clone(),
8115 src_key,
8116 src_restricted,
8117 dst_key,
8118 dst_restricted,
8119 derived: derived.contains(&(etype, src_id, dst_id)),
8120 });
8121 }
8122 }
8123 }
8124 edges.sort_by(|a, b| {
8125 a.edge_type
8126 .cmp(&b.edge_type)
8127 .then(a.src_key.cmp(&b.src_key))
8128 .then(a.dst_key.cmp(&b.dst_key))
8129 });
8130 edges.dedup_by(|a, b| {
8131 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
8132 });
8133 Ok(edges)
8134 }
8135
8136 /// Every directed edge incident on `key`, both directions, every etype.
8137 ///
8138 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
8139 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
8140 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
8141 /// Unknown key → [`GraphError::KeyNotFound`].
8142 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
8143 self.ensure_v8_base_sections_loaded();
8144 let id = self
8145 .ids
8146 .get(key)
8147 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8148 let derived: BTreeSet<(u32, u32, u32)> = self
8149 .engine
8150 .provenance_touching(id)
8151 .map(|(_rule, etype, src, dst)| (etype, src, dst))
8152 .collect();
8153 let mut edges = Vec::new();
8154 let tv = self.topo_view();
8155 for etype in tv.etypes() {
8156 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
8157 let edge_type = self
8158 .syms
8159 .resolve(etype)
8160 .ok_or_else(|| GraphError::Corrupt {
8161 detail: format!("v8: topology etype {etype} not in interner"),
8162 })?
8163 .to_string();
8164 for dir in [Direction::Out, Direction::In] {
8165 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8166 let (src, dst, src_key, dst_key) = match dir {
8167 Direction::Out => (
8168 id,
8169 nbr,
8170 key.to_string(),
8171 self.ids
8172 .key_of(nbr)
8173 .ok_or_else(|| GraphError::Corrupt {
8174 detail: format!("topology id {nbr} has no key"),
8175 })?
8176 .to_string(),
8177 ),
8178 Direction::In => (
8179 nbr,
8180 id,
8181 self.ids
8182 .key_of(nbr)
8183 .ok_or_else(|| GraphError::Corrupt {
8184 detail: format!("topology id {nbr} has no key"),
8185 })?
8186 .to_string(),
8187 key.to_string(),
8188 ),
8189 };
8190 edges.push(EdgeInfo {
8191 edge_type: edge_type.clone(),
8192 src_key,
8193 dst_key,
8194 derived: derived.contains(&(etype, src, dst)),
8195 });
8196 }
8197 }
8198 }
8199 edges.sort_by(|a, b| {
8200 a.edge_type
8201 .cmp(&b.edge_type)
8202 .then(a.src_key.cmp(&b.src_key))
8203 .then(a.dst_key.cmp(&b.dst_key))
8204 });
8205 // Self-loops appear in both Out and In; sort makes the pair adjacent
8206 // (sort key matches PartialEq for this case) so one pass drops the dup.
8207 edges.dedup();
8208 Ok(edges)
8209 }
8210
8211 // ── Backup ────────────────────────────────────────────────────────────────
8212
8213 /// Copy this store to `dest` as a consistent, verified snapshot.
8214 ///
8215 /// Copies every durable file in the database directory — `snapshot.bin`,
8216 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8217 /// `roles.json` — into a freshly created `dest` directory using OS-level
8218 /// `copy` calls (no large in-process buffers).
8219 ///
8220 /// # Consistency guarantee
8221 ///
8222 /// The guarantee is **process-local**: the caller holds `&self`, which
8223 /// prevents any concurrent writer in the **same process** from modifying
8224 /// the files during the copy. Running `mushroomdb backup` against a
8225 /// directory that is **concurrently being written by another process** (e.g.
8226 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
8227 /// `verified: true` result reduces but does not eliminate the risk of a
8228 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8229 /// consistent mid-write snapshot).
8230 ///
8231 /// **The safe path for a live-served store is `POST /backup` on the HTTP
8232 /// server.** That handler acquires the read lock on the shared database
8233 /// before calling this method, which is the correct cross-process
8234 /// synchronisation point because the server is the single process writing
8235 /// the files.
8236 ///
8237 /// After copying, opens the destination read-only and runs the CRC section
8238 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8239 /// `BackupReport::verified` reflects whether both checks passed.
8240 ///
8241 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8242 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8243 // Derive source directory from snapshot_path (RealFs only).
8244 let src_dir = match self.fs.snapshot_path() {
8245 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8246 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8247 })?,
8248 None => {
8249 return Err(GraphError::Io(std::io::Error::other(
8250 "backup_to requires a real filesystem (RealFs)",
8251 )))
8252 }
8253 };
8254
8255 std::fs::create_dir_all(dest)?;
8256
8257 let mut files: Vec<String> = Vec::new();
8258 let mut bytes: u64 = 0;
8259
8260 // Helper: copy src_dir/name → dest/name if the file exists.
8261 let mut try_copy = |name: &str| -> std::io::Result<()> {
8262 let src_path = src_dir.join(name);
8263 if src_path.exists() {
8264 let n = std::fs::copy(&src_path, dest.join(name))?;
8265 bytes += n;
8266 files.push(name.to_string());
8267 }
8268 Ok(())
8269 };
8270
8271 try_copy("snapshot.bin")?;
8272 try_copy("snapshot.bin.bak")?;
8273 try_copy("wal.bin")?;
8274 try_copy("wal.floor")?;
8275 try_copy("wal.genesis")?;
8276 try_copy("roles.json")?;
8277
8278 // Copy WAL archives.
8279 let archives = self.fs.list_archives()?;
8280 for n in &archives {
8281 let name = format!("wal.{n}.archive");
8282 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8283 bytes += n_bytes;
8284 files.push(name);
8285 }
8286
8287 files.sort();
8288
8289 // Post-copy verification: open dest and run CRC checks.
8290 let snap_in_dest = dest.join("snapshot.bin").exists();
8291 let crc_ok = if snap_in_dest {
8292 crate::verify_snapshot(dest)
8293 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8294 .unwrap_or(false)
8295 } else {
8296 true // WAL-only store: nothing to CRC-check in snapshot
8297 };
8298 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8299 let verified = crc_ok && opens_ok;
8300
8301 Ok(BackupReport {
8302 files,
8303 bytes,
8304 verified,
8305 })
8306 }
8307
8308 // ── Export helpers ────────────────────────────────────────────────────────
8309
8310 /// All live nodes, sorted by key (deterministic).
8311 ///
8312 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8313 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8314 self.ensure_v8_base_sections_loaded();
8315 let pv = self.props_view();
8316 let mut nodes = Vec::new();
8317 for id in 0..self.ids.len() as u32 {
8318 let Some(key) = self.ids.key_of(id) else {
8319 continue;
8320 };
8321 let Some(&sym) = self.labels.get(id as usize) else {
8322 continue;
8323 };
8324 if sym == u32::MAX {
8325 continue; // tombstoned
8326 }
8327 let Some(label) = self.syms.resolve(sym) else {
8328 continue;
8329 };
8330 let mut props = BTreeMap::new();
8331 for field in pv.field_names() {
8332 if let Some(vr) = pv.get(id, &field) {
8333 props.insert(field, vr.into_value());
8334 }
8335 }
8336 nodes.push(NodeInfo {
8337 key: key.to_string(),
8338 label: label.to_string(),
8339 props,
8340 });
8341 }
8342 nodes.sort_by(|a, b| a.key.cmp(&b.key));
8343 nodes
8344 }
8345
8346 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8347 ///
8348 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8349 /// Manual edges carry `derived: false` and `rule: None`.
8350 /// `weight` is the creating rule's `weight_prop` value read off the edge
8351 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8352 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8353 /// store state.
8354 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8355 self.ensure_v8_base_sections_loaded();
8356
8357 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8358 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8359 for (rule_name, triples) in self.engine.provenance() {
8360 for &(etype, src, dst) in triples {
8361 prov.insert((etype, src, dst), rule_name.clone());
8362 }
8363 }
8364
8365 // rule_name → weight_prop, for O(1) lookup per derived edge.
8366 let weight_props: HashMap<&str, Option<&str>> = self
8367 .engine
8368 .rules()
8369 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8370 .collect();
8371
8372 let tv = self.topo_view();
8373 let ep = self.edge_props_view();
8374 let mut edges = Vec::new();
8375
8376 for id in 0..self.ids.len() as u32 {
8377 let Some(key) = self.ids.key_of(id) else {
8378 continue;
8379 };
8380 let Some(&lsym) = self.labels.get(id as usize) else {
8381 continue;
8382 };
8383 if lsym == u32::MAX {
8384 continue; // tombstoned
8385 }
8386
8387 for etype_sym in tv.etypes() {
8388 // etype from archived CSR (access_unchecked, no eager CRC).
8389 // Skip edges whose etype is not in the interner; this can only
8390 // occur with a corrupt large TOPOLOGY section (bit-flip on an
8391 // etype field in the archived data). The function returns Vec,
8392 // not Result, so we continue rather than propagate.
8393 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8394 continue;
8395 };
8396 let edge_type = edge_type.to_string();
8397 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8398 let Some(dst_key) = self.ids.key_of(nbr) else {
8399 continue; // skip corrupt entries
8400 };
8401 let prov_key = (etype_sym, id, nbr);
8402 let rule = prov.get(&prov_key).cloned();
8403 let derived = rule.is_some();
8404 let weight = rule
8405 .as_deref()
8406 .and_then(|rn| weight_props.get(rn).copied().flatten())
8407 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8408 Some(Value::Float(f)) => Some(f),
8409 Some(Value::Int(i)) => Some(i as f64),
8410 _ => None,
8411 });
8412 edges.push(ExportEdge {
8413 edge_type: edge_type.clone(),
8414 src: key.to_string(),
8415 dst: dst_key.to_string(),
8416 derived,
8417 rule,
8418 weight,
8419 });
8420 }
8421 }
8422 }
8423
8424 edges.sort_by(|a, b| {
8425 a.edge_type
8426 .cmp(&b.edge_type)
8427 .then(a.src.cmp(&b.src))
8428 .then(a.dst.cmp(&b.dst))
8429 });
8430 edges
8431 }
8432
8433 /// What each edge type *is*, without building one record per edge.
8434 ///
8435 /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8436 /// question by materialising every edge — three `String`s apiece, a
8437 /// provenance `HashMap` over every derived edge, and a final sort. That is
8438 /// the right shape for an export, and the wrong one for a summary: on a
8439 /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8440 /// produce nine lines. This walks the topology instead, summing neighbour
8441 /// slice lengths and collecting *label symbols* rather than label strings,
8442 /// so the per-edge cost is an integer add and a set insert on a set with
8443 /// as many members as the store has labels.
8444 ///
8445 /// The rule names come off the rule *definitions*, which each declare the
8446 /// `edge_type` they derive, so naming them costs one pass over the rules
8447 /// rather than one provenance lookup per edge. That is also why `rules`
8448 /// is a list: two rules may derive the same type — the association store
8449 /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8450 /// talent→job rule — and naming only one of them would be a half-truth.
8451 /// A type with no rules is one written by hand.
8452 ///
8453 /// `sample` is the first edge of the type in the store's own id order,
8454 /// which is insertion order: deterministic for a given store, and not the
8455 /// same as key order, which cannot be had without resolving a key per
8456 /// edge. Sorted by `edge_type`.
8457 pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8458 self.ensure_v8_base_sections_loaded();
8459
8460 let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8461 for r in self.engine.rules() {
8462 rules_by_type
8463 .entry(r.edge_type.as_str())
8464 .or_default()
8465 .insert(r.name.as_str());
8466 }
8467
8468 let tv = self.topo_view();
8469 let node_count = self.ids.len() as u32;
8470 let mut out = Vec::new();
8471 for etype_sym in tv.etypes() {
8472 // An etype the interner cannot resolve means a corrupt TOPOLOGY
8473 // section; skip it rather than name it, as `all_edges_for_export`
8474 // does for the same reason.
8475 let Some(edge_type) = self.syms.resolve(etype_sym) else {
8476 continue;
8477 };
8478 let mut edges: u64 = 0;
8479 let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8480 let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8481 let mut sample: Option<(u32, u32)> = None;
8482 for id in 0..node_count {
8483 let Some(&lsym) = self.labels.get(id as usize) else {
8484 continue;
8485 };
8486 if lsym == u32::MAX {
8487 continue; // tombstoned
8488 }
8489 let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8490 let nbrs = nbrs.as_ref();
8491 if nbrs.is_empty() {
8492 continue;
8493 }
8494 edges += nbrs.len() as u64;
8495 src_syms.insert(lsym);
8496 for &nbr in nbrs {
8497 if let Some(&dsym) = self.labels.get(nbr as usize) {
8498 if dsym != u32::MAX {
8499 dst_syms.insert(dsym);
8500 }
8501 }
8502 }
8503 if sample.is_none() {
8504 sample = Some((id, nbrs[0]));
8505 }
8506 }
8507 let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8508 syms.iter()
8509 .filter_map(|&s| self.syms.resolve(s))
8510 .map(ToString::to_string)
8511 .collect()
8512 };
8513 out.push(EdgeTypeCensus {
8514 edge_type: edge_type.to_string(),
8515 edges,
8516 src_labels: resolve(&src_syms),
8517 dst_labels: resolve(&dst_syms),
8518 rules: rules_by_type
8519 .get(edge_type)
8520 .map(|rs| rs.iter().map(ToString::to_string).collect())
8521 .unwrap_or_default(),
8522 sample: sample.and_then(|(s, d)| {
8523 Some((
8524 self.ids.key_of(s)?.to_string(),
8525 self.ids.key_of(d)?.to_string(),
8526 ))
8527 }),
8528 });
8529 }
8530 out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8531 out
8532 }
8533
8534 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8535 /// on each edge when given.
8536 ///
8537 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8538 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8539 /// `None` — callers that want a default weight (e.g. `1.0` for missing
8540 /// props) apply it themselves, matching the convention used internally
8541 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8542 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8543 ///
8544 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8545 /// (manual + rule-derived edges). An unknown `edge_type` returns an
8546 /// empty vec.
8547 pub fn weighted_edges(
8548 &self,
8549 edge_type: &str,
8550 weight_prop: Option<&str>,
8551 ) -> Vec<(String, String, Option<f64>)> {
8552 let Some(etype_sym) = self.syms.get(edge_type) else {
8553 return Vec::new();
8554 };
8555 let tv = self.topo_view();
8556 let ep = self.edge_props_view();
8557 let mut out = Vec::new();
8558 for id in 0..self.ids.len() as u32 {
8559 let Some(key) = self.ids.key_of(id) else {
8560 continue;
8561 };
8562 let Some(&sym) = self.labels.get(id as usize) else {
8563 continue;
8564 };
8565 if sym == u32::MAX {
8566 continue; // tombstoned
8567 }
8568 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8569 let Some(dst_key) = self.ids.key_of(nbr) else {
8570 continue;
8571 };
8572 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8573 Some(Value::Float(f)) => Some(f),
8574 Some(Value::Int(i)) => Some(i as f64),
8575 _ => None,
8576 });
8577 out.push((key.to_string(), dst_key.to_string(), weight));
8578 }
8579 }
8580 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8581 out
8582 }
8583
8584 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8585 self.view()
8586 .nodes_with_label(label)
8587 .into_iter()
8588 .map(|id| NodeRef { db: self, id })
8589 .collect()
8590 }
8591
8592 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8593 let view = self.view();
8594 view.nodes_with_label(label)
8595 .into_iter()
8596 .filter(|&id| {
8597 eval_filter(filter, &|field| {
8598 view.prop(id, field).map(|vr| vr.into_value())
8599 })
8600 })
8601 .map(|id| NodeRef { db: self, id })
8602 .collect()
8603 }
8604
8605 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8606 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
8607 /// with `label = None` will use the native ANN path rather than the O(n)
8608 /// brute-force scan.
8609 pub fn has_vector_rule(&self, field: &str) -> bool {
8610 self.engine.hnsw_has_rule(field)
8611 }
8612
8613 /// How many HNSW graphs this handle has built from scratch since it was
8614 /// opened (one per side of an approximate rule).
8615 ///
8616 /// An open that restored every graph from the snapshot reports `0`.
8617 /// Exposed for tests that assert the open path reuses the persisted index
8618 /// rather than rebuilding it; not part of the stable surface.
8619 #[doc(hidden)]
8620 pub fn hnsw_build_count(&self) -> u64 {
8621 self.engine.hnsw_build_count()
8622 }
8623
8624 /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8625 ///
8626 /// Zero before the first ANN query on a clean open, and again once the
8627 /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8628 /// Exposed for tests that assert the lazy copies are released; not part of
8629 /// the stable surface.
8630 #[doc(hidden)]
8631 pub fn lazy_hnsw_len(&self) -> usize {
8632 self.engine.lazy_hnsw_len()
8633 }
8634
8635 /// Find nodes whose `field` vector is most similar to `q` (cosine
8636 /// similarity), returning up to `k` results with similarity ≥ `min`,
8637 /// sorted descending.
8638 ///
8639 /// When `label` is `None` the search spans all labels (via
8640 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8641 /// `Some(lbl)` it restricts to nodes with that label.
8642 ///
8643 /// Uses the HNSW index when one is available (fast path); otherwise falls
8644 /// back to an O(n) brute-force scan.
8645 ///
8646 /// **The index supplies candidates, never scores.** Its own distances are
8647 /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8648 /// every candidate is re-scored from the `f64` property vectors by
8649 /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8650 /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8651 /// the re-ordering cannot drop a true top-`k` member; see that constant for
8652 /// the rule. The score a caller receives is therefore the same number the
8653 /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8654 /// finds an exact duplicate.
8655 pub fn find_similar_vector(
8656 &self,
8657 field: &str,
8658 label: Option<&str>,
8659 q: &[f64],
8660 k: usize,
8661 min: f64,
8662 ) -> Vec<(String, f64)> {
8663 self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
8664 .expect("find_similar_vector_filtered is infallible without where_")
8665 }
8666
8667 /// Like [`find_similar_vector`] but restricts results to nodes visible in
8668 /// `mask`. Hidden nodes never appear in results; the mask is applied
8669 /// **before** k-truncation so a caller still receives up to `k` visible
8670 /// hits.
8671 ///
8672 /// # HNSW path (widening beam)
8673 ///
8674 /// When an HNSW index covers the request, the beam starts at an over-fetch
8675 /// of `k × n / |visible|` (plus the rescore margin) when the mask's
8676 /// selectivity is known from the index length, otherwise at `k` plus that
8677 /// margin. If fewer than `k` visible candidates remain after the mask and
8678 /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
8679 /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
8680 /// or a beam that comes back short of its own width, falls through to the
8681 /// exhaustive masked scan rather than returning a short result.
8682 ///
8683 /// Every surviving candidate is re-scored from the `f64` property vectors,
8684 /// exactly as [`find_similar_vector`] does and for the same reason.
8685 ///
8686 /// # Brute-force path
8687 ///
8688 /// When no HNSW index covers the request, or the beam cannot admit `k`
8689 /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
8690 /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
8691 /// results (or all visible nodes if fewer than `k` exist).
8692 pub fn find_similar_vector_masked(
8693 &self,
8694 field: &str,
8695 label: Option<&str>,
8696 q: &[f64],
8697 k: usize,
8698 min: f64,
8699 mask: &crate::mask::NodeMask,
8700 ) -> Vec<(String, f64)> {
8701 self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
8702 .expect("find_similar_vector_filtered is infallible without where_")
8703 }
8704
8705 /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
8706 ///
8707 /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
8708 /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
8709 /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
8710 /// uses HNSW when an index covers the field.
8711 #[allow(clippy::too_many_arguments)]
8712 pub fn find_similar_vector_filtered(
8713 &self,
8714 field: &str,
8715 label: Option<&str>,
8716 q: &[f64],
8717 k: usize,
8718 min: f64,
8719 mask: Option<&crate::mask::NodeMask>,
8720 where_: Option<&PropPredicate>,
8721 exact: bool,
8722 ) -> Result<Vec<(String, f64)>> {
8723 if let Some(pred) = where_ {
8724 pred.validate_named("where")
8725 .map_err(|detail| GraphError::QueryError { detail })?;
8726 }
8727
8728 // Ensure any HNSW blobs retained from the snapshot are deserialized
8729 // before the first ANN query on a clean-open (no-WAL) path. The
8730 // section read has to come first: on a clean open nothing else has
8731 // called it, so without it `retained_hnsw_blobs` is empty,
8732 // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8733 // approximate query on the handle runs brute force — correct results,
8734 // silently off the index. Both calls are idempotent and cheap once hot.
8735 self.ensure_v8_base_sections_loaded();
8736 self.engine.ensure_hnsw_loaded();
8737 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8738 if norm == 0.0 {
8739 return Ok(vec![]);
8740 }
8741 if let Some(m) = mask {
8742 if k == 0 || m.is_empty() {
8743 return Ok(vec![]);
8744 }
8745 }
8746 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8747
8748 // `where` implies exact: a predicate must not ride a silent ANN.
8749 let skip_hnsw = exact || where_.is_some();
8750 if !skip_hnsw {
8751 if let Some(mask) = mask {
8752 if let Some(out) =
8753 self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask)
8754 {
8755 return Ok(out);
8756 }
8757 } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
8758 return Ok(out);
8759 }
8760 }
8761
8762 let view = match mask {
8763 Some(m) => self.view_masked(m),
8764 None => self.view(),
8765 };
8766 let candidate_ids = Self::vector_candidates(&view, label, where_);
8767 Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
8768 }
8769
8770 /// Unmasked HNSW path. `None` when no populated index covers the request.
8771 fn find_similar_hnsw(
8772 &self,
8773 field: &str,
8774 label: Option<&str>,
8775 q_unit: &[f64],
8776 k: usize,
8777 min: f64,
8778 ) -> Option<Vec<(String, f64)>> {
8779 // Try HNSW fast path.
8780 // `None` label searches across all VectorSimilar rules covering `field`
8781 // (merging their results); `Some(lbl)` restricts to rules whose
8782 // dst_label matches. Returns `None` when no populated HNSW index
8783 // covers the request — the O(n) brute-force fallback handles that case.
8784 let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8785 let hits = match label {
8786 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
8787 None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
8788 };
8789 // Candidates only: the index's `f32` similarity is discarded and
8790 // each hit is re-scored against the `f64` vectors.
8791 let view = self.view();
8792 let mut out: Vec<(String, f64)> = hits
8793 .into_iter()
8794 .filter_map(|(id, _)| {
8795 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8796 if sim < min {
8797 return None;
8798 }
8799 Some((self.ids.key_of(id)?.to_string(), sim))
8800 })
8801 .collect();
8802 out.sort_by(|a, b| {
8803 b.1.partial_cmp(&a.1)
8804 .unwrap_or(std::cmp::Ordering::Equal)
8805 .then_with(|| a.0.cmp(&b.0))
8806 });
8807 out.truncate(k);
8808 Some(out)
8809 }
8810
8811 /// Masked HNSW widening beam. `None` when no index covers the request or
8812 /// the beam cannot admit `k` visible hits (caller falls through to brute).
8813 fn find_similar_hnsw_masked(
8814 &self,
8815 field: &str,
8816 label: Option<&str>,
8817 q_unit: &[f64],
8818 k: usize,
8819 min: f64,
8820 mask: &crate::mask::NodeMask,
8821 ) -> Option<Vec<(String, f64)>> {
8822 let index_len = match label {
8823 Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
8824 None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
8825 };
8826 let n = index_len?;
8827 // Same ceiling the exact-rule widening loop in `hnsw_candidates`
8828 // consults — including the `with_ef_max` test hook.
8829 let cap = ef_max();
8830 let visible = mask.len();
8831 let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
8832 if visible > 0 && n > 0 {
8833 let over = k
8834 .saturating_mul(n)
8835 .div_ceil(visible)
8836 .saturating_add(VECTOR_RESCORE_MARGIN);
8837 ef = ef.max(over);
8838 }
8839 loop {
8840 let hits = match label {
8841 Some(lbl) => self
8842 .engine
8843 .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
8844 None => self
8845 .engine
8846 .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
8847 };
8848 let hits = hits?;
8849 let full = hits.len() == ef;
8850 let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
8851 if out.len() >= k {
8852 out.truncate(k);
8853 return Some(out);
8854 }
8855 // Short of its width (frontier exhausted) or at the ceiling:
8856 // a wider beam reaches nothing new, so the scan answers.
8857 if !full || ef >= cap {
8858 return None;
8859 }
8860 ef = ef.saturating_mul(2);
8861 }
8862 }
8863
8864 /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
8865 /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
8866 fn vector_candidates(
8867 view: &GraphView<'_>,
8868 label: Option<&str>,
8869 where_: Option<&PropPredicate>,
8870 ) -> Vec<u32> {
8871 if let (Some(lbl), Some(pred)) = (label, where_) {
8872 let indexed = view
8873 .prop_index
8874 .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
8875 if indexed {
8876 match (&pred.eq, &pred.in_) {
8877 (Some(eq), None) => {
8878 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
8879 return ids;
8880 }
8881 }
8882 (None, Some(allowed)) => {
8883 let mut seen = HashSet::new();
8884 let mut out = Vec::new();
8885 for v in allowed {
8886 if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
8887 for id in ids {
8888 if seen.insert(id) {
8889 out.push(id);
8890 }
8891 }
8892 }
8893 }
8894 return out;
8895 }
8896 _ => {}
8897 }
8898 }
8899 }
8900
8901 let mut ids: Vec<u32> = match label {
8902 Some(lbl) => view
8903 .nodes_with_label(lbl)
8904 .into_iter()
8905 .filter(|&id| view.visible(id))
8906 .collect(),
8907 None => view.nodes_all(),
8908 };
8909 if let Some(pred) = where_ {
8910 ids.retain(|&id| match view.prop(id, &pred.field) {
8911 None => pred.holds(None),
8912 Some(vr) => pred.holds(Some(vr.as_value())),
8913 });
8914 }
8915 ids
8916 }
8917
8918 /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
8919 /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
8920 fn brute_vector_hits(
8921 &self,
8922 view: &GraphView<'_>,
8923 candidate_ids: impl IntoIterator<Item = u32>,
8924 field: &str,
8925 q_unit: &[f64],
8926 k: usize,
8927 min: f64,
8928 ) -> Vec<(String, f64)> {
8929 let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
8930 .into_iter()
8931 .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
8932 .collect();
8933 let packed =
8934 crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
8935 let scores = crate::exact_knn::gemv(&packed, q_unit);
8936 let mut scored: Vec<(String, f64)> = packed
8937 .ids
8938 .iter()
8939 .zip(scores.iter())
8940 .filter_map(|(&id, &sim)| {
8941 if sim < min {
8942 return None;
8943 }
8944 let key = self.ids.key_of(id)?.to_string();
8945 Some((key, sim))
8946 })
8947 .collect();
8948 scored.sort_by(|a, b| {
8949 b.1.partial_cmp(&a.1)
8950 .unwrap_or(std::cmp::Ordering::Equal)
8951 .then_with(|| a.0.cmp(&b.0))
8952 });
8953 scored.truncate(k);
8954 scored
8955 }
8956
8957 /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
8958 ///
8959 /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
8960 /// same unit and inequality as `find_similar_vector`. Self-matches are
8961 /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
8962 /// embeddings are omitted as both query and candidate. Duplicate keys are
8963 /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
8964 /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
8965 #[allow(clippy::type_complexity)]
8966 pub fn pairwise_similar(
8967 &self,
8968 keys: &[&str],
8969 field: &str,
8970 k: usize,
8971 min: f64,
8972 ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
8973 let mut seen = HashSet::new();
8974 let mut unique_ids = Vec::new();
8975 for key in keys {
8976 let Some(id) = self.ids.get(key) else {
8977 continue;
8978 };
8979 if seen.insert(id) {
8980 unique_ids.push(id);
8981 }
8982 }
8983 let max_n = crate::exact_knn::pairwise_max_n();
8984 if unique_ids.len() > max_n {
8985 return Err(GraphError::QueryError {
8986 detail: format!(
8987 "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
8988 unique_ids.len()
8989 ),
8990 });
8991 }
8992 if unique_ids.is_empty() {
8993 return Ok(Vec::new());
8994 }
8995
8996 let view = self.view();
8997 let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
8998 let mut counts: HashMap<usize, usize> = HashMap::new();
8999 for id in unique_ids {
9000 let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
9001 continue;
9002 };
9003 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
9004 if norm == 0.0 {
9005 continue;
9006 }
9007 *counts.entry(v.len()).or_default() += 1;
9008 rows.push((id, v));
9009 }
9010 if rows.is_empty() {
9011 return Ok(Vec::new());
9012 }
9013 let dim = counts
9014 .into_iter()
9015 .max_by_key(|&(d, c)| (c, d))
9016 .map(|(d, _)| d)
9017 .expect("rows non-empty");
9018 let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
9019 let n = packed.ids.len();
9020 if n == 0 {
9021 return Ok(Vec::new());
9022 }
9023 let src_keys: Vec<String> = packed
9024 .ids
9025 .iter()
9026 .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
9027 .collect();
9028
9029 let mut out = Vec::with_capacity(n);
9030 if n <= crate::exact_knn::pairwise_gram_max() {
9031 let sims = crate::exact_knn::gram(&packed);
9032 for i in 0..n {
9033 out.push(Self::topk_from_row(
9034 &src_keys,
9035 i,
9036 &sims[i * n..(i + 1) * n],
9037 k,
9038 min,
9039 ));
9040 }
9041 } else {
9042 for i in 0..n {
9043 let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
9044 let scores = crate::exact_knn::gemv(&packed, row);
9045 out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
9046 }
9047 }
9048 Ok(out)
9049 }
9050
9051 /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
9052 /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
9053 /// still appear as `(src, [])`.
9054 fn topk_from_row(
9055 src_keys: &[String],
9056 i: usize,
9057 scores: &[f64],
9058 k: usize,
9059 min: f64,
9060 ) -> (String, Vec<(String, f64)>) {
9061 let mut neigh: Vec<(String, f64)> = scores
9062 .iter()
9063 .enumerate()
9064 .filter_map(|(j, &sim)| {
9065 if i == j || sim < min {
9066 return None;
9067 }
9068 Some((src_keys[j].clone(), sim))
9069 })
9070 .collect();
9071 neigh.sort_by(|a, b| {
9072 b.1.partial_cmp(&a.1)
9073 .unwrap_or(std::cmp::Ordering::Equal)
9074 .then_with(|| a.0.cmp(&b.0))
9075 });
9076 neigh.truncate(k);
9077 (src_keys[i].clone(), neigh)
9078 }
9079
9080 /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
9081 /// hits, order by score then key. The index's own `f32` similarity is discarded.
9082 fn score_masked_hnsw_hits(
9083 &self,
9084 hits: &[(u32, f64)],
9085 field: &str,
9086 q_unit: &[f64],
9087 min: f64,
9088 mask: &crate::mask::NodeMask,
9089 ) -> Vec<(String, f64)> {
9090 let view = self.view_masked(mask);
9091 let mut out: Vec<(String, f64)> = hits
9092 .iter()
9093 .copied()
9094 .filter(|&(id, _)| mask.visible.contains(&id))
9095 .filter_map(|(id, _)| {
9096 let sim = exact_vector_similarity(&view, id, field, q_unit)?;
9097 if sim < min {
9098 return None;
9099 }
9100 Some((self.ids.key_of(id)?.to_string(), sim))
9101 })
9102 .collect();
9103 out.sort_by(|a, b| {
9104 b.1.partial_cmp(&a.1)
9105 .unwrap_or(std::cmp::Ordering::Equal)
9106 .then_with(|| a.0.cmp(&b.0))
9107 });
9108 out
9109 }
9110
9111 /// Read a single property from an edge.
9112 ///
9113 /// Returns `None` when the edge does not exist, the field is absent, or any
9114 /// of the string keys cannot be resolved to interned ids. Only edge props
9115 /// written by rules (weight fields) are accessible without a `set_edge_prop`
9116 /// binding; topology-only edges (no props set) return `None` for every field.
9117 pub fn get_edge_prop(
9118 &self,
9119 edge_type: &str,
9120 src_key: &str,
9121 dst_key: &str,
9122 field: &str,
9123 ) -> Option<Value> {
9124 let etype = self.syms.get(edge_type)?;
9125 let src = self.ids.get(src_key)?;
9126 let dst = self.ids.get(dst_key)?;
9127 self.edge_props_view().get(etype, src, dst, field)
9128 }
9129
9130 /// Lex → parse → plan → execute `cypher` over a read-only view.
9131 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
9132 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
9133 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
9134 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9135 detail: format!("lex: {e}"),
9136 })?;
9137 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9138 detail: format!("parse: {e}"),
9139 })?;
9140 let t0 = std::time::Instant::now();
9141 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
9142 GraphError::QueryError {
9143 detail: format!("execute: {e}"),
9144 }
9145 });
9146 let elapsed_ms = t0.elapsed().as_millis() as u64;
9147 let threshold = self.slow_query_threshold_ms;
9148 if threshold > 0 && elapsed_ms >= threshold {
9149 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
9150 let entry = SlowQueryEntry {
9151 ms: elapsed_ms,
9152 query: cypher.to_string(),
9153 at_commit: self.commit_seq,
9154 };
9155 if let Ok(mut log) = self.slow_queries.lock() {
9156 if log.entries.len() == SLOW_QUERY_RING_CAP {
9157 log.entries.pop_front();
9158 }
9159 log.entries.push_back(entry);
9160 log.total += 1;
9161 }
9162 }
9163 result
9164 }
9165
9166 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
9167 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
9168 /// calling [`GraphDb::query`].
9169 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
9170 let map: BTreeMap<String, Value> = params
9171 .iter()
9172 .map(|(k, v)| (k.to_string(), v.clone()))
9173 .collect();
9174 self.query(cypher, &map)
9175 }
9176
9177 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
9178 ///
9179 /// All mutations flow through the same `insert_node` / `set_prop` /
9180 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
9181 /// fires and the WAL captures everything with one fsync per statement.
9182 ///
9183 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
9184 /// and `deleted` matching the write-result contract.
9185 ///
9186 /// **Mutation routing**: mutations are collected into a single
9187 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
9188 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
9189 /// over `self.view()` — the borrow is dropped before the batch is opened.
9190 ///
9191 /// **Limitations (v1)**:
9192 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
9193 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
9194 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
9195 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
9196 /// - Deleting a derived edge → named error "cannot delete derived edge".
9197 pub fn query_write(
9198 &mut self,
9199 cypher: &str,
9200 params: &BTreeMap<String, Value>,
9201 ) -> Result<ResultSet> {
9202 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9203 detail: format!("lex: {e}"),
9204 })?;
9205 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9206 detail: format!("parse: {e}"),
9207 })?;
9208 self.exec_write_stmt(stmt, params)
9209 }
9210
9211 fn exec_write_stmt(
9212 &mut self,
9213 stmt: WriteStatement,
9214 params: &BTreeMap<String, Value>,
9215 ) -> Result<ResultSet> {
9216 match stmt {
9217 WriteStatement::Create(s) => self.exec_create(s, params),
9218 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
9219 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
9220 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
9221 WriteStatement::Merge(s) => self.exec_merge(s, params),
9222 }
9223 }
9224
9225 fn exec_create(
9226 &mut self,
9227 stmt: core_query::cypher::CreateStmt,
9228 params: &BTreeMap<String, Value>,
9229 ) -> Result<ResultSet> {
9230 // Extract the node key from props: require a string-valued `id` field.
9231 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
9232 for node in &stmt.nodes {
9233 let var = node.var.as_deref().unwrap_or("_cn0");
9234 let key = node
9235 .props
9236 .iter()
9237 .find(|(f, _)| f == "id")
9238 .and_then(|(_, v)| {
9239 if let Value::Str(s) = v {
9240 Some(s.clone())
9241 } else {
9242 None
9243 }
9244 })
9245 .ok_or_else(|| GraphError::QueryError {
9246 detail: format!(
9247 "CREATE node ({}:{}) requires a string 'id' property",
9248 var, node.label
9249 ),
9250 })?;
9251 var_to_key.insert(var.to_string(), key);
9252 }
9253
9254 let mut batch = self.batch();
9255 let mut created: usize = 0;
9256 for node in &stmt.nodes {
9257 let var = node.var.as_deref().unwrap_or("_cn0");
9258 let key = &var_to_key[var];
9259 batch.insert_node(&node.label, key, node.props.clone());
9260 created += 1;
9261 }
9262 for edge in &stmt.edges {
9263 let src_key = var_to_key
9264 .get(&edge.src_var)
9265 .ok_or_else(|| GraphError::QueryError {
9266 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
9267 })?;
9268 let dst_key = var_to_key
9269 .get(&edge.dst_var)
9270 .ok_or_else(|| GraphError::QueryError {
9271 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
9272 })?;
9273 batch.insert_edge(&edge.etype, src_key, dst_key);
9274 }
9275 batch.commit()?;
9276
9277 // Optional RETURN clause: project created bindings as a read result.
9278 if let Some(returns) = stmt.returns {
9279 // Each created node is looked up by its key via a separate MATCH pattern.
9280 // Multiple single-node patterns cross-join to produce 1 output row with
9281 // all variables bound (each pattern returns exactly 1 row).
9282 let patterns: Vec<Pattern> = stmt
9283 .nodes
9284 .iter()
9285 .map(|node| {
9286 let var = node.var.as_deref().unwrap_or("_cn0");
9287 let key = var_to_key[var].clone();
9288 Pattern {
9289 start: NodePat {
9290 var: Some(var.to_string()),
9291 label: Some(node.label.clone()),
9292 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
9293 },
9294 chain: vec![],
9295 shortest: false,
9296 }
9297 })
9298 .collect();
9299 let q = Query {
9300 matches: patterns,
9301 optional_clauses: vec![],
9302 where_expr: None,
9303 unwinds: vec![],
9304 post_unwind_where: None,
9305 stages: vec![],
9306 returns,
9307 distinct: false,
9308 order_by: vec![],
9309 skip: None,
9310 limit: None,
9311 };
9312 let ops = plan(&q).map_err(|e| GraphError::QueryError {
9313 detail: format!("plan: {e}"),
9314 })?;
9315 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
9316 GraphError::QueryError {
9317 detail: format!("execute: {e}"),
9318 }
9319 });
9320 }
9321
9322 let mut rs = write_result_set();
9323 rs.push_row(vec![
9324 Some(Value::Int(created as i64)),
9325 Some(Value::Int(0)),
9326 Some(Value::Int(0)),
9327 ]);
9328 Ok(rs)
9329 }
9330
9331 fn exec_match_set(
9332 &mut self,
9333 stmt: core_query::cypher::MatchSetStmt,
9334 params: &BTreeMap<String, Value>,
9335 ) -> Result<ResultSet> {
9336 let project_returns = stmt.returns.clone();
9337 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
9338 // so the post-write projection can look them up by key.
9339 let mut set_vars: Vec<String> = Vec::new();
9340 for s in &stmt.sets {
9341 if !set_vars.contains(&s.var) {
9342 set_vars.push(s.var.clone());
9343 }
9344 }
9345 let rel_vars = pattern_rel_vars(&stmt.matches);
9346 let mut lookup_vars = set_vars.clone();
9347 for v in pattern_node_vars(&stmt.matches) {
9348 add_var(&mut lookup_vars, &v);
9349 }
9350 if let Some(ref returns) = project_returns {
9351 for v in ret_node_vars(returns) {
9352 if !rel_vars.iter().any(|r| r == &v) {
9353 add_var(&mut lookup_vars, &v);
9354 }
9355 }
9356 }
9357
9358 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
9359 // SET values are projected as ScalarExpr items so that arithmetic expressions
9360 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
9361 let mut set_returns: Vec<RetItem> = lookup_vars
9362 .iter()
9363 .map(|v| RetItem {
9364 value: RetVal::Var(v.clone()),
9365 alias: None,
9366 })
9367 .collect();
9368 // One computed column per SET clause; alias is `__sv_<i>`.
9369 let set_val_cols: Vec<String> = stmt
9370 .sets
9371 .iter()
9372 .enumerate()
9373 .map(|(i, _)| format!("__sv_{i}"))
9374 .collect();
9375 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9376 set_returns.push(RetItem {
9377 value: RetVal::ScalarExpr(sc.value.clone()),
9378 alias: Some(col.clone()),
9379 });
9380 }
9381 // Capture relationship types while r is bound; SET does not change them.
9382 for r in &rel_vars {
9383 set_returns.push(RetItem {
9384 value: RetVal::FuncCall {
9385 name: "type".into(),
9386 args: vec![Operand::Var(r.clone())],
9387 },
9388 alias: Some(rel_type_alias(r)),
9389 });
9390 }
9391
9392 let read_q = Query {
9393 matches: stmt.matches.clone(),
9394 optional_clauses: vec![],
9395 where_expr: stmt.where_expr.clone(),
9396 unwinds: vec![],
9397 post_unwind_where: None,
9398 stages: vec![],
9399 returns: set_returns,
9400 distinct: false,
9401 order_by: vec![],
9402 skip: None,
9403 limit: None,
9404 };
9405 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9406 detail: format!("plan: {e}"),
9407 })?;
9408 // MATCH phase is read-only; borrow ends before batch opens.
9409 //
9410 // When a role-scoped write is in flight, run the MATCH read through
9411 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
9412 // zero-rows (no SetProp ops generated, no existence-oracle 403).
9413 // Full-authority writes (pending_write_authz=None) keep view().
9414 let match_rs = {
9415 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9416 if let Some(ref mask) = mask_opt {
9417 execute(&self.view_masked(mask), &ops, &Params(params))
9418 } else {
9419 execute(&self.view(), &ops, &Params(params))
9420 }
9421 }
9422 .map_err(|e| GraphError::QueryError {
9423 detail: format!("execute: {e}"),
9424 })?;
9425
9426 // Collect (key, field, value) for each matched row × each SET clause.
9427 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
9428 for row_i in 0..match_rs.len() {
9429 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9430 let key = match match_rs.get(row_i, &sc.var) {
9431 Some(Value::Str(k)) => k.clone(),
9432 _ => {
9433 return Err(GraphError::QueryError {
9434 detail: format!(
9435 "SET variable '{}' did not resolve to a node key",
9436 sc.var
9437 ),
9438 })
9439 }
9440 };
9441 // The SET value was already evaluated by the executor.
9442 let value = match match_rs.get(row_i, col) {
9443 Some(v) => v.clone(),
9444 None => {
9445 return Err(GraphError::QueryError {
9446 detail: format!(
9447 "SET value for {}.{} evaluated to null",
9448 sc.var, sc.field
9449 ),
9450 })
9451 }
9452 };
9453 set_ops.push((key, sc.field.clone(), value));
9454 }
9455 }
9456
9457 // Apply as one atomic batch.
9458 let props_set = set_ops.len();
9459 let mut batch = self.batch();
9460 for (key, field, value) in set_ops {
9461 batch.set_prop(&key, &field, value);
9462 }
9463 batch.commit()?;
9464
9465 if let Some(returns) = project_returns {
9466 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9467 }
9468
9469 let mut rs = write_result_set();
9470 rs.push_row(vec![
9471 Some(Value::Int(0)),
9472 Some(Value::Int(props_set as i64)),
9473 Some(Value::Int(0)),
9474 ]);
9475 Ok(rs)
9476 }
9477
9478 fn exec_match_delete(
9479 &mut self,
9480 stmt: core_query::cypher::MatchDeleteStmt,
9481 params: &BTreeMap<String, Value>,
9482 ) -> Result<ResultSet> {
9483 // Collect unique node vars needed to identify edge endpoints.
9484 let mut node_vars: Vec<String> = Vec::new();
9485 for ed in &stmt.deletes {
9486 if !node_vars.contains(&ed.src_var) {
9487 node_vars.push(ed.src_var.clone());
9488 }
9489 if !node_vars.contains(&ed.dst_var) {
9490 node_vars.push(ed.dst_var.clone());
9491 }
9492 }
9493
9494 // Synthesize read query.
9495 let returns: Vec<RetItem> = node_vars
9496 .iter()
9497 .map(|v| RetItem {
9498 value: RetVal::Var(v.clone()),
9499 alias: None,
9500 })
9501 .collect();
9502 let read_q = Query {
9503 matches: stmt.matches,
9504 optional_clauses: vec![],
9505 where_expr: stmt.where_expr,
9506 unwinds: vec![],
9507 post_unwind_where: None,
9508 stages: vec![],
9509 returns,
9510 distinct: false,
9511 order_by: vec![],
9512 skip: None,
9513 limit: None,
9514 };
9515 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9516 detail: format!("plan: {e}"),
9517 })?;
9518 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9519 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9520 let match_rs = {
9521 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9522 if let Some(ref mask) = mask_opt {
9523 execute(&self.view_masked(mask), &ops, &Params(params))
9524 } else {
9525 execute(&self.view(), &ops, &Params(params))
9526 }
9527 }
9528 .map_err(|e| GraphError::QueryError {
9529 detail: format!("execute: {e}"),
9530 })?;
9531
9532 // Collect (etype, src_key, dst_key) for each row × each delete target.
9533 let mut del_ops: Vec<(String, String, String)> = Vec::new();
9534 for row_i in 0..match_rs.len() {
9535 for ed in &stmt.deletes {
9536 let src_key = match match_rs.get(row_i, &ed.src_var) {
9537 Some(Value::Str(k)) => k.clone(),
9538 _ => {
9539 return Err(GraphError::QueryError {
9540 detail: format!(
9541 "DELETE src variable '{}' did not resolve to a node key",
9542 ed.src_var
9543 ),
9544 })
9545 }
9546 };
9547 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9548 Some(Value::Str(k)) => k.clone(),
9549 _ => {
9550 return Err(GraphError::QueryError {
9551 detail: format!(
9552 "DELETE dst variable '{}' did not resolve to a node key",
9553 ed.dst_var
9554 ),
9555 })
9556 }
9557 };
9558 del_ops.push((ed.etype.clone(), src_key, dst_key));
9559 }
9560 }
9561
9562 // Apply as one atomic batch.
9563 let deleted = del_ops.len();
9564 let mut batch = self.batch();
9565 for (etype, src_key, dst_key) in del_ops {
9566 batch.delete_edge(&etype, &src_key, &dst_key);
9567 }
9568 batch.commit().map_err(|e| match e {
9569 GraphError::RuleOwned { .. } => GraphError::QueryError {
9570 detail: "cannot delete derived edge; retract via the rule or change the property"
9571 .to_string(),
9572 },
9573 other => other,
9574 })?;
9575
9576 let mut rs = write_result_set();
9577 rs.push_row(vec![
9578 Some(Value::Int(0)),
9579 Some(Value::Int(0)),
9580 Some(Value::Int(deleted as i64)),
9581 ]);
9582 Ok(rs)
9583 }
9584
9585 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9586 ///
9587 /// Collects the matching node keys via an ephemeral read query, then calls
9588 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
9589 /// the executor first checks that the node has no incident edges; if any
9590 /// remain it returns a named error matching openCypher semantics.
9591 fn exec_match_delete_node(
9592 &mut self,
9593 stmt: MatchDeleteNodeStmt,
9594 params: &BTreeMap<String, Value>,
9595 ) -> Result<ResultSet> {
9596 // Build a read query returning only the node keys we need.
9597 let returns: Vec<RetItem> = stmt
9598 .node_vars
9599 .iter()
9600 .map(|v| RetItem {
9601 value: RetVal::Var(v.clone()),
9602 alias: None,
9603 })
9604 .collect();
9605 let read_q = Query {
9606 matches: stmt.matches,
9607 optional_clauses: vec![],
9608 where_expr: stmt.where_expr,
9609 unwinds: vec![],
9610 post_unwind_where: None,
9611 stages: vec![],
9612 returns,
9613 distinct: false,
9614 order_by: vec![],
9615 skip: None,
9616 limit: None,
9617 };
9618 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9619 detail: format!("plan: {e}"),
9620 })?;
9621 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9622 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9623 let match_rs = {
9624 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9625 if let Some(ref mask) = mask_opt {
9626 execute(&self.view_masked(mask), &ops, &Params(params))
9627 } else {
9628 execute(&self.view(), &ops, &Params(params))
9629 }
9630 }
9631 .map_err(|e| GraphError::QueryError {
9632 detail: format!("execute: {e}"),
9633 })?;
9634
9635 // Collect unique node keys to delete (deduplicate across rows × vars).
9636 let mut keys: Vec<String> = Vec::new();
9637 for row_i in 0..match_rs.len() {
9638 for var in &stmt.node_vars {
9639 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9640 if !keys.contains(k) {
9641 keys.push(k.clone());
9642 }
9643 }
9644 }
9645 }
9646
9647 if !stmt.detach {
9648 // openCypher bare DELETE: error if any matched node has incident edges.
9649 for key in &keys {
9650 if let Some(id) = self.ids.get(key) {
9651 let tv = self.topo_view();
9652 let has_edges = tv.etypes().any(|et| {
9653 !tv.neighbors(et, Direction::Out, id).is_empty()
9654 || !tv.neighbors(et, Direction::In, id).is_empty()
9655 });
9656 if has_edges {
9657 return Err(GraphError::QueryError {
9658 detail: format!(
9659 "Cannot delete node `{key}` because it still has incident edges. \
9660 Use DETACH DELETE to remove the node and all its edges."
9661 ),
9662 });
9663 }
9664 }
9665 }
9666 }
9667
9668 let mut nodes_deleted = 0i64;
9669 let mut edges_deleted = 0i64;
9670 for key in keys {
9671 match self.delete_node(&key) {
9672 Ok(report) => {
9673 nodes_deleted += 1;
9674 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9675 }
9676 Err(GraphError::KeyNotFound { .. }) => {
9677 // Node may have been deleted by an earlier iteration (e.g., via
9678 // multiple MATCH rows for the same node). Safe to skip.
9679 }
9680 Err(e) => return Err(e),
9681 }
9682 }
9683
9684 let mut rs = write_result_set();
9685 rs.push_row(vec![
9686 Some(Value::Int(0)),
9687 Some(Value::Int(0)),
9688 Some(Value::Int(nodes_deleted + edges_deleted)),
9689 ]);
9690 Ok(rs)
9691 }
9692
9693 /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
9694 /// the pattern named one, or the executing role's sole namespace when it
9695 /// did not. A role bound to two or more namespaces cannot choose, and is
9696 /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
9697 /// refuses a named `ns` the role cannot write.
9698 fn merge_create_props(
9699 &self,
9700 key_field: &str,
9701 key_value: &Value,
9702 named_ns: Option<&Value>,
9703 ) -> Result<Vec<(String, Value)>> {
9704 let mut props = vec![(key_field.to_string(), key_value.clone())];
9705 if let Some(ns) = named_ns {
9706 props.push((NS_PROP.to_string(), ns.clone()));
9707 return Ok(props);
9708 }
9709 if let Some(ns) = self.merge_create_stamp_ns()? {
9710 props.push((NS_PROP.to_string(), Value::Str(ns)));
9711 }
9712 Ok(props)
9713 }
9714
9715 /// The namespace a role-scoped MERGE create stamps when the pattern does
9716 /// not name `ns`. `None` = unscoped / full authority, so the node lands in
9717 /// `default`.
9718 fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
9719 let Some(authz) = self.pending_write_authz.as_ref() else {
9720 return Ok(None);
9721 };
9722 let Some(def) = self.role_def_for(&authz.role) else {
9723 return Ok(None);
9724 };
9725 match def.namespaces.as_deref() {
9726 Some([only]) => Ok(Some(only.clone())),
9727 Some(_) => Err(GraphError::RoleWriteDenied {
9728 reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
9729 }),
9730 None => Ok(None),
9731 }
9732 }
9733
9734 fn exec_merge(
9735 &mut self,
9736 stmt: core_query::cypher::MergeStmt,
9737 params: &BTreeMap<String, Value>,
9738 ) -> Result<ResultSet> {
9739 // MERGE: check if a node with the given key already exists.
9740 let key = match &stmt.key_value {
9741 Value::Str(s) => s.clone(),
9742 _ => {
9743 return Err(GraphError::QueryError {
9744 detail: format!(
9745 "MERGE key value must be a string (got {:?})",
9746 stmt.key_value
9747 ),
9748 })
9749 }
9750 };
9751
9752 if let Some(var) = stmt.var.as_deref() {
9753 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9754 if sc.var != var {
9755 return Err(GraphError::QueryError {
9756 detail: format!(
9757 "SET variable '{}' does not match MERGE variable '{var}'",
9758 sc.var
9759 ),
9760 });
9761 }
9762 }
9763 }
9764
9765 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9766 //
9767 // MERGE scope precondition: check create OR update scope for the
9768 // declared label BEFORE calling `has_node` (timing-oracle closure,
9769 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9770 // unscoped roles — the scope denial fires without touching the key store).
9771 //
9772 // Clone to avoid holding a borrow on `self.pending_write_authz` while
9773 // also calling `self.ids.get(key)`.
9774 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9775 let has_create = authz.scope.create_labels.contains(&stmt.label);
9776 let has_update = authz.scope.update_labels.contains(&stmt.label);
9777 if !has_create && !has_update {
9778 // Scope-before-lookup: 403 without has_node call (timing oracle
9779 // closure — see test_merge_unscoped_no_key_lookup).
9780 return Err(GraphError::RoleWriteDenied {
9781 reason: format!(
9782 "role-bound token: label '{}' not in write scope (create_labels)",
9783 stmt.label
9784 ),
9785 });
9786 }
9787 // Key lookup under mask.
9788 match self.ids.get(key.as_str()) {
9789 Some(id) if authz.mask.contains_id(id) => {
9790 // Visible: must have update scope to proceed to match arm.
9791 if !has_update {
9792 return Err(GraphError::RoleWriteDenied {
9793 reason: format!(
9794 "role-bound token: label '{}' not in write scope (update_labels)",
9795 stmt.label
9796 ),
9797 });
9798 }
9799 true // existed = true → match arm
9800 }
9801 Some(_) => {
9802 // Hidden: same error as absent to the role (spec §3.1/§3.3).
9803 return Err(GraphError::RoleWriteDenied {
9804 reason: "role-bound token: target node not visible".into(),
9805 });
9806 }
9807 None => {
9808 // Absent: must have create scope to proceed to the create arm.
9809 //
9810 // Update-only roles (create_labels empty, update_labels set):
9811 // return the SAME "not visible" error as the hidden-key branch
9812 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9813 // "confirm existence of hidden nodes: No").
9814 //
9815 // Create-scoped roles (has_create=true): absent → create arm
9816 // as before. The accepted structural key-existence disclosure
9817 // (§THREAT-MODEL) applies only when the role holds create scope.
9818 if !has_create {
9819 return Err(GraphError::RoleWriteDenied {
9820 reason: "role-bound token: target node not visible".into(),
9821 });
9822 }
9823 false // existed = false → create arm
9824 }
9825 }
9826 } else {
9827 // Full authority: use the existing non-masked has_node check.
9828 self.has_node(&key)
9829 };
9830
9831 let existed = merge_existed;
9832 let create_props = if existed {
9833 None
9834 } else {
9835 Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
9836 };
9837 let mut created = 0i64;
9838 if create_props.is_some() || !stmt.on_match.is_empty() {
9839 let mut batch = self.batch();
9840 if let Some(props) = create_props {
9841 batch.insert_node(&stmt.label, &key, props);
9842 for sc in &stmt.on_create {
9843 let value = resolve_merge_set_value(&sc.value, params)?;
9844 batch.set_prop(&key, &sc.field, value);
9845 }
9846 created = 1;
9847 } else {
9848 for sc in &stmt.on_match {
9849 let value = resolve_merge_set_value(&sc.value, params)?;
9850 batch.set_prop(&key, &sc.field, value);
9851 }
9852 }
9853 batch.commit()?;
9854 }
9855
9856 // Refresh the role mask so the just-created node is visible to this
9857 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9858 // (apply_schema subset rule), so the new node's label is already in the
9859 // role's read scope — this never widens beyond the role's declared labels.
9860 if !existed {
9861 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9862 let new_mask = self.mask_for_role(&role)?;
9863 if let Some(a) = self.pending_write_authz.as_mut() {
9864 a.mask = new_mask;
9865 }
9866 }
9867 }
9868
9869 // Optional RETURN clause: project the node (created or matched) as a read result.
9870 if let Some(returns) = stmt.returns {
9871 let var = stmt.var.as_deref().unwrap_or("_mn0");
9872 let q = Query {
9873 matches: vec![Pattern {
9874 start: NodePat {
9875 var: Some(var.to_string()),
9876 label: Some(stmt.label.clone()),
9877 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9878 },
9879 chain: vec![],
9880 shortest: false,
9881 }],
9882 optional_clauses: vec![],
9883 where_expr: None,
9884 unwinds: vec![],
9885 post_unwind_where: None,
9886 stages: vec![],
9887 returns,
9888 distinct: false,
9889 order_by: vec![],
9890 skip: None,
9891 limit: None,
9892 };
9893 let ops = plan(&q).map_err(|e| GraphError::QueryError {
9894 detail: format!("plan: {e}"),
9895 })?;
9896 // Use view_masked when a role-scoped write is in flight so the
9897 // post-merge projection is consistent with the masked read phase.
9898 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9899 return (if let Some(ref mask) = mask_opt {
9900 execute(&self.view_masked(mask), &ops, &Params(params))
9901 } else {
9902 execute(&self.view(), &ops, &Params(params))
9903 })
9904 .map_err(|e| GraphError::QueryError {
9905 detail: format!("execute: {e}"),
9906 });
9907 }
9908
9909 let mut rs = write_result_set();
9910 rs.push_row(vec![
9911 Some(Value::Int(created)),
9912 Some(Value::Int(0)),
9913 Some(Value::Int(0)),
9914 ]);
9915 Ok(rs)
9916 }
9917
9918 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9919 /// annotated with rule name, edge type, direction, and weight.
9920 /// Results are sorted by (rule, edge_type).
9921 /// Returns `Err(KeyNotFound)` if either key is unknown.
9922 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9923 self.ensure_v8_base_sections_loaded();
9924 let id_a = self
9925 .ids
9926 .get(key_a)
9927 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9928 let id_b = self
9929 .ids
9930 .get(key_b)
9931 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9932
9933 let mut results = Vec::new();
9934
9935 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9936 // rather than O(total provenance).
9937 let scan = if self.engine.provenance_touching_len(id_a)
9938 <= self.engine.provenance_touching_len(id_b)
9939 {
9940 id_a
9941 } else {
9942 id_b
9943 };
9944 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9945 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9946 continue;
9947 }
9948 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9949 continue;
9950 };
9951 let edge_type = match self.syms.resolve(etype) {
9952 Some(s) => s.to_string(),
9953 None => continue,
9954 };
9955 // Provenance (src, dst) ids come from the archived PROVENANCE section
9956 // (large, no eager CRC). A corrupt section can produce ids that are
9957 // out of range; return Corrupt rather than panic.
9958 let src_key = self
9959 .ids
9960 .key_of(src)
9961 .ok_or_else(|| GraphError::Corrupt {
9962 detail: format!("v8: provenance src id {src} not in id table"),
9963 })?
9964 .to_string();
9965 let dst_key = self
9966 .ids
9967 .key_of(dst)
9968 .ok_or_else(|| GraphError::Corrupt {
9969 detail: format!("v8: provenance dst id {dst} not in id table"),
9970 })?
9971 .to_string();
9972 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9973 self.edge_props_view()
9974 .get(etype, src, dst, prop)
9975 .and_then(|v| {
9976 if let Value::Float(f) = v {
9977 Some(f)
9978 } else {
9979 None
9980 }
9981 })
9982 });
9983 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9984 // still have a score: recompute it from the predicate so explain
9985 // never reports "no score" for an edge the engine scored. Via-hop
9986 // rules score over their via set, not over (src, dst), so leave
9987 // those None rather than report a number the rule did not produce.
9988 let weight = stored.or_else(|| {
9989 if rule_def.via_edge.is_some() {
9990 return None;
9991 }
9992 let props_view = build_props_view(&self.props, &self.base);
9993 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9994 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9995 let src_view = NodeView {
9996 key: &src_key,
9997 props: &src_get,
9998 };
9999 let dst_view = NodeView {
10000 key: &dst_key,
10001 props: &dst_get,
10002 };
10003 evaluate(&rule_def.predicate, &src_view, &dst_view)
10004 });
10005 results.push(Explanation {
10006 rule: rule_name.to_string(),
10007 edge_type,
10008 src_key,
10009 dst_key,
10010 weight,
10011 predicate: PredicateSummary {
10012 approximate: rule_def.approximate,
10013 ..PredicateSummary::from(&rule_def.predicate)
10014 },
10015 via_edge: rule_def.via_edge.clone(),
10016 });
10017 }
10018
10019 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
10020 Ok(results)
10021 }
10022
10023 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
10024 let id = self
10025 .ids
10026 .get(key)
10027 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10028 let Some(sym) = self.syms.get(edge_type) else {
10029 return Ok(Vec::new());
10030 };
10031 self.topo_view()
10032 .neighbors(sym, dir, id)
10033 .iter()
10034 .map(|&n| {
10035 self.ids
10036 .key_of(n)
10037 .map(|k| k.to_string())
10038 .ok_or_else(|| GraphError::Corrupt {
10039 detail: format!("topology id {n} has no key"),
10040 })
10041 })
10042 .collect::<Result<Vec<_>>>()
10043 }
10044
10045 /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
10046 /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
10047 pub fn degree(
10048 &self,
10049 key: &str,
10050 edge_type: Option<&str>,
10051 direction: crate::algo::AlgoDir,
10052 ) -> Result<u64> {
10053 let id = self
10054 .ids
10055 .get(key)
10056 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10057 let topo = self.topo_view();
10058 Ok(Self::unique_directed_degree(
10059 &topo, &self.syms, id, edge_type, direction,
10060 ))
10061 }
10062
10063 /// Unique directed degree for a subset or a label scan.
10064 ///
10065 /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
10066 /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
10067 /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
10068 #[allow(clippy::too_many_arguments)]
10069 pub fn degrees(
10070 &self,
10071 keys: Option<&[String]>,
10072 label: Option<&str>,
10073 where_: Option<&PropPredicate>,
10074 edge_type: Option<&str>,
10075 direction: crate::algo::AlgoDir,
10076 limit: Option<usize>,
10077 ) -> Result<Vec<(String, u64)>> {
10078 if let Some(pred) = where_ {
10079 pred.validate_named("where")
10080 .map_err(|detail| GraphError::QueryError { detail })?;
10081 }
10082 if matches!(keys, Some(ks) if ks.is_empty()) {
10083 return Ok(Vec::new());
10084 }
10085 let view = self.view();
10086 let ids: Vec<u32> = match keys {
10087 Some(ks) => {
10088 let mut seen = HashSet::new();
10089 let mut out = Vec::new();
10090 for k in ks {
10091 let Some(id) = view.ids.get(k) else {
10092 continue;
10093 };
10094 if !seen.insert(id) {
10095 continue;
10096 }
10097 if let Some(pred) = where_ {
10098 let holds = match view.prop(id, &pred.field) {
10099 None => pred.holds(None),
10100 Some(vr) => pred.holds(Some(vr.as_value())),
10101 };
10102 if !holds {
10103 continue;
10104 }
10105 }
10106 out.push(id);
10107 }
10108 out
10109 }
10110 None => Self::vector_candidates(&view, label, where_),
10111 };
10112 let mut out: Vec<(String, u64)> = ids
10113 .into_iter()
10114 .filter_map(|id| {
10115 let key = self.ids.key_of(id)?.to_string();
10116 let deg =
10117 Self::unique_directed_degree(&view.topo, view.syms, id, edge_type, direction);
10118 Some((key, deg))
10119 })
10120 .collect();
10121 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
10122 if let Some(lim) = limit {
10123 out.truncate(lim);
10124 }
10125 Ok(out)
10126 }
10127
10128 /// Unique neighbour count for `id` across `edge_type` (or all types) and
10129 /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
10130 fn unique_directed_degree(
10131 topo: &TopologyView<'_>,
10132 syms: &Interner,
10133 id: u32,
10134 edge_type: Option<&str>,
10135 direction: crate::algo::AlgoDir,
10136 ) -> u64 {
10137 let dirs: &[Direction] = match direction {
10138 crate::algo::AlgoDir::Out => &[Direction::Out],
10139 crate::algo::AlgoDir::In => &[Direction::In],
10140 crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
10141 };
10142 match edge_type {
10143 Some(name) => {
10144 let Some(et) = syms.get(name) else {
10145 return 0;
10146 };
10147 dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
10148 }
10149 None => topo
10150 .etypes()
10151 .map(|et| {
10152 dirs.iter()
10153 .map(|&d| topo.degree(et, d, id) as u64)
10154 .sum::<u64>()
10155 })
10156 .sum(),
10157 }
10158 }
10159
10160 /// Return the last-change commit sequence for `key`, or `None` if the node
10161 /// does not exist or has never been mutated since the last V5-V7 snapshot
10162 /// (horizon-bounded for legacy stores).
10163 ///
10164 /// The returned sequence is a monotonically increasing counter that starts
10165 /// at 1 for the first commit after `open` and increments with every
10166 /// successful write. WAL replay at open also assigns sequences (1..N for N
10167 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
10168 ///
10169 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
10170 /// in the snapshot but not touched by any WAL frame will return `None`
10171 /// (horizon-bounded: CAS against such nodes is only safe after the first
10172 /// V8 snapshot or after the node is next mutated).
10173 pub fn last_changed(&self, key: &str) -> Option<u64> {
10174 let id = self.ids.get(key)?;
10175 self.last_change.get(&id).copied()
10176 }
10177
10178 /// The current commit sequence (number of successful commits since open,
10179 /// including WAL replay frames). Useful for recording a baseline before
10180 /// a read-modify-write cycle.
10181 pub fn commit_seq(&self) -> u64 {
10182 self.commit_seq
10183 }
10184
10185 /// Check that all `preconds` are satisfied against the current db state.
10186 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
10187 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
10188 for precond in preconds {
10189 match precond {
10190 Precondition::NodeUnchangedSince { key, expected } => {
10191 // Missing entry means the node predates the WAL window or
10192 // does not exist; treat as 0 (before any commit).
10193 let actual = self.last_changed(key).unwrap_or_default();
10194 if actual != *expected {
10195 return Err(GraphError::CasConflict {
10196 key: key.clone(),
10197 expected: *expected,
10198 actual,
10199 });
10200 }
10201 }
10202 Precondition::NodeAbsent { key } => {
10203 // Node must not exist (not live).
10204 if self.ids.get(key).is_some() {
10205 let actual = self.last_changed(key).unwrap_or(0);
10206 return Err(GraphError::CasConflict {
10207 key: key.clone(),
10208 expected: u64::MAX,
10209 actual,
10210 });
10211 }
10212 }
10213 }
10214 }
10215 Ok(())
10216 }
10217
10218 /// Apply a batch of mutations with compare-and-set preconditions.
10219 ///
10220 /// All preconditions are checked atomically before any operation is applied.
10221 /// If any precondition fails, the entire batch is rejected with
10222 /// [`GraphError::CasConflict`] and no WAL frame is written.
10223 ///
10224 /// # Returns
10225 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
10226 ///
10227 /// # Errors
10228 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
10229 /// - Any error that [`write_batch`] would return for the ops themselves.
10230 pub fn write_batch_cas(
10231 &mut self,
10232 preconds: Vec<Precondition>,
10233 ops: Vec<BatchOp>,
10234 ) -> Result<(usize, usize)> {
10235 self.check_preconditions(&preconds)?;
10236 self.commit_logged_batch(ops, None, None)
10237 }
10238
10239 /// Update the per-node last-change map for a WAL record at commit `seq`.
10240 ///
10241 /// Called after a successful apply to record which nodes were touched.
10242 /// For replay, called with the WAL-frame's replayed seq.
10243 ///
10244 /// Touch definition (see [`Precondition`] doc):
10245 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
10246 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
10247 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
10248 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
10249 /// - Batch → recurse into inner records.
10250 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
10251 match rec {
10252 WalRecord::InsertNode { key, .. }
10253 | WalRecord::SetProp { key, .. }
10254 | WalRecord::RemoveProp { key, .. } => {
10255 if let Some(id) = self.ids.get(key) {
10256 self.last_change.insert(id, seq);
10257 }
10258 }
10259 WalRecord::InsertNodeId { key, .. } => {
10260 if let Some(id) = self.ids.get(key) {
10261 self.last_change.insert(id, seq);
10262 }
10263 }
10264 WalRecord::SetPropId { id, .. } => {
10265 self.last_change.insert(*id, seq);
10266 }
10267 WalRecord::InsertEdge {
10268 src_key, dst_key, ..
10269 }
10270 | WalRecord::DeleteEdge {
10271 src_key, dst_key, ..
10272 } => {
10273 if let Some(src_id) = self.ids.get(src_key) {
10274 self.last_change.insert(src_id, seq);
10275 }
10276 if let Some(dst_id) = self.ids.get(dst_key) {
10277 self.last_change.insert(dst_id, seq);
10278 }
10279 }
10280 WalRecord::InsertEdgeId { src, dst, .. } => {
10281 self.last_change.insert(*src, seq);
10282 self.last_change.insert(*dst, seq);
10283 }
10284 // DeleteNode: node is tombstoned; last_changed(key) returns None for
10285 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
10286 // History markers: state no-ops; the underlying mutation already
10287 // touched the relevant nodes' last_change entries.
10288 WalRecord::DeleteNode { .. }
10289 | WalRecord::DerivedEdgeAdded { .. }
10290 | WalRecord::DerivedEdgeRetracted { .. }
10291 | WalRecord::Intern { .. }
10292 | WalRecord::CreateRule { .. }
10293 | WalRecord::DeleteRule { .. }
10294 | WalRecord::RebuildRule { .. }
10295 | WalRecord::CreateView { .. }
10296 | WalRecord::DeleteView { .. }
10297 | WalRecord::EnableFulltext { .. }
10298 | WalRecord::DisableFulltext { .. }
10299 | WalRecord::EnableIndex { .. }
10300 | WalRecord::DisableIndex { .. } => {}
10301 // RenameNode: node id is stable; update last_change via the new key.
10302 // Called after apply(), so ids already reflects new_key.
10303 WalRecord::RenameNode { new_key, .. } => {
10304 if let Some(id) = self.ids.get(new_key) {
10305 self.last_change.insert(id, seq);
10306 }
10307 }
10308 WalRecord::Batch(inner) => {
10309 for inner_rec in inner {
10310 self.update_last_change_from_rec(inner_rec, seq);
10311 }
10312 }
10313 }
10314 }
10315
10316 pub fn node_count(&self) -> usize {
10317 self.ids.len()
10318 }
10319
10320 /// Configure archive retention: keep the `N` newest WAL archives at each
10321 /// [`snapshot_with`] call when `archive_wal: true`.
10322 ///
10323 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
10324 /// `Some(0)` or `None` → unlimited (no pruning).
10325 ///
10326 /// Pruning only ever happens inside [`snapshot_with`]; this method only
10327 /// stores the policy. Archives below the retention limit are deleted
10328 /// oldest-first. The horizon floor is updated so that
10329 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
10330 /// in pruned archives rather than silently returning wrong data.
10331 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
10332 self.wal_archive_retention = keep;
10333 }
10334
10335 /// Delete any WAL archives that are fully below the current horizon floor.
10336 ///
10337 /// Orphaned archives arise when the floor is written first during retention
10338 /// pruning and then a crash interrupts the archive-delete sequence. The
10339 /// opening cleanup ensures no subsequent read path sees stale data.
10340 ///
10341 /// Under the monotonic naming scheme, the archive name N equals the
10342 /// cumulative end-frame index of the archive in global commit space (i.e.
10343 /// the archive covers global frames `[prev_n, N)`). An archive is
10344 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
10345 /// below the floor and have already been counted in it.
10346 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
10347 if self.wal_horizon_floor == 0 {
10348 // Floor at 0 means no pruning has ever occurred; nothing to clean.
10349 return Ok(());
10350 }
10351 let archive_ns = self.fs.list_archives()?;
10352 for n in archive_ns {
10353 if n <= self.wal_horizon_floor {
10354 // Archive N ends at global frame N; all its frames are below
10355 // the floor (floor already accounts for them) → orphaned.
10356 self.fs.delete_archive(n).map_err(GraphError::Io)?;
10357 } else {
10358 // Archives are sorted ascending; first one above floor stops scan.
10359 break;
10360 }
10361 }
10362 Ok(())
10363 }
10364
10365 /// Collect all WAL frames from surviving archives (oldest-first) then the
10366 /// live WAL into one flat list, and return the total along with the number
10367 /// of archive frames at the front of the list.
10368 ///
10369 /// Commit indices into the returned list are LOCAL (0 = first frame of
10370 /// oldest surviving archive). To obtain the GLOBAL index add
10371 /// `self.wal_horizon_floor`.
10372 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
10373 let archive_ns = self.fs.list_archives()?;
10374 let mut all: Vec<WalRecord> = Vec::new();
10375 for n in archive_ns {
10376 let bytes = self.fs.read_archive(n)?;
10377 let (frames, _) = decode_all(&bytes);
10378 all.extend(frames);
10379 }
10380 let archive_count = all.len() as u64;
10381 let live_bytes = self.fs.read(FileId::Wal)?;
10382 let (live_frames, _) = decode_all(&live_bytes);
10383 all.extend(live_frames);
10384 Ok((all, archive_count))
10385 }
10386
10387 /// Return the total number of committed WAL frames visible in the current
10388 /// horizon window, including frames in surviving WAL archives.
10389 ///
10390 /// This is the exclusive upper bound for valid `at_commit` indices in
10391 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
10392 ///
10393 /// Returns the horizon floor when all surviving history is empty.
10394 pub fn wal_total_commits(&self) -> Result<u64> {
10395 let (frames, _) = self.all_frames()?;
10396 Ok(self.wal_horizon_floor + frames.len() as u64)
10397 }
10398
10399 /// The global frame index of the first commit reachable through surviving
10400 /// archives (0 when no archives have been pruned).
10401 pub fn wal_horizon_floor(&self) -> u64 {
10402 self.wal_horizon_floor
10403 }
10404
10405 /// Return the per-node change history for `key` by scanning the on-disk WAL.
10406 ///
10407 /// ## Horizon
10408 ///
10409 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
10410 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
10411 /// zero-cost contract; a durable history log is out of scope.
10412 ///
10413 /// ## Derived edges
10414 ///
10415 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
10416 /// history. Only edges written directly by the application are recorded.
10417 ///
10418 /// ## Deleted nodes
10419 ///
10420 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
10421 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
10422 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
10423 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
10424 ///
10425 /// ## Dense-id edge entries and tombstoned partners
10426 ///
10427 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
10428 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
10429 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
10430 /// Build commit-bounded alias intervals for `queried_key`.
10431 ///
10432 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
10433 /// A record written under `key` at commit `c` matches the queried identity iff
10434 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
10435 ///
10436 /// Each alias entry carries both a lower and an upper bound so that key-reuse
10437 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
10438 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
10439 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
10440 /// only identity-2's events (commits 7–9 under "a") are in scope.
10441 ///
10442 /// Only **forward aliasing**: querying the *new* key surfaces events written
10443 /// under the *old* key. The reverse direction is not supported.
10444 fn build_key_alias_intervals(
10445 &self,
10446 frames: &[core_storage::wal::WalRecord],
10447 queried_key: &str,
10448 ) -> Vec<(String, u64, Option<u64>)> {
10449 use core_storage::wal::WalRecord;
10450
10451 // Pre-pass: build reverse_rename and key_starts maps.
10452 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
10453 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
10454
10455 for (local_i, frame) in frames.iter().enumerate() {
10456 let commit = self.wal_horizon_floor + local_i as u64;
10457 let records: &[WalRecord] = match frame {
10458 WalRecord::Batch(inner) => inner.as_slice(),
10459 single => std::slice::from_ref(single),
10460 };
10461 for rec in records {
10462 match rec {
10463 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
10464 key_starts.entry(key.clone()).or_default().push(commit);
10465 }
10466 WalRecord::RenameNode { old_key, new_key } => {
10467 // new_key came into existence at this commit.
10468 key_starts.entry(new_key.clone()).or_default().push(commit);
10469 // Record the reverse rename: new_key was introduced by renaming old_key.
10470 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
10471 }
10472 _ => {}
10473 }
10474 }
10475 }
10476
10477 // Build alias intervals by following the reverse rename chain.
10478 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
10479 let mut current_key = queried_key.to_string();
10480 let mut current_valid_until: Option<u64> = None;
10481
10482 loop {
10483 // valid_from: the most recent commit where current_key was assigned to this
10484 // identity. For aliases (valid_until = Some(vu)), find the last start event
10485 // for the key strictly before vu — this is where the alias's occupancy by
10486 // this identity began, correctly excluding prior identities that reused the key.
10487 let valid_from = if let Some(vu) = current_valid_until {
10488 key_starts
10489 .get(¤t_key)
10490 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
10491 .unwrap_or(self.wal_horizon_floor)
10492 } else {
10493 // Queried key — no upper bound; may have been introduced at any commit.
10494 self.wal_horizon_floor
10495 };
10496
10497 result.push((current_key.clone(), valid_from, current_valid_until));
10498
10499 match reverse_rename.get(¤t_key) {
10500 Some((old_key, rename_commit)) => {
10501 current_valid_until = Some(*rename_commit);
10502 current_key = old_key.clone();
10503 }
10504 None => break,
10505 }
10506 }
10507
10508 result
10509 }
10510
10511 /// Returns true if `record_key` matches any alias interval that covers `commit`.
10512 fn aliases_match(
10513 intervals: &[(String, u64, Option<u64>)],
10514 record_key: &str,
10515 commit: u64,
10516 ) -> bool {
10517 intervals
10518 .iter()
10519 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
10520 }
10521
10522 /// Return the change history of node `key` by scanning the on-disk WAL.
10523 ///
10524 /// ## Horizon
10525 ///
10526 /// History reaches back only as far as the retained WAL. The returned
10527 /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
10528 /// (the exclusive upper bound for valid commit indices) and `horizon` (the
10529 /// oldest commit still reachable). When `horizon > 0`, older events were
10530 /// pruned and are not in `items`.
10531 pub fn node_history(
10532 &self,
10533 key: &str,
10534 ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
10535 use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
10536 use core_storage::wal::WalRecord;
10537
10538 let (frames, _) = self.all_frames()?;
10539 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10540
10541 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
10542 let alias_intervals = self.build_key_alias_intervals(&frames, key);
10543
10544 let mut out: Vec<HistoryEntry> = Vec::new();
10545
10546 for (local_i, frame) in frames.iter().enumerate() {
10547 let commit = self.wal_horizon_floor + local_i as u64;
10548 // Collect the inner records to process — Batch is one commit, single records are one commit.
10549 let records: &[WalRecord] = match frame {
10550 WalRecord::Batch(inner) => inner.as_slice(),
10551 single => std::slice::from_ref(single),
10552 };
10553
10554 for rec in records {
10555 let change = match rec {
10556 WalRecord::InsertNode { label, key: k, .. }
10557 if Self::aliases_match(&alias_intervals, k, commit) =>
10558 {
10559 Some(HistoryChange::NodeInserted {
10560 label: label.clone(),
10561 })
10562 }
10563 WalRecord::InsertNodeId { label, key: k, .. }
10564 if Self::aliases_match(&alias_intervals, k, commit) =>
10565 {
10566 let label_str = match self.syms.resolve(*label) {
10567 Some(s) => s.to_string(),
10568 None => continue,
10569 };
10570 Some(HistoryChange::NodeInserted { label: label_str })
10571 }
10572 WalRecord::SetProp {
10573 key: k,
10574 field,
10575 value,
10576 } if Self::aliases_match(&alias_intervals, k, commit) => {
10577 Some(HistoryChange::PropSet {
10578 field: field.clone(),
10579 value: value.clone(),
10580 })
10581 }
10582 WalRecord::SetPropId { id, field, value } => {
10583 // Use key_of_historical (not key_of) so a node's prop_set
10584 // events remain visible after the node is later deleted:
10585 // key_of returns None for a tombstoned id, which would
10586 // silently drop every PropSet between insert and delete.
10587 // Mirrors the InsertEdgeId arm below and edge_history's
10588 // own id-keyed arms.
10589 match self.ids.key_of_historical(*id) {
10590 // key_of_historical returns the last-known (possibly
10591 // post-rename, possibly post-delete) key; compare to queried key.
10592 Some(resolved) if resolved == key => {
10593 let field_str = match self.syms.resolve(*field) {
10594 Some(s) => s.to_string(),
10595 None => continue,
10596 };
10597 Some(HistoryChange::PropSet {
10598 field: field_str,
10599 value: value.clone(),
10600 })
10601 }
10602 _ => None,
10603 }
10604 }
10605 WalRecord::RemoveProp { key: k, field }
10606 if Self::aliases_match(&alias_intervals, k, commit) =>
10607 {
10608 Some(HistoryChange::PropRemoved {
10609 field: field.clone(),
10610 })
10611 }
10612 WalRecord::InsertEdge {
10613 edge_type,
10614 src_key,
10615 dst_key,
10616 } => {
10617 if Self::aliases_match(&alias_intervals, src_key, commit) {
10618 Some(HistoryChange::EdgeAdded {
10619 edge_type: edge_type.clone(),
10620 other: dst_key.clone(),
10621 outgoing: true,
10622 })
10623 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10624 Some(HistoryChange::EdgeAdded {
10625 edge_type: edge_type.clone(),
10626 other: src_key.clone(),
10627 outgoing: false,
10628 })
10629 } else {
10630 None
10631 }
10632 }
10633 WalRecord::InsertEdgeId { etype, src, dst } => {
10634 let etype_str = match self.syms.resolve(*etype) {
10635 Some(s) => s.to_string(),
10636 None => continue,
10637 };
10638 // key_of_historical (not key_of): an edge added before
10639 // either endpoint was later deleted must still resolve —
10640 // see the SetPropId arm above and edge_history's
10641 // InsertEdgeId arm, which use the same lookup for the
10642 // same reason.
10643 let src_key = self.ids.key_of_historical(*src);
10644 let dst_key = self.ids.key_of_historical(*dst);
10645 if src_key == Some(key) {
10646 let other = match dst_key {
10647 Some(s) => s.to_string(),
10648 None => continue,
10649 };
10650 Some(HistoryChange::EdgeAdded {
10651 edge_type: etype_str,
10652 other,
10653 outgoing: true,
10654 })
10655 } else if dst_key == Some(key) {
10656 let other = match src_key {
10657 Some(s) => s.to_string(),
10658 None => continue,
10659 };
10660 Some(HistoryChange::EdgeAdded {
10661 edge_type: etype_str,
10662 other,
10663 outgoing: false,
10664 })
10665 } else {
10666 None
10667 }
10668 }
10669 WalRecord::DeleteEdge {
10670 edge_type,
10671 src_key,
10672 dst_key,
10673 } => {
10674 if Self::aliases_match(&alias_intervals, src_key, commit) {
10675 Some(HistoryChange::EdgeRemoved {
10676 edge_type: edge_type.clone(),
10677 other: dst_key.clone(),
10678 outgoing: true,
10679 })
10680 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10681 Some(HistoryChange::EdgeRemoved {
10682 edge_type: edge_type.clone(),
10683 other: src_key.clone(),
10684 outgoing: false,
10685 })
10686 } else {
10687 None
10688 }
10689 }
10690 WalRecord::DeleteNode { key: k }
10691 if Self::aliases_match(&alias_intervals, k, commit) =>
10692 {
10693 Some(HistoryChange::NodeDeleted)
10694 }
10695 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10696 _ => None,
10697 };
10698
10699 if let Some(change) = change {
10700 out.push(HistoryEntry { commit, change });
10701 }
10702 }
10703 }
10704
10705 Ok(HistoryResult {
10706 items: out,
10707 total_commits,
10708 horizon: self.wal_horizon_floor,
10709 })
10710 }
10711
10712 /// Return the per-edge change history between nodes `a` and `b` by scanning
10713 /// the on-disk WAL.
10714 ///
10715 /// ## Horizon
10716 ///
10717 /// History reaches back only to the last WAL-truncating snapshot, exactly
10718 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10719 /// `total_commits` (= number of WAL frames), which is the exclusive upper
10720 /// bound for valid commit indices.
10721 ///
10722 /// ## Derived edges
10723 ///
10724 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10725 /// WAL markers written by `log_then_apply_with` after each rule-firing
10726 /// mutation. The `rule` field of those events carries the rule name.
10727 ///
10728 /// ## DeleteNode
10729 ///
10730 /// When a node is deleted, its manual incident edges are swept inline without
10731 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10732 /// events for either endpoint and synthesises `Retracted(rule:None)` events
10733 /// for each manual edge that was active at that point. Derived edges active at
10734 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10735 /// the engine appends immediately after the `DeleteNode` record; those events
10736 /// carry correct rule attribution and are emitted by the marker arm, not the
10737 /// synthetic sweep.
10738 ///
10739 /// ## Masks
10740 ///
10741 /// Like `node_history`, this method has no mask parameter and returns WAL
10742 /// history regardless of any role mask. For masked history semantics, apply
10743 /// the mask at the caller level.
10744 pub fn edge_history(
10745 &self,
10746 a: &str,
10747 b: &str,
10748 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10749 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10750 use core_storage::wal::WalRecord;
10751
10752 let (frames, _) = self.all_frames()?;
10753 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10754
10755 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10756 // Intervals are commit-bounded so recycled keys don't contaminate histories.
10757 let alias_a = self.build_key_alias_intervals(&frames, a);
10758 let alias_b = self.build_key_alias_intervals(&frames, b);
10759
10760 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10761 // The is_derived flag is used by the DeleteNode sweep: manual edges are
10762 // swept with a synthetic Retracted(rule:None); derived edges are skipped
10763 // because the engine writes a DerivedEdgeRetracted marker immediately after
10764 // the DeleteNode record, which carries the correct rule attribution.
10765 let mut active: Vec<(String, String, String, bool)> = Vec::new();
10766 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10767
10768 for (local_i, frame) in frames.iter().enumerate() {
10769 let commit = self.wal_horizon_floor + local_i as u64;
10770 let records: &[WalRecord] = match frame {
10771 WalRecord::Batch(inner) => inner.as_slice(),
10772 single => std::slice::from_ref(single),
10773 };
10774
10775 for rec in records {
10776 match rec {
10777 WalRecord::InsertEdge {
10778 edge_type,
10779 src_key,
10780 dst_key,
10781 } => {
10782 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10783 && Self::aliases_match(&alias_b, dst_key, commit);
10784 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10785 && Self::aliases_match(&alias_a, dst_key, commit);
10786 if is_ab || is_ba {
10787 active.push((
10788 edge_type.clone(),
10789 src_key.clone(),
10790 dst_key.clone(),
10791 false,
10792 ));
10793 out.push(EdgeHistoryEvent {
10794 edge_type: edge_type.clone(),
10795 commit,
10796 event: EdgeEvent::Added,
10797 rule: None,
10798 });
10799 }
10800 }
10801 WalRecord::InsertEdgeId { etype, src, dst } => {
10802 let etype_str = match self.syms.resolve(*etype) {
10803 Some(s) => s.to_string(),
10804 None => continue,
10805 };
10806 // Use key_of_historical so tombstoned nodes (deleted
10807 // later in the WAL) still resolve during the scan.
10808 let src_key = self.ids.key_of_historical(*src);
10809 let dst_key = self.ids.key_of_historical(*dst);
10810 let is_ab = src_key == Some(a) && dst_key == Some(b);
10811 let is_ba = src_key == Some(b) && dst_key == Some(a);
10812 if is_ab || is_ba {
10813 let src_str = src_key.unwrap().to_string();
10814 let dst_str = dst_key.unwrap().to_string();
10815 active.push((etype_str.clone(), src_str, dst_str, false));
10816 out.push(EdgeHistoryEvent {
10817 edge_type: etype_str,
10818 commit,
10819 event: EdgeEvent::Added,
10820 rule: None,
10821 });
10822 }
10823 }
10824 WalRecord::DeleteEdge {
10825 edge_type,
10826 src_key,
10827 dst_key,
10828 } => {
10829 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10830 && Self::aliases_match(&alias_b, dst_key, commit);
10831 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10832 && Self::aliases_match(&alias_a, dst_key, commit);
10833 if is_ab || is_ba {
10834 // Remove the first matching active entry (flag ignored).
10835 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10836 et == edge_type && s == src_key && d == dst_key
10837 }) {
10838 active.remove(pos);
10839 }
10840 out.push(EdgeHistoryEvent {
10841 edge_type: edge_type.clone(),
10842 commit,
10843 event: EdgeEvent::Retracted,
10844 rule: None,
10845 });
10846 }
10847 }
10848 WalRecord::DeleteNode { key: k }
10849 if Self::aliases_match(&alias_a, k, commit)
10850 || Self::aliases_match(&alias_b, k, commit) =>
10851 {
10852 // Sweep: implicitly retract only MANUAL active edges.
10853 // Derived active edges are skipped here because the rule
10854 // engine appends a DerivedEdgeRetracted marker immediately
10855 // after this DeleteNode record; that marker produces the
10856 // single correctly-attributed Retracted event. Derived
10857 // entries are dropped from `active` (the marker arm's
10858 // idempotent retain finds nothing to remove).
10859 for (et, _, _, is_derived) in active.drain(..) {
10860 if !is_derived {
10861 out.push(EdgeHistoryEvent {
10862 edge_type: et,
10863 commit,
10864 event: EdgeEvent::Retracted,
10865 rule: None,
10866 });
10867 }
10868 // Derived: drop silently; marker carries the Retracted event.
10869 }
10870 }
10871 WalRecord::DerivedEdgeAdded {
10872 rule,
10873 edge_type: et,
10874 src_key,
10875 dst_key,
10876 } => {
10877 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10878 && Self::aliases_match(&alias_b, dst_key, commit);
10879 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10880 && Self::aliases_match(&alias_a, dst_key, commit);
10881 if is_ab || is_ba {
10882 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10883 out.push(EdgeHistoryEvent {
10884 edge_type: et.clone(),
10885 commit,
10886 event: EdgeEvent::Added,
10887 rule: Some(rule.clone()),
10888 });
10889 }
10890 }
10891 WalRecord::DerivedEdgeRetracted {
10892 rule,
10893 edge_type: et,
10894 src_key,
10895 dst_key,
10896 } => {
10897 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10898 && Self::aliases_match(&alias_b, dst_key, commit);
10899 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10900 && Self::aliases_match(&alias_a, dst_key, commit);
10901 if is_ab || is_ba {
10902 // Push unconditionally: a derived edge whose Added marker
10903 // predates the history horizon has no `active` entry, but
10904 // the retraction is still a real in-window event.
10905 // Remove from active idempotently if present.
10906 active.retain(|(aet, s, d, _)| {
10907 !(aet == et && s == src_key && d == dst_key)
10908 });
10909 out.push(EdgeHistoryEvent {
10910 edge_type: et.clone(),
10911 commit,
10912 event: EdgeEvent::Retracted,
10913 rule: Some(rule.clone()),
10914 });
10915 }
10916 }
10917 // All other records (InsertNode, SetProp, CreateRule, etc.)
10918 // do not affect edges between a and b.
10919 _ => {}
10920 }
10921 }
10922 }
10923
10924 Ok(HistoryResult {
10925 items: out,
10926 total_commits,
10927 horizon: self.wal_horizon_floor,
10928 })
10929 }
10930
10931 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10932 /// (in either direction) at the WAL commit `at_commit`.
10933 ///
10934 /// ## Horizon
10935 ///
10936 /// Valid commit indices are `0..total_commits` where `total_commits` is the
10937 /// number of WAL frames. An `at_commit >= total_commits` is outside the
10938 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10939 ///
10940 /// ## Derived edges
10941 ///
10942 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10943 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10944 /// and therefore includes derived edges in its point-in-time evaluation,
10945 /// matching `edge_history`'s fidelity.
10946 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10947 use core_storage::wal::WalRecord;
10948
10949 let (frames, _) = self.all_frames()?;
10950 let total_commits = self.wal_horizon_floor + frames.len() as u64;
10951
10952 // Horizon floor: commits in pruned archives are unreachable.
10953 if at_commit < self.wal_horizon_floor {
10954 return Err(GraphError::CommitOutOfRange {
10955 commit: at_commit,
10956 total: total_commits,
10957 floor: self.wal_horizon_floor,
10958 });
10959 }
10960 if at_commit >= total_commits {
10961 return Err(GraphError::CommitOutOfRange {
10962 commit: at_commit,
10963 total: total_commits,
10964 floor: self.wal_horizon_floor,
10965 });
10966 }
10967
10968 // Resolve all historical names for a and b (handles RenameNode in the WAL).
10969 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10970 let alias_a = self.build_key_alias_intervals(&frames, a);
10971 let alias_b = self.build_key_alias_intervals(&frames, b);
10972
10973 // Local index into surviving frames (0 = first frame of oldest archive).
10974 let local_commit = at_commit - self.wal_horizon_floor;
10975
10976 // Replay local frames 0..=local_commit, tracking active edges.
10977 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10978
10979 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10980 let commit = self.wal_horizon_floor + local_i as u64;
10981 let records: &[WalRecord] = match frame {
10982 WalRecord::Batch(inner) => inner.as_slice(),
10983 single => std::slice::from_ref(single),
10984 };
10985
10986 for rec in records {
10987 match rec {
10988 WalRecord::InsertEdge {
10989 edge_type: et,
10990 src_key,
10991 dst_key,
10992 } => {
10993 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10994 && Self::aliases_match(&alias_b, dst_key, commit);
10995 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10996 && Self::aliases_match(&alias_a, dst_key, commit);
10997 if is_ab || is_ba {
10998 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10999 }
11000 }
11001 WalRecord::InsertEdgeId { etype, src, dst } => {
11002 let etype_str = match self.syms.resolve(*etype) {
11003 Some(s) => s.to_string(),
11004 None => continue,
11005 };
11006 // Use key_of_historical so tombstoned nodes resolve.
11007 let src_key = self.ids.key_of_historical(*src);
11008 let dst_key = self.ids.key_of_historical(*dst);
11009 let is_ab = src_key == Some(a) && dst_key == Some(b);
11010 let is_ba = src_key == Some(b) && dst_key == Some(a);
11011 if is_ab || is_ba {
11012 active.insert((
11013 etype_str,
11014 src_key.unwrap().to_string(),
11015 dst_key.unwrap().to_string(),
11016 ));
11017 }
11018 }
11019 WalRecord::DeleteEdge {
11020 edge_type: et,
11021 src_key,
11022 dst_key,
11023 } => {
11024 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11025 && Self::aliases_match(&alias_b, dst_key, commit);
11026 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11027 && Self::aliases_match(&alias_a, dst_key, commit);
11028 if is_ab || is_ba {
11029 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
11030 }
11031 }
11032 WalRecord::DeleteNode { key: k }
11033 if Self::aliases_match(&alias_a, k, commit)
11034 || Self::aliases_match(&alias_b, k, commit) =>
11035 {
11036 // All edges touching the deleted node are gone.
11037 active.retain(|(_, s, d)| s != k && d != k);
11038 }
11039 WalRecord::DerivedEdgeAdded {
11040 edge_type: et,
11041 src_key,
11042 dst_key,
11043 ..
11044 } => {
11045 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11046 && Self::aliases_match(&alias_b, dst_key, commit);
11047 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11048 && Self::aliases_match(&alias_a, dst_key, commit);
11049 if is_ab || is_ba {
11050 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
11051 }
11052 }
11053 WalRecord::DerivedEdgeRetracted {
11054 edge_type: et,
11055 src_key,
11056 dst_key,
11057 ..
11058 } => {
11059 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11060 && Self::aliases_match(&alias_b, dst_key, commit);
11061 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11062 && Self::aliases_match(&alias_a, dst_key, commit);
11063 if is_ab || is_ba {
11064 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
11065 }
11066 }
11067 _ => {}
11068 }
11069 }
11070 }
11071
11072 Ok(active.iter().any(|(et, _, _)| et == edge_type))
11073 }
11074
11075 /// Every edge incident to `key` — either endpoint — that existed at WAL
11076 /// commit `commit`, from ONE scan of the WAL.
11077 ///
11078 /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
11079 /// "what did K's relationships look like at commit C" with one call instead
11080 /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
11081 /// The two agree edge for edge.
11082 ///
11083 /// Results are sorted by `(edge_type, src_key, dst_key)`.
11084 ///
11085 /// ## Horizon
11086 ///
11087 /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
11088 /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
11089 /// `was_linked`. An unknown key is not an error — it simply had no edges.
11090 ///
11091 /// ## Derived edges
11092 ///
11093 /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
11094 /// attribution, so a rule-owned edge comes back with `derived: true` and
11095 /// `rule: Some(name)`.
11096 ///
11097 /// ## Renames
11098 ///
11099 /// `key` is matched through the same commit-bounded alias intervals
11100 /// `edge_history` uses, so querying a node's *current* key surfaces edges
11101 /// written under an earlier name. Endpoint keys in the result are reported
11102 /// under the name the node carries today, so they can be fed straight back
11103 /// into `node_info`, `explain` or another `edges_at`.
11104 ///
11105 /// ## Masks
11106 ///
11107 /// Like `edge_history` and `node_history`, this reads the WAL regardless of
11108 /// any role mask. Apply masking at the caller level.
11109 pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
11110 use core_storage::wal::WalRecord;
11111
11112 let (frames, _) = self.all_frames()?;
11113 let total_commits = self.wal_horizon_floor + frames.len() as u64;
11114
11115 // Horizon floor: commits in pruned archives are unreachable.
11116 if commit < self.wal_horizon_floor || commit >= total_commits {
11117 return Err(GraphError::CommitOutOfRange {
11118 commit,
11119 total: total_commits,
11120 floor: self.wal_horizon_floor,
11121 });
11122 }
11123
11124 // Commit-bounded historical names of `key` (handles RenameNode).
11125 let alias = self.build_key_alias_intervals(&frames, key);
11126
11127 // Forward rename chain, for reporting endpoints under their current
11128 // names: old key → [(commit, new key)] in ascending commit order.
11129 // Built over the whole WAL, not just the prefix up to `commit`, because
11130 // a rename after `commit` still changes what the node is called today.
11131 let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
11132 for (local_i, frame) in frames.iter().enumerate() {
11133 let c = self.wal_horizon_floor + local_i as u64;
11134 let records: &[WalRecord] = match frame {
11135 WalRecord::Batch(inner) => inner.as_slice(),
11136 single => std::slice::from_ref(single),
11137 };
11138 for rec in records {
11139 if let WalRecord::RenameNode { old_key, new_key } = rec {
11140 renames
11141 .entry(old_key.clone())
11142 .or_default()
11143 .push((c, new_key.clone()));
11144 }
11145 }
11146 }
11147
11148 // The name a node written as `k` at commit `from` carries today.
11149 // Follows the first rename at or after `from`, then keeps going. The
11150 // iteration cap bounds a rename cycle inside a single batch.
11151 let canon = |k: &str, from: u64| -> String {
11152 if renames.is_empty() {
11153 return k.to_string();
11154 }
11155 let mut cur = k.to_string();
11156 let mut at = from;
11157 for _ in 0..64 {
11158 match renames
11159 .get(&cur)
11160 .and_then(|v| v.iter().find(|(c, _)| *c >= at))
11161 {
11162 Some((c, new)) => {
11163 at = *c;
11164 cur = new.clone();
11165 }
11166 None => break,
11167 }
11168 }
11169 cur
11170 };
11171
11172 let local_commit = commit - self.wal_horizon_floor;
11173 // (edge_type, src_key, dst_key) → (derived, rule)
11174 let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
11175 BTreeMap::new();
11176
11177 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
11178 let c = self.wal_horizon_floor + local_i as u64;
11179 let records: &[WalRecord] = match frame {
11180 WalRecord::Batch(inner) => inner.as_slice(),
11181 single => std::slice::from_ref(single),
11182 };
11183
11184 for rec in records {
11185 match rec {
11186 WalRecord::InsertEdge {
11187 edge_type,
11188 src_key,
11189 dst_key,
11190 } => {
11191 if Self::aliases_match(&alias, src_key, c)
11192 || Self::aliases_match(&alias, dst_key, c)
11193 {
11194 active.insert(
11195 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11196 (false, None),
11197 );
11198 }
11199 }
11200 WalRecord::InsertEdgeId { etype, src, dst } => {
11201 let Some(etype_str) = self.syms.resolve(*etype) else {
11202 continue;
11203 };
11204 // `key_of_historical` resolves tombstoned ids too, and
11205 // already returns the node's current key — no rename
11206 // canonicalisation needed on this arm.
11207 let (Some(src_key), Some(dst_key)) = (
11208 self.ids.key_of_historical(*src),
11209 self.ids.key_of_historical(*dst),
11210 ) else {
11211 continue;
11212 };
11213 if src_key == key || dst_key == key {
11214 active.insert(
11215 (
11216 etype_str.to_string(),
11217 src_key.to_string(),
11218 dst_key.to_string(),
11219 ),
11220 (false, None),
11221 );
11222 }
11223 }
11224 WalRecord::DeleteEdge {
11225 edge_type,
11226 src_key,
11227 dst_key,
11228 } => {
11229 if Self::aliases_match(&alias, src_key, c)
11230 || Self::aliases_match(&alias, dst_key, c)
11231 {
11232 active.remove(&(
11233 edge_type.clone(),
11234 canon(src_key, c),
11235 canon(dst_key, c),
11236 ));
11237 }
11238 }
11239 WalRecord::DeleteNode { key: k } => {
11240 if active.is_empty() {
11241 continue;
11242 }
11243 if Self::aliases_match(&alias, k, c) {
11244 // Our node is gone; every incident edge goes with it.
11245 active.clear();
11246 } else {
11247 // A partner is gone; its edges to us go with it.
11248 let ck = canon(k, c);
11249 active.retain(|(_, s, d), _| *s != ck && *d != ck);
11250 }
11251 }
11252 WalRecord::DerivedEdgeAdded {
11253 rule,
11254 edge_type,
11255 src_key,
11256 dst_key,
11257 } => {
11258 if Self::aliases_match(&alias, src_key, c)
11259 || Self::aliases_match(&alias, dst_key, c)
11260 {
11261 active.insert(
11262 (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11263 (true, Some(rule.clone())),
11264 );
11265 }
11266 }
11267 WalRecord::DerivedEdgeRetracted {
11268 edge_type,
11269 src_key,
11270 dst_key,
11271 ..
11272 } => {
11273 if Self::aliases_match(&alias, src_key, c)
11274 || Self::aliases_match(&alias, dst_key, c)
11275 {
11276 active.remove(&(
11277 edge_type.clone(),
11278 canon(src_key, c),
11279 canon(dst_key, c),
11280 ));
11281 }
11282 }
11283 // InsertNode, SetProp, CreateRule, … do not move edges.
11284 _ => {}
11285 }
11286 }
11287 }
11288
11289 // BTreeMap iteration is already (edge_type, src, dst) order.
11290 Ok(active
11291 .into_iter()
11292 .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
11293 edge_type,
11294 src_key,
11295 dst_key,
11296 derived,
11297 rule,
11298 })
11299 .collect())
11300 }
11301
11302 /// The derived edges that would be retracted and derived if `key.field`
11303 /// were set to `value` — computed WITHOUT writing anything.
11304 ///
11305 /// Nothing is committed and nothing on `self` is mutated: the rule engine's
11306 /// provenance, its candidate indexes, the topology and the property columns
11307 /// are all cloned first, the change is applied to the clone, and the real
11308 /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
11309 /// `set_prop` makes during apply) runs against it. The derived-edge deltas
11310 /// it emits are the answer, so rule semantics — predicates, top-k,
11311 /// via-hops, chaining, weights — are the engine's, not a re-implementation.
11312 ///
11313 /// Works on a read-only handle.
11314 ///
11315 /// **While a rule's vector index is still building** (`RuleStats::building`)
11316 /// the clone carries no pending-build state, so this reports the edges that
11317 /// rule would derive — which the live store will not derive until its
11318 /// backfill runs. Right about the end state, early about the timing.
11319 ///
11320 /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
11321 /// `Err(ViewPropReadOnly)` for a field a view owns — matching
11322 /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
11323 /// (the node already holds `value`, or no rule watches `field`) returns
11324 /// empty lists.
11325 ///
11326 /// ## Cost
11327 ///
11328 /// One clone of the property columns, the topology overlay, the symbol
11329 /// interner, the edge properties and the provenance map, plus one candidate
11330 /// re-index (O(nodes × rules)). That is much cheaper than copying the store
11331 /// directory, but it is not free — this is an interactive "what if", not a
11332 /// hot path.
11333 pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
11334 // The engine's provenance, HNSW and IVF state live in the mmap'd base
11335 // until something asks for them. On a store opened cold from a snapshot
11336 // this is the first ask, and without it the clone below starts from an
11337 // empty provenance map: nothing to retract, so `lost` comes back empty.
11338 self.ensure_v8_base_sections_loaded();
11339
11340 let empty = WhatIf {
11341 lost: Vec::new(),
11342 gained: Vec::new(),
11343 };
11344
11345 if let Some(view_name) = self.view_store.view_for_prop(field) {
11346 return Err(GraphError::ViewPropReadOnly {
11347 view_name: view_name.to_string(),
11348 });
11349 }
11350 MutPreview::new(self).check_live_key(key)?;
11351 let id = self
11352 .ids
11353 .get(key)
11354 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11355
11356 let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
11357 if rules.is_empty() {
11358 return Ok(empty);
11359 }
11360
11361 // No rule watches this field → no derivation can change.
11362 if !rules.iter().any(|r| r.watched_fields().contains(field)) {
11363 return Ok(empty);
11364 }
11365
11366 let old_value = build_props_view(&self.props, &self.base)
11367 .get(id, field)
11368 .map(|vr| vr.into_value());
11369 if old_value.as_ref() == Some(&value) {
11370 return Ok(empty);
11371 }
11372
11373 // --- Clone every piece of state the re-derivation writes to. ---
11374 let mut props = self.props.clone();
11375 let mut topo = self.topo.clone();
11376 let mut syms = self.syms.clone();
11377 let mut edge_props = self.edge_props.clone();
11378
11379 let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
11380 let mut fires: BTreeMap<String, u64> = BTreeMap::new();
11381 for r in &rules {
11382 tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
11383 fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
11384 }
11385 // `provenance()` decodes retained snapshot bytes on first use; the
11386 // engine clone needs the real map, not an empty one.
11387 let provenance = self.engine.provenance().clone();
11388 let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
11389
11390 // Build the candidate indexes from the state BEFORE the change, exactly
11391 // as apply() sees them: `on_node_changed` withdraws the node under its
11392 // old value and refiles it under the new one, so the index must not
11393 // already reflect the change.
11394 engine.reindex_all_load_state(
11395 &self.ids,
11396 &syms,
11397 &self.labels,
11398 build_props_view(&self.props, &self.base),
11399 self.engine.export_ivf_state(),
11400 self.engine.export_hnsw_state_passthrough(),
11401 );
11402 engine.set_emit_deltas(true);
11403
11404 // --- Apply the hypothetical change and re-derive. ---
11405 props.set(id, field, value);
11406 {
11407 let mut gm = make_graph_mut(
11408 &self.ids,
11409 &mut syms,
11410 &self.labels,
11411 build_props_view(&props, &self.base),
11412 &mut topo,
11413 &self.base,
11414 &mut edge_props,
11415 );
11416 engine.on_node_changed(id, Some((field, old_value)), &mut gm);
11417 }
11418
11419 let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
11420 let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
11421 for d in engine.drain_deltas() {
11422 let edge = EdgeAt {
11423 edge_type: d.edge_type,
11424 src_key: d.src_key,
11425 dst_key: d.dst_key,
11426 derived: true,
11427 rule: Some(d.rule),
11428 };
11429 if d.fired {
11430 gained.insert(edge);
11431 } else {
11432 lost.insert(edge);
11433 }
11434 }
11435 // An edge retracted and re-derived within the same re-derivation (top-k
11436 // churn) is not a change the caller would see.
11437 let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
11438 for e in churn {
11439 lost.remove(&e);
11440 gained.remove(&e);
11441 }
11442
11443 Ok(WhatIf {
11444 lost: lost.into_iter().collect(),
11445 gained: gained.into_iter().collect(),
11446 })
11447 }
11448
11449 pub fn edge_count(&self) -> u64 {
11450 self.topo_view().edge_count()
11451 }
11452
11453 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
11454 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
11455 pub fn stats(&self) -> Stats {
11456 self.ensure_v8_base_sections_loaded();
11457 let building = self.engine.builds_in_progress();
11458 let rules: Vec<RuleStats> = self
11459 .engine
11460 .rules()
11461 .map(|r| RuleStats {
11462 name: r.name.clone(),
11463 edges: self
11464 .engine
11465 .provenance()
11466 .get(&r.name)
11467 .map(|s| s.len() as u64)
11468 .unwrap_or(0),
11469 tripped: self.engine.is_tripped(&r.name),
11470 fires: self.engine.fire_count(&r.name),
11471 approximate: r.approximate,
11472 building: building.iter().find(|b| b.rule == r.name).cloned(),
11473 })
11474 .collect();
11475 Stats {
11476 nodes_live: self.ids.live_len(),
11477 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
11478 edges: self.topo_view().edge_count(),
11479 rules,
11480 chain_truncations: self.engine.chain_truncations(),
11481 history_floor: self.wal_horizon_floor,
11482 namespaces: self.namespace_stats(),
11483 }
11484 }
11485
11486 /// On-disk size of the WAL file in bytes.
11487 ///
11488 /// Reads file metadata without loading WAL contents. Returns `Err` for
11489 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
11490 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
11491 let path = self.fs.wal_path().ok_or_else(|| {
11492 std::io::Error::new(
11493 std::io::ErrorKind::Unsupported,
11494 "wal_path not available for this Fs implementation",
11495 )
11496 })?;
11497 Ok(std::fs::metadata(path)?.len())
11498 }
11499
11500 /// Set the slow-query threshold. Queries whose execution time equals or
11501 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
11502 ///
11503 /// Use this setter in tests — the environment variable
11504 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
11505 /// threads.
11506 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
11507 self.slow_query_threshold_ms = ms;
11508 }
11509
11510 /// Snapshot of the slow-query ring buffer and lifetime counter.
11511 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
11512 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
11513 SlowQuerySnapshot {
11514 threshold_ms: self.slow_query_threshold_ms,
11515 count: log.total,
11516 last: log.entries.iter().cloned().collect(),
11517 }
11518 }
11519
11520 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
11521 /// to compute uptime.
11522 pub fn started_at(&self) -> std::time::Instant {
11523 self.started_at
11524 }
11525
11526 /// On-disk snapshot format version this binary writes and reads.
11527 pub fn format_version() -> u16 {
11528 core_storage::snapshot::VERSION
11529 }
11530
11531 /// Test-support: total bytes appended (SimFs only usage).
11532 pub fn fs_total_appended(&self) -> usize
11533 where
11534 F: FsIntrospect,
11535 {
11536 self.fs.total_appended()
11537 }
11538
11539 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
11540 pub fn fs_sync_count(&self) -> usize
11541 where
11542 F: FsIntrospect,
11543 {
11544 self.fs.sync_count()
11545 }
11546
11547 /// Consume the db, returning its fs (for crash simulation).
11548 pub fn into_fs(self) -> F {
11549 self.fs
11550 }
11551
11552 pub fn snapshot(&mut self) -> Result<()> {
11553 self.snapshot_with(SnapshotOptions::default())
11554 }
11555
11556 /// Snapshot with explicit options.
11557 ///
11558 /// # `keep_wal`
11559 ///
11560 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
11561 /// - The WAL is replaced with a minimal baseline containing one
11562 /// `EnableFulltext` record per active declaration. All pre-snapshot
11563 /// history is discarded; `open_at` can only reach post-snapshot commits.
11564 ///
11565 /// When `keep_wal` is `true`:
11566 /// - The WAL is left intact. All pre-snapshot commits remain reachable
11567 /// via `open_at`. The existing WAL already contains the original
11568 /// `EnableFulltext` records, so no baseline re-write is needed; the
11569 /// recovery guards in `apply()` silently skip any duplicate records on
11570 /// replay.
11571 /// - Crash window: a crash after the snapshot write but before the next
11572 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
11573 /// snapshot is loaded and the WAL replayed idempotently over it — safe
11574 /// because every `apply()` arm is idempotent when replayed over an
11575 /// already-current snapshot.
11576 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
11577 if self.read_only {
11578 return Err(GraphError::ReadOnly);
11579 }
11580 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
11581 // appending ends up holding a descriptor on an unlinked inode and loses
11582 // commits it believes durable. Snapshotting therefore requires the
11583 // cross-process write lock, exactly as appending does. Unlike the WAL
11584 // append path this does not go through `log_then_apply_with`, so both
11585 // guards are repeated here.
11586 if self.degraded {
11587 return Err(GraphError::Io(std::io::Error::other(
11588 "database degraded after group-commit fsync failure; reopen required",
11589 )));
11590 }
11591 if self.lock_denied {
11592 return Err(GraphError::Busy { holder: None });
11593 }
11594 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
11595 // Used by the archive path's conservative genesis-chain check: if a prior
11596 // snapshot exists but wal.truncated does not, we cannot distinguish a
11597 // legacy store (may have been truncated in an older code version) from a
11598 // new store that only used keep_wal=true. Conservative: refuse genesis in
11599 // both cases. Must be sampled here, before the snapshot write below.
11600 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
11601 self.ensure_v8_base_sections_loaded();
11602 // Ensure provenance is decoded before to_persist() clones it.
11603 self.engine.ensure_provenance_loaded_mut();
11604 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
11605 let rule_defs = rule_defs_typed
11606 .iter()
11607 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
11608 .collect();
11609 // Collect HNSW state and IVF state. When indexes are not yet
11610 // populated (clean open, no mutation since open), pass the retained
11611 // raw bytes through directly so that migrate/snapshot does not
11612 // silently discard fitted approximate-rule indexes.
11613 let hnsw_state = self.engine.export_hnsw_state_passthrough();
11614 let ivf_bytes = if !self.engine.indexes_populated() {
11615 // Pass retained IVF bytes through unchanged (no re-encode).
11616 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
11617 } else {
11618 // Indexes live: encode from current state.
11619 let raw_ivf = self.engine.export_ivf_state();
11620 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
11621 .into_iter()
11622 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
11623 (
11624 name,
11625 core_storage::snapshot::PerRuleIvfState {
11626 src: core_storage::snapshot::SideIvfState {
11627 centroids: sc,
11628 clusters: sa,
11629 drift: sd,
11630 },
11631 dst: core_storage::snapshot::SideIvfState {
11632 centroids: dc,
11633 clusters: da,
11634 drift: dd,
11635 },
11636 },
11637 )
11638 })
11639 .collect();
11640 if ivf_state_map.is_empty() {
11641 Vec::new()
11642 } else {
11643 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11644 }
11645 };
11646 let view_defs: Vec<Vec<u8>> = self
11647 .view_store
11648 .views()
11649 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11650 .collect();
11651 if self.base.is_some() {
11652 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11653 // write it atomically, remap it as the new base, then clear the overlay.
11654 let meta = V8Meta {
11655 labels: self.labels.clone(),
11656 edge_props: self.edge_props.clone(),
11657 rule_defs,
11658 provenance,
11659 rule_tripped,
11660 rule_fires,
11661 ivf_bytes,
11662 view_defs,
11663 wal_truncated: !opts.keep_wal,
11664 hnsw: hnsw_state,
11665 last_change: self.last_change.clone(),
11666 };
11667 let mut buf: Vec<u8> = Vec::new();
11668 {
11669 // Clone the Arc so the old base stays alive while we encode.
11670 // The borrow of archived_csr (into old_base's mmap) is released
11671 // at the end of this block, before we replace self.base.
11672 let old_base = self.base.clone().expect("is_some checked above");
11673 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11674 detail: format!("v8 snapshot: topology section: {e:?}"),
11675 })?;
11676 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11677 detail: format!("v8 snapshot: columns section: {e:?}"),
11678 })?;
11679 // `None` when the base predates V9 — the migration path: its
11680 // string columns still carry their own tables and this snapshot
11681 // is the rewrite that collapses them into section 12.
11682 let archived_strings =
11683 old_base
11684 .string_table()
11685 .transpose()
11686 .map_err(|e| GraphError::Corrupt {
11687 detail: format!("v8 snapshot: strings section: {e:?}"),
11688 })?;
11689 let archived_edge_props =
11690 old_base
11691 .edge_props_section()
11692 .map_err(|e| GraphError::Corrupt {
11693 detail: format!("v8 snapshot: edge_props section: {e:?}"),
11694 })?;
11695 let edge_props_raw =
11696 old_base
11697 .edge_props_raw_bytes()
11698 .map_err(|e| GraphError::Corrupt {
11699 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11700 })?;
11701 let prov_raw =
11702 old_base
11703 .provenance_raw_bytes()
11704 .map_err(|e| GraphError::Corrupt {
11705 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11706 })?;
11707 encode_v8(
11708 Some(archived_csr),
11709 Some(archived_cols),
11710 archived_strings,
11711 Some((archived_edge_props, edge_props_raw)),
11712 Some(prov_raw),
11713 &self.topo,
11714 &self.props,
11715 &self.ids,
11716 &self.syms,
11717 &meta,
11718 &mut buf,
11719 )?;
11720 }
11721 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11722 // Remap the freshly-written snapshot as the new base.
11723 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11724 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11725 core_storage::v8::MappedBase::map(&snap_path)
11726 } else {
11727 core_storage::v8::MappedBase::from_bytes(buf)
11728 }
11729 .map_err(|e| GraphError::Corrupt {
11730 detail: format!("v8 snapshot: remap new base: {e:?}"),
11731 })?;
11732 self.base = Some(Arc::new(new_base));
11733 // Clear the overlay and prop tombstones — all data is now in the new base.
11734 self.topo = Topology::new();
11735 self.props = core_storage::columns::ColumnStore::new();
11736 } else {
11737 // Legacy path (V5–V7 stores without a V8 base).
11738 //
11739 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11740 // clone and no encode_v8_from_state intermediate clones. The big
11741 // structures (self.topo, self.props) are borrowed, not cloned.
11742 // self.edge_props is moved (not cloned) because we immediately clear it
11743 // when we remap the new V8 snapshot as self.base (see below).
11744 //
11745 // Eliminates from peak RSS vs. the old SnapshotState path:
11746 // • self.topo.clone() (~topology HashMap footprint)
11747 // • self.props.clone() (~column-store footprint)
11748 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11749 let meta = V8Meta {
11750 labels: self.labels.clone(),
11751 wal_truncated: !opts.keep_wal,
11752 // Move edge_props out so the large overlay is freed when meta
11753 // drops at end of this block (self.edge_props is now empty; reads
11754 // after base assignment go through the mmap'd base section).
11755 edge_props: std::mem::take(&mut self.edge_props),
11756 rule_defs,
11757 provenance,
11758 rule_tripped,
11759 rule_fires,
11760 ivf_bytes,
11761 view_defs,
11762 hnsw: hnsw_state,
11763 last_change: self.last_change.clone(),
11764 };
11765 let mut buf = Vec::new();
11766 encode_v8(
11767 None,
11768 None,
11769 None,
11770 None,
11771 None,
11772 &self.topo,
11773 &self.props,
11774 &self.ids,
11775 &self.syms,
11776 &meta,
11777 &mut buf,
11778 )?;
11779 // meta (and the moved edge_props inside it) is no longer needed;
11780 // drop it before the write to keep the peak window narrow.
11781 drop(meta);
11782 self.fs.write_atomic(FileId::Snapshot, &buf)?;
11783 // Remap the freshly-written V8 snapshot as self.base.
11784 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11785 // On SimFs (tests): pass buf to from_bytes.
11786 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11787 drop(buf);
11788 core_storage::v8::MappedBase::map(&snap_path)
11789 } else {
11790 core_storage::v8::MappedBase::from_bytes(buf)
11791 }
11792 .map_err(|e| GraphError::Corrupt {
11793 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11794 })?;
11795 self.base = Some(Arc::new(new_base));
11796 // Free the large heap-allocated decoded state — all data is now in the
11797 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
11798 // self.edge_props was already moved into meta and is effectively empty.
11799 self.topo = Topology::new();
11800 self.props = core_storage::columns::ColumnStore::new();
11801 }
11802
11803 if opts.archive_wal {
11804 // History-preserving snapshot (Task 4):
11805 // 1. Snapshot already written above (write_atomic → fsynced).
11806 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
11807 // Crash window B: crash here leaves archive present, WAL
11808 // absent. Reopen: snapshot loaded (full state), no WAL
11809 // replay. Archive is NOT replayed into live state — it is
11810 // pre-snapshot by construction. Safe.
11811 // 3. Optionally write genesis marker (first archive only, no
11812 // prior WAL truncation).
11813 // 4. Prune old archives (retention), update horizon floor.
11814 // Pruning invalidates the genesis chain; delete marker.
11815 // 5. Write new minimal baseline WAL (write_atomic).
11816 // Crash window C: crash here leaves new archive plus no live
11817 // WAL. Same as window B — handled above.
11818 //
11819 // Sample existing archives BEFORE the rename so we can detect
11820 // whether this is the first archive.
11821 let existing_archives = self.fs.list_archives()?;
11822 let is_first_archive = existing_archives.is_empty();
11823
11824 // Compute a globally-monotonic archive name: the name equals the
11825 // cumulative end-frame index of the archive in global commit space.
11826 //
11827 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11828 // commit_seq is seeded from max(last_change), which underestimates
11829 // the WAL depth when trailing commits (e.g. insert_edge) do not
11830 // update last_change. A session-2 archive could then receive a name
11831 // ≤ the session-1 archive, causing incorrect sort order or collision.
11832 //
11833 // Instead: read and decode the live WAL here (before the rename) to
11834 // get its exact frame count, then add it to the last known global
11835 // end-frame index (the name of the most recent existing archive, or
11836 // wal_horizon_floor if no archives exist). This is O(WAL size) but
11837 // snapshot is already serialising the full graph state, so the cost
11838 // is dominated.
11839 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11840 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11841 let archive_n = existing_archives
11842 .last()
11843 .copied()
11844 .unwrap_or(self.wal_horizon_floor)
11845 + live_frames_for_name.len() as u64;
11846 self.fs.archive_wal(archive_n)?;
11847
11848 // Genesis marker: written once when the first archive is taken
11849 // from a store that has never undergone a WAL-truncating snapshot.
11850 // When present, `open_at` may replay archive-resident commits from
11851 // empty state (the archive chain covers from global index 0).
11852 //
11853 // Two conditions must ALL hold:
11854 // 1. This is the first archive (existing_archives was empty).
11855 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11856 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11857 // before truncating the WAL, so if any prior truncating snapshot was taken
11858 // — even in a previous session — snapshot.bin is present and this condition
11859 // is false. This subsumes the cross-session truncation case without
11860 // requiring a separate wal.truncated sidecar file.
11861 // For legacy stores (snapshot.bin written by an older code version that
11862 // may have truncated the WAL), the same conservative refusal applies:
11863 // we cannot prove the chain is complete, so we refuse genesis (cost =
11864 // no as-of-through-archives; never silent wrong data).
11865 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11866 // so SimFs always passes this check.
11867 if is_first_archive && !had_prior_snapshot {
11868 self.fs.write_genesis_marker()?;
11869 self.archive_genesis_chain = true;
11870 }
11871
11872 // Retention pruning: keep newest `keep` archives; delete oldest.
11873 // Pruning is the ONLY deletion site for archives.
11874 //
11875 // Crash-safety ordering (C1 fix):
11876 // 1. Count frames in surplus archives (reads only — no mutation).
11877 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
11878 // rename (atomic). A crash after this point leaves orphaned
11879 // archives on disk, but the floor is correct. The opening
11880 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
11881 // the next open, so the store is always safe to reopen.
11882 // 3. Delete the genesis marker (floor > 0 already blocks open_at
11883 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
11884 // 4. Delete surplus archives. A crash between any two deletes
11885 // leaves the floor committed and orphaned archives cleaned at
11886 // next open — never a stale floor with a missing archive prefix.
11887 if let Some(keep) = self.wal_archive_retention {
11888 if keep > 0 {
11889 let archives = self.fs.list_archives()?;
11890 // archives is sorted ascending (oldest first)
11891 if archives.len() as u32 > keep {
11892 let surplus = archives.len() - keep as usize;
11893 // Step 1: count pruned frames (reads, no mutation).
11894 let mut pruned_frames = 0u64;
11895 for &n in &archives[..surplus] {
11896 let bytes = self.fs.read_archive(n)?;
11897 let (frames, _) = decode_all(&bytes);
11898 pruned_frames += frames.len() as u64;
11899 }
11900 // Step 2: advance and persist floor FIRST.
11901 self.wal_horizon_floor += pruned_frames;
11902 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11903 // Step 3: delete genesis marker (floor > 0 already
11904 // blocks open_at; this is belt-and-suspenders cleanup).
11905 if pruned_frames > 0 && self.archive_genesis_chain {
11906 self.fs.delete_genesis_marker()?;
11907 self.archive_genesis_chain = false;
11908 }
11909 // Step 4: delete surplus archives. Crash here →
11910 // orphaned archives; cleaned at next open.
11911 for &n in &archives[..surplus] {
11912 self.fs.delete_archive(n)?;
11913 }
11914 }
11915 }
11916 }
11917
11918 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11919 let mut baseline_wal: Vec<u8> = Vec::new();
11920 for (label, field) in self.fulltext.enabled_pairs() {
11921 let rec = WalRecord::EnableFulltext {
11922 label: label.clone(),
11923 field: field.clone(),
11924 };
11925 baseline_wal.extend_from_slice(&encode_record(&rec));
11926 }
11927 for (label, field) in self.prop_index.enabled_pairs() {
11928 let rec = WalRecord::EnableIndex {
11929 label: label.clone(),
11930 field: field.clone(),
11931 };
11932 baseline_wal.extend_from_slice(&encode_record(&rec));
11933 }
11934 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11935 } else if opts.keep_wal {
11936 // keep_wal=true: WAL is left untouched. The existing WAL already
11937 // contains the EnableFulltext records from the original enable calls;
11938 // replay is idempotent (guards in apply() skip already-live entries).
11939 // No baseline re-write is needed or safe here — the full WAL history
11940 // must remain intact for open_at to reach pre-snapshot commits.
11941 } else {
11942 // keep_wal=false (default): truncate by replacing the WAL with a
11943 // minimal baseline of one EnableFulltext record per active pair.
11944 //
11945 // Crash-ordering: write_atomic is atomic.
11946 // • Crash before snapshot write → WAL unchanged. Safe.
11947 // • Crash after snapshot write but before this WAL write → full
11948 // pre-snapshot WAL still present; open_with replays idempotently.
11949 // • Crash after both writes → normal post-snapshot state.
11950 //
11951 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11952 // for any archives taken AFTER this point (their WAL slices would
11953 // not start at genesis). Delete any existing genesis marker so that
11954 // open_at refuses archive-resident commits. Future sessions are
11955 // covered by had_prior_snapshot: snapshot.bin written here persists
11956 // across sessions and prevents a later archiving session from
11957 // incorrectly claiming a complete genesis chain.
11958 if self.archive_genesis_chain {
11959 self.fs.delete_genesis_marker()?;
11960 self.archive_genesis_chain = false;
11961 }
11962 let mut baseline_wal: Vec<u8> = Vec::new();
11963 for (label, field) in self.fulltext.enabled_pairs() {
11964 let rec = WalRecord::EnableFulltext {
11965 label: label.clone(),
11966 field: field.clone(),
11967 };
11968 baseline_wal.extend_from_slice(&encode_record(&rec));
11969 }
11970 for (label, field) in self.prop_index.enabled_pairs() {
11971 let rec = WalRecord::EnableIndex {
11972 label: label.clone(),
11973 field: field.clone(),
11974 };
11975 baseline_wal.extend_from_slice(&encode_record(&rec));
11976 }
11977 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11978 }
11979 // After snapshot the overlay may have changed (V8 merge path clears
11980 // self.topo and self.props). Refresh the MVCC fold so future readers
11981 // see the post-snapshot state rather than stale overlay data.
11982 self.fold_now();
11983 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11984 // markers this handle uses to detect other processes' work must be
11985 // re-taken from disk. Skipping this would make our own snapshot look
11986 // like a peer's on the next staleness check and force a needless
11987 // reload.
11988 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11989 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11990 Ok(())
11991 }
11992}
11993
11994/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11995///
11996/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11997/// callers can build a set of mutations without holding `&mut GraphDb` and
11998/// hand them off to the group-committing writer for durable, batched I/O.
11999pub enum BatchOp {
12000 InsertNode {
12001 label: String,
12002 key: String,
12003 props: Vec<(String, Value)>,
12004 },
12005 InsertEdge {
12006 edge_type: String,
12007 src_key: String,
12008 dst_key: String,
12009 },
12010 SetProp {
12011 key: String,
12012 field: String,
12013 value: Value,
12014 },
12015 RemoveProp {
12016 key: String,
12017 field: String,
12018 },
12019 DeleteEdge {
12020 edge_type: String,
12021 src_key: String,
12022 dst_key: String,
12023 },
12024 DeleteNode {
12025 key: String,
12026 },
12027 CreateRule(RuleDef),
12028 DeleteRule {
12029 name: String,
12030 },
12031 /// Rename a node's key. Validated: old must exist, new must not.
12032 RenameNode {
12033 old_key: String,
12034 new_key: String,
12035 },
12036 /// Insert an edge, auto-creating any missing endpoint as a plain node with
12037 /// `placeholder_label` and no props. Rules fire and last-change is updated
12038 /// for each created endpoint (normal InsertNode semantics in the batch frame).
12039 InsertEdgeUpsert {
12040 edge_type: String,
12041 src_key: String,
12042 dst_key: String,
12043 placeholder_label: String,
12044 },
12045}
12046
12047/// Three-way node visibility status used by `check_single_op_authz`.
12048enum NodeAuthzStatus {
12049 /// Node exists in the store and is in the role's read mask.
12050 Visible(String), // carries the node's label
12051 /// Node exists in the store but is NOT in the role's read mask.
12052 Hidden,
12053 /// Node does not exist in the store.
12054 Absent,
12055}
12056
12057/// Overlay of ops already accepted earlier in the same batch. Never written
12058/// back to the database — validation only.
12059#[derive(Default)]
12060struct Overlay {
12061 extra_keys: BTreeSet<String>,
12062 deleted_keys: BTreeSet<String>,
12063 extra_props: BTreeMap<(String, String), Value>,
12064 removed_props: BTreeSet<(String, String)>,
12065 extra_edges: BTreeSet<(String, String, String)>,
12066 deleted_edges: BTreeSet<(String, String, String)>,
12067 extra_rules: BTreeSet<String>,
12068 deleted_rules: BTreeSet<String>,
12069 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
12070 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
12071 /// sees only the rules already committed to the engine. Keyed by name so a
12072 /// later `DeleteRule` in the same batch drops the arc with the rule.
12073 extra_rule_arcs: BTreeMap<String, (String, String)>,
12074}
12075
12076/// Read-only view of live db state plus a batch overlay. Shared by single-op
12077/// public methods (empty overlay) and `commit_batch`.
12078struct MutPreview<'a, F: Fs> {
12079 db: &'a GraphDb<F>,
12080 overlay: Overlay,
12081}
12082
12083/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
12084/// `None` if `target` is unreachable.
12085///
12086/// Used for rule-chain cycle detection, where an arc is "a rule hops over
12087/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
12088/// reported path is stable for a given rule set, and iterative so a pathological
12089/// rule graph cannot overflow the stack.
12090fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
12091 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
12092 for (from, to) in arcs {
12093 adj.entry(from.as_str()).or_default().insert(to.as_str());
12094 }
12095 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
12096 let mut visited: BTreeSet<&str> = BTreeSet::new();
12097 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
12098 visited.insert(start);
12099 queue.push_back(start);
12100 while let Some(node) = queue.pop_front() {
12101 if node == target {
12102 let mut path = vec![node.to_string()];
12103 let mut cur = node;
12104 while let Some(&p) = parent.get(cur) {
12105 path.push(p.to_string());
12106 cur = p;
12107 }
12108 path.reverse();
12109 return Some(path);
12110 }
12111 for &next in adj.get(node).into_iter().flatten() {
12112 if visited.insert(next) {
12113 parent.insert(next, node);
12114 queue.push_back(next);
12115 }
12116 }
12117 }
12118 None
12119}
12120
12121impl<'a, F: Fs> MutPreview<'a, F> {
12122 fn new(db: &'a GraphDb<F>) -> Self {
12123 Self {
12124 db,
12125 overlay: Overlay::default(),
12126 }
12127 }
12128
12129 fn has_key(&self, key: &str) -> bool {
12130 if self.overlay.extra_keys.contains(key) {
12131 return true;
12132 }
12133 if self.overlay.deleted_keys.contains(key) {
12134 return false;
12135 }
12136 self.db.ids.get(key).is_some()
12137 }
12138
12139 fn has_prop(&self, key: &str, field: &str) -> bool {
12140 if !self.has_key(key) {
12141 return false;
12142 }
12143 let k = (key.to_string(), field.to_string());
12144 if self.overlay.removed_props.contains(&k) {
12145 return false;
12146 }
12147 if self.overlay.extra_props.contains_key(&k) {
12148 return true;
12149 }
12150 // Fresh identity (first insert in this batch, or delete+reinsert):
12151 // ignore props still sitting on the soon-to-be-tombstoned slot.
12152 if self.overlay.extra_keys.contains(key) {
12153 return false;
12154 }
12155 self.db.get_prop(key, field).is_some()
12156 }
12157
12158 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12159 let k = (
12160 edge_type.to_string(),
12161 src_key.to_string(),
12162 dst_key.to_string(),
12163 );
12164 if self.overlay.deleted_edges.contains(&k) {
12165 return false;
12166 }
12167 if self.overlay.extra_edges.contains(&k) {
12168 return true;
12169 }
12170 // A key created in this batch (including reinsert) has no db edges.
12171 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12172 return false;
12173 }
12174 if self.overlay.deleted_keys.contains(src_key)
12175 || self.overlay.deleted_keys.contains(dst_key)
12176 {
12177 return false;
12178 }
12179 let Some(src) = self.db.ids.get(src_key) else {
12180 return false;
12181 };
12182 let Some(dst) = self.db.ids.get(dst_key) else {
12183 return false;
12184 };
12185 let Some(sym) = self.db.syms.get(edge_type) else {
12186 return false;
12187 };
12188 self.db
12189 .topo_view()
12190 .neighbors(sym, Direction::Out, src)
12191 .binary_search(&dst)
12192 .is_ok()
12193 }
12194
12195 fn has_rule(&self, name: &str) -> bool {
12196 if self.overlay.extra_rules.contains(name) {
12197 return true;
12198 }
12199 if self.overlay.deleted_rules.contains(name) {
12200 return false;
12201 }
12202 self.db.engine.rules().any(|r| r.name == name)
12203 }
12204
12205 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12206 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12207 return false;
12208 }
12209 if self.overlay.deleted_keys.contains(src_key)
12210 || self.overlay.deleted_keys.contains(dst_key)
12211 {
12212 return false;
12213 }
12214 let Some(src) = self.db.ids.get(src_key) else {
12215 return false;
12216 };
12217 let Some(dst) = self.db.ids.get(dst_key) else {
12218 return false;
12219 };
12220 let Some(et) = self.db.syms.get(edge_type) else {
12221 return false;
12222 };
12223 // extra_rules is deliberately not consulted: a CreateRule earlier in
12224 // this batch has not fired, so it contributes no provenance. That is
12225 // the documented rule-window gap (see GraphDb::batch).
12226 if self.overlay.deleted_rules.is_empty() {
12227 return self.db.engine.is_owned(et, src, dst);
12228 }
12229 for (rule, triples) in self.db.engine.provenance() {
12230 if self.overlay.deleted_rules.contains(rule) {
12231 continue;
12232 }
12233 if triples.contains(&(et, src, dst)) {
12234 return true;
12235 }
12236 }
12237 false
12238 }
12239
12240 fn check_insert_node(&self, key: &str) -> Result<()> {
12241 if self.has_key(key) {
12242 Err(GraphError::DuplicateKey { key: key.into() })
12243 } else {
12244 Ok(())
12245 }
12246 }
12247
12248 fn check_live_key(&self, key: &str) -> Result<()> {
12249 if self.has_key(key) {
12250 Ok(())
12251 } else {
12252 Err(GraphError::KeyNotFound { key: key.into() })
12253 }
12254 }
12255
12256 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12257 for k in [src_key, dst_key] {
12258 if !self.has_key(k) {
12259 return Err(GraphError::KeyNotFound { key: k.into() });
12260 }
12261 }
12262 if self.is_rule_owned(edge_type, src_key, dst_key) {
12263 return Err(GraphError::RuleOwned {
12264 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
12265 });
12266 }
12267 // A user-written edge stays inside one namespace. Derived edges do not
12268 // come through here — the engine adds them directly — and the rule
12269 // scoping check is what keeps those pure.
12270 let src_ns = self.namespace_in_batch(src_key);
12271 let dst_ns = self.namespace_in_batch(dst_key);
12272 if src_ns != dst_ns {
12273 return Err(GraphError::CrossNamespace {
12274 src: src_key.to_string(),
12275 src_ns,
12276 dst: dst_key.to_string(),
12277 dst_ns,
12278 });
12279 }
12280 Ok(!self.has_edge(edge_type, src_key, dst_key))
12281 }
12282
12283 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
12284 self.check_live_key(key)?;
12285 // Removing `ns` is changing the namespace — to `default`, the namespace
12286 // an absent property names. It goes through this one choke-point and NOT
12287 // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
12288 // the immutability rule has to be stated here as well. Without it the
12289 // node silently lands in `default` on the next open: the cross-namespace
12290 // edge guard is defeated and a default-bound role reads a tenant's node.
12291 if field == NS_PROP {
12292 let from = self.namespace_in_batch(key);
12293 if from != NS_DEFAULT {
12294 return Err(GraphError::NamespaceImmutable {
12295 key: key.to_string(),
12296 from,
12297 to: NS_DEFAULT.to_string(),
12298 });
12299 }
12300 // Already in `default`: the removal changes no namespace. It is the
12301 // no-op `set_prop` to the current namespace is, not an error.
12302 return Ok(false);
12303 }
12304 Ok(self.has_prop(key, field))
12305 }
12306
12307 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12308 for k in [src_key, dst_key] {
12309 if !self.has_key(k) {
12310 return Err(GraphError::KeyNotFound { key: k.into() });
12311 }
12312 }
12313 // Provenance-owned OR a live rule would derive this pair. User-first
12314 // edges that a later rule matches are not in `owned`, but deleting
12315 // them would leave a hole `rebuild_rule` immediately fills.
12316 if self.is_rule_owned(edge_type, src_key, dst_key) {
12317 return Err(GraphError::RuleOwned {
12318 detail: format!(
12319 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12320 delete or change the owning rule"
12321 ),
12322 });
12323 }
12324 if self.would_derive(edge_type, src_key, dst_key) {
12325 return Err(GraphError::RuleOwned {
12326 detail: format!(
12327 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12328 delete or change the owning rule, or a live rule would re-derive it"
12329 ),
12330 });
12331 }
12332 Ok(self.has_edge(edge_type, src_key, dst_key))
12333 }
12334
12335 /// True if any live rule (minus overlay-deleted names) would derive
12336 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
12337 /// CreateRule names in `extra_rules` are ignored — same documented
12338 /// same-batch rule-window as [`Self::is_rule_owned`].
12339 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12340 if src_key == dst_key {
12341 return false;
12342 }
12343 let Some(src_label) = self.label_of(src_key) else {
12344 return false;
12345 };
12346 let Some(dst_label) = self.label_of(dst_key) else {
12347 return false;
12348 };
12349 for rule in self.db.engine.rules() {
12350 if self.overlay.deleted_rules.contains(&rule.name) {
12351 continue;
12352 }
12353 if rule.edge_type != edge_type {
12354 continue;
12355 }
12356 if rule.src_label != src_label || rule.dst_label != dst_label {
12357 continue;
12358 }
12359 let src_props = |f: &str| self.prop_value(src_key, f);
12360 let dst_props = |f: &str| self.prop_value(dst_key, f);
12361 let src_view = NodeView {
12362 key: src_key,
12363 props: &src_props,
12364 };
12365 let dst_view = NodeView {
12366 key: dst_key,
12367 props: &dst_props,
12368 };
12369 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
12370 return true;
12371 }
12372 }
12373 false
12374 }
12375
12376 fn label_of(&self, key: &str) -> Option<String> {
12377 if self.overlay.deleted_keys.contains(key) {
12378 return None;
12379 }
12380 // Fresh identities created in this batch have no stored label in the
12381 // overlay; they cannot be provenance-owned yet either.
12382 let id = self.db.ids.get(key)?;
12383 let sym = self.db.labels.get(id as usize).copied()?;
12384 if sym == u32::MAX {
12385 return None;
12386 }
12387 self.db.syms.resolve(sym).map(str::to_string)
12388 }
12389
12390 /// The namespace `key` is in as this batch sees it — including a node
12391 /// inserted earlier in the same batch, which the store does not have yet.
12392 fn namespace_in_batch(&self, key: &str) -> String {
12393 namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
12394 }
12395
12396 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
12397 if !self.has_key(key) {
12398 return None;
12399 }
12400 let k = (key.to_string(), field.to_string());
12401 if self.overlay.removed_props.contains(&k) {
12402 return None;
12403 }
12404 if let Some(v) = self.overlay.extra_props.get(&k) {
12405 return Some(v.clone());
12406 }
12407 if self.overlay.extra_keys.contains(key) {
12408 return None;
12409 }
12410 self.db.get_prop(key, field)
12411 }
12412
12413 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
12414 def.validate()
12415 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
12416 if self.has_rule(&def.name) {
12417 return Err(GraphError::RuleInvalid {
12418 detail: format!("rule {:?} already exists", def.name),
12419 });
12420 }
12421 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
12422 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
12423 // `edge_type`". A cycle in that graph is a rule set that would re-fire
12424 // itself forever; the engine's depth cap would silently truncate it
12425 // instead, leaving an arbitrary partial result. Reject it here, the one
12426 // place that sees the whole rule set.
12427 //
12428 // Rules accepted earlier in the same batch count too: the overlay
12429 // carries their arcs, so a cycle cannot be assembled one op at a time.
12430 if let Some(via) = def.via_edge.as_deref() {
12431 if via == def.edge_type {
12432 return Err(GraphError::RuleInvalid {
12433 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
12434 });
12435 }
12436 let mut arcs: Vec<(String, String)> = self
12437 .db
12438 .engine
12439 .rules()
12440 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
12441 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
12442 .collect();
12443 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
12444 arcs.push((via.to_string(), def.edge_type.clone()));
12445 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
12446 return Err(GraphError::RuleInvalid {
12447 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
12448 });
12449 }
12450 }
12451 Ok(())
12452 }
12453
12454 fn check_delete_rule(&self, name: &str) -> Result<()> {
12455 if self.has_rule(name) {
12456 Ok(())
12457 } else {
12458 Err(GraphError::RuleNotFound { name: name.into() })
12459 }
12460 }
12461
12462 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
12463 self.overlay.deleted_keys.remove(key);
12464 self.overlay.extra_keys.insert(key.to_string());
12465 self.overlay.extra_props.retain(|(k, _), _| k != key);
12466 self.overlay.removed_props.retain(|(k, _)| k != key);
12467 for (field, value) in props {
12468 self.overlay
12469 .extra_props
12470 .insert((key.to_string(), field.clone()), value.clone());
12471 }
12472 }
12473
12474 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12475 let k = (
12476 edge_type.to_string(),
12477 src_key.to_string(),
12478 dst_key.to_string(),
12479 );
12480 self.overlay.deleted_edges.remove(&k);
12481 self.overlay.extra_edges.insert(k);
12482 }
12483
12484 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
12485 let k = (key.to_string(), field.to_string());
12486 self.overlay.removed_props.remove(&k);
12487 self.overlay.extra_props.insert(k, value.clone());
12488 }
12489
12490 fn note_remove_prop(&mut self, key: &str, field: &str) {
12491 let k = (key.to_string(), field.to_string());
12492 self.overlay.extra_props.remove(&k);
12493 self.overlay.removed_props.insert(k);
12494 }
12495
12496 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12497 let k = (
12498 edge_type.to_string(),
12499 src_key.to_string(),
12500 dst_key.to_string(),
12501 );
12502 self.overlay.extra_edges.remove(&k);
12503 self.overlay.deleted_edges.insert(k);
12504 }
12505
12506 fn note_delete_node(&mut self, key: &str) {
12507 self.overlay.extra_keys.remove(key);
12508 self.overlay.deleted_keys.insert(key.to_string());
12509 self.overlay.extra_props.retain(|(k, _), _| k != key);
12510 self.overlay.removed_props.retain(|(k, _)| k != key);
12511 self.overlay
12512 .extra_edges
12513 .retain(|(_, s, d)| s != key && d != key);
12514 self.overlay
12515 .deleted_edges
12516 .retain(|(_, s, d)| s != key && d != key);
12517 }
12518
12519 fn note_create_rule(&mut self, def: &RuleDef) {
12520 self.overlay.deleted_rules.remove(&def.name);
12521 self.overlay.extra_rules.insert(def.name.clone());
12522 // Rules accepted earlier in this batch are not in the engine yet, so
12523 // the cycle check would not see their arcs. Keep the arc, not just the
12524 // name, so a batch cannot smuggle in a cycle one op at a time.
12525 if let Some(via) = def.via_edge.clone() {
12526 self.overlay
12527 .extra_rule_arcs
12528 .insert(def.name.clone(), (via, def.edge_type.clone()));
12529 }
12530 }
12531
12532 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
12533 if !self.has_key(old) {
12534 return Err(GraphError::KeyNotFound { key: old.into() });
12535 }
12536 if self.has_key(new) {
12537 return Err(GraphError::DuplicateKey { key: new.into() });
12538 }
12539 Ok(())
12540 }
12541
12542 fn note_rename_node(&mut self, old: &str, new: &str) {
12543 // Mark old as deleted so subsequent batch ops cannot reference it.
12544 self.overlay.extra_keys.remove(old);
12545 self.overlay.deleted_keys.insert(old.to_string());
12546 // Mark new as extra so subsequent batch ops can reference it.
12547 self.overlay.deleted_keys.remove(new);
12548 self.overlay.extra_keys.insert(new.to_string());
12549 // Migrate any overlay props from old key to new key.
12550 let new_str = new.to_string();
12551 let transferred: Vec<((String, String), Value)> = self
12552 .overlay
12553 .extra_props
12554 .iter()
12555 .filter(|((k, _), _)| k.as_str() == old)
12556 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
12557 .collect();
12558 self.overlay
12559 .extra_props
12560 .retain(|(k, _), _| k.as_str() != old);
12561 for (k, v) in transferred {
12562 self.overlay.extra_props.insert(k, v);
12563 }
12564 // Migrate removed_props.
12565 let transferred_removed: Vec<(String, String)> = self
12566 .overlay
12567 .removed_props
12568 .iter()
12569 .filter(|(k, _)| k.as_str() == old)
12570 .map(|(_, f)| (new_str.clone(), f.clone()))
12571 .collect();
12572 self.overlay
12573 .removed_props
12574 .retain(|(k, _)| k.as_str() != old);
12575 for k in transferred_removed {
12576 self.overlay.removed_props.insert(k);
12577 }
12578 }
12579
12580 fn note_delete_rule(&mut self, name: &str) {
12581 self.overlay.extra_rules.remove(name);
12582 // Drop its chain arc too: a rule created and then deleted in the same
12583 // batch must not make a later, legal rule look like a cycle.
12584 self.overlay.extra_rule_arcs.remove(name);
12585 self.overlay.deleted_rules.insert(name.to_string());
12586 // Treat the deleted rule's current provenance as gone so a later
12587 // delete_edge of those triples is a no-op (matches sequential).
12588 if let Some(triples) = self.db.engine.provenance().get(name) {
12589 for &(et, s, d) in triples {
12590 let Some(etype) = self.db.syms.resolve(et) else {
12591 continue;
12592 };
12593 let Some(src) = self.db.ids.key_of(s) else {
12594 continue;
12595 };
12596 let Some(dst) = self.db.ids.key_of(d) else {
12597 continue;
12598 };
12599 let k = (etype.to_string(), src.to_string(), dst.to_string());
12600 self.overlay.extra_edges.remove(&k);
12601 self.overlay.deleted_edges.insert(k);
12602 }
12603 }
12604 }
12605}
12606
12607/// Collects mutations and commits them as one WAL `Batch` frame.
12608///
12609/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
12610/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
12611/// See [`GraphDb::batch`] for validation and atomicity rules.
12612pub struct BatchBuilder<'a, F: Fs> {
12613 db: &'a mut GraphDb<F>,
12614 ops: Vec<BatchOp>,
12615}
12616
12617impl<'a, F: Fs> BatchBuilder<'a, F> {
12618 pub fn insert_node(
12619 &mut self,
12620 label: &str,
12621 key: &str,
12622 props: Vec<(String, Value)>,
12623 ) -> &mut Self {
12624 self.ops.push(BatchOp::InsertNode {
12625 label: label.into(),
12626 key: key.into(),
12627 props,
12628 });
12629 self
12630 }
12631
12632 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12633 self.ops.push(BatchOp::InsertEdge {
12634 edge_type: edge_type.into(),
12635 src_key: src_key.into(),
12636 dst_key: dst_key.into(),
12637 });
12638 self
12639 }
12640
12641 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12642 self.ops.push(BatchOp::SetProp {
12643 key: key.into(),
12644 field: field.into(),
12645 value,
12646 });
12647 self
12648 }
12649
12650 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12651 self.ops.push(BatchOp::RemoveProp {
12652 key: key.into(),
12653 field: field.into(),
12654 });
12655 self
12656 }
12657
12658 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12659 self.ops.push(BatchOp::DeleteEdge {
12660 edge_type: edge_type.into(),
12661 src_key: src_key.into(),
12662 dst_key: dst_key.into(),
12663 });
12664 self
12665 }
12666
12667 pub fn delete_node(&mut self, key: &str) -> &mut Self {
12668 self.ops.push(BatchOp::DeleteNode { key: key.into() });
12669 self
12670 }
12671
12672 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12673 self.ops.push(BatchOp::CreateRule(def));
12674 self
12675 }
12676
12677 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12678 self.ops.push(BatchOp::DeleteRule { name: name.into() });
12679 self
12680 }
12681
12682 /// Queue a node-rename in this batch.
12683 ///
12684 /// Validation (old exists, new not taken) runs at commit time.
12685 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12686 self.ops.push(BatchOp::RenameNode {
12687 old_key: old_key.into(),
12688 new_key: new_key.into(),
12689 });
12690 self
12691 }
12692
12693 /// Queue an edge insert with endpoint auto-creation.
12694 ///
12695 /// Any missing endpoint is created as a plain node `{key, label:
12696 /// placeholder_label, no props}` inside this batch frame. Rules fire and
12697 /// last-change is updated for each auto-created node.
12698 pub fn insert_edge_upsert(
12699 &mut self,
12700 edge_type: &str,
12701 src_key: &str,
12702 dst_key: &str,
12703 placeholder_label: &str,
12704 ) -> &mut Self {
12705 self.ops.push(BatchOp::InsertEdgeUpsert {
12706 edge_type: edge_type.into(),
12707 src_key: src_key.into(),
12708 dst_key: dst_key.into(),
12709 placeholder_label: placeholder_label.into(),
12710 });
12711 self
12712 }
12713
12714 /// Validate every queued op, then log one `Batch` frame and apply.
12715 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12716 /// A second `commit()` after a successful one is an empty-batch no-op
12717 /// (queued ops were taken).
12718 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12719 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12720 ///
12721 /// **Rule-window limitation:** batch validation cannot see edges that a
12722 /// rule created earlier in the *same* batch will derive at apply time, so
12723 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12724 /// where sequential calls would return `Err(RuleOwned)`. State integrity
12725 /// is unaffected (idempotent apply, provenance intact). Create rules in
12726 /// their own batch, or sequentially, when later ops may touch derived
12727 /// edges.
12728 /// Validate every queued op and commit atomically.
12729 ///
12730 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12731 /// WAL records actually written (duplicate edges are silent no-ops and are
12732 /// NOT counted). Both are 0 when the batch is empty or all-noop.
12733 pub fn commit(&mut self) -> Result<(usize, usize)> {
12734 let ops = std::mem::take(&mut self.ops);
12735 self.db.commit_batch(ops)
12736 }
12737
12738 /// Same as [`commit`](Self::commit) but tail the inner events with
12739 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12740 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12741 let ops = std::mem::take(&mut self.ops);
12742 self.db
12743 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12744 }
12745}
12746
12747pub struct NodeRef<'a, F: Fs> {
12748 db: &'a GraphDb<F>,
12749 id: u32,
12750}
12751
12752impl<'a, F: Fs> NodeRef<'a, F> {
12753 pub fn key(&self) -> &str {
12754 self.db.ids.key_of(self.id).expect("dense ids")
12755 }
12756
12757 pub fn label(&self) -> &str {
12758 let sym = self
12759 .db
12760 .labels
12761 .get(self.id as usize)
12762 .copied()
12763 .filter(|&s| s != u32::MAX)
12764 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12765 self.db.syms.resolve(sym).expect("interned label symbol")
12766 }
12767
12768 pub fn prop(&self, field: &str) -> Option<Value> {
12769 self.db
12770 .props_view()
12771 .get(self.id, field)
12772 .map(|vr| vr.into_value())
12773 }
12774
12775 /// All stored fields for this node, sorted by field name.
12776 ///
12777 /// Reads from the full base+overlay view so that props stored only in the
12778 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12779 pub fn props(&self) -> BTreeMap<String, Value> {
12780 let mut out = BTreeMap::new();
12781 let pv = self.db.props_view();
12782 for field in pv.field_names() {
12783 if let Some(vr) = pv.get(self.id, &field) {
12784 out.insert(field, vr.into_value());
12785 }
12786 }
12787 out
12788 }
12789
12790 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12791 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12792 let view = self.db.view();
12793 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12794 names
12795 .iter()
12796 .filter_map(|name| view.syms.get(name))
12797 .collect()
12798 });
12799 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12800 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12801 for (nid, d) in nb.nodes {
12802 let key = view.key_of(nid);
12803 let label = view
12804 .label_of(nid)
12805 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12806 rs.push_row(vec![
12807 Some(Value::Str(key.to_string())),
12808 Some(Value::Str(label.to_string())),
12809 Some(Value::Int(d as i64)),
12810 ]);
12811 }
12812 rs
12813 }
12814
12815 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12816 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12817 let view = self.db.view();
12818 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12819 for e in expand(&view, self.id, None, Dir::Both) {
12820 // Skip edges with unknown etypes (only possible from corrupt large
12821 // TOPOLOGY section; function returns BTreeMap not Result).
12822 let Some(etype) = view.syms.resolve(e.etype) else {
12823 continue;
12824 };
12825 let etype = etype.to_string();
12826 let nbr = if e.src == self.id { e.dst } else { e.src };
12827 groups
12828 .entry(etype)
12829 .or_default()
12830 .insert(view.key_of(nbr).to_string());
12831 }
12832 groups
12833 .into_iter()
12834 .map(|(k, v)| (k, v.into_iter().collect()))
12835 .collect()
12836 }
12837}
12838
12839#[cfg(test)]
12840mod tests {
12841 use super::*;
12842 use core_rules::Predicate;
12843
12844 fn tmp_dir(name: &str) -> std::path::PathBuf {
12845 let d =
12846 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12847 let _ = std::fs::remove_dir_all(&d);
12848 d
12849 }
12850
12851 fn fk_rule() -> RuleDef {
12852 RuleDef {
12853 name: "works_at".into(),
12854 src_label: "Person".into(),
12855 dst_label: "Org".into(),
12856 predicate: Predicate::KeyMatch {
12857 field: "org_id".into(),
12858 },
12859 edge_type: "WORKS_AT".into(),
12860 weight_prop: None,
12861 max_edges: None,
12862 approximate: false,
12863 via_label: None,
12864 via_edge: None,
12865 via_dir: None,
12866 namespace: None,
12867 }
12868 }
12869
12870 /// Regression guard for the no-views delta-copy fast path.
12871 ///
12872 /// When no views are defined, `pending_deltas_since().to_vec()` must never
12873 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12874 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12875 /// a count of 0 after the entire sequence proves the guard fires correctly.
12876 #[test]
12877 fn no_delta_copy_when_no_views() {
12878 DELTA_COPY_COUNT.with(|c| c.set(0));
12879 let dir = tmp_dir("no-delta-copy");
12880 {
12881 let mut db = GraphDb::open(&dir).unwrap();
12882 // Insert 50 Org + 50 Person nodes with FK links.
12883 for i in 0..50u32 {
12884 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12885 }
12886 for i in 0..50u32 {
12887 db.insert_node(
12888 "Person",
12889 &format!("p{i}"),
12890 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12891 )
12892 .unwrap();
12893 }
12894 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12895 db.create_rule(fk_rule()).unwrap();
12896
12897 // Counter must stay 0 — no views, no copies.
12898 let copies = DELTA_COPY_COUNT.with(|c| c.get());
12899 assert_eq!(
12900 copies, 0,
12901 "pending_deltas_since().to_vec() called despite no views"
12902 );
12903
12904 // Derived edges must still be correct (the guard skips only the
12905 // empty delta propagation loop, not the rule application itself).
12906 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12907 assert_eq!(
12908 nbrs,
12909 vec!["o0"],
12910 "rule must derive edges even with no views"
12911 );
12912 }
12913 let _ = std::fs::remove_dir_all(&dir);
12914 }
12915
12916 /// Gating regression: subscribe AFTER a backfill must see no stale events.
12917 /// subscribe BEFORE a backfill must see every edge-fire event.
12918 #[test]
12919 fn subscribe_after_backfill_no_stale_events() {
12920 let dir = tmp_dir("sub-after-backfill");
12921 {
12922 let mut db = GraphDb::open(&dir).unwrap();
12923 for i in 0..10u32 {
12924 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12925 db.insert_node(
12926 "Person",
12927 &format!("p{i}"),
12928 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12929 )
12930 .unwrap();
12931 }
12932 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12933 db.create_rule(fk_rule()).unwrap();
12934
12935 // Subscribe AFTER the backfill — queue must be empty (no stale events).
12936 let sub = db.subscribe_all_rules().unwrap();
12937 // No events should have queued for the prior backfill.
12938 assert!(
12939 sub.try_recv().is_none(),
12940 "subscribe after backfill must see no stale events"
12941 );
12942
12943 // Inserting a new node now should fire an event (emit_deltas is now true).
12944 db.insert_node("Org", "o_new", vec![]).unwrap();
12945 db.insert_node(
12946 "Person",
12947 "p_new",
12948 vec![("org_id".into(), Value::Str("o_new".into()))],
12949 )
12950 .unwrap();
12951 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12952 assert!(
12953 ev.is_some(),
12954 "edge-fire event must arrive after subscribe (emit_deltas=true)"
12955 );
12956 }
12957 let _ = std::fs::remove_dir_all(&dir);
12958 }
12959
12960 /// Gating regression: subscribe BEFORE a backfill → events flow.
12961 #[test]
12962 fn subscribe_before_backfill_events_flow() {
12963 let dir = tmp_dir("sub-before-backfill");
12964 {
12965 let mut db = GraphDb::open(&dir).unwrap();
12966 // Subscribe FIRST — emit_deltas becomes true.
12967 let sub = db.subscribe_all_rules().unwrap();
12968
12969 for i in 0..5u32 {
12970 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12971 db.insert_node(
12972 "Person",
12973 &format!("p{i}"),
12974 vec![("org_id".into(), Value::Str(format!("o{i}")))],
12975 )
12976 .unwrap();
12977 }
12978 // Backfill fires with emit_deltas=true → events queued.
12979 db.create_rule(fk_rule()).unwrap();
12980
12981 // Should receive at least one edge-fired event from the backfill.
12982 let mut received = 0usize;
12983 while sub.try_recv().is_some() {
12984 received += 1;
12985 }
12986 assert!(
12987 received > 0,
12988 "subscribe before backfill must receive edge-fire events (got 0)"
12989 );
12990 }
12991 let _ = std::fs::remove_dir_all(&dir);
12992 }
12993
12994 /// Companion: when a view IS defined, the delta path fires and view values update.
12995 #[test]
12996 fn delta_copy_fires_when_view_exists() {
12997 use core_rules::ViewSource;
12998 DELTA_COPY_COUNT.with(|c| c.set(0));
12999 let dir = tmp_dir("delta-copy-with-view");
13000 {
13001 let mut db = GraphDb::open(&dir).unwrap();
13002 db.insert_node("Org", "o1", vec![]).unwrap();
13003 db.insert_node(
13004 "Person",
13005 "p1",
13006 vec![("org_id".into(), Value::Str("o1".into()))],
13007 )
13008 .unwrap();
13009 // Declare a Degree view so is_empty() returns false.
13010 db.create_view(ViewDef {
13011 name: "degree_out".into(),
13012 label: "Person".into(),
13013 view_prop: "degree_out".into(),
13014 source: ViewSource::Degree {
13015 edge_type: "WORKS_AT".into(),
13016 direction: Direction::Out,
13017 },
13018 })
13019 .unwrap();
13020 db.create_rule(fk_rule()).unwrap();
13021
13022 // At least one delta copy should have happened (CreateRule backfill).
13023 let copies = DELTA_COPY_COUNT.with(|c| c.get());
13024 assert!(
13025 copies > 0,
13026 "expected delta copy to fire when a view is defined"
13027 );
13028
13029 // View value should be computed: p1 has one WORKS_AT out-edge.
13030 let info = db.node_info("p1").unwrap();
13031 let degree = info.props.get("degree_out");
13032 assert!(
13033 degree.is_some(),
13034 "view prop should be written to node props"
13035 );
13036 }
13037 let _ = std::fs::remove_dir_all(&dir);
13038 }
13039
13040 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
13041 /// derived-edge-driven view values reflect the as-of state rather than just
13042 /// the initial backfill written at `CreateView` time.
13043 ///
13044 /// Base WAL frames (indices 0..=5 before history markers):
13045 /// 0: insert Org "o1"
13046 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
13047 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
13048 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
13049 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
13050 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
13051 ///
13052 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
13053 /// no-op), so the total commit count is higher than the base frame count.
13054 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
13055 ///
13056 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
13057 /// initial backfill value (0) instead of reflecting the replayed derived edges.
13058 #[test]
13059 fn open_at_derived_edge_view_values_correct() {
13060 use core_rules::ViewSource;
13061 let dir = tmp_dir("open-at-view-rebuild");
13062 {
13063 let mut db = GraphDb::open(&dir).unwrap();
13064 // frame 0
13065 db.insert_node("Org", "o1", vec![]).unwrap();
13066 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
13067 db.create_view(ViewDef {
13068 name: "employee_count".into(),
13069 label: "Org".into(),
13070 view_prop: "emp".into(),
13071 source: ViewSource::Degree {
13072 edge_type: "WORKS_AT".into(),
13073 direction: Direction::In,
13074 },
13075 })
13076 .unwrap();
13077 // frame 2: create rule — no Persons yet; backfill is a no-op
13078 db.create_rule(fk_rule()).unwrap();
13079 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
13080 db.insert_node(
13081 "Person",
13082 "p1",
13083 vec![("org_id".into(), Value::Str("o1".into()))],
13084 )
13085 .unwrap();
13086 // frame 4: p2 — degree = 2
13087 db.insert_node(
13088 "Person",
13089 "p2",
13090 vec![("org_id".into(), Value::Str("o1".into()))],
13091 )
13092 .unwrap();
13093 // frame 5: p3 — degree = 3
13094 db.insert_node(
13095 "Person",
13096 "p3",
13097 vec![("org_id".into(), Value::Str("o1".into()))],
13098 )
13099 .unwrap();
13100 // Sanity: normal open sees degree = 3.
13101 assert_eq!(
13102 db.get_view_prop("o1", "emp"),
13103 Some(Value::Int(3)),
13104 "normal db must show degree 3 after 3 derived edges"
13105 );
13106 } // WAL flushed
13107
13108 // Re-open normally to get the authoritative reference value.
13109 let normal_db = GraphDb::open(&dir).unwrap();
13110 let normal_emp = normal_db.get_view_prop("o1", "emp");
13111 assert_eq!(
13112 normal_emp,
13113 Some(Value::Int(3)),
13114 "re-opened normal db must show degree 3"
13115 );
13116
13117 // Latest as-of (last WAL commit): must match the normal open.
13118 // History-marker frames are appended after each rule-fire, so the total
13119 // commit count is computed dynamically rather than hardcoded.
13120 let total = crate::wal_commit_count_at(&dir).unwrap();
13121 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
13122 assert_eq!(
13123 aof_latest.get_view_prop("o1", "emp"),
13124 normal_emp,
13125 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
13126 );
13127
13128 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
13129 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
13130 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
13131 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
13132 assert_eq!(
13133 aof_mid.get_view_prop("o1", "emp"),
13134 Some(Value::Int(1)),
13135 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
13136 );
13137
13138 let _ = std::fs::remove_dir_all(&dir);
13139 }
13140
13141 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
13142 /// as-of instances never commit, so distribute_events never runs and any
13143 /// subscription would wait forever.
13144 #[test]
13145 fn subscribe_on_as_of_returns_read_only_error() {
13146 let dir = tmp_dir("sub-as-of-read-only");
13147 {
13148 let mut db = GraphDb::open(&dir).unwrap();
13149 db.insert_node("Org", "o1", vec![]).unwrap();
13150 db.create_rule(fk_rule()).unwrap();
13151 }
13152 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
13153
13154 assert!(
13155 matches!(
13156 aof.subscribe_all_rules(),
13157 Err(core_storage::GraphError::ReadOnly)
13158 ),
13159 "subscribe_all_rules on as-of must return ReadOnly"
13160 );
13161 assert!(
13162 matches!(
13163 aof.subscribe_writes(),
13164 Err(core_storage::GraphError::ReadOnly)
13165 ),
13166 "subscribe_writes on as-of must return ReadOnly"
13167 );
13168 assert!(
13169 matches!(
13170 aof.subscribe_rule("works_at"),
13171 Err(core_storage::GraphError::ReadOnly)
13172 ),
13173 "subscribe_rule on as-of must return ReadOnly"
13174 );
13175 let _ = std::fs::remove_dir_all(&dir);
13176 }
13177
13178 /// Regression: a failed dense WAL rewrite must not leave speculative
13179 /// interns in `syms`. If it does, the next successful mutation logs an
13180 /// `Intern` record with an inflated id; replay (which never saw the
13181 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
13182 #[test]
13183 fn dense_rewrite_error_rolls_back_speculative_interns() {
13184 let dir = tmp_dir("dense-rewrite-rollback");
13185 {
13186 let mut db = GraphDb::open(&dir).unwrap();
13187 db.insert_node("Person", "a", vec![]).unwrap();
13188
13189 // Bypass MutPreview validation to hit the rewrite's own error path
13190 // (same shape as an id-exhaustion failure mid-rewrite). The
13191 // InsertEdge arm interns the edge type before it resolves keys.
13192 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
13193 edge_type: "ORPHAN_TYPE".into(),
13194 src_key: "missing".into(),
13195 dst_key: "a".into(),
13196 }]);
13197 assert!(err.is_err(), "rewrite of a missing src key must fail");
13198 assert_eq!(
13199 db.syms.get("ORPHAN_TYPE"),
13200 None,
13201 "failed rewrite must roll back speculative interns"
13202 );
13203
13204 // A later successful mutation must produce a replayable WAL.
13205 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
13206 }
13207 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
13208 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
13209 let _ = std::fs::remove_dir_all(&dir);
13210 }
13211}