nedb_engine/db.rs
1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Main DAG database — coordinates ObjectStore, IdIndex, SortedIndexes, GraphStore.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use anyhow::Result;
12use dashmap::DashMap;
13use serde_json::Value;
14use parking_lot::RwLock;
15
16use crate::store::{Dek, Node, ObjectStore};
17use crate::index::{IdIndex, OrderedValue, SortedIndexes};
18use crate::graph::GraphStore;
19use crate::migrate;
20
21// ── A note on where diagnostics go ───────────────────────────────────────
22//
23// Every startup and repair message in this crate goes to STDERR, without
24// exception. `nedb-engine` is a LIBRARY, and a library that writes to stdout
25// is corrupting somebody else's output — it just does not find out until a
26// caller needs stdout to mean something.
27//
28// It found out. `nesql --json status` emitted:
29//
30// [nedbd] cold start — background scan will start after heap allocation
31// { "ok": true, ... }
32//
33// which is not JSON, so every machine consumer of that command was broken by
34// a progress note. The prints were already SPLIT between the two streams
35// before this — some `println!`, some `eprintln!`, a few lines apart — which
36// is the tell that it was never a decision in the first place.
37//
38// The daemon's own banner stays on stdout. `nedbd` is an application and its
39// stdout belongs to it; `Db::open` is a function anybody may call.
40
41/// MANIFEST: cached {seq, head} written atomically after every write.
42/// On startup, if MANIFEST exists and no sorted indexes need rebuilding,
43/// startup is O(1) — just read this one file instead of scanning all objects.
44#[derive(serde::Serialize, serde::Deserialize)]
45struct Manifest {
46 seq: u64,
47 head: String,
48 /// Object hash of the highest-seq node at flush time. Lets `tip()` resolve the
49 /// last write O(1) on a warm boot — before any scan repopulates the in-memory
50 /// seq index. `#[serde(default)]` so pre-2.5.43 MANIFESTs (no field) still parse.
51 #[serde(default)]
52 tip_hash: String,
53 /// Per-collection tip: `coll -> object hash of the highest-seq node in that
54 /// collection`. Lets `tip_collection()` resolve O(1) on a warm boot, same
55 /// contract as `tip_hash` for the global head. `#[serde(default)]` so
56 /// pre-this-field MANIFESTs still parse (empty map — self-heals on next write
57 /// or cold scan).
58 #[serde(default)]
59 coll_tips: std::collections::HashMap<String, String>,
60}
61
62/// Default cap for `since()` when the caller passes `limit == 0`. Bounds the
63/// engine primitive itself so a stale/offline consumer can never force an
64/// unbounded materialization — the safety lives in the core, not the HTTP layer.
65pub const DEFAULT_SINCE_LIMIT: usize = 10_000;
66
67/// One page of the changefeed returned by `since()`. The replication contract:
68/// apply `nodes` in ascending seq order, advance your cursor to `to_seq`, and keep
69/// paging while `has_more` is true; then attach to the live `subscribe` edge.
70/// `head_seq` tells the consumer how far the log currently extends (how far behind
71/// it is).
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct SinceBatch {
74 /// Writes in (`from_seq`, `to_seq`], ascending by seq.
75 pub nodes: Vec<Node>,
76 /// The exclusive cursor this page started from (echoes the request).
77 pub from_seq: u64,
78 /// Seq of the last node in this page — the consumer's next cursor.
79 pub to_seq: u64,
80 /// Current head seq of the log (latest committed write).
81 pub head_seq: u64,
82 /// True when more writes remain past `to_seq` (the page hit `limit`).
83 pub has_more: bool,
84}
85
86/// Replication readiness snapshot. `scan_complete` is the correctness gate: until
87/// the cold-scan finishes rebuilding the seq index, an old cursor passed to
88/// `since()` can return a PARTIAL page and look (wrongly) like "caught up". A
89/// correctness-critical consumer MUST wait for `scan_complete == true` before
90/// trusting historical catch-up. `indexed_seq_min/max` report the currently
91/// resolvable seq range; `tip_seq` is the log head.
92#[derive(Debug, Clone, serde::Serialize)]
93pub struct ScanStatus {
94 /// Cold-scan finished — historical seqs fully resolvable; catch-up is safe.
95 pub scan_complete: bool,
96 /// Head seq of the log (latest committed write).
97 pub tip_seq: u64,
98 /// Lowest seq currently in the seq index (0 if empty).
99 pub indexed_seq_min: u64,
100 /// Highest seq currently in the seq index.
101 pub indexed_seq_max: u64,
102 /// Number of seqs currently resolvable via the index.
103 pub indexed_count: usize,
104 /// True when the seq index actually covers the log — i.e. `since()` can
105 /// resolve historical seqs. DISTINCT from `scan_complete`: a warm boot is
106 /// "startup complete" in O(1) precisely because it SKIPS the scan, so
107 /// `scan_complete` is true while this is false and `since()` resolves
108 /// nothing. Replication consumers must gate on this field, not on
109 /// `scan_complete`; call `rebuild_id_index()`/`repair()` to populate it.
110 pub seq_index_ready: bool,
111}
112
113pub struct Db {
114 pub objects: ObjectStore,
115 pub id_index: IdIndex,
116 /// Deleted id → its tombstone hash. The GRAVEYARD.
117 ///
118 /// `id_index` answers "what is the current version of this key?", so a
119 /// delete has to remove the entry from it or the row would stay visible.
120 /// But that made a deleted document's whole HISTORY unreachable: `AS OF`
121 /// enumerates ids from `id_index`, so the id was never considered at any
122 /// sequence — even one long before the delete. Nothing was lost on disk
123 /// (the tombstone node keeps a `prev` link to the full version chain); it
124 /// was simply unreferenced.
125 ///
126 /// That contradicted the central promise: a `DELETE` is a tombstone, not
127 /// an erasure. So the pointer is not dropped, it is MOVED here — the id
128 /// leaves the land of the living and stays addressable in history.
129 ///
130 /// It is a second `IdIndex` rather than a new namespace inside the first
131 /// because every operation needed — set, get, list, remove, WAL buffering,
132 /// sharded on-disk layout — already exists and is already tested. A
133 /// deliberately boring choice.
134 pub del_index: IdIndex,
135 pub sorted_indexes: SortedIndexes,
136 pub graph: GraphStore,
137 pub root: PathBuf,
138 /// Advisory exclusive lock on the data directory (`LOCK` file), held for
139 /// the Db's lifetime. One process owns a durable store at a time — a
140 /// second opener gets a loud refusal instead of silent split-brain (two
141 /// engines with independent in-memory state on one dir: cross-process
142 /// writes invisible, CAS races — the 2026-07-20 aias multi-worker session
143 /// bug, caught live). Released automatically on drop AND on any process
144 /// death including SIGKILL, because the flock dies with the fd. `None`
145 /// for in-memory databases and under NEDB_SHARED_OPEN=1 (operator
146 /// override for tooling that accepts the risk).
147 _dir_lock: Option<std::fs::File>,
148 /// Dirty flag — set true when head changes, cleared after manifest flush.
149 /// Decouples flush_manifest from the hot write path so concurrent writes
150 /// don't serialise on 2× file I/O per PUT.
151 manifest_dirty: Arc<AtomicBool>,
152 pub seq: AtomicU64,
153 /// Cached Merkle head — updated incrementally on every write (O(1)).
154 head: RwLock<String>,
155 /// `(seq, object hash)` of the most recent write (highest seq). Mirrors `head`
156 /// but holds the tip's content hash, so `tip()` can resolve the last node O(1)
157 /// on a warm boot when the in-memory `seq_index` is still cold. The seq rides
158 /// along so concurrent writers can settle the tip by HIGHEST SEQ rather than
159 /// arrival order (a slow older put must never clobber a newer tip). Only the
160 /// hash is persisted in MANIFEST — format unchanged.
161 tip_hash: RwLock<(u64, String)>,
162 /// Per-collection tip: `coll -> (seq, object hash)` of the highest-seq node in
163 /// that collection. Kept current on every write (`update_head`, seq-guarded),
164 /// restored from MANIFEST on warm boot, rebuilt by the cold scan — so
165 /// `tip_collection()` is O(1) and durable across restarts in every startup
166 /// regime, by construction.
167 coll_tip_hash: Arc<DashMap<String, (u64, String)>>,
168 /// True once startup is fully ready (MANIFEST loaded or cold scan complete).
169 /// Warm starts set this true before returning from open().
170 /// Cold starts set this true in the background thread when scan completes.
171 /// Writes are held with 503 until this is true; reads always proceed.
172 pub startup_ready: Arc<AtomicBool>,
173 /// Seq → hash lookup for v1 compatibility. Populated by put(), put_batch(),
174 /// and the cold-scan background pass. Only covers nodes from the current
175 /// process session + cold-scan; older seqs not in this map cannot be resolved.
176 seq_index: Arc<DashMap<u64, String>>,
177 /// Collections already known to be registered, so the common case — every
178 /// write after a collection's first — costs one lock-free map hit instead of
179 /// an index lookup.
180 ///
181 /// A CACHE, never the answer. `collections()` reads the registry in the DAG.
182 /// A stale or empty cache can only cause a redundant registry check, never a
183 /// wrong namespace, which is the asymmetry that makes it safe to keep it
184 /// this simple.
185 known_collections: Arc<DashMap<String, ()>>,
186}
187
188impl Db {
189 /// Create a pure in-memory database — no disk I/O, no migration, instant startup.
190 /// Perfect for tests, hot-cache layers, and ephemeral sessions.
191 /// All data is lost when the Db is dropped.
192 pub fn in_memory() -> Self {
193 Self {
194 objects: ObjectStore::in_memory(),
195 id_index: IdIndex::in_memory(),
196 del_index: IdIndex::in_memory(),
197 sorted_indexes: SortedIndexes::new(),
198 graph: GraphStore::in_memory(),
199 root: std::path::PathBuf::from(":memory:"),
200 _dir_lock: None,
201 seq: AtomicU64::new(0),
202 head: RwLock::new(String::new()),
203 tip_hash: RwLock::new((0, String::new())),
204 coll_tip_hash: Arc::new(DashMap::new()),
205 startup_ready: Arc::new(AtomicBool::new(true)), // always ready
206 manifest_dirty: Arc::new(AtomicBool::new(false)),
207 seq_index: Arc::new(DashMap::new()),
208 known_collections: Arc::new(DashMap::new()),
209 }
210 }
211
212 /// Acquire the exclusive advisory lock on a durable data directory.
213 /// Refuses (with the holder's pid when known) rather than allowing a
214 /// second live engine on the same files. NEDB_SHARED_OPEN=1 skips the
215 /// guard entirely — for tooling that knowingly accepts split-brain risk.
216 fn acquire_dir_lock(db_root: &Path) -> Result<Option<std::fs::File>> {
217 if std::env::var("NEDB_SHARED_OPEN").map(|v| v.trim() == "1").unwrap_or(false) {
218 return Ok(None);
219 }
220 use fs2::FileExt as _;
221 use std::io::Write as _;
222 let lock_path = db_root.join("LOCK");
223 let lock_file = std::fs::OpenOptions::new()
224 .create(true).read(true).write(true).open(&lock_path)?;
225 if lock_file.try_lock_exclusive().is_err() {
226 let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
227 let holder = holder.trim();
228 anyhow::bail!(
229 "data directory {:?} is locked by another process{} — refusing a \
230 split-brain open: a second engine on the same files cannot see this \
231 process's writes (invisible sessions, CAS races). Stop the other \
232 process, or set NEDB_SHARED_OPEN=1 only if you accept that risk.",
233 db_root,
234 if holder.is_empty() { String::new() } else { format!(" (pid {holder})") }
235 );
236 }
237 // Best-effort: record our pid for the next contender's error message.
238 let _ = lock_file.set_len(0);
239 let _ = writeln!(&lock_file, "{}", std::process::id());
240 let _ = lock_file.sync_all();
241 Ok(Some(lock_file))
242 }
243
244 /// Open (or create) a database. Runs v1→v2 migration automatically if log.aof is present.
245 pub fn open(db_root: &Path, dek: Option<Dek>) -> Result<Self> {
246 std::fs::create_dir_all(db_root)?;
247
248 // Split-brain guard FIRST — refuse before touching any store state.
249 let dir_lock = Self::acquire_dir_lock(db_root)?;
250
251 let objects = ObjectStore::new(db_root, dek.clone())?;
252 let id_index = IdIndex::new(db_root)?;
253 // The graveyard lives under its own root so it shares no path with the
254 // live index and cannot be confused with it by any existing reader.
255 let del_index = IdIndex::new(&db_root.join("graveyard"))?;
256 let sorted_indexes = SortedIndexes::new();
257 let graph = GraphStore::new(db_root)?;
258
259 let mut db = Self {
260 objects,
261 id_index,
262 del_index,
263 sorted_indexes,
264 graph,
265 root: db_root.to_path_buf(),
266 _dir_lock: dir_lock,
267 seq: AtomicU64::new(0),
268 head: RwLock::new(String::new()),
269 tip_hash: RwLock::new((0, String::new())),
270 coll_tip_hash: Arc::new(DashMap::new()),
271 startup_ready: Arc::new(AtomicBool::new(false)),
272 manifest_dirty: Arc::new(AtomicBool::new(false)),
273 seq_index: Arc::new(DashMap::new()),
274 known_collections: Arc::new(DashMap::new()),
275 };
276
277 // Auto-migrate v1 → v2 if needed (pass DEK so encrypted AOFs convert correctly)
278 migrate::migrate_if_needed(
279 db_root,
280 &db.objects,
281 &db.id_index,
282 &db.sorted_indexes,
283 &db.graph,
284 dek.as_ref(),
285 )?;
286
287 // Fast startup: load seq+head from MANIFEST if no sorted indexes need rebuilding.
288 // Falls back to full object scan only when necessary (first open, or post-migration).
289 db.startup_rebuild()?;
290
291 Ok(db)
292 }
293
294 /// Smart startup:
295 /// - Warm (MANIFEST exists): O(1) load → startup_ready = true immediately.
296 /// - Cold (no MANIFEST): start server immediately, run scan in background thread.
297 /// Writes return 503 until scan completes; reads always proceed.
298 fn startup_rebuild(&mut self) -> Result<()> {
299 let manifest_path = self.root.join("MANIFEST");
300 let needs_index_rebuild = !self.sorted_indexes.is_empty();
301
302 // Warm path: MANIFEST + no sorted indexes to rebuild → instant start
303 if manifest_path.exists() && !needs_index_rebuild {
304 if let Some(m) = fs::read_to_string(&manifest_path)
305 .ok()
306 .and_then(|s| serde_json::from_str::<Manifest>(&s).ok())
307 {
308 // Self-heal: MANIFEST with an empty or short head is corrupt/stale.
309 // Fall through to cold scan so the head is rebuilt correctly from objects.
310 if m.head.len() < 8 {
311 eprintln!(" [nedbd] MANIFEST head invalid (len={}), self-healing via cold scan", m.head.len());
312 } else {
313 // Pre-2.5.43 MANIFEST (no persisted tip): warm-boot ANYWAY.
314 //
315 // The old policy forced a full cold scan "once to upgrade" —
316 // on multi-million-object embedded stores (itcd -dagv3:
317 // 1.7M+ objects per database) that scan is hours of random
318 // reads on seek-bound media, it races the host's own boot
319 // I/O, and if the process exits before it completes the
320 // NEXT boot pays it again — a permanent boot tax for
321 // exactly the deployments that can least afford it. And it
322 // buys nothing that can't heal lazily: seq + head in the
323 // old MANIFEST are perfectly valid, and flush_manifest
324 // writes tip_hash + coll_tips from live state, so the very
325 // first write + flush after boot upgrades the MANIFEST
326 // organically. Until then tip()/tip_collection() simply
327 // return None on this boot — exactly their documented
328 // behavior for an unresolvable tip — and every other read
329 // and write path is unaffected.
330 if m.tip_hash.is_empty() {
331 eprintln!(" [nedbd] MANIFEST predates durable tip() — warm boot; tip()/tip_collection() heal on first flush (no forced scan)");
332 }
333 self.seq.store(m.seq, Ordering::SeqCst); // m.seq is already the next-to-assign counter
334 *self.head.write() = m.head.clone();
335 // The tip's seq is the last ASSIGNED seq (m.seq is next-to-assign).
336 *self.tip_hash.write() = (m.seq.saturating_sub(1), m.tip_hash.clone());
337 for (coll, hash) in &m.coll_tips {
338 // Per-coll seqs aren't persisted (MANIFEST format unchanged);
339 // seed 0 — every future write has seq >= m.seq > 0 and wins,
340 // and nothing older than the persisted tip can ever arrive
341 // because the seq counter resumes at m.seq.
342 self.coll_tip_hash.insert(coll.clone(), (0, hash.clone()));
343 }
344 self.startup_ready.store(true, Ordering::SeqCst);
345 eprintln!(" [nedbd] warm start — seq={} head={}... tip={}...",
346 m.seq, &m.head[..8],
347 if m.tip_hash.is_empty() { "(pre-2.5.43, heals on flush)" }
348 else { &m.tip_hash[..8.min(m.tip_hash.len())] });
349 return Ok(());
350 }
351 } else {
352 eprintln!(" [nedbd] MANIFEST corrupt or missing, falling back to cold scan");
353 }
354 }
355
356 // Cold path: mark as not ready, return immediately.
357 // The actual background scan is started by Db::start_cold_scan(arc)
358 // which is called from Manager::open_all() AFTER Arc::new(db) — when
359 // the Db is heap-allocated and its field addresses are permanently stable.
360 // Capturing field addresses here would cause UB: Db moves on return.
361 eprintln!(" [nedbd] cold start — background scan will start after heap allocation");
362 Ok(())
363 }
364
365 /// Call this from Manager::open_all() after Arc::new(db).
366 /// Spawns the cold scan background thread with stable heap addresses.
367 /// No-op if startup is already complete (warm start).
368 pub fn start_cold_scan(self_arc: Arc<Self>) {
369 if self_arc.startup_ready.load(Ordering::SeqCst) {
370 return; // warm start — already ready
371 }
372 // Fast path: if the database is empty (new or just created), skip the
373 // background thread entirely. No objects to scan = instant startup.
374 if self_arc.objects.all_hashes().next().is_none() {
375 self_arc.startup_ready.store(true, Ordering::SeqCst);
376 return;
377 }
378 eprintln!(" [nedbd] cold start — background scan starting, server accepting reads now");
379 std::thread::spawn(move || {
380 let db = self_arc;
381 cold_scan_background_arc(db);
382 });
383 }
384
385 /// Rebuild the id index from the object store, synchronously.
386 ///
387 /// Every object carries its own `coll`, `id` and `seq`, so the id index is
388 /// fully derivable: for each (coll, id) the highest seq wins. Use this to
389 /// recover a database whose id-index WAL never reached disk — the objects
390 /// are intact and verify, but `list()`/`get()` return nothing.
391 ///
392 /// Idempotent, and safe on a healthy store (it rewrites the same winners).
393 /// Returns the number of entries written. Flushes before returning.
394 pub fn rebuild_id_index(&self) -> Result<usize> {
395 let hashes: Vec<String> = self.objects.all_hashes().collect();
396 let mut nodes: Vec<Node> = Vec::with_capacity(hashes.len());
397 for h in &hashes {
398 if let Ok(node) = self.objects.read(h) {
399 self.seq_index.insert(node.seq, node.hash.clone());
400 nodes.push(node);
401 }
402 }
403 let written = rebuild_id_index_from_nodes(self, &nodes);
404
405 // Per-collection tips, so tip_collection() resolves after a repair.
406 let mut coll_max: std::collections::HashMap<String, (u64, String)> =
407 std::collections::HashMap::new();
408 for node in &nodes {
409 coll_max
410 .entry(node.coll.clone())
411 .and_modify(|cur| {
412 if node.seq > cur.0 {
413 *cur = (node.seq, node.hash.clone());
414 }
415 })
416 .or_insert((node.seq, node.hash.clone()));
417 }
418 for (coll, (seq, hash)) in coll_max {
419 self.coll_tip_hash.insert(coll, (seq, hash));
420 }
421
422 let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
423 // Keep the seq counter ahead of everything we just found, so the next
424 // write cannot reuse a seq that already exists in the log.
425 let next = max_seq + 1;
426 if !nodes.is_empty() && self.seq.load(Ordering::SeqCst) < next {
427 self.seq.store(next, Ordering::SeqCst);
428 }
429
430 // Recompute head + tip through the shared implementation, so a repaired
431 // database reopens WARM with a valid MANIFEST instead of coming back up
432 // cold with an empty head (which reads as corruption to the next boot).
433 if !nodes.is_empty() {
434 recompute_head_and_tip(self, hashes, max_seq);
435 }
436
437 self.try_flush_all()?;
438 Ok(written)
439 }
440
441 /// Full repair: rebuild the seq index and the id index from objects, even on
442 /// a WARM store, then flush.
443 ///
444 /// [`start_cold_scan`] deliberately no-ops when startup is already complete,
445 /// which meant the documented repair path ("idempotent — a no-op on a warm
446 /// store, a full self-heal on a stale MANIFEST") could never repair a
447 /// database that had a valid MANIFEST and a damaged id index. This is the
448 /// forcing entry point; `start_cold_scan` keeps its O(1) warm-boot contract.
449 pub fn repair(&self) -> Result<usize> {
450 self.rebuild_id_index()
451 }
452
453 /// Write a document. Returns the new node with its content hash set.
454 ///
455 /// Refuses an unusable or engine-owned collection name, and registers the
456 /// collection if this is its first write — so that "this collection exists"
457 /// becomes a durable fact at the moment it becomes true, rather than an
458 /// inference drawn later from whatever the storage layer happens to have
459 /// lying around.
460 pub fn put(
461 &self,
462 coll: &str,
463 id: &str,
464 data: Value,
465 caused_by: Vec<String>,
466 valid_from: Option<String>,
467 valid_to: Option<String>,
468 ) -> Result<Node> {
469 crate::namespace::validate_writable(coll)?;
470 self.ensure_collection(coll)?;
471 self.put_unchecked(coll, id, data, caused_by, valid_from, valid_to)
472 }
473
474 /// The write itself, with no namespace policy applied.
475 ///
476 /// Exists so the engine can write its own reserved records through exactly
477 /// the same path user data takes — same object store, same version chain,
478 /// same Merkle head. A registry that was written by a side channel would be
479 /// a registry `verify()` does not cover.
480 pub(crate) fn put_unchecked(
481 &self,
482 coll: &str,
483 id: &str,
484 data: Value,
485 caused_by: Vec<String>,
486 valid_from: Option<String>,
487 valid_to: Option<String>,
488 ) -> Result<Node> {
489 let seq = self.seq.fetch_add(1, Ordering::SeqCst);
490 let prev = self.id_index.get(coll, id);
491
492 // Remove old node from sorted indexes (it's being superseded).
493 // Skip the old-object disk read entirely when no sorted index exists —
494 // the read (open + BLAKE2b verify + optional AES-GCM decrypt + JSON
495 // parse) was pure waste in the common unindexed case, ~2x read
496 // amplification on every update (the itcd chainstate shape).
497 if !self.sorted_indexes.is_empty() {
498 if let Some(old_hash) = &prev {
499 if let Ok(old_node) = self.objects.read(old_hash) {
500 if let Value::Object(ref obj) = old_node.data {
501 for (field, value) in obj {
502 self.sorted_indexes.remove(coll, field, value, old_hash);
503 }
504 }
505 }
506 }
507 }
508
509 let mut node = Node {
510 id: id.to_string(),
511 coll: coll.to_string(),
512 seq,
513 data: data.clone(),
514 prev,
515 caused_by: caused_by.clone(),
516 ts: now(),
517 valid_from,
518 valid_to,
519 hash: String::new(),
520 };
521
522 // Write to object store (atomic, content-addressed)
523 let hash = self.objects.write(&mut node)?;
524 self.seq_index.insert(seq, hash.clone());
525
526 // Update id index (atomic file)
527 self.id_index.set(coll, id, &hash)?;
528
529 // Update sorted indexes
530 if let Value::Object(ref obj) = data {
531 for (field, value) in obj {
532 if self.sorted_indexes.has(coll, field) {
533 self.sorted_indexes.insert(coll, field, value, &hash);
534 }
535 }
536 }
537
538 // Write causal graph edges
539 for cause in &caused_by {
540 self.graph.add_edge(&hash, "caused_by", cause)?;
541 self.graph.add_edge(cause, "caused_by_rev", &hash)?;
542 }
543
544 // Update running Merkle head: O(1) chain, no full recompute.
545 // new_head = BLAKE2b(prev_head || seq_bytes || new_object_hash)
546 self.update_head(coll, seq, &hash);
547
548 Ok(node)
549 }
550
551 // ── Collection registry ───────────────────────────────────────────────
552 //
553 // See `crate::namespace` for why a collection's existence has to be a
554 // recorded event rather than an inference from storage.
555
556 /// Record that a collection exists, if that is not already recorded.
557 ///
558 /// Idempotent, and cheap after the first write to a given collection: a
559 /// `DashMap` hit. On a miss it consults the registry itself before writing,
560 /// so reopening a database does not re-register everything in it.
561 pub(crate) fn ensure_collection(&self, coll: &str) -> Result<()> {
562 // Fast path: already known, no locking at all. This is every write
563 // after a collection's first.
564 if self.known_collections.contains_key(coll) {
565 return Ok(());
566 }
567
568 // Slow path, taken once per collection per process. The entry lock is
569 // held across the registry write ON PURPOSE: registration has to be
570 // exactly-once, and a check-then-write without it is a race that N
571 // concurrent first-writers all win.
572 //
573 // That race was not hypothetical. Four threads writing into a fresh
574 // collection each saw it as unregistered and each appended a registry
575 // record — harmless to the ANSWER (same id, the version chain just
576 // grows) but four seqs and four nodes spent on one fact, and on a
577 // wide parallel ingest it would be one per writer. A concurrency test
578 // asserting exact sequence counts is what caught it.
579 //
580 // Holding a shard lock across I/O is safe here because nothing in the
581 // write path touches `known_collections`, so there is no path back
582 // into this map to deadlock against.
583 use dashmap::mapref::entry::Entry;
584 match self.known_collections.entry(coll.to_string()) {
585 Entry::Occupied(_) => Ok(()),
586 Entry::Vacant(slot) => {
587 if let Some(rec) = self.get(crate::namespace::COLLECTIONS, coll) {
588 // Registered in a previous process. Revive it if it was
589 // dropped and is being written to again — a write is an
590 // unambiguous assertion that the caller means for this
591 // collection to exist.
592 if rec.data.get("dropped").and_then(|v| v.as_bool()).unwrap_or(false) {
593 self.write_collection_record(coll, false)?;
594 }
595 } else {
596 self.write_collection_record(coll, false)?;
597 }
598 slot.insert(());
599 Ok(())
600 }
601 }
602 }
603
604 /// Append a registry record. Creation and drop are the same shape, because
605 /// they are the same kind of event: an assertion, at a sequence, about
606 /// whether a name is currently live. The `prev` chain makes the history of
607 /// that name walkable by exactly the machinery that walks every other
608 /// document's history.
609 fn write_collection_record(&self, coll: &str, dropped: bool) -> Result<()> {
610 let seq = self.seq.load(Ordering::SeqCst);
611 self.put_unchecked(
612 crate::namespace::COLLECTIONS,
613 coll,
614 serde_json::json!({ "name": coll, "dropped": dropped, "at_seq": seq }),
615 vec![], None, None,
616 )?;
617 Ok(())
618 }
619
620 /// Every collection that currently exists.
621 ///
622 /// THE authoritative answer, and the one a state root must commit to.
623 /// Invariant across storage backends and independent of flush timing,
624 /// because it reads recorded events rather than directory entries.
625 ///
626 /// An empty-but-created collection is present here. That is the whole
627 /// point: a database where `orders` was created and then emptied is not the
628 /// same database as one where `orders` never existed, and a root that
629 /// cannot tell them apart is not committing to the namespace.
630 pub fn collections(&self) -> Vec<String> {
631 let mut live: Vec<String> = self.id_index
632 .list_ids(crate::namespace::COLLECTIONS)
633 .into_iter()
634 .filter(|name| {
635 self.get(crate::namespace::COLLECTIONS, name)
636 .map(|rec| !rec.data.get("dropped")
637 .and_then(|v| v.as_bool())
638 .unwrap_or(false))
639 .unwrap_or(false)
640 })
641 .collect();
642 live.sort();
643 live
644 }
645
646 /// Which collections existed as of a sequence. The namespace is versioned
647 /// for free, because the registry is ordinary documents in the DAG.
648 pub fn collections_as_of(&self, target_seq: u64) -> Vec<String> {
649 let mut live: Vec<String> = self
650 .list_ids_including_deleted(crate::namespace::COLLECTIONS)
651 .into_iter()
652 .filter(|name| {
653 self.get_as_of(crate::namespace::COLLECTIONS, name, target_seq)
654 .map(|rec| !rec.data.get("dropped")
655 .and_then(|v| v.as_bool())
656 .unwrap_or(false))
657 .unwrap_or(false)
658 })
659 .collect();
660 live.sort();
661 live
662 }
663
664 /// Drop a collection: record that the name is no longer live.
665 ///
666 /// A TOMBSTONE, not an erasure — the same contract `delete` already has for
667 /// documents. The registry keeps the name, marked dropped, so `AS OF`
668 /// before the drop still reports the collection as having existed, and a
669 /// later root can distinguish "dropped" from "never created".
670 ///
671 /// Documents are left where they are. Reclaiming them is `compact`'s job
672 /// and an operator's explicit decision; quietly destroying history behind a
673 /// namespace operation is exactly the behaviour the engine refuses to have.
674 ///
675 /// Returns false when the collection was not live to begin with.
676 pub fn drop_collection(&self, coll: &str) -> Result<bool> {
677 crate::namespace::validate_writable(coll)?;
678 let live = self.get(crate::namespace::COLLECTIONS, coll)
679 .map(|rec| !rec.data.get("dropped")
680 .and_then(|v| v.as_bool())
681 .unwrap_or(false))
682 .unwrap_or(false);
683 if !live {
684 return Ok(false);
685 }
686 self.write_collection_record(coll, true)?;
687 self.known_collections.remove(coll);
688 Ok(true)
689 }
690
691 // ── State roots ───────────────────────────────────────────────────────
692 //
693 // See `crate::root` for the format and for why the leaves are logical
694 // content rather than object hashes.
695
696 /// Every live document, as the material a root is computed from.
697 fn live_records(&self) -> Vec<Node> {
698 let mut out = Vec::new();
699 for coll in self.collections() {
700 for id in self.id_index.list_ids(&coll) {
701 if let Some(n) = self.get(&coll, &id) {
702 out.push(n);
703 }
704 }
705 }
706 out
707 }
708
709 /// The database's current state root.
710 ///
711 /// A stateless recomputation over live state, not a maintained tree. That
712 /// is a deliberate v1 choice: an incrementally-updated Merkle tree is a
713 /// second source of truth that can silently drift from the first, and the
714 /// cost of being wrong about a root is much higher than the cost of
715 /// recomputing one.
716 pub fn state_root(&self) -> std::result::Result<crate::root::StateRoot, String> {
717 let colls = self.collections();
718 let nodes = self.live_records();
719 let refs: Vec<crate::root::RecordRef<'_>> = nodes.iter()
720 .map(|n| crate::root::RecordRef {
721 coll: &n.coll,
722 id: &n.id,
723 data: &n.data,
724 valid_from: n.valid_from.as_deref(),
725 valid_to: n.valid_to.as_deref(),
726 })
727 .collect();
728 crate::root::compute(&colls, &refs)
729 }
730
731 /// The state root as of a sequence.
732 ///
733 /// Reuses the same enumeration `AS OF` queries already use — live ids plus
734 /// the graveyard — so a historical root sees exactly what a historical
735 /// query would see. Anything else would be a root for a state no query can
736 /// return.
737 ///
738 /// `None` when the material is gone: `compact` prunes superseded versions,
739 /// and a root over history that has been discarded cannot be recomputed.
740 /// Reported as unavailable rather than approximated.
741 pub fn state_root_as_of(&self, target_seq: u64)
742 -> std::result::Result<crate::root::StateRoot, String>
743 {
744 let colls = self.collections_as_of(target_seq);
745 let mut nodes = Vec::new();
746 for coll in &colls {
747 for id in self.list_ids_including_deleted(coll) {
748 if let Some(n) = self.get_as_of(coll, &id, target_seq) {
749 nodes.push(n);
750 }
751 }
752 }
753 let refs: Vec<crate::root::RecordRef<'_>> = nodes.iter()
754 .map(|n| crate::root::RecordRef {
755 coll: &n.coll,
756 id: &n.id,
757 data: &n.data,
758 valid_from: n.valid_from.as_deref(),
759 valid_to: n.valid_to.as_deref(),
760 })
761 .collect();
762 crate::root::compute(&colls, &refs)
763 }
764
765 // ── Persisted root records ────────────────────────────────────────────
766
767 /// Persist the state root as of a sequence.
768 ///
769 /// Creation and BACKFILL are the same operation with different arguments,
770 /// and they are deliberately not the same COMMAND: `at_seq` at the tip is
771 /// O(live state), while `at_seq` in the past is O(live state) plus a
772 /// version-chain walk per document. Hiding the second behind something
773 /// that looks like the first is how an operator discovers the cost by
774 /// waiting.
775 pub fn create_root_at(&self, at_seq: u64) -> Result<crate::root::RootRecord> {
776 let computed = self.state_root_as_of(at_seq)
777 .map_err(|e| anyhow::anyhow!("compute root at seq {}: {}", at_seq, e))?;
778 let rec = crate::root::RootRecord { at_seq, root: computed };
779 let data = serde_json::to_value(&rec)?;
780 self.put_unchecked(
781 crate::namespace::ROOTS,
782 &crate::namespace::seq_id(at_seq),
783 data, vec![], None, None,
784 )?;
785 Ok(rec)
786 }
787
788 /// Persist the state root at the current tip.
789 pub fn create_root(&self) -> Result<crate::root::RootRecord> {
790 // The tip is the last ASSIGNED seq, so one below the next one out.
791 let tip = self.seq.load(Ordering::SeqCst).saturating_sub(1);
792 self.create_root_at(tip)
793 }
794
795 /// A persisted root record, if one was taken at this sequence.
796 pub fn get_root(&self, at_seq: u64) -> Option<crate::root::RootRecord> {
797 let n = self.get(crate::namespace::ROOTS, &crate::namespace::seq_id(at_seq))?;
798 serde_json::from_value(n.data).ok()
799 }
800
801 /// Every persisted root, oldest first.
802 pub fn list_roots(&self) -> Vec<crate::root::RootRecord> {
803 self.id_index
804 .list_ids(crate::namespace::ROOTS)
805 .into_iter()
806 .filter_map(|id| self.get(crate::namespace::ROOTS, &id))
807 .filter_map(|n| serde_json::from_value::<crate::root::RootRecord>(n.data).ok())
808 .collect()
809 }
810
811 /// Check a persisted root against a fresh recomputation.
812 ///
813 /// Two INDEPENDENT facts, reported independently:
814 ///
815 /// - the record exists and is well-formed
816 /// - the history needed to recompute it is still here
817 ///
818 /// A persisted root may outlive the material that produced it — `compact`
819 /// discards superseded versions, and after that a historical root is a
820 /// perfectly valid record of something no longer reconstructable. Folding
821 /// that into PASS would claim a verification that did not happen, and
822 /// folding it into FAIL would report tampering that did not occur. So it
823 /// is neither.
824 pub fn verify_root(&self, at_seq: u64) -> crate::root::RootVerification {
825 let record = match self.get_root(at_seq) {
826 None => return crate::root::RootVerification {
827 at_seq,
828 record: crate::root::RecordStatus::Missing,
829 recomputation: crate::root::Recomputation::NotAttempted,
830 recomputed: None,
831 },
832 Some(r) => r,
833 };
834 if record.root.version != "state_root_v1" {
835 return crate::root::RootVerification {
836 at_seq,
837 record: crate::root::RecordStatus::UnknownVersion(record.root.version.clone()),
838 recomputation: crate::root::Recomputation::NotAttempted,
839 recomputed: None,
840 };
841 }
842 // The floor is the oldest sequence still reconstructable. Below it the
843 // material is gone and a mismatch would say nothing about integrity.
844 if at_seq < self.history_floor() {
845 return crate::root::RootVerification {
846 at_seq,
847 record: crate::root::RecordStatus::Valid,
848 recomputation: crate::root::Recomputation::Unavailable(crate::root::UnavailableReason::HistoryPruned),
849 recomputed: None,
850 };
851 }
852 match self.state_root_as_of(at_seq) {
853 Err(e) => crate::root::RootVerification {
854 at_seq,
855 record: crate::root::RecordStatus::Valid,
856 recomputation: crate::root::Recomputation::Unavailable(crate::root::UnavailableReason::Other(e)),
857 recomputed: None,
858 },
859 Ok(fresh) => {
860 let agrees = fresh.state_root == record.root.state_root;
861 crate::root::RootVerification {
862 at_seq,
863 record: crate::root::RecordStatus::Valid,
864 recomputation: if agrees {
865 crate::root::Recomputation::Matches
866 } else {
867 crate::root::Recomputation::Differs
868 },
869 recomputed: Some(fresh),
870 }
871 }
872 }
873 }
874
875 /// The oldest sequence whose state can still be reconstructed.
876 ///
877 /// 0 until something prunes. `compact` records where it cut, because after
878 /// it runs the engine cannot otherwise tell "this sequence had no writes"
879 /// from "this sequence's writes were discarded" — and those two answers
880 /// differ by whether a failed verification means anything.
881 pub fn history_floor(&self) -> u64 {
882 self.get(crate::namespace::META, "history_floor")
883 .and_then(|n| n.data.get("floor").and_then(|v| v.as_u64()))
884 .unwrap_or(0)
885 }
886
887 /// Declare where reconstructable history begins.
888 ///
889 /// Public because pruning is not only something `compact` does: an
890 /// operator who restores from a trimmed backup, or ships a database with
891 /// its early segments removed, has pruned history that the engine has no
892 /// way to notice. Without a way to say so, every historical root in that
893 /// database would fail verification as if it had been tampered with.
894 ///
895 /// MONOTONIC. The floor may rise and may never fall, because lowering it
896 /// asserts that history exists which demonstrably does not — and the first
897 /// thing that assertion does is turn an honest "unavailable" into a
898 /// confident, wrong "mismatch".
899 pub fn set_history_floor(&self, floor: u64) -> Result<()> {
900 let current = self.history_floor();
901 if floor < current {
902 anyhow::bail!(
903 "refusing to lower the history floor from {} to {}: the floor records \
904 what was DISCARDED, and material does not come back. Lowering it would \
905 make the engine attempt recomputations it cannot perform and report the \
906 failures as mismatches.",
907 current, floor
908 );
909 }
910 if floor == current {
911 return Ok(());
912 }
913 self.write_history_floor(floor)
914 }
915
916 fn write_history_floor(&self, floor: u64) -> Result<()> {
917 self.put_unchecked(
918 crate::namespace::META, "history_floor",
919 serde_json::json!({"floor": floor}),
920 vec![], None, None,
921 )?;
922 Ok(())
923 }
924
925 /// Batch put: write N documents in parallel, preserving monotonic seq ordering.
926 /// Pre-allocates N seq numbers atomically, then parallelises object writes and
927 /// id-index updates via Rayon. Each op is independent — safe to parallelise.
928 /// Returns nodes in input order with assigned seq numbers.
929 pub fn put_batch(
930 &self,
931 ops: Vec<(String, String, Value, Vec<String>, Option<String>, Option<String>)>,
932 // (coll, id, data, caused_by, valid_from, valid_to)
933 ) -> Result<Vec<Node>> {
934 use rayon::prelude::*;
935
936 if ops.is_empty() { return Ok(vec![]); }
937
938 // Validate and register EVERY collection before allocating a single
939 // seq. A batch that is going to be refused must be refused before it
940 // has written anything, and registration consumes seqs of its own — so
941 // it cannot happen inside the block that assumes N consecutive ones.
942 for (coll, ..) in ops.iter() {
943 crate::namespace::validate_writable(coll)?;
944 }
945 for coll in ops.iter()
946 .map(|(c, ..)| c.as_str())
947 .collect::<std::collections::BTreeSet<_>>()
948 {
949 self.ensure_collection(coll)?;
950 }
951
952 let n = ops.len() as u64;
953
954 // Pre-allocate N consecutive seq numbers — preserves ordering under concurrency
955 let base_seq = self.seq.fetch_add(n, Ordering::SeqCst);
956 let ts = now();
957
958 // Build nodes with assigned seq numbers
959 let index_live = !self.sorted_indexes.is_empty();
960 let mut nodes: Vec<Node> = ops.into_iter().enumerate().map(|(i, (coll, id, data, caused_by, valid_from, valid_to))| {
961 let prev = self.id_index.get(&coll, &id);
962 // Parity with put(): drop the superseded version's values from any
963 // sorted indexes, so top-k never returns stale hashes after a batch
964 // update. Without this, batch updates left the old version's index
965 // entries in place — ORDER BY surfaced superseded rows alongside
966 // current ones. Only pay the old-object read when an index exists.
967 if index_live {
968 if let Some(old_hash) = &prev {
969 if let Ok(old_node) = self.objects.read(old_hash) {
970 if let Value::Object(ref obj) = old_node.data {
971 for (field, value) in obj {
972 self.sorted_indexes.remove(&coll, field, value, old_hash);
973 }
974 }
975 }
976 }
977 }
978 Node {
979 id, coll, seq: base_seq + i as u64,
980 data, prev, caused_by,
981 ts, valid_from, valid_to,
982 hash: String::new(),
983 }
984 }).collect();
985
986 // Parallel object writes (content-addressed, idempotent, safe to parallelise)
987 let write_errors: Vec<anyhow::Error> = nodes.par_iter_mut()
988 .filter_map(|node| self.objects.write(node).err())
989 .collect();
990 if let Some(e) = write_errors.into_iter().next() { return Err(e); }
991
992 // Parallel id-index updates
993 let index_errors: Vec<anyhow::Error> = nodes.par_iter()
994 .filter_map(|node| self.id_index.set(&node.coll, &node.id, &node.hash).err())
995 .collect();
996 if let Some(e) = index_errors.into_iter().next() { return Err(e); }
997
998 // Sorted indexes + causal graph (sequential — small overhead, usually no indexes)
999 for node in &nodes {
1000 self.seq_index.insert(node.seq, node.hash.clone());
1001 if let Value::Object(ref obj) = node.data {
1002 for (field, value) in obj {
1003 if self.sorted_indexes.has(&node.coll, field) {
1004 self.sorted_indexes.insert(&node.coll, field, value, &node.hash);
1005 }
1006 }
1007 }
1008 for cause in &node.caused_by {
1009 self.graph.add_edge(&node.hash, "caused_by", cause).ok();
1010 self.graph.add_edge(cause, "caused_by_rev", &node.hash).ok();
1011 }
1012 }
1013
1014 // Single Merkle head update for the whole batch (chain all hashes)
1015 for node in &nodes {
1016 self.update_head(&node.coll, node.seq, &node.hash);
1017 }
1018
1019 Ok(nodes)
1020 }
1021
1022 /// Update the running Merkle head with a new write. O(1); no file I/O — the
1023 /// background ticker flushes MANIFEST.
1024 ///
1025 /// Concurrency contract (this function is reached by parallel `put()`s —
1026 /// the server runs puts on blocking threads):
1027 /// - The head chain is extended under ONE write lock held across the whole
1028 /// read-modify-write. The old read-then-write shape let two concurrent
1029 /// writers both read the same prev head; one contribution was silently
1030 /// dropped from the chain — a corrupted tamper-evidence primitive. The
1031 /// chain is arrival-ordered under concurrency (a seq-ordered canonical
1032 /// head is tracked as follow-up work); what this lock guarantees is that
1033 /// EVERY write is committed into the chain exactly once.
1034 /// - Tip pointers settle by HIGHEST SEQ, not arrival order: concurrent
1035 /// puts can reach here out of seq order, and "last call wins" could
1036 /// persist a stale tip into MANIFEST for the next warm boot.
1037 fn update_head(&self, coll: &str, seq: u64, new_hash: &str) {
1038 use blake2::{Blake2b512, Digest};
1039 {
1040 let mut head = self.head.write();
1041 let mut h = Blake2b512::new();
1042 h.update(head.as_bytes());
1043 h.update(seq.to_le_bytes());
1044 h.update(new_hash.as_bytes());
1045 *head = hex::encode(&h.finalize()[..32]);
1046 }
1047 {
1048 let mut tip = self.tip_hash.write();
1049 if seq >= tip.0 {
1050 *tip = (seq, new_hash.to_string());
1051 }
1052 }
1053 self.coll_tip_hash
1054 .entry(coll.to_string())
1055 .and_modify(|t| {
1056 if seq >= t.0 {
1057 *t = (seq, new_hash.to_string());
1058 }
1059 })
1060 .or_insert_with(|| (seq, new_hash.to_string()));
1061 // Mark dirty — background ticker will flush to MANIFEST (no I/O on write path)
1062 self.manifest_dirty.store(true, Ordering::Release);
1063 }
1064
1065 /// Flush both the id-index WAL and MANIFEST, REPORTING failure.
1066 ///
1067 /// This is the durability boundary: until it returns `Ok(())`, writes that
1068 /// `put()` acknowledged may not be on disk. Callers that must not lose data
1069 /// — anything about to take a destructive or externally-visible action on
1070 /// the strength of a persisted record — should use this, not [`flush_all`].
1071 ///
1072 /// Every stage is attempted even if an earlier one fails (a MANIFEST flush
1073 /// is still worth doing when one index leaf failed), and the first error is
1074 /// returned. Failed id-index entries stay in the WAL for retry.
1075 pub fn try_flush_all(&self) -> Result<()> {
1076 let index_result = self.id_index.try_flush_write_buf()
1077 // The graveyard is as durable as the live index: a tombstone
1078 // pointer lost to a crash would take a document's history back out
1079 // of reach, which is the bug this index exists to prevent.
1080 .and(self.del_index.try_flush_write_buf());
1081 // v3: fsync the active segment (no-op for loose/in-memory stores).
1082 // One durability point per batch instead of one fsync per object.
1083 let sync_result = self.objects.sync();
1084 let manifest_result = self.try_flush_manifest();
1085
1086 index_result.map_err(|e| anyhow::anyhow!("id-index WAL flush failed: {}", e))?;
1087 sync_result.map_err(|e| anyhow::anyhow!("object segment sync failed: {}", e))?;
1088 manifest_result.map_err(|e| anyhow::anyhow!("MANIFEST flush failed: {}", e))?;
1089 Ok(())
1090 }
1091
1092 /// Flush both the id-index WAL and MANIFEST. Used on graceful shutdown.
1093 ///
1094 /// Errors are logged, not returned — kept for back-compat and for the
1095 /// ticker/`Drop` paths that have nowhere to propagate. Prefer
1096 /// [`try_flush_all`] whenever the outcome matters.
1097 pub fn flush_all(&self) {
1098 if let Err(e) = self.try_flush_all() {
1099 eprintln!("nedb: flush_all failed: {}", e);
1100 }
1101 }
1102
1103 /// Compact the v3 packed object store: keep the CURRENT version of every
1104 /// document (from the id-index) and reclaim everything else. No-op unless
1105 /// running with the v3 segment substrate (`--dag-v3` / NEDB_DAG_V3).
1106 ///
1107 /// This is a PRUNING operation: superseded/historical object versions are
1108 /// dropped, so AS OF / TRACE over pruned versions is discarded — that is
1109 /// what reclaims the space. Flushes first so all data is durable on disk
1110 /// before the old segments are deleted.
1111 /// Reclaim space by rewriting the segments with only CURRENT versions.
1112 ///
1113 /// # This discards history. On purpose.
1114 ///
1115 /// The live set is each document's current-version hash and nothing else,
1116 /// so compaction drops every superseded version and every tombstone. After
1117 /// it runs, `AS OF` can no longer reach a prior value and `TRACE` can no
1118 /// longer walk to a pruned ancestor — the rows simply become unavailable
1119 /// rather than wrong, and `verify()` stays clean because what remains is
1120 /// still internally consistent.
1121 ///
1122 /// That is worth stating loudly, because NEDB's headline property is that
1123 /// history is permanent and never garbage-collected — and it is, right up
1124 /// until an operator calls THIS. Nothing calls it automatically: it is not
1125 /// on the HTTP surface, not in the CLI, and not on any timer. It exists for
1126 /// the operator who has decided, explicitly, to trade the audit trail for
1127 /// disk space.
1128 ///
1129 /// A graveyard entry whose tombstone was pruned is left pointing at an
1130 /// object that no longer exists. `get_as_of` degrades to `None` there
1131 /// rather than failing, so a compacted store answers "not available at that
1132 /// sequence" instead of erroring or inventing a value.
1133 ///
1134 /// # Live branches veto it
1135 ///
1136 /// A branch promises a future three-way merge, and a three-way merge needs
1137 /// the BASE side: the parent state as of the branch's fork point. This
1138 /// prunes every superseded version down to the tip, which is exactly the
1139 /// material that base is made of. Running it under a live branch would
1140 /// produce "branch exists, merge ancestry gone" — a branch that can never
1141 /// be reconciled and does not find that out until someone tries.
1142 ///
1143 /// Because compaction here is all-or-nothing to the tip, there is no honest
1144 /// partial answer ("prune down to the pin" is a different algorithm, not a
1145 /// parameter). So the answer is REFUSAL, naming the branches and what they
1146 /// pin. There is deliberately no force flag: a bypass would be reached for
1147 /// exactly when it does the damage, and a silent bypass is the thing this
1148 /// interlock exists to design out. The operator's escape hatch is to merge
1149 /// or abandon the branch — both of which are recorded decisions.
1150 pub fn compact(&self) -> Result<crate::segment::CompactStats> {
1151 // Interlock first: before touching anything, ask what history is spoken
1152 // for. `None` means no live branch, which is the only state in which
1153 // history may be discarded freely.
1154 if let Some(pinned) = crate::branch::minimum_pinned_seq(self) {
1155 anyhow::bail!("{}", crate::branch::compaction_refusal(self, pinned));
1156 }
1157
1158 self.flush_all();
1159 let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
1160 for coll in self.id_index.collections() {
1161 for id in self.id_index.list_ids(&coll) {
1162 if let Some(h) = self.id_index.get(&coll, &id) {
1163 live.insert(h);
1164 }
1165 }
1166 }
1167 let stats = self.objects.compact(&live)?;
1168
1169 // Record where history now begins — but ONLY if history was actually
1170 // discarded.
1171 //
1172 // `ObjectStore::compact` is a no-op that returns zeroed stats for the
1173 // loose-object (v2) and in-memory substrates: it prunes nothing at all
1174 // unless the v3 segment store is active. Raising the floor
1175 // unconditionally therefore declared every earlier sequence pruned on
1176 // a database where nothing had been pruned, and every historical root
1177 // became permanently unverifiable with reason HISTORY_PRUNED.
1178 //
1179 // That is a FALSE ALARM, and a false alarm is the one failure this
1180 // three-state verification exists to prevent — an operator who cannot
1181 // trust "unavailable" is back to guessing, which is where PASS/FAIL
1182 // left them. So the floor moves on evidence: objects were dropped.
1183 if stats.dropped_objects > 0 {
1184 let tip = self.seq.load(Ordering::SeqCst).saturating_sub(1);
1185 self.set_history_floor(tip)?;
1186 }
1187 Ok(stats)
1188 }
1189
1190 /// Flush MANIFEST to disk if dirty. No-op for in-memory databases.
1191 pub fn flush_manifest_if_dirty(&self) {
1192 if self.root == std::path::PathBuf::from(":memory:") { return; }
1193 if self.manifest_dirty.compare_exchange(
1194 true, false, Ordering::AcqRel, Ordering::Relaxed
1195 ).is_ok() {
1196 self.flush_manifest();
1197 }
1198 }
1199
1200 /// Atomically persist current seq+head to MANIFEST, reporting failure.
1201 /// No-op (`Ok`) for in-memory databases.
1202 ///
1203 /// A silently failed MANIFEST write is not data loss — the startup
1204 /// self-heal rescans — but it IS a warm-boot regression and, on a full
1205 /// disk, the first symptom that persistence is failing. Callers deserve
1206 /// to know.
1207 pub fn try_flush_manifest(&self) -> std::io::Result<()> {
1208 if self.root == std::path::PathBuf::from(":memory:") { return Ok(()); }
1209 let seq = self.seq.load(Ordering::SeqCst);
1210 let head = self.head.read().clone();
1211 let tip_hash = self.tip_hash.read().1.clone();
1212 let coll_tips: std::collections::HashMap<String, String> = self.coll_tip_hash
1213 .iter()
1214 .map(|kv| (kv.key().clone(), kv.value().1.clone()))
1215 .collect();
1216 let m = Manifest { seq, head, tip_hash, coll_tips };
1217 let json = serde_json::to_string(&m)
1218 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1219 let path = self.root.join("MANIFEST");
1220 let tmp = self.root.join("MANIFEST.tmp");
1221 // fsync the tmp file BEFORE the rename: rename-without-fsync can
1222 // leave a zero-length/partial MANIFEST at the final path after
1223 // power loss (ext4 delayed allocation). The startup self-heal
1224 // (invalid head -> cold scan) catches that, but a full rescan is
1225 // exactly the cost MANIFEST exists to avoid. One fsync per flush,
1226 // and flushes are already off the hot write path (ticker-driven).
1227 let wrote = (|| -> std::io::Result<()> {
1228 use std::io::Write;
1229 let mut f = fs::File::create(&tmp)?;
1230 f.write_all(json.as_bytes())?;
1231 f.sync_all()
1232 })();
1233 if let Err(e) = wrote {
1234 let _ = fs::remove_file(&tmp);
1235 return Err(e);
1236 }
1237 fs::rename(&tmp, &path)?;
1238 // Make the rename itself durable (directory entry). Unix-only;
1239 // on Windows directory handles don't support this and the
1240 // rename is already journaled by NTFS.
1241 #[cfg(unix)]
1242 if let Ok(dir) = fs::File::open(&self.root) {
1243 let _ = dir.sync_all();
1244 }
1245 Ok(())
1246 }
1247
1248 /// Atomically persist current seq+head to MANIFEST. No-op for in-memory databases.
1249 /// Errors are logged; prefer [`try_flush_manifest`] when the outcome matters.
1250 pub fn flush_manifest(&self) {
1251 if let Err(e) = self.try_flush_manifest() {
1252 eprintln!("nedb: MANIFEST flush failed: {}", e);
1253 }
1254 }
1255
1256
1257 /// Start a background thread that flushes both the id-index WAL and MANIFEST
1258 /// every `interval_ms` milliseconds.
1259 /// Call this after Arc::new(db) — the Arc keeps Db alive for the thread's lifetime.
1260 /// Flush cadence for EMBEDDED durable handles (the napi and pyo3 `open()` paths).
1261 ///
1262 /// `nedbd` has always run the manifest ticker at 1 s, so a server flushes the id-index WAL and
1263 /// MANIFEST every second and a hard kill loses at most a second of acknowledged writes. The
1264 /// embedded bindings did not start a ticker at all: their WAL was flushed only by the exit hooks
1265 /// (SIGINT/SIGTERM/atexit) — so an embedded app killed with SIGKILL, OOM-killed, or cut by power
1266 /// lost EVERY write since open, with no bound. Found by CHALK / Sports-Rater on 2026-09-04
1267 /// (acknowledged fan writes gone after `kill -9`). Since 2.8.5 the bindings start the ticker on
1268 /// durable open with this cadence — parity with nedbd.
1269 ///
1270 /// `NEDB_FLUSH_MS` overrides: an integer of milliseconds (min 50), or `0` / `off` to disable
1271 /// (only for hosts that own their own flush cadence). Unset → 1000.
1272 pub fn embedded_flush_interval_ms() -> Option<u64> {
1273 match std::env::var("NEDB_FLUSH_MS") {
1274 Err(_) => Some(1000),
1275 Ok(v) => {
1276 let v = v.trim().to_ascii_lowercase();
1277 if v.is_empty() { return Some(1000); }
1278 if v == "0" || v == "off" || v == "false" || v == "no" { return None; }
1279 match v.parse::<u64>() {
1280 Ok(ms) => Some(ms.max(50)),
1281 Err(_) => { eprintln!("nedb: NEDB_FLUSH_MS={:?} is not a number — using 1000", v); Some(1000) }
1282 }
1283 }
1284 }
1285 }
1286
1287 /// Spawn the background flush ticker.
1288 ///
1289 /// The ticker holds a **`Weak<Db>`** and exits the first time the upgrade
1290 /// fails — i.e. as soon as the last real owner drops the database. The
1291 /// caller must therefore keep its own `Arc` alive for as long as it wants
1292 /// ticking; every current caller already does (nedbd stores it in its
1293 /// database map, the napi and pyo3 handles own theirs).
1294 ///
1295 /// It used to hold a strong `Arc` inside an unconditional `loop`, which
1296 /// meant the thread never exited and the `Db` was never dropped. Three
1297 /// consequences, all of them live since 2.8.5:
1298 ///
1299 /// * The exclusive data-dir `LOCK` taken in `Db::open` was never released,
1300 /// so reopening the same path **in the same process** failed with
1301 /// "locked by another process (pid N)" where N was the caller's own pid.
1302 /// * Every `open()` leaked a thread and the entire `Db` — indexes, caches,
1303 /// segment handles — for the lifetime of the process.
1304 /// * `Drop for Db` (flush-on-close) could never fire for embedded users,
1305 /// exactly as its own doc comment warned: it "only fires once every
1306 /// owning handle is gone", and an immortal thread always held one.
1307 ///
1308 /// nedbd's `drop_db` was hit by the same thing: removing a database from
1309 /// the map did not free it, and an orphaned ticker went on fsyncing it.
1310 ///
1311 /// The `Arc` is upgraded inside the loop and dropped before the next
1312 /// sleep, so the ticker never extends the database's life across a tick.
1313 /// No final flush is needed here — the owner's `Drop` does it.
1314 pub fn start_manifest_ticker(self_arc: Arc<Self>, interval_ms: u64) {
1315 let weak = Arc::downgrade(&self_arc);
1316 // Do not let this function's own argument keep the database alive.
1317 drop(self_arc);
1318 std::thread::spawn(move || {
1319 loop {
1320 std::thread::sleep(std::time::Duration::from_millis(interval_ms));
1321 // Last owner gone: stop ticking and let the thread die.
1322 let db = match weak.upgrade() {
1323 Some(db) => db,
1324 None => break,
1325 };
1326 // Flush id-index WAL to disk (parallel Rayon writes)
1327 db.id_index.flush_write_buf();
1328 db.del_index.flush_write_buf();
1329 // Segment bytes must be durable BEFORE a MANIFEST that
1330 // references them: otherwise power loss can leave MANIFEST
1331 // pointing at a tip whose object bytes were still in the page
1332 // cache — the torn tail is truncated on reopen and the warm
1333 // boot resolves a tip that no longer exists, with the seq
1334 // counter ahead of durable data. Order: sync segments, then
1335 // MANIFEST. Gated on the dirty flag so an idle database pays
1336 // no per-tick fsync. (flush_all already used this order; the
1337 // ticker now matches it.)
1338 if db.manifest_dirty.load(Ordering::Acquire) {
1339 if let Err(e) = db.objects.sync() {
1340 eprintln!("nedb: segment sync failed: {}", e);
1341 }
1342 db.flush_manifest_if_dirty();
1343 }
1344 }
1345 });
1346 }
1347
1348 /// Return the current Merkle head string. O(1) — read from cache.
1349 pub fn head(&self) -> String {
1350 self.head.read().clone()
1351 }
1352
1353 /// Delete a document — writes a tombstone node and removes the id from the index.
1354 /// The object history is preserved in the DAG; only the live id pointer is cleared.
1355 pub fn delete(&self, coll: &str, id: &str) -> Result<bool> {
1356 crate::namespace::validate_writable(coll)?;
1357 let prev = match self.id_index.get(coll, id) {
1358 None => return Ok(false), // already gone
1359 Some(h) => h,
1360 };
1361 let seq = self.seq.fetch_add(1, Ordering::SeqCst);
1362 let mut tombstone = Node {
1363 id: format!("_del_{}", id),
1364 coll: coll.to_string(),
1365 seq,
1366 data: serde_json::json!({"_deleted": id, "_prev": prev}),
1367 prev: Some(prev),
1368 caused_by: vec![],
1369 ts: now(),
1370 valid_from: None,
1371 valid_to: None,
1372 hash: String::new(),
1373 };
1374 let hash = self.objects.write(&mut tombstone)?;
1375 self.update_head(coll, seq, &hash);
1376 // Remove the live id pointer — doc is now invisible to queries and list()
1377 self.id_index.remove(coll, id)?;
1378 // …and MOVE it to the graveyard, so history stays reachable.
1379 //
1380 // Removing the live pointer without this made the document's whole
1381 // version chain unaddressable: `AS OF` walks ids from `id_index`, so a
1382 // deleted id was skipped at every sequence — including sequences long
1383 // before the delete, where the row demonstrably existed. Nothing was
1384 // lost on disk, only unreferenced, which is the worst kind of data
1385 // loss because `verify()` still counts every object as healthy.
1386 //
1387 // The tombstone hash is the entry point: its `prev` links to the last
1388 // live version, and that chain back to the first write.
1389 self.del_index.set(coll, id, &hash)?;
1390 Ok(true)
1391 }
1392
1393 /// Get the current version of a document by id.
1394 pub fn get(&self, coll: &str, id: &str) -> Option<Node> {
1395 let hash = self.id_index.get(coll, id)?;
1396 self.objects.read(&hash).ok()
1397 }
1398
1399 /// Get a specific version of a document by object hash.
1400 pub fn get_by_hash(&self, hash: &str) -> Option<Node> {
1401 self.objects.read(hash).ok()
1402 }
1403
1404 /// Get a document AS OF a specific sequence number.
1405 /// Walks the version chain (prev links) backward until seq <= target.
1406 ///
1407 /// Reaches DELETED documents too. A delete moves the id's pointer into the
1408 /// graveyard rather than dropping it, so the version chain stays walkable
1409 /// and a row is still readable at a sequence before it was deleted — which
1410 /// is what "a DELETE is a tombstone, not an erasure" has to mean in
1411 /// practice. At or after the tombstone's own sequence the document is
1412 /// correctly absent.
1413 pub fn get_as_of(&self, coll: &str, id: &str, target_seq: u64) -> Option<Node> {
1414 // The live chain first: the common case, and the only one for an id
1415 // that was never deleted.
1416 if let Some(hash) = self.id_index.get(coll, id) {
1417 if let Some(node) = self.walk_back_to(&hash, target_seq) {
1418 return Some(node);
1419 }
1420 // Falling through matters for a RE-CREATED id. A `put` after a
1421 // delete starts a fresh chain with no `prev`, so the live chain
1422 // cannot reach a sequence from before the delete — but the
1423 // graveyard still can.
1424 }
1425 let tomb_hash = self.del_index.get(coll, id)?;
1426 let tomb = self.objects.read(&tomb_hash).ok()?;
1427 // As of the tombstone's own sequence the document is deleted. Returning
1428 // the tombstone node itself would surface `{_deleted, _prev}` as if it
1429 // were the document.
1430 if tomb.seq <= target_seq {
1431 return None;
1432 }
1433 self.walk_back_to(tomb.prev.as_deref()?, target_seq)
1434 }
1435
1436 /// Walk `prev` links back from `hash` to the newest version at or before
1437 /// `target_seq`. `None` when the chain starts after it.
1438 fn walk_back_to(&self, hash: &str, target_seq: u64) -> Option<Node> {
1439 let mut current = self.objects.read(hash).ok()?;
1440 loop {
1441 if current.seq <= target_seq {
1442 return Some(current);
1443 }
1444 let prev_hash = current.prev.as_deref()?;
1445 current = self.objects.read(prev_hash).ok()?;
1446 }
1447 }
1448
1449 /// Every id in a collection that AS OF must consider: the live ones, plus
1450 /// the deleted ones whose history is still addressable.
1451 ///
1452 /// Order is stable (sorted, deduplicated) so a historical query answers the
1453 /// same way run to run.
1454 pub fn list_ids_including_deleted(&self, coll: &str) -> Vec<String> {
1455 let mut ids = self.id_index.list_ids(coll);
1456 ids.extend(self.del_index.list_ids(coll));
1457 ids.sort_unstable();
1458 ids.dedup();
1459 ids
1460 }
1461
1462 /// List all documents in a collection, returning current versions.
1463 pub fn list(&self, coll: &str) -> Vec<Node> {
1464 self.id_index
1465 .list_ids(coll)
1466 .into_iter()
1467 .filter_map(|id| self.get(coll, &id))
1468 .collect()
1469 }
1470
1471 /// Candidate nodes whose `field` falls in the given range, via the sorted
1472 /// index. `None` when no index covers (coll, field) — the caller must then
1473 /// fall back to a scan.
1474 ///
1475 /// Returns CURRENT versions only (the index drops a superseded hash on
1476 /// overwrite), so this must not be used to serve an `AS OF` query.
1477 pub fn range_scan(
1478 &self,
1479 coll: &str,
1480 field: &str,
1481 low: Option<&Value>,
1482 high: Option<&Value>,
1483 low_incl: bool,
1484 high_incl: bool,
1485 ) -> Option<Vec<Node>> {
1486 if !self.sorted_indexes.has(coll, field) {
1487 return None;
1488 }
1489 Some(
1490 self.sorted_indexes
1491 .range(coll, field, low, high, low_incl, high_incl)
1492 .into_iter()
1493 .filter_map(|h| self.objects.read(&h).ok())
1494 .collect(),
1495 )
1496 }
1497
1498 /// Candidate nodes whose `field` equals any of `values` — the indexed path
1499 /// for `=` and for `IN (...)`. `None` when no index covers the field.
1500 pub fn index_lookup(&self, coll: &str, field: &str, values: &[Value]) -> Option<Vec<Node>> {
1501 if !self.sorted_indexes.has(coll, field) {
1502 return None;
1503 }
1504 // A value may legitimately appear in several arms of an IN list, and a
1505 // hash must not be returned twice.
1506 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1507 let mut out = vec![];
1508 for v in values {
1509 for h in self.sorted_indexes.exact(coll, field, v) {
1510 if seen.insert(h.clone()) {
1511 if let Ok(node) = self.objects.read(&h) {
1512 out.push(node);
1513 }
1514 }
1515 }
1516 }
1517 Some(out)
1518 }
1519
1520 /// How many rows an indexed range covers, without reading any of them.
1521 /// `None` when no index covers the field.
1522 pub fn range_cardinality(
1523 &self,
1524 coll: &str,
1525 field: &str,
1526 low: Option<&Value>,
1527 high: Option<&Value>,
1528 low_incl: bool,
1529 high_incl: bool,
1530 ) -> Option<usize> {
1531 if !self.sorted_indexes.has(coll, field) {
1532 return None;
1533 }
1534 Some(self.sorted_indexes.range_len(coll, field, low, high, low_incl, high_incl))
1535 }
1536
1537 /// True when a sorted index covers (coll, field).
1538 pub fn has_sorted_index(&self, coll: &str, field: &str) -> bool {
1539 self.sorted_indexes.has(coll, field)
1540 }
1541
1542 /// ORDER BY field ASC LIMIT n — uses sorted index if available, else falls back to full scan.
1543 pub fn order_by_asc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
1544 if self.sorted_indexes.has(coll, field) {
1545 self.sorted_indexes
1546 .top_k_asc(coll, field, limit)
1547 .into_iter()
1548 .filter_map(|h| self.objects.read(&h).ok())
1549 .collect()
1550 } else {
1551 let mut docs = self.list(coll);
1552 docs.sort_by(|a, b| {
1553 let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1554 let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1555 av.cmp(&bv)
1556 });
1557 docs.truncate(limit);
1558 docs
1559 }
1560 }
1561
1562 /// ORDER BY field DESC LIMIT n
1563 pub fn order_by_desc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
1564 if self.sorted_indexes.has(coll, field) {
1565 self.sorted_indexes
1566 .top_k_desc(coll, field, limit)
1567 .into_iter()
1568 .filter_map(|h| self.objects.read(&h).ok())
1569 .collect()
1570 } else {
1571 let mut docs = self.list(coll);
1572 docs.sort_by(|a, b| {
1573 let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1574 let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1575 bv.cmp(&av)
1576 });
1577 docs.truncate(limit);
1578 docs
1579 }
1580 }
1581
1582 /// TRACE caused_by — walk causal graph from a node.
1583 pub fn trace(&self, hash: &str, reverse: bool, limit: usize) -> Vec<Node> {
1584 self.graph
1585 .trace(hash, "caused_by", reverse, limit)
1586 .into_iter()
1587 .filter_map(|h| self.objects.read(&h).ok())
1588 .collect()
1589 }
1590
1591 /// Verify tamper-evidence of all objects.
1592 pub fn verify(&self) -> (usize, Vec<String>) {
1593 self.objects.verify_all()
1594 }
1595
1596 /// Create a sorted index for a (coll, field) pair.
1597 pub fn create_sorted_index(&self, coll: &str, field: &str) {
1598 self.sorted_indexes.ensure(coll, field);
1599 // Backfill from existing objects
1600 for id in self.id_index.list_ids(coll) {
1601 if let Some(node) = self.get(coll, &id) {
1602 if let Value::Object(ref obj) = node.data {
1603 if let Some(value) = obj.get(field) {
1604 self.sorted_indexes.insert(coll, field, value, &node.hash);
1605 }
1606 }
1607 }
1608 }
1609 }
1610
1611 /// Resolve a sequence number to its content hash (v1 compatibility).
1612 /// Only covers nodes written in the current process session + cold-scan nodes.
1613 pub fn get_hash_by_seq(&self, seq: u64) -> Option<String> {
1614 self.seq_index.get(&seq).map(|r| r.clone())
1615 }
1616
1617 /// The tip — the most recently written node (highest seq), or `None` if the
1618 /// database is empty. O(1): `self.seq` is the next-to-assign counter, so the
1619 /// latest write sits at `seq - 1`; we resolve it through the same
1620 /// seq_index → object-store path a normal read uses, so the returned Node is
1621 /// byte-identical to one fetched by id or hash (it carries its own seq, hash,
1622 /// causal links, and valid-time). This is the cheap "give me the latest write"
1623 /// primitive — the head of the log, not an aggregate.
1624 pub fn tip(&self) -> Option<Node> {
1625 let next = self.seq.load(Ordering::SeqCst);
1626 if next == 0 {
1627 return None; // nothing written yet
1628 }
1629 // Fast path: resolve the head seq through the in-memory seq index
1630 // (populated by this session's writes or by the cold scan).
1631 if let Some(hash) = self.get_hash_by_seq(next - 1) {
1632 return self.get_by_hash(&hash);
1633 }
1634 // Warm-boot fallback: the seq index is still cold (warm start skips the
1635 // scan), but the tip's object hash was persisted in MANIFEST and restored
1636 // on open. O(1), no scan — this is what makes tip() survive a restart.
1637 let th = self.tip_hash.read().1.clone();
1638 if !th.is_empty() {
1639 return self.get_by_hash(&th);
1640 }
1641 None
1642 }
1643
1644 /// The collection-local tip — the most recent write into `coll` (highest seq in
1645 /// that collection), or `None` if the collection has no writes. O(1): resolves
1646 /// through `coll_tip_hash`, a dedicated per-collection map kept current on every
1647 /// write (`update_head`), restored from MANIFEST on warm boot, and rebuilt by the
1648 /// cold scan — durable across restarts by construction, same contract as `tip()`
1649 /// for the global head. Conceptually a different index than the global `tip()`
1650 /// (global head vs collection head), kept as a separate method so each is
1651 /// explicit — parity with the Python reference's `tip(coll)`. Lets a consumer
1652 /// resume one chain (e.g. blocks / tx / utxo) without pulling global tip and
1653 /// filtering.
1654 pub fn tip_collection(&self, coll: &str) -> Option<Node> {
1655 let hash = self.coll_tip_hash.get(coll)?.1.clone();
1656 self.get_by_hash(&hash)
1657 }
1658
1659 /// Changefeed page: up to `limit` nodes written AFTER `after_seq` (EXCLUSIVE),
1660 /// ascending by seq, wrapped in a `SinceBatch` cursor envelope. `after_seq` is
1661 /// the cursor you last applied (a prior `tip()` seq or `to_seq`). `limit` bounds
1662 /// the page — `0` means DEFAULT_SINCE_LIMIT, so the engine primitive can never
1663 /// materialize an unbounded batch even when embedders call it directly (the
1664 /// safety is here, not only in the HTTP layer). Drain by paging while
1665 /// `has_more`, advancing your cursor to `to_seq`, then hand off to the live
1666 /// `subscribe` edge. The append-only log IS the changefeed, so this is an
1667 /// O(page) walk; unresolved seqs (outside seq_index coverage — see
1668 /// `scan_status()`) are skipped rather than faked.
1669 pub fn since(&self, after_seq: u64, limit: usize) -> SinceBatch {
1670 let next = self.seq.load(Ordering::SeqCst); // head + 1
1671 let head_seq = next.saturating_sub(1);
1672 let cap = if limit == 0 { DEFAULT_SINCE_LIMIT } else { limit };
1673 let mut nodes: Vec<Node> = Vec::new();
1674 let mut to_seq = after_seq;
1675 let mut hit_limit = false;
1676 let mut s = after_seq.saturating_add(1);
1677 while s < next {
1678 if nodes.len() >= cap { hit_limit = true; break; }
1679 if let Some(hash) = self.get_hash_by_seq(s) {
1680 if let Some(node) = self.get_by_hash(&hash) {
1681 to_seq = node.seq;
1682 nodes.push(node);
1683 }
1684 }
1685 s += 1;
1686 }
1687 // `has_more` must never say "caught up" while the cursor is behind the
1688 // log head. Before 2.8.6 this was `hit_limit` alone, so any page whose
1689 // seqs could not be resolved (the whole range, on a warm boot: the warm
1690 // path skips the scan, leaving seq_index empty) returned zero nodes with
1691 // has_more=false — indistinguishable from genuinely up to date. A
1692 // consumer following the documented drain loop stopped forever, one call
1693 // in, on a database with every record unread.
1694 let has_more = hit_limit || to_seq < head_seq;
1695 SinceBatch { nodes, from_seq: after_seq, to_seq, head_seq, has_more }
1696 }
1697
1698 /// Replication readiness — see `ScanStatus`. `scan_complete` gates safe
1699 /// historical catch-up: a consumer pulling an old cursor right after a cold
1700 /// start must wait for it, or `since()` may hand back a partial page that looks
1701 /// like "caught up". Computes the indexed range by scanning the in-memory seq
1702 /// index (O(index)) — intended for periodic status polls, not the per-write
1703 /// hot path.
1704 pub fn scan_status(&self) -> ScanStatus {
1705 let next = self.seq.load(Ordering::SeqCst);
1706 let mut min = u64::MAX;
1707 let mut max = 0u64;
1708 let mut count = 0usize;
1709 for kv in self.seq_index.iter() {
1710 let s = *kv.key();
1711 if s < min { min = s; }
1712 if s > max { max = s; }
1713 count += 1;
1714 }
1715 if count == 0 { min = 0; }
1716 ScanStatus {
1717 scan_complete: self.startup_ready.load(Ordering::SeqCst),
1718 tip_seq: next.saturating_sub(1),
1719 indexed_seq_min: min,
1720 indexed_seq_max: max,
1721 indexed_count: count,
1722 // The seq index covers the log when it resolves as many seqs as the
1723 // log has entries. On a warm boot it is empty while the log is not.
1724 seq_index_ready: count > 0 && (count as u64) >= next.saturating_sub(1),
1725 }
1726 }
1727
1728 /// Add an explicit named relation edge between two documents.
1729 /// Add an explicit named relation between two "coll:id" nodes.
1730 /// Relations stored as __links__ documents — NQL-queryable, time-travelable,
1731 /// consistent with the PyO3 binding which uses the same __links__ convention.
1732 pub fn link(&self, frm: &str, rel: &str, to: &str) -> Result<()> {
1733 let (frm_coll, frm_id) = frm.split_once(':')
1734 .ok_or_else(|| anyhow::anyhow!("link frm must be 'coll:id', got: {}", frm))?;
1735 let (to_coll, to_id) = to.split_once(':')
1736 .ok_or_else(|| anyhow::anyhow!("link to must be 'coll:id', got: {}", to))?;
1737 if self.id_index.get(frm_coll, frm_id).is_none() {
1738 anyhow::bail!("link: frm not found: {}", frm);
1739 }
1740 if self.id_index.get(to_coll, to_id).is_none() {
1741 anyhow::bail!("link: to not found: {}", to);
1742 }
1743 let link_id = format!("{}|{}|{}", frm, rel, to);
1744 let doc = serde_json::json!({"_from": frm, "_rel": rel, "_to": to});
1745 self.put("__links__", &link_id, doc, vec![], None, None)?;
1746 Ok(())
1747 }
1748
1749 /// Remove a named relation (deletes the __links__ document).
1750 pub fn unlink(&self, frm: &str, rel: &str, to: &str) -> Result<bool> {
1751 let link_id = format!("{}|{}|{}", frm, rel, to);
1752 self.delete("__links__", &link_id)
1753 }
1754
1755 /// Get neighbor nodes via a named relation.
1756 /// Queries __links__ — consistent with the PyO3 binding.
1757 pub fn neighbors(&self, frm: &str, rel: &str) -> Vec<Node> {
1758 self.id_index
1759 .list_ids("__links__")
1760 .into_iter()
1761 .filter_map(|id| self.get("__links__", &id))
1762 .filter(|node| {
1763 node.data.get("_from").and_then(|v| v.as_str()) == Some(frm)
1764 && node.data.get("_rel").and_then(|v| v.as_str()) == Some(rel)
1765 })
1766 .filter_map(|node| {
1767 let to = node.data.get("_to")?.as_str()?;
1768 let (to_coll, to_id) = to.split_once(':')?;
1769 self.get(to_coll, to_id)
1770 })
1771 .collect()
1772 }
1773}
1774
1775impl Drop for Db {
1776 /// Flush buffered state when the database is closed so a write-then-drop
1777 /// sequence is durable without an explicit `flush_all()`.
1778 ///
1779 /// `IdIndex::set` only stages updates in the in-memory WAL `write_buf`;
1780 /// disk persistence happens in `flush_write_buf()`, normally driven by the
1781 /// manifest ticker. A short-lived `Db` (a library user's `{ let db =
1782 /// Db::open(p)?; db.put(..)?; }` block, or a test) has no ticker, so without
1783 /// this its writes would be silently lost on reopen. Flushing on drop
1784 /// mirrors the flush-on-close contract of other embedded stores (sled,
1785 /// RocksDB).
1786 ///
1787 /// In production this is a harmless safety net, not the primary durability
1788 /// path: the manifest ticker thread holds an `Arc<Db>` for the process
1789 /// lifetime, so `Drop` only fires once every owning handle is gone. No-op
1790 /// for in-memory databases (`flush_all` short-circuits on `:memory:`).
1791 fn drop(&mut self) {
1792 self.flush_all();
1793 }
1794}
1795
1796/// Background cold-scan worker. Takes Arc<Db> — safe, Db is on the heap.
1797fn cold_scan_background_arc(db: Arc<Db>) {
1798 use rayon::prelude::*;
1799
1800 let objects = &db.objects;
1801 let seq_atomic = &db.seq;
1802 let sorted_indexes = &db.sorted_indexes;
1803 let seq_index = &db.seq_index;
1804 let ready_flag = Arc::clone(&db.startup_ready);
1805
1806 let hashes: Vec<String> = objects.all_hashes().collect();
1807 let total = hashes.len();
1808
1809 if total == 0 {
1810 ready_flag.store(true, Ordering::SeqCst);
1811 return;
1812 }
1813
1814 eprintln!(" [nedbd] background scan — {} objects...", total);
1815 let t0 = std::time::Instant::now();
1816 let step = (total / 10).max(1000);
1817
1818 // Populate the seq index AS objects are read here, not in a second pass
1819 // afterward: this loop is the slow, disk-I/O-bound phase (verifying and
1820 // parsing every object), and it can run for minutes on a multi-million
1821 // object store. `scan_status().indexed_count` reads `seq_index`'s size, so
1822 // inserting here — not after `.collect()` — is what makes that a real, live
1823 // progress signal through the phase that actually takes the time, instead
1824 // of reporting a flat 0 until this whole pass finishes. Safe: DashMap
1825 // supports concurrent inserts, and every parallel worker here inserts a
1826 // disjoint key (each object has its own seq).
1827 let nodes: Vec<Node> = hashes.par_iter()
1828 .enumerate()
1829 .filter_map(|(i, h)| {
1830 if i > 0 && i % step == 0 {
1831 let pct = i * 100 / total;
1832 let elapsed = t0.elapsed().as_secs_f32();
1833 let rate = i as f32 / elapsed;
1834 let eta = (total - i) as f32 / rate;
1835 eprint!("\r [nedbd] {:>3}% {:>8} / {:>8} ({:>8.0}/s eta {:.0}s) ",
1836 pct, i, total, rate, eta);
1837 }
1838 let node = objects.read(h).ok()?;
1839 seq_index.insert(node.seq, node.hash.clone());
1840 Some(node)
1841 })
1842 .collect();
1843
1844 eprintln!("\r [nedbd] 100% {:>8} / {:>8} ({:.1}s) ",
1845 total, total, t0.elapsed().as_secs_f32());
1846
1847 let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
1848 seq_atomic.store(max_seq + 1, Ordering::SeqCst);
1849
1850 // Per-collection tip: highest-seq node's hash, per coll. `nodes` is NOT
1851 // seq-ordered here (it comes from an unordered object-hash scan), so this
1852 // must track the max explicitly — unlike the live write path's "last call
1853 // wins" (which relies on ascending call order that a scan doesn't have).
1854 let mut coll_max: std::collections::HashMap<String, (u64, String)> = std::collections::HashMap::new();
1855
1856 for node in &nodes {
1857 // seq_index was already populated above, during the read pass.
1858 coll_max.entry(node.coll.clone())
1859 .and_modify(|(s, h)| if node.seq > *s { *s = node.seq; *h = node.hash.clone(); })
1860 .or_insert_with(|| (node.seq, node.hash.clone()));
1861 if let Value::Object(ref obj) = node.data {
1862 for (field, value) in obj {
1863 if sorted_indexes.has(&node.coll, field) {
1864 sorted_indexes.insert(&node.coll, field, value, &node.hash);
1865 }
1866 }
1867 }
1868 }
1869
1870 for (coll, (seq, hash)) in coll_max {
1871 db.coll_tip_hash.insert(coll, (seq, hash));
1872 }
1873
1874 // Rebuild the id index when it has no collections at all — the lost-WAL
1875 // case. Until 2.8.6 the cold scan restored seq_index, coll_tips, head and
1876 // MANIFEST but NEVER the id index, so a database whose id-index WAL never
1877 // reached disk came back with every object present and verifying while
1878 // `list()` and `get()` returned nothing — and `nedb-cli repair`, whose whole
1879 // job is this, reported success without fixing it.
1880 //
1881 // Gated on "no collections" so a normal cold boot of a healthy store (itcd:
1882 // millions of objects) does not pay N extra index writes. A partially lost
1883 // index is repaired by the explicit `rebuild_id_index()` path.
1884 if db.id_index.collections().is_empty() && !nodes.is_empty() {
1885 let restored = rebuild_id_index_from_nodes(&db, &nodes);
1886 eprintln!(" [nedbd] id index was empty — rebuilt {} entries from objects", restored);
1887 }
1888
1889 // Merkle head + tip, through the one shared implementation so the cold scan
1890 // and the explicit repair path can never drift apart.
1891 recompute_head_and_tip(&db, hashes, max_seq);
1892
1893 // Write MANIFEST through the one canonical writer. The hand-rolled write
1894 // this replaces stored `seq: max_seq` (the last USED seq) — but the warm
1895 // boot loads `m.seq` as the NEXT-TO-ASSIGN counter, so a restart right
1896 // after a quiet cold scan handed the next write the tip's seq: a duplicate
1897 // seq in the log (seq_index overwrite, wrong since() page). flush_manifest
1898 // reads the live counter (already max_seq + 1) — correct by construction.
1899 db.flush_manifest();
1900
1901 // Signal server: writes can now proceed
1902 ready_flag.store(true, Ordering::SeqCst);
1903 eprintln!(" [nedbd] background scan complete — seq={} objects={} MANIFEST written", max_seq, total);
1904}
1905
1906/// Recompute the Merkle head and the tip hash from the full object-hash set.
1907///
1908/// Shared by the cold scan and by `repair()` so the two can never disagree
1909/// about what the head of a rebuilt database is. `hashes` must be every object
1910/// hash in the store; `max_seq` the highest seq observed.
1911fn recompute_head_and_tip(db: &Db, hashes: Vec<String>, max_seq: u64) {
1912 use blake2::{Blake2b512, Digest};
1913 let mut sorted_hashes = hashes;
1914 sorted_hashes.sort();
1915 let mut h = Blake2b512::new();
1916 h.update(max_seq.to_le_bytes());
1917 for hash_str in &sorted_hashes {
1918 h.update(hash_str.as_bytes());
1919 }
1920 *db.head.write() = hex::encode(&h.finalize()[..32]);
1921
1922 // Tip = the highest-seq object indexed. Persisting its hash lets tip()
1923 // resolve O(1) on the next warm boot, before any scan repopulates seq_index.
1924 let tip_hash = db.seq_index.iter()
1925 .max_by_key(|kv| *kv.key())
1926 .map(|kv| kv.value().clone())
1927 .unwrap_or_default();
1928 *db.tip_hash.write() = (max_seq, tip_hash);
1929}
1930
1931/// Reconstruct id-index entries from already-read nodes: for every (coll, id),
1932/// the winner is the HIGHEST seq, which is exactly what `put()` would have left
1933/// behind. Returns the number of entries written.
1934///
1935/// The id index is fully derivable from the object store because every object
1936/// carries its own `coll`, `id` and `seq` — so a lost WAL is recoverable, and
1937/// nothing here invents data.
1938fn rebuild_id_index_from_nodes(db: &Db, nodes: &[Node]) -> usize {
1939 let mut winner: std::collections::HashMap<(String, String), (u64, String)> =
1940 std::collections::HashMap::new();
1941 for node in nodes {
1942 let key = (node.coll.clone(), node.id.clone());
1943 winner
1944 .entry(key)
1945 .and_modify(|cur| {
1946 if node.seq > cur.0 {
1947 *cur = (node.seq, node.hash.clone());
1948 }
1949 })
1950 .or_insert((node.seq, node.hash.clone()));
1951 }
1952 let mut written = 0usize;
1953 for ((coll, id), (_seq, hash)) in &winner {
1954 if db.id_index.set(coll, id, hash).is_ok() {
1955 written += 1;
1956 }
1957 }
1958 // Persist immediately: a rebuild that only lands in the WAL would be lost
1959 // again by the very crash class this recovers from.
1960 if let Err(e) = db.id_index.try_flush_write_buf() {
1961 eprintln!("nedb: id-index rebuild flush failed: {}", e);
1962 }
1963 written
1964}
1965
1966fn now() -> f64 {
1967 std::time::SystemTime::now()
1968 .duration_since(std::time::UNIX_EPOCH)
1969 .map(|d| d.as_secs_f64())
1970 .unwrap_or(0.0)
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975 use super::*;
1976 use tempfile::tempdir;
1977
1978 #[test]
1979 fn put_and_get() {
1980 let dir = tempdir().unwrap();
1981 let db = Db::open(dir.path(), None).unwrap();
1982 db.put(
1983 "blocks", "618000",
1984 serde_json::json!({"height": 618000, "hash": "0000abc"}),
1985 vec![], None, None,
1986 ).unwrap();
1987 let node = db.get("blocks", "618000").unwrap();
1988 assert_eq!(node.id, "618000");
1989 assert_eq!(node.data["height"], 618000);
1990 }
1991
1992 #[test]
1993 fn order_by_with_sorted_index() {
1994 let dir = tempdir().unwrap();
1995 let db = Db::open(dir.path(), None).unwrap();
1996 db.create_sorted_index("blocks", "height");
1997 for h in [3u64, 1, 5, 2, 4] {
1998 db.put("blocks", &h.to_string(),
1999 serde_json::json!({"height": h}),
2000 vec![], None, None).unwrap();
2001 }
2002 let asc = db.order_by_asc("blocks", "height", 3);
2003 let heights: Vec<u64> = asc.iter()
2004 .filter_map(|n| n.data["height"].as_u64())
2005 .collect();
2006 assert_eq!(heights, vec![1, 2, 3]);
2007 }
2008
2009 #[test]
2010 fn causal_trace() {
2011 let dir = tempdir().unwrap();
2012 let db = Db::open(dir.path(), None).unwrap();
2013 let a = db.put("ops", "a", serde_json::json!({"op": "create"}), vec![], None, None).unwrap();
2014 let b = db.put("ops", "b", serde_json::json!({"op": "transfer"}), vec![a.hash.clone()], None, None).unwrap();
2015 let c = db.put("ops", "c", serde_json::json!({"op": "burn"}), vec![b.hash.clone()], None, None).unwrap();
2016
2017 let trace = db.trace(&c.hash, false, 10);
2018 assert_eq!(trace.len(), 3); // c → b → a
2019 }
2020
2021 #[test]
2022 fn as_of() {
2023 let dir = tempdir().unwrap();
2024 let db = Db::open(dir.path(), None).unwrap();
2025 let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2026 let _v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
2027
2028 let at_v1 = db.get_as_of("docs", "x", v1.seq).unwrap();
2029 assert_eq!(at_v1.data["v"], 1);
2030 let current = db.get("docs", "x").unwrap();
2031 assert_eq!(current.data["v"], 2);
2032 }
2033}
2034
2035#[cfg(test)]
2036mod tests_v2 {
2037 use super::*;
2038 use tempfile::tempdir;
2039
2040 // ── a DELETE is a tombstone, not an erasure ─────────────────────────────
2041 //
2042 // `delete()` used to just remove the live id pointer, which made the
2043 // document's whole history unreachable: `AS OF` enumerates ids from
2044 // `id_index`, so a deleted id was skipped at EVERY sequence — including
2045 // sequences long before the delete, where the row demonstrably existed.
2046 //
2047 // Nothing was lost on disk. The tombstone node keeps a `prev` link to the
2048 // full version chain and `verify()` counted every object as healthy — which
2049 // makes it the worst kind of data loss, the kind that passes its own audit.
2050 // The pointer is now MOVED to the graveyard instead of dropped.
2051
2052 #[test]
2053 fn a_deleted_documents_history_is_still_readable_before_the_delete() {
2054 let db = Db::in_memory();
2055 let v1 = db.put("o", "a", serde_json::json!({"t": 55}), vec![], None, None).unwrap();
2056 let v2 = db.put("o", "a", serde_json::json!({"t": 66}), vec![], None, None).unwrap();
2057 assert!(db.delete("o", "a").unwrap());
2058
2059 // Gone from the present — a delete must still delete.
2060 assert!(db.get("o", "a").is_none(), "a deleted doc must not be visible now");
2061
2062 // …and readable at each sequence it existed at.
2063 let at_v1 = db.get_as_of("o", "a", v1.seq).expect("the ORIGINAL value survives");
2064 assert_eq!(at_v1.data["t"], serde_json::json!(55));
2065 let at_v2 = db.get_as_of("o", "a", v2.seq).expect("the UPDATED value survives");
2066 assert_eq!(at_v2.data["t"], serde_json::json!(66));
2067 }
2068
2069 #[test]
2070 fn as_of_the_tombstone_or_later_reports_the_document_absent() {
2071 let db = Db::in_memory();
2072 db.put("o", "a", serde_json::json!({"t": 55}), vec![], None, None).unwrap();
2073 db.delete("o", "a").unwrap();
2074 let tomb_seq = db.tip().expect("the tombstone is the tip").seq;
2075
2076 assert!(db.get_as_of("o", "a", tomb_seq).is_none(),
2077 "at the delete's own sequence the document is gone");
2078 assert!(db.get_as_of("o", "a", tomb_seq + 10).is_none(), "and after it");
2079 // Never the tombstone node itself: `{_deleted, _prev}` is bookkeeping,
2080 // and surfacing it would look like a document with strange fields.
2081 for s in 0..=tomb_seq + 1 {
2082 if let Some(n) = db.get_as_of("o", "a", s) {
2083 assert!(n.data.get("_deleted").is_none(),
2084 "seq {} surfaced the tombstone as a document: {:?}", s, n.data);
2085 }
2086 }
2087 }
2088
2089 #[test]
2090 fn an_as_of_query_lists_deleted_ids_alongside_live_ones() {
2091 let db = Db::in_memory();
2092 db.put("o", "keep", serde_json::json!({"n": 1}), vec![], None, None).unwrap();
2093 let gone = db.put("o", "gone", serde_json::json!({"n": 2}), vec![], None, None).unwrap();
2094 db.delete("o", "gone").unwrap();
2095
2096 assert_eq!(db.id_index.list_ids("o"), vec!["keep".to_string()],
2097 "the live index holds only the living");
2098 assert_eq!(db.list_ids_including_deleted("o"),
2099 vec!["gone".to_string(), "keep".to_string()],
2100 "AS OF must consider both, in a stable order");
2101
2102 // The query path, end to end — this is what actually regressed.
2103 let (rows, _) = crate::nql::query(&db, &format!("FROM o AS OF {}", gone.seq)).unwrap();
2104 let ids: Vec<&str> = rows.iter().filter_map(|r| r["_id"].as_str()).collect();
2105 assert!(ids.contains(&"gone"), "AS OF must see the deleted row: {:?}", ids);
2106 assert!(ids.contains(&"keep"), "{:?}", ids);
2107
2108 // And the present must not.
2109 let (now, _) = crate::nql::query(&db, "FROM o").unwrap();
2110 let ids: Vec<&str> = now.iter().filter_map(|r| r["_id"].as_str()).collect();
2111 assert_eq!(ids, vec!["keep"], "a delete still deletes");
2112 }
2113
2114 #[test]
2115 fn a_recreated_id_keeps_the_history_from_before_its_delete() {
2116 // The edge case the graveyard fallback exists for: a `put` after a
2117 // delete starts a FRESH chain with no `prev`, so the live chain cannot
2118 // reach a sequence from before the delete. Only the graveyard can.
2119 let db = Db::in_memory();
2120 let old = db.put("o", "a", serde_json::json!({"era": "first"}), vec![], None, None).unwrap();
2121 db.delete("o", "a").unwrap();
2122 let new = db.put("o", "a", serde_json::json!({"era": "second"}), vec![], None, None).unwrap();
2123
2124 assert_eq!(db.get("o", "a").unwrap().data["era"], serde_json::json!("second"));
2125 assert_eq!(db.get_as_of("o", "a", new.seq).unwrap().data["era"],
2126 serde_json::json!("second"));
2127 assert_eq!(db.get_as_of("o", "a", old.seq).expect("the FIRST era survives").data["era"],
2128 serde_json::json!("first"),
2129 "re-creating an id must not orphan what came before it");
2130 }
2131
2132 #[test]
2133 fn the_graveyard_survives_a_reopen() {
2134 // A tombstone pointer lost to a restart would put the history back out
2135 // of reach — the exact bug, just deferred. So it is flushed with the
2136 // live index and read back from disk.
2137 let dir = tempdir().unwrap();
2138 let seq = {
2139 let db = Db::open(dir.path(), None).unwrap();
2140 let v1 = db.put("o", "a", serde_json::json!({"t": 7}), vec![], None, None).unwrap();
2141 db.delete("o", "a").unwrap();
2142 db.try_flush_all().expect("flush must succeed");
2143 v1.seq
2144 };
2145 let db = Db::open(dir.path(), None).unwrap();
2146 assert!(db.get("o", "a").is_none(), "still deleted after a reopen");
2147 assert_eq!(db.get_as_of("o", "a", seq).expect("history survives a reopen").data["t"],
2148 serde_json::json!(7));
2149 assert_eq!(db.list_ids_including_deleted("o"), vec!["a".to_string()]);
2150 }
2151
2152 #[test]
2153 fn the_graveyard_is_invisible_to_everything_that_enumerates_the_store() {
2154 // It adds a directory to the data dir, so the risk is that it shows up
2155 // as a phantom COLLECTION or a phantom OBJECT. Both enumerations are
2156 // rooted at their own subdirectory rather than at the data dir, which
2157 // is why it cannot — but that is exactly the kind of reasoning worth
2158 // pinning, because a stray "graveyard" collection would be nasty and
2159 // would only surface in someone's UI.
2160 let dir = tempdir().unwrap();
2161 let db = Db::open(dir.path(), None).unwrap();
2162 db.put("orders", "a", serde_json::json!({"t": 1}), vec![], None, None).unwrap();
2163 // A surviving sibling. This used to be load-bearing: with `a` alone,
2164 // `orders` had no index entries left and so no directory to enumerate,
2165 // and the test would have asserted the wrong thing for a reason that
2166 // had nothing to do with the graveyard. The collection registry fixed
2167 // that — an emptied collection stays in the namespace — so the sibling
2168 // is now just a second row.
2169 db.put("orders", "b", serde_json::json!({"t": 2}), vec![], None, None).unwrap();
2170 db.delete("orders", "a").unwrap();
2171 db.try_flush_all().unwrap();
2172
2173 let colls = db.collections();
2174 assert!(!colls.iter().any(|c| c == "graveyard"),
2175 "the graveyard must not look like a collection: {:?}", colls);
2176 assert_eq!(colls, vec!["orders".to_string()]);
2177
2178 let (_checked, tampered) = db.verify();
2179 assert!(tampered.is_empty(), "{:?}", tampered);
2180 }
2181
2182 #[test]
2183 fn a_delete_leaves_the_hash_chain_verifiable() {
2184 // The graveyard is an index, not a second source of truth: it must not
2185 // be able to make `verify()` disagree with the objects on disk.
2186 let db = Db::in_memory();
2187 db.put("o", "a", serde_json::json!({"t": 1}), vec![], None, None).unwrap();
2188 db.put("o", "b", serde_json::json!({"t": 2}), vec![], None, None).unwrap();
2189 db.delete("o", "a").unwrap();
2190 let (checked, tampered) = db.verify();
2191 assert!(tampered.is_empty(), "a delete must not break verify(): {:?}", tampered);
2192 assert!(checked >= 3, "the tombstone is an object too, got {}", checked);
2193 }
2194
2195 #[test]
2196 fn deleting_a_missing_id_stays_a_no_op() {
2197 let db = Db::in_memory();
2198 assert!(!db.delete("o", "nope").unwrap(), "nothing to delete");
2199 assert!(db.list_ids_including_deleted("o").is_empty(),
2200 "a failed delete must not put anything in the graveyard");
2201 }
2202
2203 #[test]
2204 fn seq_index_populated_on_put() {
2205 let db = Db::in_memory();
2206 let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
2207 let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
2208 assert_eq!(db.get_hash_by_seq(a.seq), Some(a.hash.clone()));
2209 assert_eq!(db.get_hash_by_seq(b.seq), Some(b.hash.clone()));
2210 assert_eq!(db.get_hash_by_seq(9999), None);
2211 }
2212
2213 #[test]
2214 fn tip_and_since() {
2215 let db = Db::in_memory();
2216 // Empty db: no tip, empty changefeed.
2217 assert!(db.tip().is_none());
2218 assert!(db.since(0, 0).nodes.is_empty());
2219
2220 let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
2221 let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
2222
2223 // tip() = the most recent write (highest seq), returned as a full node.
2224 let t = db.tip().expect("tip after writes");
2225 assert_eq!(t.seq, b.seq);
2226 assert_eq!(t.id, "b");
2227 assert_eq!(t.hash, b.hash);
2228
2229 // since(after_seq, limit) — EXCLUSIVE cursor, bounded page + envelope.
2230 let after_a = db.since(a.seq, 0);
2231 assert_eq!(after_a.nodes.len(), 1);
2232 assert_eq!(after_a.nodes[0].id, "b");
2233 assert_eq!(after_a.from_seq, a.seq);
2234 assert_eq!(after_a.to_seq, b.seq);
2235 assert_eq!(after_a.head_seq, b.seq);
2236 assert!(!after_a.has_more);
2237
2238 // Nothing written after the tip.
2239 assert!(db.since(b.seq, 0).nodes.is_empty());
2240
2241 // `limit` bounds the page and sets has_more; resume from to_seq.
2242 let c = db.put("item", "c", serde_json::json!({"x": 3}), vec![], None, None).unwrap();
2243 let page = db.since(a.seq, 1); // (a..] capped at 1 -> [b], more pending
2244 assert_eq!(page.nodes.len(), 1);
2245 assert_eq!(page.nodes[0].id, "b");
2246 assert_eq!(page.to_seq, b.seq);
2247 assert!(page.has_more);
2248 let page2 = db.since(page.to_seq, 1); // resume from b -> [c], done
2249 assert_eq!(page2.nodes.len(), 1);
2250 assert_eq!(page2.nodes[0].id, "c");
2251 assert_eq!(page2.to_seq, c.seq);
2252 assert!(!page2.has_more);
2253 }
2254
2255 #[test]
2256 fn tip_collection_per_chain() {
2257 // The ITC sync-client case: separate chains in separate collections; a
2258 // consumer resumes ONE without pulling global tip and filtering.
2259 let db = Db::in_memory();
2260 assert!(db.tip_collection("blocks").is_none());
2261
2262 db.put("blocks", "b0", serde_json::json!({"h": 0}), vec![], None, None).unwrap();
2263 db.put("tx", "t0", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2264 let b1 = db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
2265 let t1 = db.put("tx", "t1", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
2266
2267 // global tip = latest write overall (t1)
2268 assert_eq!(db.tip().unwrap().id, "t1");
2269 // collection-local tips = latest write in each collection
2270 let bt = db.tip_collection("blocks").expect("blocks tip");
2271 assert_eq!(bt.id, "b1");
2272 assert_eq!(bt.seq, b1.seq);
2273 assert_eq!(db.tip_collection("tx").unwrap().seq, t1.seq);
2274 assert!(db.tip_collection("absent").is_none());
2275 }
2276
2277 #[test]
2278 fn seq_index_survives_batch() {
2279 let db = Db::in_memory();
2280 let nodes = db.put_batch(vec![
2281 ("item".into(), "x".into(), serde_json::json!({"v": 1}), vec![], None, None),
2282 ("item".into(), "y".into(), serde_json::json!({"v": 2}), vec![], None, None),
2283 ]).unwrap();
2284 for node in &nodes {
2285 assert_eq!(db.get_hash_by_seq(node.seq), Some(node.hash.clone()));
2286 }
2287 }
2288
2289 /// Regression: put_batch must remove the superseded version's sorted-index
2290 /// entries, exactly like put() does. Old behavior left the old hashes in
2291 /// the BTree — ORDER BY returned superseded rows alongside current ones
2292 /// (they resolve fine through the content-addressed store, which made the
2293 /// stale rows look legitimate).
2294 #[test]
2295 fn put_batch_removes_superseded_sorted_index_entries() {
2296 let db = Db::in_memory();
2297 db.create_sorted_index("blocks", "height");
2298 db.put("blocks", "x", serde_json::json!({"height": 1}), vec![], None, None).unwrap();
2299 db.put_batch(vec![
2300 ("blocks".into(), "x".into(), serde_json::json!({"height": 99}), vec![], None, None),
2301 ]).unwrap();
2302
2303 let asc = db.order_by_asc("blocks", "height", 10);
2304 assert_eq!(asc.len(), 1, "stale index entry for the superseded version must be gone");
2305 assert_eq!(asc[0].data["height"], 99);
2306 assert_eq!(asc[0].id, "x");
2307 }
2308
2309 /// Updates without any sorted index must keep full version-chain semantics
2310 /// (guards the new skip-old-object-read fast path in put()).
2311 #[test]
2312 fn update_without_indexes_preserves_chain() {
2313 let db = Db::in_memory();
2314 let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2315 let v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
2316 assert_eq!(v2.prev.as_deref(), Some(v1.hash.as_str()), "prev chain must survive the fast path");
2317 assert_eq!(db.get("docs", "x").unwrap().data["v"], 2);
2318 assert_eq!(db.get_as_of("docs", "x", v1.seq).unwrap().data["v"], 1);
2319 }
2320
2321 #[test]
2322 fn link_and_neighbors() {
2323 let db = Db::in_memory();
2324 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
2325 db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
2326 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
2327 db.put("trip", "t2", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
2328
2329 db.link("driver:d1", "handles", "trip:t1").unwrap();
2330 db.link("driver:d1", "handles", "trip:t2").unwrap();
2331 db.link("driver:d2", "handles", "trip:t1").unwrap();
2332
2333 let d1_trips = db.neighbors("driver:d1", "handles");
2334 assert_eq!(d1_trips.len(), 2);
2335 let ids: std::collections::HashSet<&str> = d1_trips.iter().map(|n| n.id.as_str()).collect();
2336 assert!(ids.contains("t1") && ids.contains("t2"));
2337
2338 let d2_trips = db.neighbors("driver:d2", "handles");
2339 assert_eq!(d2_trips.len(), 1);
2340 assert_eq!(d2_trips[0].id, "t1");
2341 }
2342
2343 #[test]
2344 fn link_stored_in_links_collection() {
2345 // Links are stored as __links__ documents, not as graph edges.
2346 // The __links__ collection is NQL-queryable and consistent with the PyO3 binding.
2347 let db = Db::in_memory();
2348 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
2349 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
2350 db.link("driver:d1", "handles", "trip:t1").unwrap();
2351 // Verify the __links__ document was created
2352 let link_doc = db.get("__links__", "driver:d1|handles|trip:t1");
2353 assert!(link_doc.is_some(), "__links__ doc should exist");
2354 let doc = link_doc.unwrap();
2355 assert_eq!(doc.data["_from"], "driver:d1");
2356 assert_eq!(doc.data["_rel"], "handles");
2357 assert_eq!(doc.data["_to"], "trip:t1");
2358 // neighbors() resolves to the target node
2359 let nb = db.neighbors("driver:d1", "handles");
2360 assert_eq!(nb.len(), 1);
2361 assert_eq!(nb[0].id, "t1");
2362 }
2363
2364 /// A lost id-index WAL must be recoverable: the objects carry coll/id/seq,
2365 /// so `repair()` can reconstruct every row, and the repaired database must
2366 /// reopen WARM with a valid head.
2367 ///
2368 /// Regression for 2.8.5, where the cold scan rebuilt seq_index, coll_tips,
2369 /// head and MANIFEST but never the id index — so a database in this state
2370 /// returned 0 rows from `list()` while `verify()` reported every object
2371 /// healthy, and `nedb-cli repair` printed success without fixing anything.
2372 #[test]
2373 fn repair_rebuilds_id_index_after_lost_wal() {
2374 let dir = tempdir().unwrap();
2375 {
2376 let db = Db::open(dir.path(), None).unwrap();
2377 for i in 0..25 {
2378 db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
2379 .unwrap();
2380 }
2381 db.put("rows", "r0", serde_json::json!({"i": 0, "v": 2}), vec![], None, None).unwrap();
2382 db.try_flush_all().unwrap();
2383 }
2384
2385 // Simulate the lost WAL: objects survive, the id index does not.
2386 std::fs::remove_dir_all(dir.path().join("indexes")).unwrap();
2387
2388 {
2389 let db = Db::open(dir.path(), None).unwrap();
2390 assert_eq!(db.list("rows").len(), 0, "precondition: rows unreachable");
2391 let (ok, bad) = db.verify();
2392 assert!(ok > 0 && bad.is_empty(), "objects must still be intact and verifying");
2393
2394 let written = db.repair().unwrap();
2395 // 25 rows plus the one `_nedb.collections` record that registered
2396 // the collection. The registry is written through the ordinary
2397 // object path precisely so that repair, verify and replication
2398 // cover it without knowing it is special.
2399 assert_eq!(written, 26, "one entry per distinct (coll, id)");
2400 assert_eq!(db.list("rows").len(), 25, "every row must come back");
2401
2402 // The winner for a re-put id is the HIGHEST seq, matching put().
2403 let r0 = db.get("rows", "r0").expect("r0 present");
2404 assert_eq!(r0.data.get("v").and_then(|v| v.as_i64()), Some(2),
2405 "repair must restore the latest version, not an older one");
2406 }
2407
2408 // A repaired database must reopen warm with a real head.
2409 let db3 = Db::open(dir.path(), None).unwrap();
2410 assert_eq!(db3.list("rows").len(), 25);
2411 assert!(!db3.head().is_empty(), "repair must leave a valid MANIFEST head");
2412 assert!(db3.tip_collection("rows").is_some(), "tip_collection must resolve after repair");
2413 }
2414
2415 /// `since()` must never report "caught up" while the cursor is behind head.
2416 ///
2417 /// Regression for 2.8.5: on a warm boot the seq index is empty by design
2418 /// (the warm path skips the scan), so every seq lookup missed and `since()`
2419 /// returned zero nodes with `has_more = false` — identical to genuinely up
2420 /// to date. A consumer following the documented drain loop stopped one call
2421 /// in, on a database with every record unread.
2422 #[test]
2423 fn since_never_reports_caught_up_while_behind_head() {
2424 let dir = tempdir().unwrap();
2425 {
2426 let db = Db::open(dir.path(), None).unwrap();
2427 for i in 0..10 {
2428 db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
2429 .unwrap();
2430 }
2431 db.try_flush_all().unwrap();
2432 }
2433
2434 // Warm reopen: startup is "complete" in O(1) because the scan is skipped.
2435 let db2 = Db::open(dir.path(), None).unwrap();
2436 let st = db2.scan_status();
2437 assert!(st.tip_seq > 0, "log has entries");
2438 assert!(
2439 !st.seq_index_ready,
2440 "warm boot leaves the seq index cold — that is the honest signal"
2441 );
2442
2443 let batch = db2.since(0, 100);
2444 assert!(
2445 batch.to_seq < batch.head_seq,
2446 "cursor is behind the log head in this state"
2447 );
2448 assert!(
2449 batch.has_more,
2450 "has_more must be true while the cursor is behind head — otherwise the \
2451 consumer reads 'caught up' and stops with every record unread"
2452 );
2453
2454 // After a repair the index resolves and the drain actually completes.
2455 db2.repair().unwrap();
2456 assert!(db2.scan_status().seq_index_ready);
2457 let drained = db2.since(0, 100);
2458 assert!(!drained.has_more, "genuinely caught up reports has_more=false");
2459
2460 // KNOWN SHARP EDGE, pinned here deliberately: the cursor is EXCLUSIVE
2461 // and seqs start at 0, so `since(0, _)` returns (0, head] and whatever
2462 // holds seq 0 is not reachable through any cursor value. Changing the
2463 // cursor convention would break existing replication consumers, so this
2464 // is documented rather than silently altered.
2465 //
2466 // The collection registry softened it by accident and in the right
2467 // direction: seq 0 is now the `_nedb.collections` record rather than a
2468 // user's first row, so all 10 writes drain. A replica seeded from
2469 // since() alone is still one record short — but the record it misses is
2470 // one it can re-derive, instead of somebody's data.
2471 assert_eq!(
2472 drained.nodes.len(),
2473 10,
2474 "since(0) is exclusive of seq 0 — see the sharp edge noted above"
2475 );
2476 assert!(
2477 drained.nodes.iter().all(|n| n.seq >= 1),
2478 "seq 0 is unreachable via since()"
2479 );
2480 }
2481
2482 #[test]
2483 fn link_missing_node_errors() {
2484 let db = Db::in_memory();
2485 db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
2486 assert!(db.link("driver:d1", "handles", "trip:ghost").is_err());
2487 }
2488
2489 #[test]
2490 fn link_durable_survives_reopen() {
2491 let dir = tempdir().unwrap();
2492 {
2493 let db = Db::open(dir.path(), None).unwrap();
2494 db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
2495 db.put("trip", "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
2496 db.link("driver:d1", "handles", "trip:t1").unwrap();
2497 }
2498 let db2 = Db::open(dir.path(), None).unwrap();
2499 db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
2500 let trips = db2.neighbors("driver:d1", "handles");
2501 assert_eq!(trips.len(), 1);
2502 assert_eq!(trips[0].id, "t1");
2503 }
2504
2505 #[test]
2506 fn tip_survives_warm_restart() {
2507 // v2.5.43: tip() returns the last written object AND survives a warm restart.
2508 // On reopen the seq_index is cold (warm start skips the scan), so tip() must
2509 // resolve the last write via the MANIFEST tip_hash fallback — no scan.
2510 let dir = tempdir().unwrap();
2511 {
2512 let db = Db::open(dir.path(), None).unwrap();
2513 db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
2514 db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
2515 db.flush_all(); // persists MANIFEST incl. tip_hash
2516 assert_eq!(db.tip().expect("tip in-session").id, "b2");
2517 }
2518 // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
2519 let db2 = Db::open(dir.path(), None).unwrap();
2520 assert!(db2.get_hash_by_seq(1).is_none(), "seq_index is cold on a warm boot");
2521 let tip = db2.tip().expect("tip() must survive a warm restart");
2522 assert_eq!(tip.id, "b2");
2523 assert_eq!(tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
2524 }
2525
2526 #[test]
2527 fn tip_collection_survives_warm_restart() {
2528 // Same contract as tip(), per collection: itc-node-rs resumes headers /
2529 // blocks / l2_receipts independently, so each must be its own durable
2530 // resume point — not just the global tip.
2531 let dir = tempdir().unwrap();
2532 {
2533 let db = Db::open(dir.path(), None).unwrap();
2534 db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
2535 db.put("tx", "t1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2536 let b2 = db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
2537 db.flush_all(); // persists MANIFEST incl. coll_tips
2538 assert_eq!(db.tip_collection("blocks").unwrap().id, "b2");
2539 assert_eq!(db.tip_collection("blocks").unwrap().seq, b2.seq);
2540 }
2541 // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
2542 let db2 = Db::open(dir.path(), None).unwrap();
2543 assert!(db2.get_hash_by_seq(0).is_none(), "seq_index is cold on a warm boot");
2544 let blocks_tip = db2.tip_collection("blocks").expect("tip_collection must survive a warm restart");
2545 assert_eq!(blocks_tip.id, "b2");
2546 assert_eq!(blocks_tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
2547 let tx_tip = db2.tip_collection("tx").expect("tx tip must also survive");
2548 assert_eq!(tx_tip.id, "t1");
2549 assert!(db2.tip_collection("absent").is_none());
2550 }
2551
2552 #[test]
2553 fn cold_scan_indexes_every_object_and_reports_completion() {
2554 // Regression guard for the cold-scan refactor: seq_index is now populated
2555 // DURING the parallel read pass (for live scan_status().indexed_count
2556 // progress — see cold_scan_background_arc), not in a second pass
2557 // afterward. This asserts the end state is unchanged: every written
2558 // object is indexed, tip()/tip_collection() are correct, and
2559 // scan_complete eventually reports true.
2560 let dir = tempdir().unwrap();
2561 let n = 25u64;
2562 {
2563 let db = Db::open(dir.path(), None).unwrap();
2564 for i in 0..n {
2565 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2566 }
2567 db.flush_all();
2568 }
2569 // Force a COLD start regardless of the MANIFEST nedb-v2 itself would
2570 // have written: delete it so startup_rebuild() takes the cold path and
2571 // start_cold_scan() actually spawns the background scan this test needs
2572 // to exercise.
2573 std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
2574
2575 let db = Db::open(dir.path(), None).unwrap();
2576 assert!(!db.scan_status().scan_complete, "should be cold immediately after open");
2577 let db = std::sync::Arc::new(db);
2578 Db::start_cold_scan(std::sync::Arc::clone(&db));
2579
2580 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2581 while !db.scan_status().scan_complete {
2582 assert!(std::time::Instant::now() < deadline, "cold scan did not complete in time");
2583 std::thread::sleep(std::time::Duration::from_millis(5));
2584 }
2585
2586 let status = db.scan_status();
2587 // n rows + the collection registry record for "things".
2588 assert_eq!(status.indexed_count, n as usize + 1, "every written object must be indexed");
2589 assert!(status.scan_complete);
2590
2591 let tip = db.tip().expect("tip resolves after cold scan");
2592 assert_eq!(tip.data.get("i").and_then(|v| v.as_u64()), Some(n - 1));
2593 let coll_tip = db.tip_collection("things").expect("tip_collection resolves after cold scan");
2594 assert_eq!(coll_tip.id, tip.id);
2595 }
2596
2597 /// Concurrent writers must settle the tip at the HIGHEST SEQ, and that tip
2598 /// must survive a warm restart. Before the seq-guarded tip fix, update_head
2599 /// was "last call wins": a slower thread carrying an OLDER seq could
2600 /// overwrite tip_hash after a newer write, and MANIFEST then persisted the
2601 /// stale tip for the next warm boot (flaky by nature — this pins the
2602 /// contract deterministically for the fixed code).
2603 #[test]
2604 fn concurrent_puts_tip_resolves_to_highest_seq_after_warm_restart() {
2605 let dir = tempdir().unwrap();
2606 let total: u64 = 100;
2607 {
2608 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2609 let mut handles = vec![];
2610 for t in 0..4u64 {
2611 let db2 = std::sync::Arc::clone(&db);
2612 handles.push(std::thread::spawn(move || {
2613 for i in 0..25u64 {
2614 db2.put("c", &format!("{}-{}", t, i),
2615 serde_json::json!({"t": t, "i": i}),
2616 vec![], None, None).unwrap();
2617 }
2618 }));
2619 }
2620 for h in handles { h.join().unwrap(); }
2621 // In-session: tip must be the highest assigned seq.
2622 let expected = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
2623 // `total` user writes plus one registry record for collection "c",
2624 // so the highest assigned seq is `total`, not `total - 1`.
2625 assert_eq!(expected, total, "exactly {} writes expected", total);
2626 assert_eq!(db.tip().expect("in-session tip").seq, expected);
2627 db.flush_all(); // persist MANIFEST incl. tip_hash
2628 }
2629 // Warm reopen: seq_index cold; tip() resolves via MANIFEST tip_hash.
2630 let db2 = Db::open(dir.path(), None).unwrap();
2631 let tip = db2.tip().expect("tip must survive warm restart after concurrent writes");
2632 assert_eq!(tip.seq, total, "warm-boot tip must be the highest-seq write");
2633 // Per-collection tip: same contract.
2634 let ct = db2.tip_collection("c").expect("coll tip survives");
2635 assert_eq!(ct.seq, total);
2636 }
2637
2638 /// Pre-2.5.43 MANIFESTs (no tip_hash) must warm-boot, NOT force a cold
2639 /// scan. The old "cold scan once to upgrade" policy was hours of random
2640 /// reads on multi-million-object seek-bound stores (itcd -dagv3), re-paid
2641 /// on every boot if the process exited before the scan finished. seq+head
2642 /// in the old MANIFEST are valid; tip()/tip_collection() return None until
2643 /// the first write+flush organically rewrites MANIFEST with a tip.
2644 #[test]
2645 fn pre_durable_tip_manifest_warm_boots_and_heals_lazily() {
2646 let dir = tempdir().unwrap();
2647 {
2648 let db = Db::open(dir.path(), None).unwrap();
2649 for i in 0..5u64 {
2650 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2651 }
2652 db.flush_all();
2653 }
2654 // Rewrite MANIFEST in the pre-2.5.43 shape: seq + head only.
2655 let manifest_path = dir.path().join("MANIFEST");
2656 let m: serde_json::Value =
2657 serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
2658 let old_format = serde_json::json!({ "seq": m["seq"], "head": m["head"] });
2659 std::fs::write(&manifest_path, serde_json::to_string(&old_format).unwrap()).unwrap();
2660
2661 // Reopen: must be WARM (startup_ready immediately — no cold scan gate).
2662 let db2 = Db::open(dir.path(), None).unwrap();
2663 assert!(db2.startup_ready.load(std::sync::atomic::Ordering::SeqCst),
2664 "pre-2.5.43 MANIFEST must warm-boot, not fall to a cold scan");
2665 // tip() unresolvable this boot — documented None, not a panic or scan.
2666 assert!(db2.tip().is_none(), "tip() is None until the manifest heals");
2667 // seq continuity: a new write gets a FRESH seq (no reuse).
2668 let n = db2.put("things", "next", serde_json::json!({"fresh": true}), vec![], None, None).unwrap();
2669 assert_eq!(n.seq, m["seq"].as_u64().unwrap(), "next write takes the persisted next-to-assign seq");
2670 db2.flush_all(); // organic upgrade: MANIFEST now carries tip_hash
2671 drop(db2);
2672
2673 // Healed: next boot is warm AND tip() resolves.
2674 let db3 = Db::open(dir.path(), None).unwrap();
2675 assert!(db3.startup_ready.load(std::sync::atomic::Ordering::SeqCst));
2676 let tip = db3.tip().expect("tip() must resolve after the organic upgrade");
2677 assert_eq!(tip.id, "next");
2678 }
2679
2680 /// Regression for the cold-scan MANIFEST seq off-by-one. The scan's old
2681 /// hand-rolled MANIFEST stored `seq: max_seq` (the last USED seq), but the
2682 /// warm boot loads `m.seq` as the NEXT-TO-ASSIGN counter — so a restart
2683 /// right after a quiet cold scan handed the next write the tip's seq:
2684 /// a DUPLICATE seq in the log (seq_index overwrite, wrong since() page).
2685 /// The scan now writes MANIFEST via flush_manifest(), which reads the live
2686 /// counter (max_seq + 1).
2687 #[test]
2688 fn manifest_after_cold_scan_does_not_reuse_tip_seq() {
2689 let dir = tempdir().unwrap();
2690 let old_tip_seq;
2691 {
2692 let db = Db::open(dir.path(), None).unwrap();
2693 for i in 0..5u64 {
2694 db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2695 }
2696 db.flush_all();
2697 old_tip_seq = db.tip().unwrap().seq;
2698 }
2699 // Force a cold start: remove MANIFEST so the background scan runs and
2700 // writes a fresh MANIFEST itself.
2701 std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
2702 {
2703 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2704 Db::start_cold_scan(std::sync::Arc::clone(&db));
2705 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2706 while !db.scan_status().scan_complete {
2707 assert!(std::time::Instant::now() < deadline, "cold scan did not complete");
2708 std::thread::sleep(std::time::Duration::from_millis(5));
2709 }
2710 // No further writes — the scan's own MANIFEST is what the next boot sees.
2711 }
2712 // Warm reopen from the scan-written MANIFEST: the next write must get a
2713 // FRESH seq, never the tip's.
2714 let db3 = Db::open(dir.path(), None).unwrap();
2715 let tip_before = db3.tip().expect("tip survives scan-written MANIFEST");
2716 assert_eq!(tip_before.seq, old_tip_seq, "tip identity preserved across the scan");
2717 let new_node = db3.put("things", "next", serde_json::json!({"fresh": true}),
2718 vec![], None, None).unwrap();
2719 assert!(new_node.seq > old_tip_seq,
2720 "new write reused seq {} (tip was {}) — duplicate seq in the log",
2721 new_node.seq, old_tip_seq);
2722 }
2723
2724 /// Regression: the flush ticker must NOT pin the database.
2725 ///
2726 /// Before this was fixed, `start_manifest_ticker` held a strong `Arc<Db>`
2727 /// in an unconditional `loop`, so the thread never exited, the `Db` was
2728 /// never dropped, and the exclusive data-dir `LOCK` from `Db::open` was
2729 /// never released. Reopening the same path in the SAME PROCESS then failed
2730 /// with "locked by another process (pid N)" — where N was the caller's own
2731 /// pid. Live in every release from 2.8.5 through 3.1.0, and invisible
2732 /// because no CI ran the suite (tests/test_native.py) that hit it.
2733 ///
2734 /// Put the strong `Arc` back in the ticker and this test fails.
2735 #[test]
2736 fn ticker_does_not_pin_the_db_across_a_reopen() {
2737 let dir = tempdir().unwrap();
2738 {
2739 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2740 Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
2741 db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2742 // Let the ticker run at least a couple of times while the db lives.
2743 std::thread::sleep(std::time::Duration::from_millis(90));
2744 } // last owner dropped here -> Drop flushes -> LOCK released
2745
2746 // The ticker upgrades its Weak for the duration of a tick, so at any
2747 // given instant it may legitimately hold a transient strong reference.
2748 // Release is therefore "eventual, within about one interval", not
2749 // instantaneous -- poll for it.
2750 //
2751 // The first version of this test sampled Arc::strong_count once and
2752 // asserted it was 1. That passed on an idle machine and failed the
2753 // first time it met a loaded CI runner, because the sample landed
2754 // mid-tick. A leak still fails this test deterministically: if the
2755 // ticker holds a strong Arc forever the LOCK is never released and
2756 // the deadline expires.
2757 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2758 let db2 = loop {
2759 match Db::open(dir.path(), None) {
2760 Ok(db) => break db,
2761 Err(e) => {
2762 assert!(std::time::Instant::now() < deadline,
2763 "reopen never succeeded -- the ticker is pinning the Db: {e}");
2764 std::thread::sleep(std::time::Duration::from_millis(25));
2765 }
2766 }
2767 };
2768 assert!(db2.get("t", "a").is_some(), "the write survived close/reopen");
2769 }
2770
2771 /// The ticker thread must actually terminate, not merely stop pinning.
2772 #[test]
2773 fn ticker_thread_exits_when_the_last_owner_drops() {
2774 let dir = tempdir().unwrap();
2775 let weak = {
2776 let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2777 Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
2778 db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2779 std::thread::sleep(std::time::Duration::from_millis(60));
2780 std::sync::Arc::downgrade(&db)
2781 };
2782 // Same reasoning as above: a tick in flight holds a real strong
2783 // reference for a few microseconds, so this is an eventual property.
2784 // A genuine leak never releases and blows the deadline.
2785 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2786 while weak.upgrade().is_some() {
2787 assert!(std::time::Instant::now() < deadline,
2788 "the Db outlived its last owner — the ticker is leaking it");
2789 std::thread::sleep(std::time::Duration::from_millis(25));
2790 }
2791 }
2792}
2793
2794/// Collection identity: does the database know which collections exist,
2795/// independently of how and when it happened to store them?
2796///
2797/// The three tests that used to fail are the first three here. They failed
2798/// like this, on the running engine:
2799///
2800/// ```text
2801/// disk, flush between : ["orders"]
2802/// disk, one tick : []
2803/// memory : []
2804/// ```
2805#[cfg(test)]
2806mod collection_identity {
2807 use super::*;
2808 use tempfile::tempdir;
2809
2810 fn j(v: u64) -> serde_json::Value { serde_json::json!({"v": v}) }
2811
2812 /// Create a collection, then empty it — flushing BETWEEN the two.
2813 fn disk_emptied_with_flush_between() -> Vec<String> {
2814 let dir = tempdir().unwrap();
2815 let db = Db::open(dir.path(), None).unwrap();
2816 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2817 db.flush_all();
2818 db.delete("orders", "1").unwrap();
2819 db.flush_all();
2820 db.collections()
2821 }
2822
2823 /// The same logical history, with no flush in between. Before the registry
2824 /// this returned `[]`, because the WAL buffer is keyed by `(coll, id)` and
2825 /// the tombstone overwrote the PUT before any directory was created.
2826 fn disk_emptied_within_one_tick() -> Vec<String> {
2827 let dir = tempdir().unwrap();
2828 let db = Db::open(dir.path(), None).unwrap();
2829 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2830 db.delete("orders", "1").unwrap();
2831 db.flush_all();
2832 db.collections()
2833 }
2834
2835 fn memory_emptied() -> Vec<String> {
2836 let db = Db::in_memory();
2837 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2838 db.delete("orders", "1").unwrap();
2839 db.collections()
2840 }
2841
2842 /// A background timer is not a fact about the data.
2843 #[test]
2844 fn the_same_history_yields_the_same_namespace_regardless_of_flush_timing() {
2845 assert_eq!(
2846 disk_emptied_with_flush_between(),
2847 disk_emptied_within_one_tick(),
2848 "a 1-second flush ticker decided the namespace"
2849 );
2850 }
2851
2852 /// A root computed on a disk replica and on a memory replica of the same
2853 /// database has to be the same root.
2854 #[test]
2855 fn the_namespace_does_not_depend_on_the_storage_backend() {
2856 assert_eq!(
2857 disk_emptied_with_flush_between(),
2858 memory_emptied(),
2859 "disk and memory disagree about which collections exist"
2860 );
2861 }
2862
2863 /// The property the Oracle named: an empty-but-durable collection must not
2864 /// be indistinguishable from one that never existed.
2865 #[test]
2866 fn an_emptied_collection_is_not_the_same_as_one_that_never_existed() {
2867 assert_eq!(memory_emptied(), vec!["orders".to_string()]);
2868
2869 let never = Db::in_memory();
2870 assert!(never.collections().is_empty());
2871 }
2872
2873 #[test]
2874 fn a_dropped_collection_is_gone_but_a_merely_empty_one_is_not() {
2875 let db = Db::in_memory();
2876 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2877 db.delete("orders", "1").unwrap();
2878 assert_eq!(db.collections(), vec!["orders".to_string()], "emptying is not dropping");
2879
2880 assert!(db.drop_collection("orders").unwrap());
2881 assert!(db.collections().is_empty());
2882
2883 // Dropping twice is not an error, it is just not a second event.
2884 assert!(!db.drop_collection("orders").unwrap());
2885 }
2886
2887 #[test]
2888 fn writing_to_a_dropped_collection_revives_it() {
2889 let db = Db::in_memory();
2890 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2891 db.drop_collection("orders").unwrap();
2892 assert!(db.collections().is_empty());
2893
2894 db.put("orders", "2", j(2), vec![], None, None).unwrap();
2895 assert_eq!(db.collections(), vec!["orders".to_string()]);
2896 }
2897
2898 /// The namespace is versioned, because the registry is ordinary documents.
2899 #[test]
2900 fn the_namespace_can_be_read_as_of_a_sequence() {
2901 let db = Db::in_memory();
2902 let a = db.put("alpha", "1", j(1), vec![], None, None).unwrap();
2903 let b = db.put("beta", "1", j(1), vec![], None, None).unwrap();
2904
2905 assert_eq!(db.collections_as_of(a.seq), vec!["alpha".to_string()]);
2906 assert_eq!(
2907 db.collections_as_of(b.seq),
2908 vec!["alpha".to_string(), "beta".to_string()]
2909 );
2910 }
2911
2912 #[test]
2913 fn a_drop_is_visible_as_a_drop_in_history_not_as_an_absence() {
2914 let db = Db::in_memory();
2915 let a = db.put("orders", "1", j(1), vec![], None, None).unwrap();
2916 db.drop_collection("orders").unwrap();
2917
2918 assert!(db.collections().is_empty(), "not live now");
2919 assert_eq!(
2920 db.collections_as_of(a.seq), vec!["orders".to_string()],
2921 "but it existed then, and history says so"
2922 );
2923 }
2924
2925 #[test]
2926 fn the_registry_does_not_list_itself() {
2927 let db = Db::in_memory();
2928 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2929 assert_eq!(db.collections(), vec!["orders".to_string()]);
2930 assert!(
2931 !db.collections().iter().any(|c| crate::namespace::is_reserved(c)),
2932 "an engine-owned collection is not part of the user's namespace"
2933 );
2934 }
2935
2936 #[test]
2937 fn a_client_cannot_write_to_the_registry() {
2938 let db = Db::in_memory();
2939 assert!(db.put(crate::namespace::COLLECTIONS, "forged", j(1), vec![], None, None).is_err());
2940 assert!(db.put("_nedb.anything", "x", j(1), vec![], None, None).is_err());
2941 assert!(db.delete(crate::namespace::COLLECTIONS, "orders").is_err());
2942 assert!(db.drop_collection(crate::namespace::COLLECTIONS).is_err());
2943 }
2944
2945 #[test]
2946 fn a_collection_name_cannot_escape_the_data_directory() {
2947 let db = Db::in_memory();
2948 for escape in ["../etc", "a/b", "..", ""] {
2949 assert!(
2950 db.put(escape, "x", j(1), vec![], None, None).is_err(),
2951 "{:?} must not be usable as a collection name", escape
2952 );
2953 }
2954 }
2955
2956 #[test]
2957 fn registration_survives_a_reopen_without_re_registering() {
2958 let dir = tempdir().unwrap();
2959 let seq_after_first_open;
2960 {
2961 let db = Db::open(dir.path(), None).unwrap();
2962 db.put("orders", "1", j(1), vec![], None, None).unwrap();
2963 db.put("orders", "2", j(2), vec![], None, None).unwrap();
2964 db.flush_all();
2965 seq_after_first_open = db.seq.load(Ordering::SeqCst);
2966 }
2967 let db = Db::open(dir.path(), None).unwrap();
2968 assert_eq!(db.collections(), vec!["orders".to_string()]);
2969 db.put("orders", "3", j(3), vec![], None, None).unwrap();
2970 assert_eq!(
2971 db.seq.load(Ordering::SeqCst), seq_after_first_open + 1,
2972 "reopening and writing again must not append a second registry record"
2973 );
2974 }
2975
2976 #[test]
2977 fn a_batch_registers_every_collection_it_touches_exactly_once() {
2978 let db = Db::in_memory();
2979 db.put_batch(vec![
2980 ("a".into(), "1".into(), j(1), vec![], None, None),
2981 ("b".into(), "1".into(), j(1), vec![], None, None),
2982 ("a".into(), "2".into(), j(2), vec![], None, None),
2983 ]).unwrap();
2984 assert_eq!(db.collections(), vec!["a".to_string(), "b".to_string()]);
2985 assert_eq!(
2986 db.id_index.list_ids(crate::namespace::COLLECTIONS).len(), 2,
2987 "three writes across two collections is two registry records"
2988 );
2989 }
2990
2991 #[test]
2992 fn a_batch_naming_a_reserved_collection_writes_nothing_at_all() {
2993 let db = Db::in_memory();
2994 let before = db.seq.load(Ordering::SeqCst);
2995 let r = db.put_batch(vec![
2996 ("ok".into(), "1".into(), j(1), vec![], None, None),
2997 (crate::namespace::COLLECTIONS.into(), "forged".into(), j(1), vec![], None, None),
2998 ]);
2999 assert!(r.is_err(), "a batch with a refused collection must be refused");
3000 assert_eq!(
3001 db.seq.load(Ordering::SeqCst), before,
3002 "and must not have written the acceptable half of itself first"
3003 );
3004 assert!(db.collections().is_empty());
3005 }
3006}
3007
3008/// State roots against a live engine: does the root actually track state, and
3009/// does verification tell the truth about what it could and could not check?
3010#[cfg(test)]
3011mod state_roots {
3012 use super::*;
3013 use crate::root::{RecordStatus, Recomputation, UnavailableReason};
3014 use tempfile::tempdir;
3015
3016 fn j(v: u64) -> serde_json::Value { serde_json::json!({"v": v}) }
3017
3018 #[test]
3019 fn an_empty_database_has_a_stable_nonzero_root() {
3020 let a = Db::in_memory().state_root().unwrap();
3021 let b = Db::in_memory().state_root().unwrap();
3022 assert_eq!(a, b);
3023 assert_ne!(a.state_root, "0".repeat(64));
3024 assert_eq!(a.collection_count, 0);
3025 assert_eq!(a.record_count, 0);
3026 }
3027
3028 /// The invariance the whole format exists for.
3029 #[test]
3030 fn disk_and_memory_agree_on_the_root_of_the_same_history() {
3031 let dir = tempdir().unwrap();
3032 let disk = Db::open(dir.path(), None).unwrap();
3033 let mem = Db::in_memory();
3034 for db in [&disk, &mem] {
3035 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3036 db.put("orders", "2", j(2), vec![], None, None).unwrap();
3037 db.put("users", "u", j(9), vec![], None, None).unwrap();
3038 }
3039 assert_eq!(disk.state_root().unwrap(), mem.state_root().unwrap());
3040 }
3041
3042 /// Encryption changes object hashes; it must not change the root.
3043 #[test]
3044 fn an_encrypted_replica_has_the_same_root_as_a_plaintext_one() {
3045 let plain_dir = tempdir().unwrap();
3046 let enc_dir = tempdir().unwrap();
3047 let plain = Db::open(plain_dir.path(), None).unwrap();
3048 let enc = Db::open(enc_dir.path(), Some(crate::store::Dek([7u8; 32]))).unwrap();
3049 for db in [&plain, &enc] {
3050 db.put("orders", "1", serde_json::json!({"total": 100}), vec![], None, None).unwrap();
3051 }
3052 assert_ne!(
3053 plain.get("orders", "1").unwrap().hash,
3054 enc.get("orders", "1").unwrap().hash,
3055 "precondition: encryption really does change the object hash"
3056 );
3057 assert_eq!(
3058 plain.state_root().unwrap(), enc.state_root().unwrap(),
3059 "but the root commits to logical content, so it must not move"
3060 );
3061 }
3062
3063 #[test]
3064 fn the_root_moves_when_the_state_moves_and_not_otherwise() {
3065 let db = Db::in_memory();
3066 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3067 let a = db.state_root().unwrap().state_root;
3068
3069 // A no-op rewrite of the same value: new node, new seq, same state.
3070 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3071 assert_eq!(db.state_root().unwrap().state_root, a,
3072 "the root commits to state, not to how many times you wrote it");
3073
3074 db.put("orders", "1", j(2), vec![], None, None).unwrap();
3075 assert_ne!(db.state_root().unwrap().state_root, a);
3076 }
3077
3078 #[test]
3079 fn a_delete_removes_a_record_but_keeps_the_collection() {
3080 let db = Db::in_memory();
3081 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3082 db.delete("orders", "1").unwrap();
3083 let r = db.state_root().unwrap();
3084 assert_eq!(r.record_count, 0, "a tombstoned document is not live state");
3085 assert_eq!(r.collection_count, 1, "but its collection still exists");
3086
3087 let never = Db::in_memory();
3088 assert_ne!(r.state_root, never.state_root().unwrap().state_root);
3089 }
3090
3091 #[test]
3092 fn a_historical_root_matches_what_the_tip_root_was_at_that_time() {
3093 let db = Db::in_memory();
3094 let a = db.put("orders", "1", j(1), vec![], None, None).unwrap();
3095 let then = db.state_root().unwrap();
3096 db.put("orders", "2", j(2), vec![], None, None).unwrap();
3097 assert_ne!(db.state_root().unwrap().state_root, then.state_root);
3098 assert_eq!(
3099 db.state_root_as_of(a.seq).unwrap().state_root, then.state_root,
3100 "AS OF the first write is the state after the first write"
3101 );
3102 }
3103
3104 #[test]
3105 fn a_persisted_root_verifies_against_a_fresh_recomputation() {
3106 let db = Db::in_memory();
3107 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3108 let rec = db.create_root().unwrap();
3109
3110 let v = db.verify_root(rec.at_seq);
3111 assert_eq!(v.record, RecordStatus::Valid);
3112 assert_eq!(v.recomputation, Recomputation::Matches);
3113 assert!(v.is_verified());
3114 assert!(!v.is_mismatch());
3115 assert_eq!(v.exit_code(), 0);
3116 }
3117
3118 /// Taking a root must not change the state it describes.
3119 #[test]
3120 fn taking_a_root_does_not_change_the_root() {
3121 let db = Db::in_memory();
3122 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3123 let before = db.state_root().unwrap().state_root.clone();
3124 db.create_root().unwrap();
3125 db.create_root().unwrap();
3126 assert_eq!(db.state_root().unwrap().state_root, before,
3127 "root records are reserved, so they are not part of the state");
3128 }
3129
3130 #[test]
3131 fn later_writes_do_not_retroactively_change_an_old_root() {
3132 let db = Db::in_memory();
3133 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3134 let rec = db.create_root().unwrap();
3135 db.put("orders", "2", j(2), vec![], None, None).unwrap();
3136 db.put("orders", "1", j(99), vec![], None, None).unwrap();
3137
3138 let v = db.verify_root(rec.at_seq);
3139 assert!(v.is_verified(), "a root is a statement about a sequence, not about now");
3140 }
3141
3142 #[test]
3143 fn a_missing_root_is_reported_as_missing_not_as_a_failure() {
3144 let db = Db::in_memory();
3145 let v = db.verify_root(42);
3146 assert_eq!(v.record, RecordStatus::Missing);
3147 assert_eq!(v.recomputation, Recomputation::NotAttempted);
3148 assert!(!v.is_verified());
3149 assert!(!v.is_mismatch(), "absent is not wrong");
3150 assert_eq!(v.exit_code(), 4);
3151 }
3152
3153 /// Compaction must not raise the floor when it pruned nothing.
3154 ///
3155 /// `ObjectStore::compact` is a NO-OP returning zeroed stats on the
3156 /// loose-object and in-memory substrates. An unconditional floor bump
3157 /// after it declared every earlier sequence pruned on a database where
3158 /// nothing had been — turning every historical root permanently
3159 /// unverifiable for a reason that was not true. A false alarm defeats the
3160 /// whole point of having an "unavailable" state.
3161 #[test]
3162 fn a_compaction_that_prunes_nothing_does_not_raise_the_floor() {
3163 let dir = tempdir().unwrap();
3164 let db = Db::open(dir.path(), None).unwrap();
3165 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3166 let rec = db.create_root().unwrap();
3167 db.put("orders", "1", j(2), vec![], None, None).unwrap();
3168 db.flush_all();
3169
3170 let stats = db.compact().expect("compact");
3171 assert_eq!(stats.dropped_objects, 0, "precondition: v2 compaction prunes nothing");
3172 assert_eq!(db.history_floor(), 0, "so no history was lost, and the floor must not move");
3173 assert!(
3174 db.verify_root(rec.at_seq).is_verified(),
3175 "the root must still verify — nothing was discarded"
3176 );
3177 }
3178
3179 /// The distinction the Oracle asked for.
3180 ///
3181 /// Driven through `set_history_floor` rather than a real prune because the
3182 /// only substrate that prunes is selected by the process-global
3183 /// `NEDB_DAG_V3` environment variable, and tests run threaded in one
3184 /// process — setting it here changed the substrate under every other test
3185 /// that opened a database at the same moment. The end-to-end prune is
3186 /// covered in `tests/v3_integration.rs`, which is its own process.
3187 #[test]
3188 fn a_pruned_history_reports_unavailable_rather_than_pass_or_fail() {
3189 let db = Db::in_memory();
3190 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3191 let rec = db.create_root().unwrap();
3192 db.put("orders", "1", j(2), vec![], None, None).unwrap();
3193
3194 assert!(db.verify_root(rec.at_seq).is_verified(), "verifiable before the prune");
3195
3196 let tip = db.seq.load(Ordering::SeqCst).saturating_sub(1);
3197 db.set_history_floor(tip).unwrap();
3198
3199 let v = db.verify_root(rec.at_seq);
3200 assert_eq!(v.record, RecordStatus::Valid, "the record itself is still fine");
3201 assert_eq!(
3202 v.recomputation,
3203 Recomputation::Unavailable(UnavailableReason::HistoryPruned),
3204 "and the engine says plainly that it could not check it"
3205 );
3206 assert!(!v.is_verified(), "unavailable is not verified");
3207 assert!(!v.is_mismatch(), "and it is not a mismatch either");
3208 assert_eq!(v.exit_code(), 3, "its own exit code, distinct from pass and fail");
3209 }
3210
3211 #[test]
3212 fn roots_are_listed_in_sequence_order() {
3213 let db = Db::in_memory();
3214 for i in 0..12u64 {
3215 db.put("c", &i.to_string(), j(i), vec![], None, None).unwrap();
3216 db.create_root().unwrap();
3217 }
3218 let seqs: Vec<u64> = db.list_roots().iter().map(|r| r.at_seq).collect();
3219 let mut sorted = seqs.clone();
3220 sorted.sort();
3221 assert_eq!(seqs, sorted, "zero-padded ids must order numerically");
3222 assert_eq!(seqs.len(), 12);
3223 }
3224
3225 #[test]
3226 fn a_root_survives_a_reopen(){
3227 let dir = tempdir().unwrap();
3228 let at;
3229 let expected;
3230 {
3231 let db = Db::open(dir.path(), None).unwrap();
3232 db.put("orders", "1", j(1), vec![], None, None).unwrap();
3233 let r = db.create_root().unwrap();
3234 at = r.at_seq;
3235 expected = r.root.state_root.clone();
3236 db.flush_all();
3237 }
3238 let db = Db::open(dir.path(), None).unwrap();
3239 let got = db.get_root(at).expect("root record survives a reopen");
3240 assert_eq!(got.root.state_root, expected);
3241 assert!(db.verify_root(at).is_verified());
3242 }
3243}