Skip to main content

nedb_engine/
db.rs

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