Skip to main content

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}