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/// MANIFEST: cached {seq, head} written atomically after every write.
22/// On startup, if MANIFEST exists and no sorted indexes need rebuilding,
23/// startup is O(1) — just read this one file instead of scanning all objects.
24#[derive(serde::Serialize, serde::Deserialize)]
25struct Manifest {
26    seq:  u64,
27    head: String,
28    /// Object hash of the highest-seq node at flush time. Lets `tip()` resolve the
29    /// last write O(1) on a warm boot — before any scan repopulates the in-memory
30    /// seq index. `#[serde(default)]` so pre-2.5.43 MANIFESTs (no field) still parse.
31    #[serde(default)]
32    tip_hash: String,
33    /// Per-collection tip: `coll -> object hash of the highest-seq node in that
34    /// collection`. Lets `tip_collection()` resolve O(1) on a warm boot, same
35    /// contract as `tip_hash` for the global head. `#[serde(default)]` so
36    /// pre-this-field MANIFESTs still parse (empty map — self-heals on next write
37    /// or cold scan).
38    #[serde(default)]
39    coll_tips: std::collections::HashMap<String, String>,
40}
41
42/// Default cap for `since()` when the caller passes `limit == 0`. Bounds the
43/// engine primitive itself so a stale/offline consumer can never force an
44/// unbounded materialization — the safety lives in the core, not the HTTP layer.
45pub const DEFAULT_SINCE_LIMIT: usize = 10_000;
46
47/// One page of the changefeed returned by `since()`. The replication contract:
48/// apply `nodes` in ascending seq order, advance your cursor to `to_seq`, and keep
49/// paging while `has_more` is true; then attach to the live `subscribe` edge.
50/// `head_seq` tells the consumer how far the log currently extends (how far behind
51/// it is).
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct SinceBatch {
54    /// Writes in (`from_seq`, `to_seq`], ascending by seq.
55    pub nodes:    Vec<Node>,
56    /// The exclusive cursor this page started from (echoes the request).
57    pub from_seq: u64,
58    /// Seq of the last node in this page — the consumer's next cursor.
59    pub to_seq:   u64,
60    /// Current head seq of the log (latest committed write).
61    pub head_seq: u64,
62    /// True when more writes remain past `to_seq` (the page hit `limit`).
63    pub has_more: bool,
64}
65
66/// Replication readiness snapshot. `scan_complete` is the correctness gate: until
67/// the cold-scan finishes rebuilding the seq index, an old cursor passed to
68/// `since()` can return a PARTIAL page and look (wrongly) like "caught up". A
69/// correctness-critical consumer MUST wait for `scan_complete == true` before
70/// trusting historical catch-up. `indexed_seq_min/max` report the currently
71/// resolvable seq range; `tip_seq` is the log head.
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct ScanStatus {
74    /// Cold-scan finished — historical seqs fully resolvable; catch-up is safe.
75    pub scan_complete:   bool,
76    /// Head seq of the log (latest committed write).
77    pub tip_seq:         u64,
78    /// Lowest seq currently in the seq index (0 if empty).
79    pub indexed_seq_min: u64,
80    /// Highest seq currently in the seq index.
81    pub indexed_seq_max: u64,
82    /// Number of seqs currently resolvable via the index.
83    pub indexed_count:   usize,
84    /// True when the seq index actually covers the log — i.e. `since()` can
85    /// resolve historical seqs. DISTINCT from `scan_complete`: a warm boot is
86    /// "startup complete" in O(1) precisely because it SKIPS the scan, so
87    /// `scan_complete` is true while this is false and `since()` resolves
88    /// nothing. Replication consumers must gate on this field, not on
89    /// `scan_complete`; call `rebuild_id_index()`/`repair()` to populate it.
90    pub seq_index_ready: bool,
91}
92
93pub struct Db {
94    pub objects:        ObjectStore,
95    pub id_index:       IdIndex,
96    /// Deleted id → its tombstone hash. The GRAVEYARD.
97    ///
98    /// `id_index` answers "what is the current version of this key?", so a
99    /// delete has to remove the entry from it or the row would stay visible.
100    /// But that made a deleted document's whole HISTORY unreachable: `AS OF`
101    /// enumerates ids from `id_index`, so the id was never considered at any
102    /// sequence — even one long before the delete. Nothing was lost on disk
103    /// (the tombstone node keeps a `prev` link to the full version chain); it
104    /// was simply unreferenced.
105    ///
106    /// That contradicted the central promise: a `DELETE` is a tombstone, not
107    /// an erasure. So the pointer is not dropped, it is MOVED here — the id
108    /// leaves the land of the living and stays addressable in history.
109    ///
110    /// It is a second `IdIndex` rather than a new namespace inside the first
111    /// because every operation needed — set, get, list, remove, WAL buffering,
112    /// sharded on-disk layout — already exists and is already tested. A
113    /// deliberately boring choice.
114    pub del_index:      IdIndex,
115    pub sorted_indexes: SortedIndexes,
116    pub graph:          GraphStore,
117    pub root:           PathBuf,
118    /// Advisory exclusive lock on the data directory (`LOCK` file), held for
119    /// the Db's lifetime. One process owns a durable store at a time — a
120    /// second opener gets a loud refusal instead of silent split-brain (two
121    /// engines with independent in-memory state on one dir: cross-process
122    /// writes invisible, CAS races — the 2026-07-20 aias multi-worker session
123    /// bug, caught live). Released automatically on drop AND on any process
124    /// death including SIGKILL, because the flock dies with the fd. `None`
125    /// for in-memory databases and under NEDB_SHARED_OPEN=1 (operator
126    /// override for tooling that accepts the risk).
127    _dir_lock:          Option<std::fs::File>,
128    /// Dirty flag — set true when head changes, cleared after manifest flush.
129    /// Decouples flush_manifest from the hot write path so concurrent writes
130    /// don't serialise on 2× file I/O per PUT.
131    manifest_dirty:     Arc<AtomicBool>,
132    pub seq:            AtomicU64,
133    /// Cached Merkle head — updated incrementally on every write (O(1)).
134    head:               RwLock<String>,
135    /// `(seq, object hash)` of the most recent write (highest seq). Mirrors `head`
136    /// but holds the tip's content hash, so `tip()` can resolve the last node O(1)
137    /// on a warm boot when the in-memory `seq_index` is still cold. The seq rides
138    /// along so concurrent writers can settle the tip by HIGHEST SEQ rather than
139    /// arrival order (a slow older put must never clobber a newer tip). Only the
140    /// hash is persisted in MANIFEST — format unchanged.
141    tip_hash:           RwLock<(u64, String)>,
142    /// Per-collection tip: `coll -> (seq, object hash)` of the highest-seq node in
143    /// that collection. Kept current on every write (`update_head`, seq-guarded),
144    /// restored from MANIFEST on warm boot, rebuilt by the cold scan — so
145    /// `tip_collection()` is O(1) and durable across restarts in every startup
146    /// regime, by construction.
147    coll_tip_hash:      Arc<DashMap<String, (u64, String)>>,
148    /// True once startup is fully ready (MANIFEST loaded or cold scan complete).
149    /// Warm starts set this true before returning from open().
150    /// Cold starts set this true in the background thread when scan completes.
151    /// Writes are held with 503 until this is true; reads always proceed.
152    pub startup_ready:  Arc<AtomicBool>,
153    /// Seq → hash lookup for v1 compatibility. Populated by put(), put_batch(),
154    /// and the cold-scan background pass. Only covers nodes from the current
155    /// process session + cold-scan; older seqs not in this map cannot be resolved.
156    seq_index:          Arc<DashMap<u64, String>>,
157}
158
159impl Db {
160    /// Create a pure in-memory database — no disk I/O, no migration, instant startup.
161    /// Perfect for tests, hot-cache layers, and ephemeral sessions.
162    /// All data is lost when the Db is dropped.
163    pub fn in_memory() -> Self {
164        Self {
165            objects:        ObjectStore::in_memory(),
166            id_index:       IdIndex::in_memory(),
167            del_index:      IdIndex::in_memory(),
168            sorted_indexes: SortedIndexes::new(),
169            graph:          GraphStore::in_memory(),
170            root:           std::path::PathBuf::from(":memory:"),
171            _dir_lock:      None,
172            seq:            AtomicU64::new(0),
173            head:           RwLock::new(String::new()),
174            tip_hash:       RwLock::new((0, String::new())),
175            coll_tip_hash:  Arc::new(DashMap::new()),
176            startup_ready:  Arc::new(AtomicBool::new(true)),  // always ready
177            manifest_dirty: Arc::new(AtomicBool::new(false)),
178            seq_index:      Arc::new(DashMap::new()),
179        }
180    }
181
182    /// Acquire the exclusive advisory lock on a durable data directory.
183    /// Refuses (with the holder's pid when known) rather than allowing a
184    /// second live engine on the same files. NEDB_SHARED_OPEN=1 skips the
185    /// guard entirely — for tooling that knowingly accepts split-brain risk.
186    fn acquire_dir_lock(db_root: &Path) -> Result<Option<std::fs::File>> {
187        if std::env::var("NEDB_SHARED_OPEN").map(|v| v.trim() == "1").unwrap_or(false) {
188            return Ok(None);
189        }
190        use fs2::FileExt as _;
191        use std::io::Write as _;
192        let lock_path = db_root.join("LOCK");
193        let lock_file = std::fs::OpenOptions::new()
194            .create(true).read(true).write(true).open(&lock_path)?;
195        if lock_file.try_lock_exclusive().is_err() {
196            let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
197            let holder = holder.trim();
198            anyhow::bail!(
199                "data directory {:?} is locked by another process{} — refusing a \
200                 split-brain open: a second engine on the same files cannot see this \
201                 process's writes (invisible sessions, CAS races). Stop the other \
202                 process, or set NEDB_SHARED_OPEN=1 only if you accept that risk.",
203                db_root,
204                if holder.is_empty() { String::new() } else { format!(" (pid {holder})") }
205            );
206        }
207        // Best-effort: record our pid for the next contender's error message.
208        let _ = lock_file.set_len(0);
209        let _ = writeln!(&lock_file, "{}", std::process::id());
210        let _ = lock_file.sync_all();
211        Ok(Some(lock_file))
212    }
213
214    /// Open (or create) a database. Runs v1→v2 migration automatically if log.aof is present.
215    pub fn open(db_root: &Path, dek: Option<Dek>) -> Result<Self> {
216        std::fs::create_dir_all(db_root)?;
217
218        // Split-brain guard FIRST — refuse before touching any store state.
219        let dir_lock = Self::acquire_dir_lock(db_root)?;
220
221        let objects        = ObjectStore::new(db_root, dek.clone())?;
222        let id_index       = IdIndex::new(db_root)?;
223        // The graveyard lives under its own root so it shares no path with the
224        // live index and cannot be confused with it by any existing reader.
225        let del_index      = IdIndex::new(&db_root.join("graveyard"))?;
226        let sorted_indexes = SortedIndexes::new();
227        let graph          = GraphStore::new(db_root)?;
228
229        let mut db = Self {
230            objects,
231            id_index,
232            del_index,
233            sorted_indexes,
234            graph,
235            root: db_root.to_path_buf(),
236            _dir_lock: dir_lock,
237            seq:  AtomicU64::new(0),
238            head: RwLock::new(String::new()),
239            tip_hash: RwLock::new((0, String::new())),
240            coll_tip_hash: Arc::new(DashMap::new()),
241            startup_ready:  Arc::new(AtomicBool::new(false)),
242            manifest_dirty: Arc::new(AtomicBool::new(false)),
243            seq_index:      Arc::new(DashMap::new()),
244        };
245
246        // Auto-migrate v1 → v2 if needed (pass DEK so encrypted AOFs convert correctly)
247        migrate::migrate_if_needed(
248            db_root,
249            &db.objects,
250            &db.id_index,
251            &db.sorted_indexes,
252            &db.graph,
253            dek.as_ref(),
254        )?;
255
256        // Fast startup: load seq+head from MANIFEST if no sorted indexes need rebuilding.
257        // Falls back to full object scan only when necessary (first open, or post-migration).
258        db.startup_rebuild()?;
259
260        Ok(db)
261    }
262
263    /// Smart startup:
264    /// - Warm (MANIFEST exists): O(1) load → startup_ready = true immediately.
265    /// - Cold (no MANIFEST): start server immediately, run scan in background thread.
266    ///   Writes return 503 until scan completes; reads always proceed.
267    fn startup_rebuild(&mut self) -> Result<()> {
268        let manifest_path = self.root.join("MANIFEST");
269        let needs_index_rebuild = !self.sorted_indexes.is_empty();
270
271        // Warm path: MANIFEST + no sorted indexes to rebuild → instant start
272        if manifest_path.exists() && !needs_index_rebuild {
273            if let Some(m) = fs::read_to_string(&manifest_path)
274                .ok()
275                .and_then(|s| serde_json::from_str::<Manifest>(&s).ok())
276            {
277                // Self-heal: MANIFEST with an empty or short head is corrupt/stale.
278                // Fall through to cold scan so the head is rebuilt correctly from objects.
279                if m.head.len() < 8 {
280                    eprintln!("  [nedbd] MANIFEST head invalid (len={}), self-healing via cold scan", m.head.len());
281                } else {
282                    // Pre-2.5.43 MANIFEST (no persisted tip): warm-boot ANYWAY.
283                    //
284                    // The old policy forced a full cold scan "once to upgrade" —
285                    // on multi-million-object embedded stores (itcd -dagv3:
286                    // 1.7M+ objects per database) that scan is hours of random
287                    // reads on seek-bound media, it races the host's own boot
288                    // I/O, and if the process exits before it completes the
289                    // NEXT boot pays it again — a permanent boot tax for
290                    // exactly the deployments that can least afford it. And it
291                    // buys nothing that can't heal lazily: seq + head in the
292                    // old MANIFEST are perfectly valid, and flush_manifest
293                    // writes tip_hash + coll_tips from live state, so the very
294                    // first write + flush after boot upgrades the MANIFEST
295                    // organically. Until then tip()/tip_collection() simply
296                    // return None on this boot — exactly their documented
297                    // behavior for an unresolvable tip — and every other read
298                    // and write path is unaffected.
299                    if m.tip_hash.is_empty() {
300                        eprintln!("  [nedbd] MANIFEST predates durable tip() — warm boot; tip()/tip_collection() heal on first flush (no forced scan)");
301                    }
302                    self.seq.store(m.seq, Ordering::SeqCst); // m.seq is already the next-to-assign counter
303                    *self.head.write() = m.head.clone();
304                    // The tip's seq is the last ASSIGNED seq (m.seq is next-to-assign).
305                    *self.tip_hash.write() = (m.seq.saturating_sub(1), m.tip_hash.clone());
306                    for (coll, hash) in &m.coll_tips {
307                        // Per-coll seqs aren't persisted (MANIFEST format unchanged);
308                        // seed 0 — every future write has seq >= m.seq > 0 and wins,
309                        // and nothing older than the persisted tip can ever arrive
310                        // because the seq counter resumes at m.seq.
311                        self.coll_tip_hash.insert(coll.clone(), (0, hash.clone()));
312                    }
313                    self.startup_ready.store(true, Ordering::SeqCst);
314                    println!("  [nedbd] warm start — seq={} head={}... tip={}...",
315                        m.seq, &m.head[..8],
316                        if m.tip_hash.is_empty() { "(pre-2.5.43, heals on flush)" }
317                        else { &m.tip_hash[..8.min(m.tip_hash.len())] });
318                    return Ok(());
319                }
320            } else {
321                eprintln!("  [nedbd] MANIFEST corrupt or missing, falling back to cold scan");
322            }
323        }
324
325        // Cold path: mark as not ready, return immediately.
326        // The actual background scan is started by Db::start_cold_scan(arc)
327        // which is called from Manager::open_all() AFTER Arc::new(db) — when
328        // the Db is heap-allocated and its field addresses are permanently stable.
329        // Capturing field addresses here would cause UB: Db moves on return.
330        println!("  [nedbd] cold start — background scan will start after heap allocation");
331        Ok(())
332    }
333
334    /// Call this from Manager::open_all() after Arc::new(db).
335    /// Spawns the cold scan background thread with stable heap addresses.
336    /// No-op if startup is already complete (warm start).
337    pub fn start_cold_scan(self_arc: Arc<Self>) {
338        if self_arc.startup_ready.load(Ordering::SeqCst) {
339            return; // warm start — already ready
340        }
341        // Fast path: if the database is empty (new or just created), skip the
342        // background thread entirely. No objects to scan = instant startup.
343        if self_arc.objects.all_hashes().next().is_none() {
344            self_arc.startup_ready.store(true, Ordering::SeqCst);
345            return;
346        }
347        println!("  [nedbd] cold start — background scan starting, server accepting reads now");
348        std::thread::spawn(move || {
349            let db = self_arc;
350            cold_scan_background_arc(db);
351        });
352    }
353
354    /// Rebuild the id index from the object store, synchronously.
355    ///
356    /// Every object carries its own `coll`, `id` and `seq`, so the id index is
357    /// fully derivable: for each (coll, id) the highest seq wins. Use this to
358    /// recover a database whose id-index WAL never reached disk — the objects
359    /// are intact and verify, but `list()`/`get()` return nothing.
360    ///
361    /// Idempotent, and safe on a healthy store (it rewrites the same winners).
362    /// Returns the number of entries written. Flushes before returning.
363    pub fn rebuild_id_index(&self) -> Result<usize> {
364        let hashes: Vec<String> = self.objects.all_hashes().collect();
365        let mut nodes: Vec<Node> = Vec::with_capacity(hashes.len());
366        for h in &hashes {
367            if let Ok(node) = self.objects.read(h) {
368                self.seq_index.insert(node.seq, node.hash.clone());
369                nodes.push(node);
370            }
371        }
372        let written = rebuild_id_index_from_nodes(self, &nodes);
373
374        // Per-collection tips, so tip_collection() resolves after a repair.
375        let mut coll_max: std::collections::HashMap<String, (u64, String)> =
376            std::collections::HashMap::new();
377        for node in &nodes {
378            coll_max
379                .entry(node.coll.clone())
380                .and_modify(|cur| {
381                    if node.seq > cur.0 {
382                        *cur = (node.seq, node.hash.clone());
383                    }
384                })
385                .or_insert((node.seq, node.hash.clone()));
386        }
387        for (coll, (seq, hash)) in coll_max {
388            self.coll_tip_hash.insert(coll, (seq, hash));
389        }
390
391        let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
392        // Keep the seq counter ahead of everything we just found, so the next
393        // write cannot reuse a seq that already exists in the log.
394        let next = max_seq + 1;
395        if !nodes.is_empty() && self.seq.load(Ordering::SeqCst) < next {
396            self.seq.store(next, Ordering::SeqCst);
397        }
398
399        // Recompute head + tip through the shared implementation, so a repaired
400        // database reopens WARM with a valid MANIFEST instead of coming back up
401        // cold with an empty head (which reads as corruption to the next boot).
402        if !nodes.is_empty() {
403            recompute_head_and_tip(self, hashes, max_seq);
404        }
405
406        self.try_flush_all()?;
407        Ok(written)
408    }
409
410    /// Full repair: rebuild the seq index and the id index from objects, even on
411    /// a WARM store, then flush.
412    ///
413    /// [`start_cold_scan`] deliberately no-ops when startup is already complete,
414    /// which meant the documented repair path ("idempotent — a no-op on a warm
415    /// store, a full self-heal on a stale MANIFEST") could never repair a
416    /// database that had a valid MANIFEST and a damaged id index. This is the
417    /// forcing entry point; `start_cold_scan` keeps its O(1) warm-boot contract.
418    pub fn repair(&self) -> Result<usize> {
419        self.rebuild_id_index()
420    }
421
422    /// Write a document. Returns the new node with its content hash set.
423    pub fn put(
424        &self,
425        coll: &str,
426        id: &str,
427        data: Value,
428        caused_by: Vec<String>,
429        valid_from: Option<String>,
430        valid_to:   Option<String>,
431    ) -> Result<Node> {
432        let seq  = self.seq.fetch_add(1, Ordering::SeqCst);
433        let prev = self.id_index.get(coll, id);
434
435        // Remove old node from sorted indexes (it's being superseded).
436        // Skip the old-object disk read entirely when no sorted index exists —
437        // the read (open + BLAKE2b verify + optional AES-GCM decrypt + JSON
438        // parse) was pure waste in the common unindexed case, ~2x read
439        // amplification on every update (the itcd chainstate shape).
440        if !self.sorted_indexes.is_empty() {
441            if let Some(old_hash) = &prev {
442                if let Ok(old_node) = self.objects.read(old_hash) {
443                    if let Value::Object(ref obj) = old_node.data {
444                        for (field, value) in obj {
445                            self.sorted_indexes.remove(coll, field, value, old_hash);
446                        }
447                    }
448                }
449            }
450        }
451
452        let mut node = Node {
453            id:         id.to_string(),
454            coll:       coll.to_string(),
455            seq,
456            data:       data.clone(),
457            prev,
458            caused_by:  caused_by.clone(),
459            ts:         now(),
460            valid_from,
461            valid_to,
462            hash:       String::new(),
463        };
464
465        // Write to object store (atomic, content-addressed)
466        let hash = self.objects.write(&mut node)?;
467        self.seq_index.insert(seq, hash.clone());
468
469        // Update id index (atomic file)
470        self.id_index.set(coll, id, &hash)?;
471
472        // Update sorted indexes
473        if let Value::Object(ref obj) = data {
474            for (field, value) in obj {
475                if self.sorted_indexes.has(coll, field) {
476                    self.sorted_indexes.insert(coll, field, value, &hash);
477                }
478            }
479        }
480
481        // Write causal graph edges
482        for cause in &caused_by {
483            self.graph.add_edge(&hash, "caused_by", cause)?;
484            self.graph.add_edge(cause, "caused_by_rev", &hash)?;
485        }
486
487        // Update running Merkle head: O(1) chain, no full recompute.
488        // new_head = BLAKE2b(prev_head || seq_bytes || new_object_hash)
489        self.update_head(coll, seq, &hash);
490
491        Ok(node)
492    }
493
494    /// Batch put: write N documents in parallel, preserving monotonic seq ordering.
495    /// Pre-allocates N seq numbers atomically, then parallelises object writes and
496    /// id-index updates via Rayon. Each op is independent — safe to parallelise.
497    /// Returns nodes in input order with assigned seq numbers.
498    pub fn put_batch(
499        &self,
500        ops: Vec<(String, String, Value, Vec<String>, Option<String>, Option<String>)>,
501        // (coll, id, data, caused_by, valid_from, valid_to)
502    ) -> Result<Vec<Node>> {
503        use rayon::prelude::*;
504
505        if ops.is_empty() { return Ok(vec![]); }
506        let n = ops.len() as u64;
507
508        // Pre-allocate N consecutive seq numbers — preserves ordering under concurrency
509        let base_seq = self.seq.fetch_add(n, Ordering::SeqCst);
510        let ts = now();
511
512        // Build nodes with assigned seq numbers
513        let index_live = !self.sorted_indexes.is_empty();
514        let mut nodes: Vec<Node> = ops.into_iter().enumerate().map(|(i, (coll, id, data, caused_by, valid_from, valid_to))| {
515            let prev = self.id_index.get(&coll, &id);
516            // Parity with put(): drop the superseded version's values from any
517            // sorted indexes, so top-k never returns stale hashes after a batch
518            // update. Without this, batch updates left the old version's index
519            // entries in place — ORDER BY surfaced superseded rows alongside
520            // current ones. Only pay the old-object read when an index exists.
521            if index_live {
522                if let Some(old_hash) = &prev {
523                    if let Ok(old_node) = self.objects.read(old_hash) {
524                        if let Value::Object(ref obj) = old_node.data {
525                            for (field, value) in obj {
526                                self.sorted_indexes.remove(&coll, field, value, old_hash);
527                            }
528                        }
529                    }
530                }
531            }
532            Node {
533                id, coll, seq: base_seq + i as u64,
534                data, prev, caused_by,
535                ts, valid_from, valid_to,
536                hash: String::new(),
537            }
538        }).collect();
539
540        // Parallel object writes (content-addressed, idempotent, safe to parallelise)
541        let write_errors: Vec<anyhow::Error> = nodes.par_iter_mut()
542            .filter_map(|node| self.objects.write(node).err())
543            .collect();
544        if let Some(e) = write_errors.into_iter().next() { return Err(e); }
545
546        // Parallel id-index updates
547        let index_errors: Vec<anyhow::Error> = nodes.par_iter()
548            .filter_map(|node| self.id_index.set(&node.coll, &node.id, &node.hash).err())
549            .collect();
550        if let Some(e) = index_errors.into_iter().next() { return Err(e); }
551
552        // Sorted indexes + causal graph (sequential — small overhead, usually no indexes)
553        for node in &nodes {
554            self.seq_index.insert(node.seq, node.hash.clone());
555            if let Value::Object(ref obj) = node.data {
556                for (field, value) in obj {
557                    if self.sorted_indexes.has(&node.coll, field) {
558                        self.sorted_indexes.insert(&node.coll, field, value, &node.hash);
559                    }
560                }
561            }
562            for cause in &node.caused_by {
563                self.graph.add_edge(&node.hash, "caused_by", cause).ok();
564                self.graph.add_edge(cause, "caused_by_rev", &node.hash).ok();
565            }
566        }
567
568        // Single Merkle head update for the whole batch (chain all hashes)
569        for node in &nodes {
570            self.update_head(&node.coll, node.seq, &node.hash);
571        }
572
573        Ok(nodes)
574    }
575
576    /// Update the running Merkle head with a new write. O(1); no file I/O — the
577    /// background ticker flushes MANIFEST.
578    ///
579    /// Concurrency contract (this function is reached by parallel `put()`s —
580    /// the server runs puts on blocking threads):
581    /// - The head chain is extended under ONE write lock held across the whole
582    ///   read-modify-write. The old read-then-write shape let two concurrent
583    ///   writers both read the same prev head; one contribution was silently
584    ///   dropped from the chain — a corrupted tamper-evidence primitive. The
585    ///   chain is arrival-ordered under concurrency (a seq-ordered canonical
586    ///   head is tracked as follow-up work); what this lock guarantees is that
587    ///   EVERY write is committed into the chain exactly once.
588    /// - Tip pointers settle by HIGHEST SEQ, not arrival order: concurrent
589    ///   puts can reach here out of seq order, and "last call wins" could
590    ///   persist a stale tip into MANIFEST for the next warm boot.
591    fn update_head(&self, coll: &str, seq: u64, new_hash: &str) {
592        use blake2::{Blake2b512, Digest};
593        {
594            let mut head = self.head.write();
595            let mut h = Blake2b512::new();
596            h.update(head.as_bytes());
597            h.update(seq.to_le_bytes());
598            h.update(new_hash.as_bytes());
599            *head = hex::encode(&h.finalize()[..32]);
600        }
601        {
602            let mut tip = self.tip_hash.write();
603            if seq >= tip.0 {
604                *tip = (seq, new_hash.to_string());
605            }
606        }
607        self.coll_tip_hash
608            .entry(coll.to_string())
609            .and_modify(|t| {
610                if seq >= t.0 {
611                    *t = (seq, new_hash.to_string());
612                }
613            })
614            .or_insert_with(|| (seq, new_hash.to_string()));
615        // Mark dirty — background ticker will flush to MANIFEST (no I/O on write path)
616        self.manifest_dirty.store(true, Ordering::Release);
617    }
618
619    /// Flush both the id-index WAL and MANIFEST, REPORTING failure.
620    ///
621    /// This is the durability boundary: until it returns `Ok(())`, writes that
622    /// `put()` acknowledged may not be on disk. Callers that must not lose data
623    /// — anything about to take a destructive or externally-visible action on
624    /// the strength of a persisted record — should use this, not [`flush_all`].
625    ///
626    /// Every stage is attempted even if an earlier one fails (a MANIFEST flush
627    /// is still worth doing when one index leaf failed), and the first error is
628    /// returned. Failed id-index entries stay in the WAL for retry.
629    pub fn try_flush_all(&self) -> Result<()> {
630        let index_result = self.id_index.try_flush_write_buf()
631            // The graveyard is as durable as the live index: a tombstone
632            // pointer lost to a crash would take a document's history back out
633            // of reach, which is the bug this index exists to prevent.
634            .and(self.del_index.try_flush_write_buf());
635        // v3: fsync the active segment (no-op for loose/in-memory stores).
636        // One durability point per batch instead of one fsync per object.
637        let sync_result = self.objects.sync();
638        let manifest_result = self.try_flush_manifest();
639
640        index_result.map_err(|e| anyhow::anyhow!("id-index WAL flush failed: {}", e))?;
641        sync_result.map_err(|e| anyhow::anyhow!("object segment sync failed: {}", e))?;
642        manifest_result.map_err(|e| anyhow::anyhow!("MANIFEST flush failed: {}", e))?;
643        Ok(())
644    }
645
646    /// Flush both the id-index WAL and MANIFEST. Used on graceful shutdown.
647    ///
648    /// Errors are logged, not returned — kept for back-compat and for the
649    /// ticker/`Drop` paths that have nowhere to propagate. Prefer
650    /// [`try_flush_all`] whenever the outcome matters.
651    pub fn flush_all(&self) {
652        if let Err(e) = self.try_flush_all() {
653            eprintln!("nedb: flush_all failed: {}", e);
654        }
655    }
656
657    /// Compact the v3 packed object store: keep the CURRENT version of every
658    /// document (from the id-index) and reclaim everything else. No-op unless
659    /// running with the v3 segment substrate (`--dag-v3` / NEDB_DAG_V3).
660    ///
661    /// This is a PRUNING operation: superseded/historical object versions are
662    /// dropped, so AS OF / TRACE over pruned versions is discarded — that is
663    /// what reclaims the space. Flushes first so all data is durable on disk
664    /// before the old segments are deleted.
665    /// Reclaim space by rewriting the segments with only CURRENT versions.
666    ///
667    /// # This discards history. On purpose.
668    ///
669    /// The live set is each document's current-version hash and nothing else,
670    /// so compaction drops every superseded version and every tombstone. After
671    /// it runs, `AS OF` can no longer reach a prior value and `TRACE` can no
672    /// longer walk to a pruned ancestor — the rows simply become unavailable
673    /// rather than wrong, and `verify()` stays clean because what remains is
674    /// still internally consistent.
675    ///
676    /// That is worth stating loudly, because NEDB's headline property is that
677    /// history is permanent and never garbage-collected — and it is, right up
678    /// until an operator calls THIS. Nothing calls it automatically: it is not
679    /// on the HTTP surface, not in the CLI, and not on any timer. It exists for
680    /// the operator who has decided, explicitly, to trade the audit trail for
681    /// disk space.
682    ///
683    /// A graveyard entry whose tombstone was pruned is left pointing at an
684    /// object that no longer exists. `get_as_of` degrades to `None` there
685    /// rather than failing, so a compacted store answers "not available at that
686    /// sequence" instead of erroring or inventing a value.
687    pub fn compact(&self) -> Result<crate::segment::CompactStats> {
688        self.flush_all();
689        let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
690        for coll in self.id_index.collections() {
691            for id in self.id_index.list_ids(&coll) {
692                if let Some(h) = self.id_index.get(&coll, &id) {
693                    live.insert(h);
694                }
695            }
696        }
697        self.objects.compact(&live)
698    }
699
700    /// Flush MANIFEST to disk if dirty. No-op for in-memory databases.
701    pub fn flush_manifest_if_dirty(&self) {
702        if self.root == std::path::PathBuf::from(":memory:") { return; }
703        if self.manifest_dirty.compare_exchange(
704            true, false, Ordering::AcqRel, Ordering::Relaxed
705        ).is_ok() {
706            self.flush_manifest();
707        }
708    }
709
710    /// Atomically persist current seq+head to MANIFEST, reporting failure.
711    /// No-op (`Ok`) for in-memory databases.
712    ///
713    /// A silently failed MANIFEST write is not data loss — the startup
714    /// self-heal rescans — but it IS a warm-boot regression and, on a full
715    /// disk, the first symptom that persistence is failing. Callers deserve
716    /// to know.
717    pub fn try_flush_manifest(&self) -> std::io::Result<()> {
718        if self.root == std::path::PathBuf::from(":memory:") { return Ok(()); }
719        let seq  = self.seq.load(Ordering::SeqCst);
720        let head = self.head.read().clone();
721        let tip_hash = self.tip_hash.read().1.clone();
722        let coll_tips: std::collections::HashMap<String, String> = self.coll_tip_hash
723            .iter()
724            .map(|kv| (kv.key().clone(), kv.value().1.clone()))
725            .collect();
726        let m = Manifest { seq, head, tip_hash, coll_tips };
727        let json = serde_json::to_string(&m)
728            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
729        let path = self.root.join("MANIFEST");
730        let tmp  = self.root.join("MANIFEST.tmp");
731        // fsync the tmp file BEFORE the rename: rename-without-fsync can
732        // leave a zero-length/partial MANIFEST at the final path after
733        // power loss (ext4 delayed allocation). The startup self-heal
734        // (invalid head -> cold scan) catches that, but a full rescan is
735        // exactly the cost MANIFEST exists to avoid. One fsync per flush,
736        // and flushes are already off the hot write path (ticker-driven).
737        let wrote = (|| -> std::io::Result<()> {
738            use std::io::Write;
739            let mut f = fs::File::create(&tmp)?;
740            f.write_all(json.as_bytes())?;
741            f.sync_all()
742        })();
743        if let Err(e) = wrote {
744            let _ = fs::remove_file(&tmp);
745            return Err(e);
746        }
747        fs::rename(&tmp, &path)?;
748        // Make the rename itself durable (directory entry). Unix-only;
749        // on Windows directory handles don't support this and the
750        // rename is already journaled by NTFS.
751        #[cfg(unix)]
752        if let Ok(dir) = fs::File::open(&self.root) {
753            let _ = dir.sync_all();
754        }
755        Ok(())
756    }
757
758    /// Atomically persist current seq+head to MANIFEST. No-op for in-memory databases.
759    /// Errors are logged; prefer [`try_flush_manifest`] when the outcome matters.
760    pub fn flush_manifest(&self) {
761        if let Err(e) = self.try_flush_manifest() {
762            eprintln!("nedb: MANIFEST flush failed: {}", e);
763        }
764    }
765
766
767    /// Start a background thread that flushes both the id-index WAL and MANIFEST
768    /// every `interval_ms` milliseconds.
769    /// Call this after Arc::new(db) — the Arc keeps Db alive for the thread's lifetime.
770    /// Flush cadence for EMBEDDED durable handles (the napi and pyo3 `open()` paths).
771    ///
772    /// `nedbd` has always run the manifest ticker at 1 s, so a server flushes the id-index WAL and
773    /// MANIFEST every second and a hard kill loses at most a second of acknowledged writes. The
774    /// embedded bindings did not start a ticker at all: their WAL was flushed only by the exit hooks
775    /// (SIGINT/SIGTERM/atexit) — so an embedded app killed with SIGKILL, OOM-killed, or cut by power
776    /// lost EVERY write since open, with no bound. Found by CHALK / Sports-Rater on 2026-09-04
777    /// (acknowledged fan writes gone after `kill -9`). Since 2.8.5 the bindings start the ticker on
778    /// durable open with this cadence — parity with nedbd.
779    ///
780    /// `NEDB_FLUSH_MS` overrides: an integer of milliseconds (min 50), or `0` / `off` to disable
781    /// (only for hosts that own their own flush cadence). Unset → 1000.
782    pub fn embedded_flush_interval_ms() -> Option<u64> {
783        match std::env::var("NEDB_FLUSH_MS") {
784            Err(_) => Some(1000),
785            Ok(v) => {
786                let v = v.trim().to_ascii_lowercase();
787                if v.is_empty() { return Some(1000); }
788                if v == "0" || v == "off" || v == "false" || v == "no" { return None; }
789                match v.parse::<u64>() {
790                    Ok(ms) => Some(ms.max(50)),
791                    Err(_) => { eprintln!("nedb: NEDB_FLUSH_MS={:?} is not a number — using 1000", v); Some(1000) }
792                }
793            }
794        }
795    }
796
797    /// Spawn the background flush ticker.
798    ///
799    /// The ticker holds a **`Weak<Db>`** and exits the first time the upgrade
800    /// fails — i.e. as soon as the last real owner drops the database. The
801    /// caller must therefore keep its own `Arc` alive for as long as it wants
802    /// ticking; every current caller already does (nedbd stores it in its
803    /// database map, the napi and pyo3 handles own theirs).
804    ///
805    /// It used to hold a strong `Arc` inside an unconditional `loop`, which
806    /// meant the thread never exited and the `Db` was never dropped. Three
807    /// consequences, all of them live since 2.8.5:
808    ///
809    /// * The exclusive data-dir `LOCK` taken in `Db::open` was never released,
810    ///   so reopening the same path **in the same process** failed with
811    ///   "locked by another process (pid N)" where N was the caller's own pid.
812    /// * Every `open()` leaked a thread and the entire `Db` — indexes, caches,
813    ///   segment handles — for the lifetime of the process.
814    /// * `Drop for Db` (flush-on-close) could never fire for embedded users,
815    ///   exactly as its own doc comment warned: it "only fires once every
816    ///   owning handle is gone", and an immortal thread always held one.
817    ///
818    /// nedbd's `drop_db` was hit by the same thing: removing a database from
819    /// the map did not free it, and an orphaned ticker went on fsyncing it.
820    ///
821    /// The `Arc` is upgraded inside the loop and dropped before the next
822    /// sleep, so the ticker never extends the database's life across a tick.
823    /// No final flush is needed here — the owner's `Drop` does it.
824    pub fn start_manifest_ticker(self_arc: Arc<Self>, interval_ms: u64) {
825        let weak = Arc::downgrade(&self_arc);
826        // Do not let this function's own argument keep the database alive.
827        drop(self_arc);
828        std::thread::spawn(move || {
829            loop {
830                std::thread::sleep(std::time::Duration::from_millis(interval_ms));
831                // Last owner gone: stop ticking and let the thread die.
832                let db = match weak.upgrade() {
833                    Some(db) => db,
834                    None => break,
835                };
836                // Flush id-index WAL to disk (parallel Rayon writes)
837                db.id_index.flush_write_buf();
838                db.del_index.flush_write_buf();
839                // Segment bytes must be durable BEFORE a MANIFEST that
840                // references them: otherwise power loss can leave MANIFEST
841                // pointing at a tip whose object bytes were still in the page
842                // cache — the torn tail is truncated on reopen and the warm
843                // boot resolves a tip that no longer exists, with the seq
844                // counter ahead of durable data. Order: sync segments, then
845                // MANIFEST. Gated on the dirty flag so an idle database pays
846                // no per-tick fsync. (flush_all already used this order; the
847                // ticker now matches it.)
848                if db.manifest_dirty.load(Ordering::Acquire) {
849                    if let Err(e) = db.objects.sync() {
850                        eprintln!("nedb: segment sync failed: {}", e);
851                    }
852                    db.flush_manifest_if_dirty();
853                }
854            }
855        });
856    }
857
858    /// Return the current Merkle head string. O(1) — read from cache.
859    pub fn head(&self) -> String {
860        self.head.read().clone()
861    }
862
863    /// Delete a document — writes a tombstone node and removes the id from the index.
864    /// The object history is preserved in the DAG; only the live id pointer is cleared.
865    pub fn delete(&self, coll: &str, id: &str) -> Result<bool> {
866        let prev = match self.id_index.get(coll, id) {
867            None => return Ok(false),   // already gone
868            Some(h) => h,
869        };
870        let seq = self.seq.fetch_add(1, Ordering::SeqCst);
871        let mut tombstone = Node {
872            id:         format!("_del_{}", id),
873            coll:       coll.to_string(),
874            seq,
875            data:       serde_json::json!({"_deleted": id, "_prev": prev}),
876            prev:       Some(prev),
877            caused_by:  vec![],
878            ts:         now(),
879            valid_from: None,
880            valid_to:   None,
881            hash:       String::new(),
882        };
883        let hash = self.objects.write(&mut tombstone)?;
884        self.update_head(coll, seq, &hash);
885        // Remove the live id pointer — doc is now invisible to queries and list()
886        self.id_index.remove(coll, id)?;
887        // …and MOVE it to the graveyard, so history stays reachable.
888        //
889        // Removing the live pointer without this made the document's whole
890        // version chain unaddressable: `AS OF` walks ids from `id_index`, so a
891        // deleted id was skipped at every sequence — including sequences long
892        // before the delete, where the row demonstrably existed. Nothing was
893        // lost on disk, only unreferenced, which is the worst kind of data
894        // loss because `verify()` still counts every object as healthy.
895        //
896        // The tombstone hash is the entry point: its `prev` links to the last
897        // live version, and that chain back to the first write.
898        self.del_index.set(coll, id, &hash)?;
899        Ok(true)
900    }
901
902    /// Get the current version of a document by id.
903    pub fn get(&self, coll: &str, id: &str) -> Option<Node> {
904        let hash = self.id_index.get(coll, id)?;
905        self.objects.read(&hash).ok()
906    }
907
908    /// Get a specific version of a document by object hash.
909    pub fn get_by_hash(&self, hash: &str) -> Option<Node> {
910        self.objects.read(hash).ok()
911    }
912
913    /// Get a document AS OF a specific sequence number.
914    /// Walks the version chain (prev links) backward until seq <= target.
915    ///
916    /// Reaches DELETED documents too. A delete moves the id's pointer into the
917    /// graveyard rather than dropping it, so the version chain stays walkable
918    /// and a row is still readable at a sequence before it was deleted — which
919    /// is what "a DELETE is a tombstone, not an erasure" has to mean in
920    /// practice. At or after the tombstone's own sequence the document is
921    /// correctly absent.
922    pub fn get_as_of(&self, coll: &str, id: &str, target_seq: u64) -> Option<Node> {
923        // The live chain first: the common case, and the only one for an id
924        // that was never deleted.
925        if let Some(hash) = self.id_index.get(coll, id) {
926            if let Some(node) = self.walk_back_to(&hash, target_seq) {
927                return Some(node);
928            }
929            // Falling through matters for a RE-CREATED id. A `put` after a
930            // delete starts a fresh chain with no `prev`, so the live chain
931            // cannot reach a sequence from before the delete — but the
932            // graveyard still can.
933        }
934        let tomb_hash = self.del_index.get(coll, id)?;
935        let tomb = self.objects.read(&tomb_hash).ok()?;
936        // As of the tombstone's own sequence the document is deleted. Returning
937        // the tombstone node itself would surface `{_deleted, _prev}` as if it
938        // were the document.
939        if tomb.seq <= target_seq {
940            return None;
941        }
942        self.walk_back_to(tomb.prev.as_deref()?, target_seq)
943    }
944
945    /// Walk `prev` links back from `hash` to the newest version at or before
946    /// `target_seq`. `None` when the chain starts after it.
947    fn walk_back_to(&self, hash: &str, target_seq: u64) -> Option<Node> {
948        let mut current = self.objects.read(hash).ok()?;
949        loop {
950            if current.seq <= target_seq {
951                return Some(current);
952            }
953            let prev_hash = current.prev.as_deref()?;
954            current = self.objects.read(prev_hash).ok()?;
955        }
956    }
957
958    /// Every id in a collection that AS OF must consider: the live ones, plus
959    /// the deleted ones whose history is still addressable.
960    ///
961    /// Order is stable (sorted, deduplicated) so a historical query answers the
962    /// same way run to run.
963    pub fn list_ids_including_deleted(&self, coll: &str) -> Vec<String> {
964        let mut ids = self.id_index.list_ids(coll);
965        ids.extend(self.del_index.list_ids(coll));
966        ids.sort_unstable();
967        ids.dedup();
968        ids
969    }
970
971    /// List all documents in a collection, returning current versions.
972    pub fn list(&self, coll: &str) -> Vec<Node> {
973        self.id_index
974            .list_ids(coll)
975            .into_iter()
976            .filter_map(|id| self.get(coll, &id))
977            .collect()
978    }
979
980    /// Candidate nodes whose `field` falls in the given range, via the sorted
981    /// index. `None` when no index covers (coll, field) — the caller must then
982    /// fall back to a scan.
983    ///
984    /// Returns CURRENT versions only (the index drops a superseded hash on
985    /// overwrite), so this must not be used to serve an `AS OF` query.
986    pub fn range_scan(
987        &self,
988        coll: &str,
989        field: &str,
990        low: Option<&Value>,
991        high: Option<&Value>,
992        low_incl: bool,
993        high_incl: bool,
994    ) -> Option<Vec<Node>> {
995        if !self.sorted_indexes.has(coll, field) {
996            return None;
997        }
998        Some(
999            self.sorted_indexes
1000                .range(coll, field, low, high, low_incl, high_incl)
1001                .into_iter()
1002                .filter_map(|h| self.objects.read(&h).ok())
1003                .collect(),
1004        )
1005    }
1006
1007    /// Candidate nodes whose `field` equals any of `values` — the indexed path
1008    /// for `=` and for `IN (...)`. `None` when no index covers the field.
1009    pub fn index_lookup(&self, coll: &str, field: &str, values: &[Value]) -> Option<Vec<Node>> {
1010        if !self.sorted_indexes.has(coll, field) {
1011            return None;
1012        }
1013        // A value may legitimately appear in several arms of an IN list, and a
1014        // hash must not be returned twice.
1015        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1016        let mut out = vec![];
1017        for v in values {
1018            for h in self.sorted_indexes.exact(coll, field, v) {
1019                if seen.insert(h.clone()) {
1020                    if let Ok(node) = self.objects.read(&h) {
1021                        out.push(node);
1022                    }
1023                }
1024            }
1025        }
1026        Some(out)
1027    }
1028
1029    /// How many rows an indexed range covers, without reading any of them.
1030    /// `None` when no index covers the field.
1031    pub fn range_cardinality(
1032        &self,
1033        coll: &str,
1034        field: &str,
1035        low: Option<&Value>,
1036        high: Option<&Value>,
1037        low_incl: bool,
1038        high_incl: bool,
1039    ) -> Option<usize> {
1040        if !self.sorted_indexes.has(coll, field) {
1041            return None;
1042        }
1043        Some(self.sorted_indexes.range_len(coll, field, low, high, low_incl, high_incl))
1044    }
1045
1046    /// True when a sorted index covers (coll, field).
1047    pub fn has_sorted_index(&self, coll: &str, field: &str) -> bool {
1048        self.sorted_indexes.has(coll, field)
1049    }
1050
1051    /// ORDER BY field ASC LIMIT n — uses sorted index if available, else falls back to full scan.
1052    pub fn order_by_asc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
1053        if self.sorted_indexes.has(coll, field) {
1054            self.sorted_indexes
1055                .top_k_asc(coll, field, limit)
1056                .into_iter()
1057                .filter_map(|h| self.objects.read(&h).ok())
1058                .collect()
1059        } else {
1060            let mut docs = self.list(coll);
1061            docs.sort_by(|a, b| {
1062                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1063                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1064                av.cmp(&bv)
1065            });
1066            docs.truncate(limit);
1067            docs
1068        }
1069    }
1070
1071    /// ORDER BY field DESC LIMIT n
1072    pub fn order_by_desc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
1073        if self.sorted_indexes.has(coll, field) {
1074            self.sorted_indexes
1075                .top_k_desc(coll, field, limit)
1076                .into_iter()
1077                .filter_map(|h| self.objects.read(&h).ok())
1078                .collect()
1079        } else {
1080            let mut docs = self.list(coll);
1081            docs.sort_by(|a, b| {
1082                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1083                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
1084                bv.cmp(&av)
1085            });
1086            docs.truncate(limit);
1087            docs
1088        }
1089    }
1090
1091    /// TRACE caused_by — walk causal graph from a node.
1092    pub fn trace(&self, hash: &str, reverse: bool, limit: usize) -> Vec<Node> {
1093        self.graph
1094            .trace(hash, "caused_by", reverse, limit)
1095            .into_iter()
1096            .filter_map(|h| self.objects.read(&h).ok())
1097            .collect()
1098    }
1099
1100    /// Verify tamper-evidence of all objects.
1101    pub fn verify(&self) -> (usize, Vec<String>) {
1102        self.objects.verify_all()
1103    }
1104
1105    /// Create a sorted index for a (coll, field) pair.
1106    pub fn create_sorted_index(&self, coll: &str, field: &str) {
1107        self.sorted_indexes.ensure(coll, field);
1108        // Backfill from existing objects
1109        for id in self.id_index.list_ids(coll) {
1110            if let Some(node) = self.get(coll, &id) {
1111                if let Value::Object(ref obj) = node.data {
1112                    if let Some(value) = obj.get(field) {
1113                        self.sorted_indexes.insert(coll, field, value, &node.hash);
1114                    }
1115                }
1116            }
1117        }
1118    }
1119
1120    /// Resolve a sequence number to its content hash (v1 compatibility).
1121    /// Only covers nodes written in the current process session + cold-scan nodes.
1122    pub fn get_hash_by_seq(&self, seq: u64) -> Option<String> {
1123        self.seq_index.get(&seq).map(|r| r.clone())
1124    }
1125
1126    /// The tip — the most recently written node (highest seq), or `None` if the
1127    /// database is empty. O(1): `self.seq` is the next-to-assign counter, so the
1128    /// latest write sits at `seq - 1`; we resolve it through the same
1129    /// seq_index → object-store path a normal read uses, so the returned Node is
1130    /// byte-identical to one fetched by id or hash (it carries its own seq, hash,
1131    /// causal links, and valid-time). This is the cheap "give me the latest write"
1132    /// primitive — the head of the log, not an aggregate.
1133    pub fn tip(&self) -> Option<Node> {
1134        let next = self.seq.load(Ordering::SeqCst);
1135        if next == 0 {
1136            return None; // nothing written yet
1137        }
1138        // Fast path: resolve the head seq through the in-memory seq index
1139        // (populated by this session's writes or by the cold scan).
1140        if let Some(hash) = self.get_hash_by_seq(next - 1) {
1141            return self.get_by_hash(&hash);
1142        }
1143        // Warm-boot fallback: the seq index is still cold (warm start skips the
1144        // scan), but the tip's object hash was persisted in MANIFEST and restored
1145        // on open. O(1), no scan — this is what makes tip() survive a restart.
1146        let th = self.tip_hash.read().1.clone();
1147        if !th.is_empty() {
1148            return self.get_by_hash(&th);
1149        }
1150        None
1151    }
1152
1153    /// The collection-local tip — the most recent write into `coll` (highest seq in
1154    /// that collection), or `None` if the collection has no writes. O(1): resolves
1155    /// through `coll_tip_hash`, a dedicated per-collection map kept current on every
1156    /// write (`update_head`), restored from MANIFEST on warm boot, and rebuilt by the
1157    /// cold scan — durable across restarts by construction, same contract as `tip()`
1158    /// for the global head. Conceptually a different index than the global `tip()`
1159    /// (global head vs collection head), kept as a separate method so each is
1160    /// explicit — parity with the Python reference's `tip(coll)`. Lets a consumer
1161    /// resume one chain (e.g. blocks / tx / utxo) without pulling global tip and
1162    /// filtering.
1163    pub fn tip_collection(&self, coll: &str) -> Option<Node> {
1164        let hash = self.coll_tip_hash.get(coll)?.1.clone();
1165        self.get_by_hash(&hash)
1166    }
1167
1168    /// Changefeed page: up to `limit` nodes written AFTER `after_seq` (EXCLUSIVE),
1169    /// ascending by seq, wrapped in a `SinceBatch` cursor envelope. `after_seq` is
1170    /// the cursor you last applied (a prior `tip()` seq or `to_seq`). `limit` bounds
1171    /// the page — `0` means DEFAULT_SINCE_LIMIT, so the engine primitive can never
1172    /// materialize an unbounded batch even when embedders call it directly (the
1173    /// safety is here, not only in the HTTP layer). Drain by paging while
1174    /// `has_more`, advancing your cursor to `to_seq`, then hand off to the live
1175    /// `subscribe` edge. The append-only log IS the changefeed, so this is an
1176    /// O(page) walk; unresolved seqs (outside seq_index coverage — see
1177    /// `scan_status()`) are skipped rather than faked.
1178    pub fn since(&self, after_seq: u64, limit: usize) -> SinceBatch {
1179        let next = self.seq.load(Ordering::SeqCst);          // head + 1
1180        let head_seq = next.saturating_sub(1);
1181        let cap = if limit == 0 { DEFAULT_SINCE_LIMIT } else { limit };
1182        let mut nodes: Vec<Node> = Vec::new();
1183        let mut to_seq = after_seq;
1184        let mut hit_limit = false;
1185        let mut s = after_seq.saturating_add(1);
1186        while s < next {
1187            if nodes.len() >= cap { hit_limit = true; break; }
1188            if let Some(hash) = self.get_hash_by_seq(s) {
1189                if let Some(node) = self.get_by_hash(&hash) {
1190                    to_seq = node.seq;
1191                    nodes.push(node);
1192                }
1193            }
1194            s += 1;
1195        }
1196        // `has_more` must never say "caught up" while the cursor is behind the
1197        // log head. Before 2.8.6 this was `hit_limit` alone, so any page whose
1198        // seqs could not be resolved (the whole range, on a warm boot: the warm
1199        // path skips the scan, leaving seq_index empty) returned zero nodes with
1200        // has_more=false — indistinguishable from genuinely up to date. A
1201        // consumer following the documented drain loop stopped forever, one call
1202        // in, on a database with every record unread.
1203        let has_more = hit_limit || to_seq < head_seq;
1204        SinceBatch { nodes, from_seq: after_seq, to_seq, head_seq, has_more }
1205    }
1206
1207    /// Replication readiness — see `ScanStatus`. `scan_complete` gates safe
1208    /// historical catch-up: a consumer pulling an old cursor right after a cold
1209    /// start must wait for it, or `since()` may hand back a partial page that looks
1210    /// like "caught up". Computes the indexed range by scanning the in-memory seq
1211    /// index (O(index)) — intended for periodic status polls, not the per-write
1212    /// hot path.
1213    pub fn scan_status(&self) -> ScanStatus {
1214        let next = self.seq.load(Ordering::SeqCst);
1215        let mut min = u64::MAX;
1216        let mut max = 0u64;
1217        let mut count = 0usize;
1218        for kv in self.seq_index.iter() {
1219            let s = *kv.key();
1220            if s < min { min = s; }
1221            if s > max { max = s; }
1222            count += 1;
1223        }
1224        if count == 0 { min = 0; }
1225        ScanStatus {
1226            scan_complete:   self.startup_ready.load(Ordering::SeqCst),
1227            tip_seq:         next.saturating_sub(1),
1228            indexed_seq_min: min,
1229            indexed_seq_max: max,
1230            indexed_count:   count,
1231            // The seq index covers the log when it resolves as many seqs as the
1232            // log has entries. On a warm boot it is empty while the log is not.
1233            seq_index_ready: count > 0 && (count as u64) >= next.saturating_sub(1),
1234        }
1235    }
1236
1237    /// Add an explicit named relation edge between two documents.
1238    /// Add an explicit named relation between two "coll:id" nodes.
1239    /// Relations stored as __links__ documents — NQL-queryable, time-travelable,
1240    /// consistent with the PyO3 binding which uses the same __links__ convention.
1241    pub fn link(&self, frm: &str, rel: &str, to: &str) -> Result<()> {
1242        let (frm_coll, frm_id) = frm.split_once(':')
1243            .ok_or_else(|| anyhow::anyhow!("link frm must be 'coll:id', got: {}", frm))?;
1244        let (to_coll, to_id) = to.split_once(':')
1245            .ok_or_else(|| anyhow::anyhow!("link to must be 'coll:id', got: {}", to))?;
1246        if self.id_index.get(frm_coll, frm_id).is_none() {
1247            anyhow::bail!("link: frm not found: {}", frm);
1248        }
1249        if self.id_index.get(to_coll, to_id).is_none() {
1250            anyhow::bail!("link: to not found: {}", to);
1251        }
1252        let link_id = format!("{}|{}|{}", frm, rel, to);
1253        let doc = serde_json::json!({"_from": frm, "_rel": rel, "_to": to});
1254        self.put("__links__", &link_id, doc, vec![], None, None)?;
1255        Ok(())
1256    }
1257
1258    /// Remove a named relation (deletes the __links__ document).
1259    pub fn unlink(&self, frm: &str, rel: &str, to: &str) -> Result<bool> {
1260        let link_id = format!("{}|{}|{}", frm, rel, to);
1261        self.delete("__links__", &link_id)
1262    }
1263
1264    /// Get neighbor nodes via a named relation.
1265    /// Queries __links__ — consistent with the PyO3 binding.
1266    pub fn neighbors(&self, frm: &str, rel: &str) -> Vec<Node> {
1267        self.id_index
1268            .list_ids("__links__")
1269            .into_iter()
1270            .filter_map(|id| self.get("__links__", &id))
1271            .filter(|node| {
1272                node.data.get("_from").and_then(|v| v.as_str()) == Some(frm)
1273                    && node.data.get("_rel").and_then(|v| v.as_str()) == Some(rel)
1274            })
1275            .filter_map(|node| {
1276                let to = node.data.get("_to")?.as_str()?;
1277                let (to_coll, to_id) = to.split_once(':')?;
1278                self.get(to_coll, to_id)
1279            })
1280            .collect()
1281    }
1282}
1283
1284impl Drop for Db {
1285    /// Flush buffered state when the database is closed so a write-then-drop
1286    /// sequence is durable without an explicit `flush_all()`.
1287    ///
1288    /// `IdIndex::set` only stages updates in the in-memory WAL `write_buf`;
1289    /// disk persistence happens in `flush_write_buf()`, normally driven by the
1290    /// manifest ticker. A short-lived `Db` (a library user's `{ let db =
1291    /// Db::open(p)?; db.put(..)?; }` block, or a test) has no ticker, so without
1292    /// this its writes would be silently lost on reopen. Flushing on drop
1293    /// mirrors the flush-on-close contract of other embedded stores (sled,
1294    /// RocksDB).
1295    ///
1296    /// In production this is a harmless safety net, not the primary durability
1297    /// path: the manifest ticker thread holds an `Arc<Db>` for the process
1298    /// lifetime, so `Drop` only fires once every owning handle is gone. No-op
1299    /// for in-memory databases (`flush_all` short-circuits on `:memory:`).
1300    fn drop(&mut self) {
1301        self.flush_all();
1302    }
1303}
1304
1305/// Background cold-scan worker. Takes Arc<Db> — safe, Db is on the heap.
1306fn cold_scan_background_arc(db: Arc<Db>) {
1307    use rayon::prelude::*;
1308
1309    let objects        = &db.objects;
1310    let seq_atomic     = &db.seq;
1311    let sorted_indexes = &db.sorted_indexes;
1312    let seq_index      = &db.seq_index;
1313    let ready_flag     = Arc::clone(&db.startup_ready);
1314
1315    let hashes: Vec<String> = objects.all_hashes().collect();
1316    let total = hashes.len();
1317
1318    if total == 0 {
1319        ready_flag.store(true, Ordering::SeqCst);
1320        return;
1321    }
1322
1323    println!("  [nedbd] background scan — {} objects...", total);
1324    let t0 = std::time::Instant::now();
1325    let step = (total / 10).max(1000);
1326
1327    // Populate the seq index AS objects are read here, not in a second pass
1328    // afterward: this loop is the slow, disk-I/O-bound phase (verifying and
1329    // parsing every object), and it can run for minutes on a multi-million
1330    // object store. `scan_status().indexed_count` reads `seq_index`'s size, so
1331    // inserting here — not after `.collect()` — is what makes that a real, live
1332    // progress signal through the phase that actually takes the time, instead
1333    // of reporting a flat 0 until this whole pass finishes. Safe: DashMap
1334    // supports concurrent inserts, and every parallel worker here inserts a
1335    // disjoint key (each object has its own seq).
1336    let nodes: Vec<Node> = hashes.par_iter()
1337        .enumerate()
1338        .filter_map(|(i, h)| {
1339            if i > 0 && i % step == 0 {
1340                let pct     = i * 100 / total;
1341                let elapsed = t0.elapsed().as_secs_f32();
1342                let rate    = i as f32 / elapsed;
1343                let eta     = (total - i) as f32 / rate;
1344                eprint!("\r  [nedbd]   {:>3}%  {:>8} / {:>8}  ({:>8.0}/s  eta {:.0}s)   ",
1345                    pct, i, total, rate, eta);
1346            }
1347            let node = objects.read(h).ok()?;
1348            seq_index.insert(node.seq, node.hash.clone());
1349            Some(node)
1350        })
1351        .collect();
1352
1353    eprintln!("\r  [nedbd]   100%  {:>8} / {:>8}  ({:.1}s)                        ",
1354        total, total, t0.elapsed().as_secs_f32());
1355
1356    let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
1357    seq_atomic.store(max_seq + 1, Ordering::SeqCst);
1358
1359    // Per-collection tip: highest-seq node's hash, per coll. `nodes` is NOT
1360    // seq-ordered here (it comes from an unordered object-hash scan), so this
1361    // must track the max explicitly — unlike the live write path's "last call
1362    // wins" (which relies on ascending call order that a scan doesn't have).
1363    let mut coll_max: std::collections::HashMap<String, (u64, String)> = std::collections::HashMap::new();
1364
1365    for node in &nodes {
1366        // seq_index was already populated above, during the read pass.
1367        coll_max.entry(node.coll.clone())
1368            .and_modify(|(s, h)| if node.seq > *s { *s = node.seq; *h = node.hash.clone(); })
1369            .or_insert_with(|| (node.seq, node.hash.clone()));
1370        if let Value::Object(ref obj) = node.data {
1371            for (field, value) in obj {
1372                if sorted_indexes.has(&node.coll, field) {
1373                    sorted_indexes.insert(&node.coll, field, value, &node.hash);
1374                }
1375            }
1376        }
1377    }
1378
1379    for (coll, (seq, hash)) in coll_max {
1380        db.coll_tip_hash.insert(coll, (seq, hash));
1381    }
1382
1383    // Rebuild the id index when it has no collections at all — the lost-WAL
1384    // case. Until 2.8.6 the cold scan restored seq_index, coll_tips, head and
1385    // MANIFEST but NEVER the id index, so a database whose id-index WAL never
1386    // reached disk came back with every object present and verifying while
1387    // `list()` and `get()` returned nothing — and `nedb-cli repair`, whose whole
1388    // job is this, reported success without fixing it.
1389    //
1390    // Gated on "no collections" so a normal cold boot of a healthy store (itcd:
1391    // millions of objects) does not pay N extra index writes. A partially lost
1392    // index is repaired by the explicit `rebuild_id_index()` path.
1393    if db.id_index.collections().is_empty() && !nodes.is_empty() {
1394        let restored = rebuild_id_index_from_nodes(&db, &nodes);
1395        println!("  [nedbd] id index was empty — rebuilt {} entries from objects", restored);
1396    }
1397
1398    // Merkle head + tip, through the one shared implementation so the cold scan
1399    // and the explicit repair path can never drift apart.
1400    recompute_head_and_tip(&db, hashes, max_seq);
1401
1402    // Write MANIFEST through the one canonical writer. The hand-rolled write
1403    // this replaces stored `seq: max_seq` (the last USED seq) — but the warm
1404    // boot loads `m.seq` as the NEXT-TO-ASSIGN counter, so a restart right
1405    // after a quiet cold scan handed the next write the tip's seq: a duplicate
1406    // seq in the log (seq_index overwrite, wrong since() page). flush_manifest
1407    // reads the live counter (already max_seq + 1) — correct by construction.
1408    db.flush_manifest();
1409
1410    // Signal server: writes can now proceed
1411    ready_flag.store(true, Ordering::SeqCst);
1412    println!("  [nedbd] background scan complete — seq={} objects={} MANIFEST written", max_seq, total);
1413}
1414
1415/// Recompute the Merkle head and the tip hash from the full object-hash set.
1416///
1417/// Shared by the cold scan and by `repair()` so the two can never disagree
1418/// about what the head of a rebuilt database is. `hashes` must be every object
1419/// hash in the store; `max_seq` the highest seq observed.
1420fn recompute_head_and_tip(db: &Db, hashes: Vec<String>, max_seq: u64) {
1421    use blake2::{Blake2b512, Digest};
1422    let mut sorted_hashes = hashes;
1423    sorted_hashes.sort();
1424    let mut h = Blake2b512::new();
1425    h.update(max_seq.to_le_bytes());
1426    for hash_str in &sorted_hashes {
1427        h.update(hash_str.as_bytes());
1428    }
1429    *db.head.write() = hex::encode(&h.finalize()[..32]);
1430
1431    // Tip = the highest-seq object indexed. Persisting its hash lets tip()
1432    // resolve O(1) on the next warm boot, before any scan repopulates seq_index.
1433    let tip_hash = db.seq_index.iter()
1434        .max_by_key(|kv| *kv.key())
1435        .map(|kv| kv.value().clone())
1436        .unwrap_or_default();
1437    *db.tip_hash.write() = (max_seq, tip_hash);
1438}
1439
1440/// Reconstruct id-index entries from already-read nodes: for every (coll, id),
1441/// the winner is the HIGHEST seq, which is exactly what `put()` would have left
1442/// behind. Returns the number of entries written.
1443///
1444/// The id index is fully derivable from the object store because every object
1445/// carries its own `coll`, `id` and `seq` — so a lost WAL is recoverable, and
1446/// nothing here invents data.
1447fn rebuild_id_index_from_nodes(db: &Db, nodes: &[Node]) -> usize {
1448    let mut winner: std::collections::HashMap<(String, String), (u64, String)> =
1449        std::collections::HashMap::new();
1450    for node in nodes {
1451        let key = (node.coll.clone(), node.id.clone());
1452        winner
1453            .entry(key)
1454            .and_modify(|cur| {
1455                if node.seq > cur.0 {
1456                    *cur = (node.seq, node.hash.clone());
1457                }
1458            })
1459            .or_insert((node.seq, node.hash.clone()));
1460    }
1461    let mut written = 0usize;
1462    for ((coll, id), (_seq, hash)) in &winner {
1463        if db.id_index.set(coll, id, hash).is_ok() {
1464            written += 1;
1465        }
1466    }
1467    // Persist immediately: a rebuild that only lands in the WAL would be lost
1468    // again by the very crash class this recovers from.
1469    if let Err(e) = db.id_index.try_flush_write_buf() {
1470        eprintln!("nedb: id-index rebuild flush failed: {}", e);
1471    }
1472    written
1473}
1474
1475fn now() -> f64 {
1476    std::time::SystemTime::now()
1477        .duration_since(std::time::UNIX_EPOCH)
1478        .map(|d| d.as_secs_f64())
1479        .unwrap_or(0.0)
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485    use tempfile::tempdir;
1486
1487    #[test]
1488    fn put_and_get() {
1489        let dir = tempdir().unwrap();
1490        let db = Db::open(dir.path(), None).unwrap();
1491        db.put(
1492            "blocks", "618000",
1493            serde_json::json!({"height": 618000, "hash": "0000abc"}),
1494            vec![], None, None,
1495        ).unwrap();
1496        let node = db.get("blocks", "618000").unwrap();
1497        assert_eq!(node.id, "618000");
1498        assert_eq!(node.data["height"], 618000);
1499    }
1500
1501    #[test]
1502    fn order_by_with_sorted_index() {
1503        let dir = tempdir().unwrap();
1504        let db = Db::open(dir.path(), None).unwrap();
1505        db.create_sorted_index("blocks", "height");
1506        for h in [3u64, 1, 5, 2, 4] {
1507            db.put("blocks", &h.to_string(),
1508                serde_json::json!({"height": h}),
1509                vec![], None, None).unwrap();
1510        }
1511        let asc = db.order_by_asc("blocks", "height", 3);
1512        let heights: Vec<u64> = asc.iter()
1513            .filter_map(|n| n.data["height"].as_u64())
1514            .collect();
1515        assert_eq!(heights, vec![1, 2, 3]);
1516    }
1517
1518    #[test]
1519    fn causal_trace() {
1520        let dir = tempdir().unwrap();
1521        let db = Db::open(dir.path(), None).unwrap();
1522        let a = db.put("ops", "a", serde_json::json!({"op": "create"}), vec![], None, None).unwrap();
1523        let b = db.put("ops", "b", serde_json::json!({"op": "transfer"}), vec![a.hash.clone()], None, None).unwrap();
1524        let c = db.put("ops", "c", serde_json::json!({"op": "burn"}), vec![b.hash.clone()], None, None).unwrap();
1525
1526        let trace = db.trace(&c.hash, false, 10);
1527        assert_eq!(trace.len(), 3);  // c → b → a
1528    }
1529
1530    #[test]
1531    fn as_of() {
1532        let dir = tempdir().unwrap();
1533        let db = Db::open(dir.path(), None).unwrap();
1534        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1535        let _v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1536
1537        let at_v1 = db.get_as_of("docs", "x", v1.seq).unwrap();
1538        assert_eq!(at_v1.data["v"], 1);
1539        let current = db.get("docs", "x").unwrap();
1540        assert_eq!(current.data["v"], 2);
1541    }
1542}
1543
1544#[cfg(test)]
1545mod tests_v2 {
1546    use super::*;
1547    use tempfile::tempdir;
1548
1549    // ── a DELETE is a tombstone, not an erasure ─────────────────────────────
1550    //
1551    // `delete()` used to just remove the live id pointer, which made the
1552    // document's whole history unreachable: `AS OF` enumerates ids from
1553    // `id_index`, so a deleted id was skipped at EVERY sequence — including
1554    // sequences long before the delete, where the row demonstrably existed.
1555    //
1556    // Nothing was lost on disk. The tombstone node keeps a `prev` link to the
1557    // full version chain and `verify()` counted every object as healthy — which
1558    // makes it the worst kind of data loss, the kind that passes its own audit.
1559    // The pointer is now MOVED to the graveyard instead of dropped.
1560
1561    #[test]
1562    fn a_deleted_documents_history_is_still_readable_before_the_delete() {
1563        let db = Db::in_memory();
1564        let v1 = db.put("o", "a", serde_json::json!({"t": 55}), vec![], None, None).unwrap();
1565        let v2 = db.put("o", "a", serde_json::json!({"t": 66}), vec![], None, None).unwrap();
1566        assert!(db.delete("o", "a").unwrap());
1567
1568        // Gone from the present — a delete must still delete.
1569        assert!(db.get("o", "a").is_none(), "a deleted doc must not be visible now");
1570
1571        // …and readable at each sequence it existed at.
1572        let at_v1 = db.get_as_of("o", "a", v1.seq).expect("the ORIGINAL value survives");
1573        assert_eq!(at_v1.data["t"], serde_json::json!(55));
1574        let at_v2 = db.get_as_of("o", "a", v2.seq).expect("the UPDATED value survives");
1575        assert_eq!(at_v2.data["t"], serde_json::json!(66));
1576    }
1577
1578    #[test]
1579    fn as_of_the_tombstone_or_later_reports_the_document_absent() {
1580        let db = Db::in_memory();
1581        db.put("o", "a", serde_json::json!({"t": 55}), vec![], None, None).unwrap();
1582        db.delete("o", "a").unwrap();
1583        let tomb_seq = db.tip().expect("the tombstone is the tip").seq;
1584
1585        assert!(db.get_as_of("o", "a", tomb_seq).is_none(),
1586                "at the delete's own sequence the document is gone");
1587        assert!(db.get_as_of("o", "a", tomb_seq + 10).is_none(), "and after it");
1588        // Never the tombstone node itself: `{_deleted, _prev}` is bookkeeping,
1589        // and surfacing it would look like a document with strange fields.
1590        for s in 0..=tomb_seq + 1 {
1591            if let Some(n) = db.get_as_of("o", "a", s) {
1592                assert!(n.data.get("_deleted").is_none(),
1593                        "seq {} surfaced the tombstone as a document: {:?}", s, n.data);
1594            }
1595        }
1596    }
1597
1598    #[test]
1599    fn an_as_of_query_lists_deleted_ids_alongside_live_ones() {
1600        let db = Db::in_memory();
1601        db.put("o", "keep", serde_json::json!({"n": 1}), vec![], None, None).unwrap();
1602        let gone = db.put("o", "gone", serde_json::json!({"n": 2}), vec![], None, None).unwrap();
1603        db.delete("o", "gone").unwrap();
1604
1605        assert_eq!(db.id_index.list_ids("o"), vec!["keep".to_string()],
1606                   "the live index holds only the living");
1607        assert_eq!(db.list_ids_including_deleted("o"),
1608                   vec!["gone".to_string(), "keep".to_string()],
1609                   "AS OF must consider both, in a stable order");
1610
1611        // The query path, end to end — this is what actually regressed.
1612        let (rows, _) = crate::nql::query(&db, &format!("FROM o AS OF {}", gone.seq)).unwrap();
1613        let ids: Vec<&str> = rows.iter().filter_map(|r| r["_id"].as_str()).collect();
1614        assert!(ids.contains(&"gone"), "AS OF must see the deleted row: {:?}", ids);
1615        assert!(ids.contains(&"keep"), "{:?}", ids);
1616
1617        // And the present must not.
1618        let (now, _) = crate::nql::query(&db, "FROM o").unwrap();
1619        let ids: Vec<&str> = now.iter().filter_map(|r| r["_id"].as_str()).collect();
1620        assert_eq!(ids, vec!["keep"], "a delete still deletes");
1621    }
1622
1623    #[test]
1624    fn a_recreated_id_keeps_the_history_from_before_its_delete() {
1625        // The edge case the graveyard fallback exists for: a `put` after a
1626        // delete starts a FRESH chain with no `prev`, so the live chain cannot
1627        // reach a sequence from before the delete. Only the graveyard can.
1628        let db = Db::in_memory();
1629        let old = db.put("o", "a", serde_json::json!({"era": "first"}), vec![], None, None).unwrap();
1630        db.delete("o", "a").unwrap();
1631        let new = db.put("o", "a", serde_json::json!({"era": "second"}), vec![], None, None).unwrap();
1632
1633        assert_eq!(db.get("o", "a").unwrap().data["era"], serde_json::json!("second"));
1634        assert_eq!(db.get_as_of("o", "a", new.seq).unwrap().data["era"],
1635                   serde_json::json!("second"));
1636        assert_eq!(db.get_as_of("o", "a", old.seq).expect("the FIRST era survives").data["era"],
1637                   serde_json::json!("first"),
1638                   "re-creating an id must not orphan what came before it");
1639    }
1640
1641    #[test]
1642    fn the_graveyard_survives_a_reopen() {
1643        // A tombstone pointer lost to a restart would put the history back out
1644        // of reach — the exact bug, just deferred. So it is flushed with the
1645        // live index and read back from disk.
1646        let dir = tempdir().unwrap();
1647        let seq = {
1648            let db = Db::open(dir.path(), None).unwrap();
1649            let v1 = db.put("o", "a", serde_json::json!({"t": 7}), vec![], None, None).unwrap();
1650            db.delete("o", "a").unwrap();
1651            db.try_flush_all().expect("flush must succeed");
1652            v1.seq
1653        };
1654        let db = Db::open(dir.path(), None).unwrap();
1655        assert!(db.get("o", "a").is_none(), "still deleted after a reopen");
1656        assert_eq!(db.get_as_of("o", "a", seq).expect("history survives a reopen").data["t"],
1657                   serde_json::json!(7));
1658        assert_eq!(db.list_ids_including_deleted("o"), vec!["a".to_string()]);
1659    }
1660
1661    #[test]
1662    fn the_graveyard_is_invisible_to_everything_that_enumerates_the_store() {
1663        // It adds a directory to the data dir, so the risk is that it shows up
1664        // as a phantom COLLECTION or a phantom OBJECT. Both enumerations are
1665        // rooted at their own subdirectory rather than at the data dir, which
1666        // is why it cannot — but that is exactly the kind of reasoning worth
1667        // pinning, because a stray "graveyard" collection would be nasty and
1668        // would only surface in someone's UI.
1669        let dir = tempdir().unwrap();
1670        let db = Db::open(dir.path(), None).unwrap();
1671        db.put("orders", "a", serde_json::json!({"t": 1}), vec![], None, None).unwrap();
1672        // A surviving sibling, so the collection is still live after the
1673        // delete. (With `a` alone, `orders` would have no index entries left
1674        // and so no directory to enumerate — existing behaviour, unrelated to
1675        // the graveyard, but it would make this test assert the wrong thing.)
1676        db.put("orders", "b", serde_json::json!({"t": 2}), vec![], None, None).unwrap();
1677        db.delete("orders", "a").unwrap();
1678        db.try_flush_all().unwrap();
1679
1680        let colls = db.id_index.collections();
1681        assert!(!colls.iter().any(|c| c == "graveyard"),
1682                "the graveyard must not look like a collection: {:?}", colls);
1683        assert_eq!(colls, vec!["orders".to_string()]);
1684
1685        let (_checked, tampered) = db.verify();
1686        assert!(tampered.is_empty(), "{:?}", tampered);
1687    }
1688
1689    #[test]
1690    fn a_delete_leaves_the_hash_chain_verifiable() {
1691        // The graveyard is an index, not a second source of truth: it must not
1692        // be able to make `verify()` disagree with the objects on disk.
1693        let db = Db::in_memory();
1694        db.put("o", "a", serde_json::json!({"t": 1}), vec![], None, None).unwrap();
1695        db.put("o", "b", serde_json::json!({"t": 2}), vec![], None, None).unwrap();
1696        db.delete("o", "a").unwrap();
1697        let (checked, tampered) = db.verify();
1698        assert!(tampered.is_empty(), "a delete must not break verify(): {:?}", tampered);
1699        assert!(checked >= 3, "the tombstone is an object too, got {}", checked);
1700    }
1701
1702    #[test]
1703    fn deleting_a_missing_id_stays_a_no_op() {
1704        let db = Db::in_memory();
1705        assert!(!db.delete("o", "nope").unwrap(), "nothing to delete");
1706        assert!(db.list_ids_including_deleted("o").is_empty(),
1707                "a failed delete must not put anything in the graveyard");
1708    }
1709
1710    #[test]
1711    fn seq_index_populated_on_put() {
1712        let db = Db::in_memory();
1713        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1714        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1715        assert_eq!(db.get_hash_by_seq(a.seq), Some(a.hash.clone()));
1716        assert_eq!(db.get_hash_by_seq(b.seq), Some(b.hash.clone()));
1717        assert_eq!(db.get_hash_by_seq(9999), None);
1718    }
1719
1720    #[test]
1721    fn tip_and_since() {
1722        let db = Db::in_memory();
1723        // Empty db: no tip, empty changefeed.
1724        assert!(db.tip().is_none());
1725        assert!(db.since(0, 0).nodes.is_empty());
1726
1727        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1728        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1729
1730        // tip() = the most recent write (highest seq), returned as a full node.
1731        let t = db.tip().expect("tip after writes");
1732        assert_eq!(t.seq, b.seq);
1733        assert_eq!(t.id, "b");
1734        assert_eq!(t.hash, b.hash);
1735
1736        // since(after_seq, limit) — EXCLUSIVE cursor, bounded page + envelope.
1737        let after_a = db.since(a.seq, 0);
1738        assert_eq!(after_a.nodes.len(), 1);
1739        assert_eq!(after_a.nodes[0].id, "b");
1740        assert_eq!(after_a.from_seq, a.seq);
1741        assert_eq!(after_a.to_seq, b.seq);
1742        assert_eq!(after_a.head_seq, b.seq);
1743        assert!(!after_a.has_more);
1744
1745        // Nothing written after the tip.
1746        assert!(db.since(b.seq, 0).nodes.is_empty());
1747
1748        // `limit` bounds the page and sets has_more; resume from to_seq.
1749        let c = db.put("item", "c", serde_json::json!({"x": 3}), vec![], None, None).unwrap();
1750        let page = db.since(a.seq, 1);             // (a..] capped at 1 -> [b], more pending
1751        assert_eq!(page.nodes.len(), 1);
1752        assert_eq!(page.nodes[0].id, "b");
1753        assert_eq!(page.to_seq, b.seq);
1754        assert!(page.has_more);
1755        let page2 = db.since(page.to_seq, 1);      // resume from b -> [c], done
1756        assert_eq!(page2.nodes.len(), 1);
1757        assert_eq!(page2.nodes[0].id, "c");
1758        assert_eq!(page2.to_seq, c.seq);
1759        assert!(!page2.has_more);
1760    }
1761
1762    #[test]
1763    fn tip_collection_per_chain() {
1764        // The ITC sync-client case: separate chains in separate collections; a
1765        // consumer resumes ONE without pulling global tip and filtering.
1766        let db = Db::in_memory();
1767        assert!(db.tip_collection("blocks").is_none());
1768
1769        db.put("blocks", "b0", serde_json::json!({"h": 0}), vec![], None, None).unwrap();
1770        db.put("tx",     "t0", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1771        let b1 = db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1772        let t1 = db.put("tx",     "t1", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1773
1774        // global tip = latest write overall (t1)
1775        assert_eq!(db.tip().unwrap().id, "t1");
1776        // collection-local tips = latest write in each collection
1777        let bt = db.tip_collection("blocks").expect("blocks tip");
1778        assert_eq!(bt.id, "b1");
1779        assert_eq!(bt.seq, b1.seq);
1780        assert_eq!(db.tip_collection("tx").unwrap().seq, t1.seq);
1781        assert!(db.tip_collection("absent").is_none());
1782    }
1783
1784    #[test]
1785    fn seq_index_survives_batch() {
1786        let db = Db::in_memory();
1787        let nodes = db.put_batch(vec![
1788            ("item".into(), "x".into(), serde_json::json!({"v": 1}), vec![], None, None),
1789            ("item".into(), "y".into(), serde_json::json!({"v": 2}), vec![], None, None),
1790        ]).unwrap();
1791        for node in &nodes {
1792            assert_eq!(db.get_hash_by_seq(node.seq), Some(node.hash.clone()));
1793        }
1794    }
1795
1796    /// Regression: put_batch must remove the superseded version's sorted-index
1797    /// entries, exactly like put() does. Old behavior left the old hashes in
1798    /// the BTree — ORDER BY returned superseded rows alongside current ones
1799    /// (they resolve fine through the content-addressed store, which made the
1800    /// stale rows look legitimate).
1801    #[test]
1802    fn put_batch_removes_superseded_sorted_index_entries() {
1803        let db = Db::in_memory();
1804        db.create_sorted_index("blocks", "height");
1805        db.put("blocks", "x", serde_json::json!({"height": 1}), vec![], None, None).unwrap();
1806        db.put_batch(vec![
1807            ("blocks".into(), "x".into(), serde_json::json!({"height": 99}), vec![], None, None),
1808        ]).unwrap();
1809
1810        let asc = db.order_by_asc("blocks", "height", 10);
1811        assert_eq!(asc.len(), 1, "stale index entry for the superseded version must be gone");
1812        assert_eq!(asc[0].data["height"], 99);
1813        assert_eq!(asc[0].id, "x");
1814    }
1815
1816    /// Updates without any sorted index must keep full version-chain semantics
1817    /// (guards the new skip-old-object-read fast path in put()).
1818    #[test]
1819    fn update_without_indexes_preserves_chain() {
1820        let db = Db::in_memory();
1821        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1822        let v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1823        assert_eq!(v2.prev.as_deref(), Some(v1.hash.as_str()), "prev chain must survive the fast path");
1824        assert_eq!(db.get("docs", "x").unwrap().data["v"], 2);
1825        assert_eq!(db.get_as_of("docs", "x", v1.seq).unwrap().data["v"], 1);
1826    }
1827
1828    #[test]
1829    fn link_and_neighbors() {
1830        let db = Db::in_memory();
1831        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1832        db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
1833        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1834        db.put("trip",   "t2", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1835
1836        db.link("driver:d1", "handles", "trip:t1").unwrap();
1837        db.link("driver:d1", "handles", "trip:t2").unwrap();
1838        db.link("driver:d2", "handles", "trip:t1").unwrap();
1839
1840        let d1_trips = db.neighbors("driver:d1", "handles");
1841        assert_eq!(d1_trips.len(), 2);
1842        let ids: std::collections::HashSet<&str> = d1_trips.iter().map(|n| n.id.as_str()).collect();
1843        assert!(ids.contains("t1") && ids.contains("t2"));
1844
1845        let d2_trips = db.neighbors("driver:d2", "handles");
1846        assert_eq!(d2_trips.len(), 1);
1847        assert_eq!(d2_trips[0].id, "t1");
1848    }
1849
1850    #[test]
1851    fn link_stored_in_links_collection() {
1852        // Links are stored as __links__ documents, not as graph edges.
1853        // The __links__ collection is NQL-queryable and consistent with the PyO3 binding.
1854        let db = Db::in_memory();
1855        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1856        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1857        db.link("driver:d1", "handles", "trip:t1").unwrap();
1858        // Verify the __links__ document was created
1859        let link_doc = db.get("__links__", "driver:d1|handles|trip:t1");
1860        assert!(link_doc.is_some(), "__links__ doc should exist");
1861        let doc = link_doc.unwrap();
1862        assert_eq!(doc.data["_from"], "driver:d1");
1863        assert_eq!(doc.data["_rel"],  "handles");
1864        assert_eq!(doc.data["_to"],   "trip:t1");
1865        // neighbors() resolves to the target node
1866        let nb = db.neighbors("driver:d1", "handles");
1867        assert_eq!(nb.len(), 1);
1868        assert_eq!(nb[0].id, "t1");
1869    }
1870
1871    /// A lost id-index WAL must be recoverable: the objects carry coll/id/seq,
1872    /// so `repair()` can reconstruct every row, and the repaired database must
1873    /// reopen WARM with a valid head.
1874    ///
1875    /// Regression for 2.8.5, where the cold scan rebuilt seq_index, coll_tips,
1876    /// head and MANIFEST but never the id index — so a database in this state
1877    /// returned 0 rows from `list()` while `verify()` reported every object
1878    /// healthy, and `nedb-cli repair` printed success without fixing anything.
1879    #[test]
1880    fn repair_rebuilds_id_index_after_lost_wal() {
1881        let dir = tempdir().unwrap();
1882        {
1883            let db = Db::open(dir.path(), None).unwrap();
1884            for i in 0..25 {
1885                db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1886                    .unwrap();
1887            }
1888            db.put("rows", "r0", serde_json::json!({"i": 0, "v": 2}), vec![], None, None).unwrap();
1889            db.try_flush_all().unwrap();
1890        }
1891
1892        // Simulate the lost WAL: objects survive, the id index does not.
1893        std::fs::remove_dir_all(dir.path().join("indexes")).unwrap();
1894
1895        {
1896            let db = Db::open(dir.path(), None).unwrap();
1897            assert_eq!(db.list("rows").len(), 0, "precondition: rows unreachable");
1898            let (ok, bad) = db.verify();
1899            assert!(ok > 0 && bad.is_empty(), "objects must still be intact and verifying");
1900
1901            let written = db.repair().unwrap();
1902            assert_eq!(written, 25, "one entry per distinct (coll, id)");
1903            assert_eq!(db.list("rows").len(), 25, "every row must come back");
1904
1905            // The winner for a re-put id is the HIGHEST seq, matching put().
1906            let r0 = db.get("rows", "r0").expect("r0 present");
1907            assert_eq!(r0.data.get("v").and_then(|v| v.as_i64()), Some(2),
1908                "repair must restore the latest version, not an older one");
1909        }
1910
1911        // A repaired database must reopen warm with a real head.
1912        let db3 = Db::open(dir.path(), None).unwrap();
1913        assert_eq!(db3.list("rows").len(), 25);
1914        assert!(!db3.head().is_empty(), "repair must leave a valid MANIFEST head");
1915        assert!(db3.tip_collection("rows").is_some(), "tip_collection must resolve after repair");
1916    }
1917
1918    /// `since()` must never report "caught up" while the cursor is behind head.
1919    ///
1920    /// Regression for 2.8.5: on a warm boot the seq index is empty by design
1921    /// (the warm path skips the scan), so every seq lookup missed and `since()`
1922    /// returned zero nodes with `has_more = false` — identical to genuinely up
1923    /// to date. A consumer following the documented drain loop stopped one call
1924    /// in, on a database with every record unread.
1925    #[test]
1926    fn since_never_reports_caught_up_while_behind_head() {
1927        let dir = tempdir().unwrap();
1928        {
1929            let db = Db::open(dir.path(), None).unwrap();
1930            for i in 0..10 {
1931                db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1932                    .unwrap();
1933            }
1934            db.try_flush_all().unwrap();
1935        }
1936
1937        // Warm reopen: startup is "complete" in O(1) because the scan is skipped.
1938        let db2 = Db::open(dir.path(), None).unwrap();
1939        let st = db2.scan_status();
1940        assert!(st.tip_seq > 0, "log has entries");
1941        assert!(
1942            !st.seq_index_ready,
1943            "warm boot leaves the seq index cold — that is the honest signal"
1944        );
1945
1946        let batch = db2.since(0, 100);
1947        assert!(
1948            batch.to_seq < batch.head_seq,
1949            "cursor is behind the log head in this state"
1950        );
1951        assert!(
1952            batch.has_more,
1953            "has_more must be true while the cursor is behind head — otherwise the \
1954             consumer reads 'caught up' and stops with every record unread"
1955        );
1956
1957        // After a repair the index resolves and the drain actually completes.
1958        db2.repair().unwrap();
1959        assert!(db2.scan_status().seq_index_ready);
1960        let drained = db2.since(0, 100);
1961        assert!(!drained.has_more, "genuinely caught up reports has_more=false");
1962
1963        // KNOWN SHARP EDGE, pinned here deliberately: the cursor is EXCLUSIVE
1964        // and seqs start at 0, so `since(0, _)` returns (0, head] and the very
1965        // first write in a database (seq 0) is not reachable through any cursor
1966        // value. 10 writes therefore drain as 9 records. Changing the cursor
1967        // convention would break existing replication consumers, so this is
1968        // documented rather than silently altered — but a replica seeded from
1969        // since() alone starts one record short.
1970        assert_eq!(
1971            drained.nodes.len(),
1972            9,
1973            "since(0) is exclusive of seq 0 — see the sharp edge noted above"
1974        );
1975        assert!(
1976            drained.nodes.iter().all(|n| n.seq >= 1),
1977            "seq 0 is unreachable via since()"
1978        );
1979    }
1980
1981    #[test]
1982    fn link_missing_node_errors() {
1983        let db = Db::in_memory();
1984        db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
1985        assert!(db.link("driver:d1", "handles", "trip:ghost").is_err());
1986    }
1987
1988    #[test]
1989    fn link_durable_survives_reopen() {
1990        let dir = tempdir().unwrap();
1991        {
1992            let db = Db::open(dir.path(), None).unwrap();
1993            db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1994            db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1995            db.link("driver:d1", "handles", "trip:t1").unwrap();
1996        }
1997        let db2 = Db::open(dir.path(), None).unwrap();
1998        db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
1999        let trips = db2.neighbors("driver:d1", "handles");
2000        assert_eq!(trips.len(), 1);
2001        assert_eq!(trips[0].id, "t1");
2002    }
2003
2004    #[test]
2005    fn tip_survives_warm_restart() {
2006        // v2.5.43: tip() returns the last written object AND survives a warm restart.
2007        // On reopen the seq_index is cold (warm start skips the scan), so tip() must
2008        // resolve the last write via the MANIFEST tip_hash fallback — no scan.
2009        let dir = tempdir().unwrap();
2010        {
2011            let db = Db::open(dir.path(), None).unwrap();
2012            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
2013            db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
2014            db.flush_all(); // persists MANIFEST incl. tip_hash
2015            assert_eq!(db.tip().expect("tip in-session").id, "b2");
2016        }
2017        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
2018        let db2 = Db::open(dir.path(), None).unwrap();
2019        assert!(db2.get_hash_by_seq(1).is_none(), "seq_index is cold on a warm boot");
2020        let tip = db2.tip().expect("tip() must survive a warm restart");
2021        assert_eq!(tip.id, "b2");
2022        assert_eq!(tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
2023    }
2024
2025    #[test]
2026    fn tip_collection_survives_warm_restart() {
2027        // Same contract as tip(), per collection: itc-node-rs resumes headers /
2028        // blocks / l2_receipts independently, so each must be its own durable
2029        // resume point — not just the global tip.
2030        let dir = tempdir().unwrap();
2031        {
2032            let db = Db::open(dir.path(), None).unwrap();
2033            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
2034            db.put("tx",     "t1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2035            let b2 = db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
2036            db.flush_all(); // persists MANIFEST incl. coll_tips
2037            assert_eq!(db.tip_collection("blocks").unwrap().id, "b2");
2038            assert_eq!(db.tip_collection("blocks").unwrap().seq, b2.seq);
2039        }
2040        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
2041        let db2 = Db::open(dir.path(), None).unwrap();
2042        assert!(db2.get_hash_by_seq(0).is_none(), "seq_index is cold on a warm boot");
2043        let blocks_tip = db2.tip_collection("blocks").expect("tip_collection must survive a warm restart");
2044        assert_eq!(blocks_tip.id, "b2");
2045        assert_eq!(blocks_tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
2046        let tx_tip = db2.tip_collection("tx").expect("tx tip must also survive");
2047        assert_eq!(tx_tip.id, "t1");
2048        assert!(db2.tip_collection("absent").is_none());
2049    }
2050
2051    #[test]
2052    fn cold_scan_indexes_every_object_and_reports_completion() {
2053        // Regression guard for the cold-scan refactor: seq_index is now populated
2054        // DURING the parallel read pass (for live scan_status().indexed_count
2055        // progress — see cold_scan_background_arc), not in a second pass
2056        // afterward. This asserts the end state is unchanged: every written
2057        // object is indexed, tip()/tip_collection() are correct, and
2058        // scan_complete eventually reports true.
2059        let dir = tempdir().unwrap();
2060        let n = 25u64;
2061        {
2062            let db = Db::open(dir.path(), None).unwrap();
2063            for i in 0..n {
2064                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2065            }
2066            db.flush_all();
2067        }
2068        // Force a COLD start regardless of the MANIFEST nedb-v2 itself would
2069        // have written: delete it so startup_rebuild() takes the cold path and
2070        // start_cold_scan() actually spawns the background scan this test needs
2071        // to exercise.
2072        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
2073
2074        let db = Db::open(dir.path(), None).unwrap();
2075        assert!(!db.scan_status().scan_complete, "should be cold immediately after open");
2076        let db = std::sync::Arc::new(db);
2077        Db::start_cold_scan(std::sync::Arc::clone(&db));
2078
2079        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2080        while !db.scan_status().scan_complete {
2081            assert!(std::time::Instant::now() < deadline, "cold scan did not complete in time");
2082            std::thread::sleep(std::time::Duration::from_millis(5));
2083        }
2084
2085        let status = db.scan_status();
2086        assert_eq!(status.indexed_count, n as usize, "every written object must be indexed");
2087        assert!(status.scan_complete);
2088
2089        let tip = db.tip().expect("tip resolves after cold scan");
2090        assert_eq!(tip.data.get("i").and_then(|v| v.as_u64()), Some(n - 1));
2091        let coll_tip = db.tip_collection("things").expect("tip_collection resolves after cold scan");
2092        assert_eq!(coll_tip.id, tip.id);
2093    }
2094
2095    /// Concurrent writers must settle the tip at the HIGHEST SEQ, and that tip
2096    /// must survive a warm restart. Before the seq-guarded tip fix, update_head
2097    /// was "last call wins": a slower thread carrying an OLDER seq could
2098    /// overwrite tip_hash after a newer write, and MANIFEST then persisted the
2099    /// stale tip for the next warm boot (flaky by nature — this pins the
2100    /// contract deterministically for the fixed code).
2101    #[test]
2102    fn concurrent_puts_tip_resolves_to_highest_seq_after_warm_restart() {
2103        let dir = tempdir().unwrap();
2104        let total: u64 = 100;
2105        {
2106            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2107            let mut handles = vec![];
2108            for t in 0..4u64 {
2109                let db2 = std::sync::Arc::clone(&db);
2110                handles.push(std::thread::spawn(move || {
2111                    for i in 0..25u64 {
2112                        db2.put("c", &format!("{}-{}", t, i),
2113                                serde_json::json!({"t": t, "i": i}),
2114                                vec![], None, None).unwrap();
2115                    }
2116                }));
2117            }
2118            for h in handles { h.join().unwrap(); }
2119            // In-session: tip must be the highest assigned seq.
2120            let expected = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
2121            assert_eq!(expected, total - 1, "exactly {} writes expected", total);
2122            assert_eq!(db.tip().expect("in-session tip").seq, expected);
2123            db.flush_all(); // persist MANIFEST incl. tip_hash
2124        }
2125        // Warm reopen: seq_index cold; tip() resolves via MANIFEST tip_hash.
2126        let db2 = Db::open(dir.path(), None).unwrap();
2127        let tip = db2.tip().expect("tip must survive warm restart after concurrent writes");
2128        assert_eq!(tip.seq, total - 1, "warm-boot tip must be the highest-seq write");
2129        // Per-collection tip: same contract.
2130        let ct = db2.tip_collection("c").expect("coll tip survives");
2131        assert_eq!(ct.seq, total - 1);
2132    }
2133
2134    /// Pre-2.5.43 MANIFESTs (no tip_hash) must warm-boot, NOT force a cold
2135    /// scan. The old "cold scan once to upgrade" policy was hours of random
2136    /// reads on multi-million-object seek-bound stores (itcd -dagv3), re-paid
2137    /// on every boot if the process exited before the scan finished. seq+head
2138    /// in the old MANIFEST are valid; tip()/tip_collection() return None until
2139    /// the first write+flush organically rewrites MANIFEST with a tip.
2140    #[test]
2141    fn pre_durable_tip_manifest_warm_boots_and_heals_lazily() {
2142        let dir = tempdir().unwrap();
2143        {
2144            let db = Db::open(dir.path(), None).unwrap();
2145            for i in 0..5u64 {
2146                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2147            }
2148            db.flush_all();
2149        }
2150        // Rewrite MANIFEST in the pre-2.5.43 shape: seq + head only.
2151        let manifest_path = dir.path().join("MANIFEST");
2152        let m: serde_json::Value =
2153            serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
2154        let old_format = serde_json::json!({ "seq": m["seq"], "head": m["head"] });
2155        std::fs::write(&manifest_path, serde_json::to_string(&old_format).unwrap()).unwrap();
2156
2157        // Reopen: must be WARM (startup_ready immediately — no cold scan gate).
2158        let db2 = Db::open(dir.path(), None).unwrap();
2159        assert!(db2.startup_ready.load(std::sync::atomic::Ordering::SeqCst),
2160                "pre-2.5.43 MANIFEST must warm-boot, not fall to a cold scan");
2161        // tip() unresolvable this boot — documented None, not a panic or scan.
2162        assert!(db2.tip().is_none(), "tip() is None until the manifest heals");
2163        // seq continuity: a new write gets a FRESH seq (no reuse).
2164        let n = db2.put("things", "next", serde_json::json!({"fresh": true}), vec![], None, None).unwrap();
2165        assert_eq!(n.seq, m["seq"].as_u64().unwrap(), "next write takes the persisted next-to-assign seq");
2166        db2.flush_all(); // organic upgrade: MANIFEST now carries tip_hash
2167        drop(db2);
2168
2169        // Healed: next boot is warm AND tip() resolves.
2170        let db3 = Db::open(dir.path(), None).unwrap();
2171        assert!(db3.startup_ready.load(std::sync::atomic::Ordering::SeqCst));
2172        let tip = db3.tip().expect("tip() must resolve after the organic upgrade");
2173        assert_eq!(tip.id, "next");
2174    }
2175
2176    /// Regression for the cold-scan MANIFEST seq off-by-one. The scan's old
2177    /// hand-rolled MANIFEST stored `seq: max_seq` (the last USED seq), but the
2178    /// warm boot loads `m.seq` as the NEXT-TO-ASSIGN counter — so a restart
2179    /// right after a quiet cold scan handed the next write the tip's seq:
2180    /// a DUPLICATE seq in the log (seq_index overwrite, wrong since() page).
2181    /// The scan now writes MANIFEST via flush_manifest(), which reads the live
2182    /// counter (max_seq + 1).
2183    #[test]
2184    fn manifest_after_cold_scan_does_not_reuse_tip_seq() {
2185        let dir = tempdir().unwrap();
2186        let old_tip_seq;
2187        {
2188            let db = Db::open(dir.path(), None).unwrap();
2189            for i in 0..5u64 {
2190                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
2191            }
2192            db.flush_all();
2193            old_tip_seq = db.tip().unwrap().seq;
2194        }
2195        // Force a cold start: remove MANIFEST so the background scan runs and
2196        // writes a fresh MANIFEST itself.
2197        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
2198        {
2199            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2200            Db::start_cold_scan(std::sync::Arc::clone(&db));
2201            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2202            while !db.scan_status().scan_complete {
2203                assert!(std::time::Instant::now() < deadline, "cold scan did not complete");
2204                std::thread::sleep(std::time::Duration::from_millis(5));
2205            }
2206            // No further writes — the scan's own MANIFEST is what the next boot sees.
2207        }
2208        // Warm reopen from the scan-written MANIFEST: the next write must get a
2209        // FRESH seq, never the tip's.
2210        let db3 = Db::open(dir.path(), None).unwrap();
2211        let tip_before = db3.tip().expect("tip survives scan-written MANIFEST");
2212        assert_eq!(tip_before.seq, old_tip_seq, "tip identity preserved across the scan");
2213        let new_node = db3.put("things", "next", serde_json::json!({"fresh": true}),
2214                               vec![], None, None).unwrap();
2215        assert!(new_node.seq > old_tip_seq,
2216                "new write reused seq {} (tip was {}) — duplicate seq in the log",
2217                new_node.seq, old_tip_seq);
2218    }
2219
2220    /// Regression: the flush ticker must NOT pin the database.
2221    ///
2222    /// Before this was fixed, `start_manifest_ticker` held a strong `Arc<Db>`
2223    /// in an unconditional `loop`, so the thread never exited, the `Db` was
2224    /// never dropped, and the exclusive data-dir `LOCK` from `Db::open` was
2225    /// never released. Reopening the same path in the SAME PROCESS then failed
2226    /// with "locked by another process (pid N)" — where N was the caller's own
2227    /// pid. Live in every release from 2.8.5 through 3.1.0, and invisible
2228    /// because no CI ran the suite (tests/test_native.py) that hit it.
2229    ///
2230    /// Put the strong `Arc` back in the ticker and this test fails.
2231    #[test]
2232    fn ticker_does_not_pin_the_db_across_a_reopen() {
2233        let dir = tempdir().unwrap();
2234        {
2235            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2236            Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
2237            db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2238            // Let the ticker run at least a couple of times while the db lives.
2239            std::thread::sleep(std::time::Duration::from_millis(90));
2240        } // last owner dropped here -> Drop flushes -> LOCK released
2241
2242        // The ticker upgrades its Weak for the duration of a tick, so at any
2243        // given instant it may legitimately hold a transient strong reference.
2244        // Release is therefore "eventual, within about one interval", not
2245        // instantaneous -- poll for it.
2246        //
2247        // The first version of this test sampled Arc::strong_count once and
2248        // asserted it was 1. That passed on an idle machine and failed the
2249        // first time it met a loaded CI runner, because the sample landed
2250        // mid-tick. A leak still fails this test deterministically: if the
2251        // ticker holds a strong Arc forever the LOCK is never released and
2252        // the deadline expires.
2253        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2254        let db2 = loop {
2255            match Db::open(dir.path(), None) {
2256                Ok(db) => break db,
2257                Err(e) => {
2258                    assert!(std::time::Instant::now() < deadline,
2259                            "reopen never succeeded -- the ticker is pinning the Db: {e}");
2260                    std::thread::sleep(std::time::Duration::from_millis(25));
2261                }
2262            }
2263        };
2264        assert!(db2.get("t", "a").is_some(), "the write survived close/reopen");
2265    }
2266
2267    /// The ticker thread must actually terminate, not merely stop pinning.
2268    #[test]
2269    fn ticker_thread_exits_when_the_last_owner_drops() {
2270        let dir = tempdir().unwrap();
2271        let weak = {
2272            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
2273            Db::start_manifest_ticker(std::sync::Arc::clone(&db), 25);
2274            db.put("t", "a", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
2275            std::thread::sleep(std::time::Duration::from_millis(60));
2276            std::sync::Arc::downgrade(&db)
2277        };
2278        // Same reasoning as above: a tick in flight holds a real strong
2279        // reference for a few microseconds, so this is an eventual property.
2280        // A genuine leak never releases and blows the deadline.
2281        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2282        while weak.upgrade().is_some() {
2283            assert!(std::time::Instant::now() < deadline,
2284                    "the Db outlived its last owner — the ticker is leaking it");
2285            std::thread::sleep(std::time::Duration::from_millis(25));
2286        }
2287    }
2288}