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    pub fn start_manifest_ticker(self_arc: Arc<Self>, interval_ms: u64) {
744        let db = self_arc;
745        std::thread::spawn(move || {
746            loop {
747                std::thread::sleep(std::time::Duration::from_millis(interval_ms));
748                // Flush id-index WAL to disk (parallel Rayon writes)
749                db.id_index.flush_write_buf();
750                // Segment bytes must be durable BEFORE a MANIFEST that
751                // references them: otherwise power loss can leave MANIFEST
752                // pointing at a tip whose object bytes were still in the page
753                // cache — the torn tail is truncated on reopen and the warm
754                // boot resolves a tip that no longer exists, with the seq
755                // counter ahead of durable data. Order: sync segments, then
756                // MANIFEST. Gated on the dirty flag so an idle database pays
757                // no per-tick fsync. (flush_all already used this order; the
758                // ticker now matches it.)
759                if db.manifest_dirty.load(Ordering::Acquire) {
760                    if let Err(e) = db.objects.sync() {
761                        eprintln!("nedb: segment sync failed: {}", e);
762                    }
763                    db.flush_manifest_if_dirty();
764                }
765            }
766        });
767    }
768
769    /// Return the current Merkle head string. O(1) — read from cache.
770    pub fn head(&self) -> String {
771        self.head.read().clone()
772    }
773
774    /// Delete a document — writes a tombstone node and removes the id from the index.
775    /// The object history is preserved in the DAG; only the live id pointer is cleared.
776    pub fn delete(&self, coll: &str, id: &str) -> Result<bool> {
777        let prev = match self.id_index.get(coll, id) {
778            None => return Ok(false),   // already gone
779            Some(h) => h,
780        };
781        let seq = self.seq.fetch_add(1, Ordering::SeqCst);
782        let mut tombstone = Node {
783            id:         format!("_del_{}", id),
784            coll:       coll.to_string(),
785            seq,
786            data:       serde_json::json!({"_deleted": id, "_prev": prev}),
787            prev:       Some(prev),
788            caused_by:  vec![],
789            ts:         now(),
790            valid_from: None,
791            valid_to:   None,
792            hash:       String::new(),
793        };
794        let hash = self.objects.write(&mut tombstone)?;
795        self.update_head(coll, seq, &hash);
796        // Remove the live id pointer — doc is now invisible to queries and list()
797        self.id_index.remove(coll, id)?;
798        Ok(true)
799    }
800
801    /// Get the current version of a document by id.
802    pub fn get(&self, coll: &str, id: &str) -> Option<Node> {
803        let hash = self.id_index.get(coll, id)?;
804        self.objects.read(&hash).ok()
805    }
806
807    /// Get a specific version of a document by object hash.
808    pub fn get_by_hash(&self, hash: &str) -> Option<Node> {
809        self.objects.read(hash).ok()
810    }
811
812    /// Get a document AS OF a specific sequence number.
813    /// Walks the version chain (prev links) backward until seq <= target.
814    pub fn get_as_of(&self, coll: &str, id: &str, target_seq: u64) -> Option<Node> {
815        let hash = self.id_index.get(coll, id)?;
816        let mut current = self.objects.read(&hash).ok()?;
817        loop {
818            if current.seq <= target_seq {
819                return Some(current);
820            }
821            let prev_hash = current.prev.as_deref()?;
822            current = self.objects.read(prev_hash).ok()?;
823        }
824    }
825
826    /// List all documents in a collection, returning current versions.
827    pub fn list(&self, coll: &str) -> Vec<Node> {
828        self.id_index
829            .list_ids(coll)
830            .into_iter()
831            .filter_map(|id| self.get(coll, &id))
832            .collect()
833    }
834
835    /// ORDER BY field ASC LIMIT n — uses sorted index if available, else falls back to full scan.
836    pub fn order_by_asc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
837        if self.sorted_indexes.has(coll, field) {
838            self.sorted_indexes
839                .top_k_asc(coll, field, limit)
840                .into_iter()
841                .filter_map(|h| self.objects.read(&h).ok())
842                .collect()
843        } else {
844            let mut docs = self.list(coll);
845            docs.sort_by(|a, b| {
846                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
847                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
848                av.cmp(&bv)
849            });
850            docs.truncate(limit);
851            docs
852        }
853    }
854
855    /// ORDER BY field DESC LIMIT n
856    pub fn order_by_desc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
857        if self.sorted_indexes.has(coll, field) {
858            self.sorted_indexes
859                .top_k_desc(coll, field, limit)
860                .into_iter()
861                .filter_map(|h| self.objects.read(&h).ok())
862                .collect()
863        } else {
864            let mut docs = self.list(coll);
865            docs.sort_by(|a, b| {
866                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
867                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
868                bv.cmp(&av)
869            });
870            docs.truncate(limit);
871            docs
872        }
873    }
874
875    /// TRACE caused_by — walk causal graph from a node.
876    pub fn trace(&self, hash: &str, reverse: bool, limit: usize) -> Vec<Node> {
877        self.graph
878            .trace(hash, "caused_by", reverse, limit)
879            .into_iter()
880            .filter_map(|h| self.objects.read(&h).ok())
881            .collect()
882    }
883
884    /// Verify tamper-evidence of all objects.
885    pub fn verify(&self) -> (usize, Vec<String>) {
886        self.objects.verify_all()
887    }
888
889    /// Create a sorted index for a (coll, field) pair.
890    pub fn create_sorted_index(&self, coll: &str, field: &str) {
891        self.sorted_indexes.ensure(coll, field);
892        // Backfill from existing objects
893        for id in self.id_index.list_ids(coll) {
894            if let Some(node) = self.get(coll, &id) {
895                if let Value::Object(ref obj) = node.data {
896                    if let Some(value) = obj.get(field) {
897                        self.sorted_indexes.insert(coll, field, value, &node.hash);
898                    }
899                }
900            }
901        }
902    }
903
904    /// Resolve a sequence number to its content hash (v1 compatibility).
905    /// Only covers nodes written in the current process session + cold-scan nodes.
906    pub fn get_hash_by_seq(&self, seq: u64) -> Option<String> {
907        self.seq_index.get(&seq).map(|r| r.clone())
908    }
909
910    /// The tip — the most recently written node (highest seq), or `None` if the
911    /// database is empty. O(1): `self.seq` is the next-to-assign counter, so the
912    /// latest write sits at `seq - 1`; we resolve it through the same
913    /// seq_index → object-store path a normal read uses, so the returned Node is
914    /// byte-identical to one fetched by id or hash (it carries its own seq, hash,
915    /// causal links, and valid-time). This is the cheap "give me the latest write"
916    /// primitive — the head of the log, not an aggregate.
917    pub fn tip(&self) -> Option<Node> {
918        let next = self.seq.load(Ordering::SeqCst);
919        if next == 0 {
920            return None; // nothing written yet
921        }
922        // Fast path: resolve the head seq through the in-memory seq index
923        // (populated by this session's writes or by the cold scan).
924        if let Some(hash) = self.get_hash_by_seq(next - 1) {
925            return self.get_by_hash(&hash);
926        }
927        // Warm-boot fallback: the seq index is still cold (warm start skips the
928        // scan), but the tip's object hash was persisted in MANIFEST and restored
929        // on open. O(1), no scan — this is what makes tip() survive a restart.
930        let th = self.tip_hash.read().1.clone();
931        if !th.is_empty() {
932            return self.get_by_hash(&th);
933        }
934        None
935    }
936
937    /// The collection-local tip — the most recent write into `coll` (highest seq in
938    /// that collection), or `None` if the collection has no writes. O(1): resolves
939    /// through `coll_tip_hash`, a dedicated per-collection map kept current on every
940    /// write (`update_head`), restored from MANIFEST on warm boot, and rebuilt by the
941    /// cold scan — durable across restarts by construction, same contract as `tip()`
942    /// for the global head. Conceptually a different index than the global `tip()`
943    /// (global head vs collection head), kept as a separate method so each is
944    /// explicit — parity with the Python reference's `tip(coll)`. Lets a consumer
945    /// resume one chain (e.g. blocks / tx / utxo) without pulling global tip and
946    /// filtering.
947    pub fn tip_collection(&self, coll: &str) -> Option<Node> {
948        let hash = self.coll_tip_hash.get(coll)?.1.clone();
949        self.get_by_hash(&hash)
950    }
951
952    /// Changefeed page: up to `limit` nodes written AFTER `after_seq` (EXCLUSIVE),
953    /// ascending by seq, wrapped in a `SinceBatch` cursor envelope. `after_seq` is
954    /// the cursor you last applied (a prior `tip()` seq or `to_seq`). `limit` bounds
955    /// the page — `0` means DEFAULT_SINCE_LIMIT, so the engine primitive can never
956    /// materialize an unbounded batch even when embedders call it directly (the
957    /// safety is here, not only in the HTTP layer). Drain by paging while
958    /// `has_more`, advancing your cursor to `to_seq`, then hand off to the live
959    /// `subscribe` edge. The append-only log IS the changefeed, so this is an
960    /// O(page) walk; unresolved seqs (outside seq_index coverage — see
961    /// `scan_status()`) are skipped rather than faked.
962    pub fn since(&self, after_seq: u64, limit: usize) -> SinceBatch {
963        let next = self.seq.load(Ordering::SeqCst);          // head + 1
964        let head_seq = next.saturating_sub(1);
965        let cap = if limit == 0 { DEFAULT_SINCE_LIMIT } else { limit };
966        let mut nodes: Vec<Node> = Vec::new();
967        let mut to_seq = after_seq;
968        let mut hit_limit = false;
969        let mut s = after_seq.saturating_add(1);
970        while s < next {
971            if nodes.len() >= cap { hit_limit = true; break; }
972            if let Some(hash) = self.get_hash_by_seq(s) {
973                if let Some(node) = self.get_by_hash(&hash) {
974                    to_seq = node.seq;
975                    nodes.push(node);
976                }
977            }
978            s += 1;
979        }
980        // `has_more` must never say "caught up" while the cursor is behind the
981        // log head. Before 2.8.6 this was `hit_limit` alone, so any page whose
982        // seqs could not be resolved (the whole range, on a warm boot: the warm
983        // path skips the scan, leaving seq_index empty) returned zero nodes with
984        // has_more=false — indistinguishable from genuinely up to date. A
985        // consumer following the documented drain loop stopped forever, one call
986        // in, on a database with every record unread.
987        let has_more = hit_limit || to_seq < head_seq;
988        SinceBatch { nodes, from_seq: after_seq, to_seq, head_seq, has_more }
989    }
990
991    /// Replication readiness — see `ScanStatus`. `scan_complete` gates safe
992    /// historical catch-up: a consumer pulling an old cursor right after a cold
993    /// start must wait for it, or `since()` may hand back a partial page that looks
994    /// like "caught up". Computes the indexed range by scanning the in-memory seq
995    /// index (O(index)) — intended for periodic status polls, not the per-write
996    /// hot path.
997    pub fn scan_status(&self) -> ScanStatus {
998        let next = self.seq.load(Ordering::SeqCst);
999        let mut min = u64::MAX;
1000        let mut max = 0u64;
1001        let mut count = 0usize;
1002        for kv in self.seq_index.iter() {
1003            let s = *kv.key();
1004            if s < min { min = s; }
1005            if s > max { max = s; }
1006            count += 1;
1007        }
1008        if count == 0 { min = 0; }
1009        ScanStatus {
1010            scan_complete:   self.startup_ready.load(Ordering::SeqCst),
1011            tip_seq:         next.saturating_sub(1),
1012            indexed_seq_min: min,
1013            indexed_seq_max: max,
1014            indexed_count:   count,
1015            // The seq index covers the log when it resolves as many seqs as the
1016            // log has entries. On a warm boot it is empty while the log is not.
1017            seq_index_ready: count > 0 && (count as u64) >= next.saturating_sub(1),
1018        }
1019    }
1020
1021    /// Add an explicit named relation edge between two documents.
1022    /// Add an explicit named relation between two "coll:id" nodes.
1023    /// Relations stored as __links__ documents — NQL-queryable, time-travelable,
1024    /// consistent with the PyO3 binding which uses the same __links__ convention.
1025    pub fn link(&self, frm: &str, rel: &str, to: &str) -> Result<()> {
1026        let (frm_coll, frm_id) = frm.split_once(':')
1027            .ok_or_else(|| anyhow::anyhow!("link frm must be 'coll:id', got: {}", frm))?;
1028        let (to_coll, to_id) = to.split_once(':')
1029            .ok_or_else(|| anyhow::anyhow!("link to must be 'coll:id', got: {}", to))?;
1030        if self.id_index.get(frm_coll, frm_id).is_none() {
1031            anyhow::bail!("link: frm not found: {}", frm);
1032        }
1033        if self.id_index.get(to_coll, to_id).is_none() {
1034            anyhow::bail!("link: to not found: {}", to);
1035        }
1036        let link_id = format!("{}|{}|{}", frm, rel, to);
1037        let doc = serde_json::json!({"_from": frm, "_rel": rel, "_to": to});
1038        self.put("__links__", &link_id, doc, vec![], None, None)?;
1039        Ok(())
1040    }
1041
1042    /// Remove a named relation (deletes the __links__ document).
1043    pub fn unlink(&self, frm: &str, rel: &str, to: &str) -> Result<bool> {
1044        let link_id = format!("{}|{}|{}", frm, rel, to);
1045        self.delete("__links__", &link_id)
1046    }
1047
1048    /// Get neighbor nodes via a named relation.
1049    /// Queries __links__ — consistent with the PyO3 binding.
1050    pub fn neighbors(&self, frm: &str, rel: &str) -> Vec<Node> {
1051        self.id_index
1052            .list_ids("__links__")
1053            .into_iter()
1054            .filter_map(|id| self.get("__links__", &id))
1055            .filter(|node| {
1056                node.data.get("_from").and_then(|v| v.as_str()) == Some(frm)
1057                    && node.data.get("_rel").and_then(|v| v.as_str()) == Some(rel)
1058            })
1059            .filter_map(|node| {
1060                let to = node.data.get("_to")?.as_str()?;
1061                let (to_coll, to_id) = to.split_once(':')?;
1062                self.get(to_coll, to_id)
1063            })
1064            .collect()
1065    }
1066}
1067
1068impl Drop for Db {
1069    /// Flush buffered state when the database is closed so a write-then-drop
1070    /// sequence is durable without an explicit `flush_all()`.
1071    ///
1072    /// `IdIndex::set` only stages updates in the in-memory WAL `write_buf`;
1073    /// disk persistence happens in `flush_write_buf()`, normally driven by the
1074    /// manifest ticker. A short-lived `Db` (a library user's `{ let db =
1075    /// Db::open(p)?; db.put(..)?; }` block, or a test) has no ticker, so without
1076    /// this its writes would be silently lost on reopen. Flushing on drop
1077    /// mirrors the flush-on-close contract of other embedded stores (sled,
1078    /// RocksDB).
1079    ///
1080    /// In production this is a harmless safety net, not the primary durability
1081    /// path: the manifest ticker thread holds an `Arc<Db>` for the process
1082    /// lifetime, so `Drop` only fires once every owning handle is gone. No-op
1083    /// for in-memory databases (`flush_all` short-circuits on `:memory:`).
1084    fn drop(&mut self) {
1085        self.flush_all();
1086    }
1087}
1088
1089/// Background cold-scan worker. Takes Arc<Db> — safe, Db is on the heap.
1090fn cold_scan_background_arc(db: Arc<Db>) {
1091    use rayon::prelude::*;
1092
1093    let objects        = &db.objects;
1094    let seq_atomic     = &db.seq;
1095    let sorted_indexes = &db.sorted_indexes;
1096    let seq_index      = &db.seq_index;
1097    let ready_flag     = Arc::clone(&db.startup_ready);
1098
1099    let hashes: Vec<String> = objects.all_hashes().collect();
1100    let total = hashes.len();
1101
1102    if total == 0 {
1103        ready_flag.store(true, Ordering::SeqCst);
1104        return;
1105    }
1106
1107    println!("  [nedbd] background scan — {} objects...", total);
1108    let t0 = std::time::Instant::now();
1109    let step = (total / 10).max(1000);
1110
1111    // Populate the seq index AS objects are read here, not in a second pass
1112    // afterward: this loop is the slow, disk-I/O-bound phase (verifying and
1113    // parsing every object), and it can run for minutes on a multi-million
1114    // object store. `scan_status().indexed_count` reads `seq_index`'s size, so
1115    // inserting here — not after `.collect()` — is what makes that a real, live
1116    // progress signal through the phase that actually takes the time, instead
1117    // of reporting a flat 0 until this whole pass finishes. Safe: DashMap
1118    // supports concurrent inserts, and every parallel worker here inserts a
1119    // disjoint key (each object has its own seq).
1120    let nodes: Vec<Node> = hashes.par_iter()
1121        .enumerate()
1122        .filter_map(|(i, h)| {
1123            if i > 0 && i % step == 0 {
1124                let pct     = i * 100 / total;
1125                let elapsed = t0.elapsed().as_secs_f32();
1126                let rate    = i as f32 / elapsed;
1127                let eta     = (total - i) as f32 / rate;
1128                eprint!("\r  [nedbd]   {:>3}%  {:>8} / {:>8}  ({:>8.0}/s  eta {:.0}s)   ",
1129                    pct, i, total, rate, eta);
1130            }
1131            let node = objects.read(h).ok()?;
1132            seq_index.insert(node.seq, node.hash.clone());
1133            Some(node)
1134        })
1135        .collect();
1136
1137    eprintln!("\r  [nedbd]   100%  {:>8} / {:>8}  ({:.1}s)                        ",
1138        total, total, t0.elapsed().as_secs_f32());
1139
1140    let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
1141    seq_atomic.store(max_seq + 1, Ordering::SeqCst);
1142
1143    // Per-collection tip: highest-seq node's hash, per coll. `nodes` is NOT
1144    // seq-ordered here (it comes from an unordered object-hash scan), so this
1145    // must track the max explicitly — unlike the live write path's "last call
1146    // wins" (which relies on ascending call order that a scan doesn't have).
1147    let mut coll_max: std::collections::HashMap<String, (u64, String)> = std::collections::HashMap::new();
1148
1149    for node in &nodes {
1150        // seq_index was already populated above, during the read pass.
1151        coll_max.entry(node.coll.clone())
1152            .and_modify(|(s, h)| if node.seq > *s { *s = node.seq; *h = node.hash.clone(); })
1153            .or_insert_with(|| (node.seq, node.hash.clone()));
1154        if let Value::Object(ref obj) = node.data {
1155            for (field, value) in obj {
1156                if sorted_indexes.has(&node.coll, field) {
1157                    sorted_indexes.insert(&node.coll, field, value, &node.hash);
1158                }
1159            }
1160        }
1161    }
1162
1163    for (coll, (seq, hash)) in coll_max {
1164        db.coll_tip_hash.insert(coll, (seq, hash));
1165    }
1166
1167    // Rebuild the id index when it has no collections at all — the lost-WAL
1168    // case. Until 2.8.6 the cold scan restored seq_index, coll_tips, head and
1169    // MANIFEST but NEVER the id index, so a database whose id-index WAL never
1170    // reached disk came back with every object present and verifying while
1171    // `list()` and `get()` returned nothing — and `nedb-cli repair`, whose whole
1172    // job is this, reported success without fixing it.
1173    //
1174    // Gated on "no collections" so a normal cold boot of a healthy store (itcd:
1175    // millions of objects) does not pay N extra index writes. A partially lost
1176    // index is repaired by the explicit `rebuild_id_index()` path.
1177    if db.id_index.collections().is_empty() && !nodes.is_empty() {
1178        let restored = rebuild_id_index_from_nodes(&db, &nodes);
1179        println!("  [nedbd] id index was empty — rebuilt {} entries from objects", restored);
1180    }
1181
1182    // Merkle head + tip, through the one shared implementation so the cold scan
1183    // and the explicit repair path can never drift apart.
1184    recompute_head_and_tip(&db, hashes, max_seq);
1185
1186    // Write MANIFEST through the one canonical writer. The hand-rolled write
1187    // this replaces stored `seq: max_seq` (the last USED seq) — but the warm
1188    // boot loads `m.seq` as the NEXT-TO-ASSIGN counter, so a restart right
1189    // after a quiet cold scan handed the next write the tip's seq: a duplicate
1190    // seq in the log (seq_index overwrite, wrong since() page). flush_manifest
1191    // reads the live counter (already max_seq + 1) — correct by construction.
1192    db.flush_manifest();
1193
1194    // Signal server: writes can now proceed
1195    ready_flag.store(true, Ordering::SeqCst);
1196    println!("  [nedbd] background scan complete — seq={} objects={} MANIFEST written", max_seq, total);
1197}
1198
1199/// Recompute the Merkle head and the tip hash from the full object-hash set.
1200///
1201/// Shared by the cold scan and by `repair()` so the two can never disagree
1202/// about what the head of a rebuilt database is. `hashes` must be every object
1203/// hash in the store; `max_seq` the highest seq observed.
1204fn recompute_head_and_tip(db: &Db, hashes: Vec<String>, max_seq: u64) {
1205    use blake2::{Blake2b512, Digest};
1206    let mut sorted_hashes = hashes;
1207    sorted_hashes.sort();
1208    let mut h = Blake2b512::new();
1209    h.update(max_seq.to_le_bytes());
1210    for hash_str in &sorted_hashes {
1211        h.update(hash_str.as_bytes());
1212    }
1213    *db.head.write() = hex::encode(&h.finalize()[..32]);
1214
1215    // Tip = the highest-seq object indexed. Persisting its hash lets tip()
1216    // resolve O(1) on the next warm boot, before any scan repopulates seq_index.
1217    let tip_hash = db.seq_index.iter()
1218        .max_by_key(|kv| *kv.key())
1219        .map(|kv| kv.value().clone())
1220        .unwrap_or_default();
1221    *db.tip_hash.write() = (max_seq, tip_hash);
1222}
1223
1224/// Reconstruct id-index entries from already-read nodes: for every (coll, id),
1225/// the winner is the HIGHEST seq, which is exactly what `put()` would have left
1226/// behind. Returns the number of entries written.
1227///
1228/// The id index is fully derivable from the object store because every object
1229/// carries its own `coll`, `id` and `seq` — so a lost WAL is recoverable, and
1230/// nothing here invents data.
1231fn rebuild_id_index_from_nodes(db: &Db, nodes: &[Node]) -> usize {
1232    let mut winner: std::collections::HashMap<(String, String), (u64, String)> =
1233        std::collections::HashMap::new();
1234    for node in nodes {
1235        let key = (node.coll.clone(), node.id.clone());
1236        winner
1237            .entry(key)
1238            .and_modify(|cur| {
1239                if node.seq > cur.0 {
1240                    *cur = (node.seq, node.hash.clone());
1241                }
1242            })
1243            .or_insert((node.seq, node.hash.clone()));
1244    }
1245    let mut written = 0usize;
1246    for ((coll, id), (_seq, hash)) in &winner {
1247        if db.id_index.set(coll, id, hash).is_ok() {
1248            written += 1;
1249        }
1250    }
1251    // Persist immediately: a rebuild that only lands in the WAL would be lost
1252    // again by the very crash class this recovers from.
1253    if let Err(e) = db.id_index.try_flush_write_buf() {
1254        eprintln!("nedb: id-index rebuild flush failed: {}", e);
1255    }
1256    written
1257}
1258
1259fn now() -> f64 {
1260    std::time::SystemTime::now()
1261        .duration_since(std::time::UNIX_EPOCH)
1262        .map(|d| d.as_secs_f64())
1263        .unwrap_or(0.0)
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269    use tempfile::tempdir;
1270
1271    #[test]
1272    fn put_and_get() {
1273        let dir = tempdir().unwrap();
1274        let db = Db::open(dir.path(), None).unwrap();
1275        db.put(
1276            "blocks", "618000",
1277            serde_json::json!({"height": 618000, "hash": "0000abc"}),
1278            vec![], None, None,
1279        ).unwrap();
1280        let node = db.get("blocks", "618000").unwrap();
1281        assert_eq!(node.id, "618000");
1282        assert_eq!(node.data["height"], 618000);
1283    }
1284
1285    #[test]
1286    fn order_by_with_sorted_index() {
1287        let dir = tempdir().unwrap();
1288        let db = Db::open(dir.path(), None).unwrap();
1289        db.create_sorted_index("blocks", "height");
1290        for h in [3u64, 1, 5, 2, 4] {
1291            db.put("blocks", &h.to_string(),
1292                serde_json::json!({"height": h}),
1293                vec![], None, None).unwrap();
1294        }
1295        let asc = db.order_by_asc("blocks", "height", 3);
1296        let heights: Vec<u64> = asc.iter()
1297            .filter_map(|n| n.data["height"].as_u64())
1298            .collect();
1299        assert_eq!(heights, vec![1, 2, 3]);
1300    }
1301
1302    #[test]
1303    fn causal_trace() {
1304        let dir = tempdir().unwrap();
1305        let db = Db::open(dir.path(), None).unwrap();
1306        let a = db.put("ops", "a", serde_json::json!({"op": "create"}), vec![], None, None).unwrap();
1307        let b = db.put("ops", "b", serde_json::json!({"op": "transfer"}), vec![a.hash.clone()], None, None).unwrap();
1308        let c = db.put("ops", "c", serde_json::json!({"op": "burn"}), vec![b.hash.clone()], None, None).unwrap();
1309
1310        let trace = db.trace(&c.hash, false, 10);
1311        assert_eq!(trace.len(), 3);  // c → b → a
1312    }
1313
1314    #[test]
1315    fn as_of() {
1316        let dir = tempdir().unwrap();
1317        let db = Db::open(dir.path(), None).unwrap();
1318        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1319        let _v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1320
1321        let at_v1 = db.get_as_of("docs", "x", v1.seq).unwrap();
1322        assert_eq!(at_v1.data["v"], 1);
1323        let current = db.get("docs", "x").unwrap();
1324        assert_eq!(current.data["v"], 2);
1325    }
1326}
1327
1328#[cfg(test)]
1329mod tests_v2 {
1330    use super::*;
1331    use tempfile::tempdir;
1332
1333    #[test]
1334    fn seq_index_populated_on_put() {
1335        let db = Db::in_memory();
1336        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1337        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1338        assert_eq!(db.get_hash_by_seq(a.seq), Some(a.hash.clone()));
1339        assert_eq!(db.get_hash_by_seq(b.seq), Some(b.hash.clone()));
1340        assert_eq!(db.get_hash_by_seq(9999), None);
1341    }
1342
1343    #[test]
1344    fn tip_and_since() {
1345        let db = Db::in_memory();
1346        // Empty db: no tip, empty changefeed.
1347        assert!(db.tip().is_none());
1348        assert!(db.since(0, 0).nodes.is_empty());
1349
1350        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1351        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1352
1353        // tip() = the most recent write (highest seq), returned as a full node.
1354        let t = db.tip().expect("tip after writes");
1355        assert_eq!(t.seq, b.seq);
1356        assert_eq!(t.id, "b");
1357        assert_eq!(t.hash, b.hash);
1358
1359        // since(after_seq, limit) — EXCLUSIVE cursor, bounded page + envelope.
1360        let after_a = db.since(a.seq, 0);
1361        assert_eq!(after_a.nodes.len(), 1);
1362        assert_eq!(after_a.nodes[0].id, "b");
1363        assert_eq!(after_a.from_seq, a.seq);
1364        assert_eq!(after_a.to_seq, b.seq);
1365        assert_eq!(after_a.head_seq, b.seq);
1366        assert!(!after_a.has_more);
1367
1368        // Nothing written after the tip.
1369        assert!(db.since(b.seq, 0).nodes.is_empty());
1370
1371        // `limit` bounds the page and sets has_more; resume from to_seq.
1372        let c = db.put("item", "c", serde_json::json!({"x": 3}), vec![], None, None).unwrap();
1373        let page = db.since(a.seq, 1);             // (a..] capped at 1 -> [b], more pending
1374        assert_eq!(page.nodes.len(), 1);
1375        assert_eq!(page.nodes[0].id, "b");
1376        assert_eq!(page.to_seq, b.seq);
1377        assert!(page.has_more);
1378        let page2 = db.since(page.to_seq, 1);      // resume from b -> [c], done
1379        assert_eq!(page2.nodes.len(), 1);
1380        assert_eq!(page2.nodes[0].id, "c");
1381        assert_eq!(page2.to_seq, c.seq);
1382        assert!(!page2.has_more);
1383    }
1384
1385    #[test]
1386    fn tip_collection_per_chain() {
1387        // The ITC sync-client case: separate chains in separate collections; a
1388        // consumer resumes ONE without pulling global tip and filtering.
1389        let db = Db::in_memory();
1390        assert!(db.tip_collection("blocks").is_none());
1391
1392        db.put("blocks", "b0", serde_json::json!({"h": 0}), vec![], None, None).unwrap();
1393        db.put("tx",     "t0", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1394        let b1 = db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1395        let t1 = db.put("tx",     "t1", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1396
1397        // global tip = latest write overall (t1)
1398        assert_eq!(db.tip().unwrap().id, "t1");
1399        // collection-local tips = latest write in each collection
1400        let bt = db.tip_collection("blocks").expect("blocks tip");
1401        assert_eq!(bt.id, "b1");
1402        assert_eq!(bt.seq, b1.seq);
1403        assert_eq!(db.tip_collection("tx").unwrap().seq, t1.seq);
1404        assert!(db.tip_collection("absent").is_none());
1405    }
1406
1407    #[test]
1408    fn seq_index_survives_batch() {
1409        let db = Db::in_memory();
1410        let nodes = db.put_batch(vec![
1411            ("item".into(), "x".into(), serde_json::json!({"v": 1}), vec![], None, None),
1412            ("item".into(), "y".into(), serde_json::json!({"v": 2}), vec![], None, None),
1413        ]).unwrap();
1414        for node in &nodes {
1415            assert_eq!(db.get_hash_by_seq(node.seq), Some(node.hash.clone()));
1416        }
1417    }
1418
1419    /// Regression: put_batch must remove the superseded version's sorted-index
1420    /// entries, exactly like put() does. Old behavior left the old hashes in
1421    /// the BTree — ORDER BY returned superseded rows alongside current ones
1422    /// (they resolve fine through the content-addressed store, which made the
1423    /// stale rows look legitimate).
1424    #[test]
1425    fn put_batch_removes_superseded_sorted_index_entries() {
1426        let db = Db::in_memory();
1427        db.create_sorted_index("blocks", "height");
1428        db.put("blocks", "x", serde_json::json!({"height": 1}), vec![], None, None).unwrap();
1429        db.put_batch(vec![
1430            ("blocks".into(), "x".into(), serde_json::json!({"height": 99}), vec![], None, None),
1431        ]).unwrap();
1432
1433        let asc = db.order_by_asc("blocks", "height", 10);
1434        assert_eq!(asc.len(), 1, "stale index entry for the superseded version must be gone");
1435        assert_eq!(asc[0].data["height"], 99);
1436        assert_eq!(asc[0].id, "x");
1437    }
1438
1439    /// Updates without any sorted index must keep full version-chain semantics
1440    /// (guards the new skip-old-object-read fast path in put()).
1441    #[test]
1442    fn update_without_indexes_preserves_chain() {
1443        let db = Db::in_memory();
1444        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1445        let v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1446        assert_eq!(v2.prev.as_deref(), Some(v1.hash.as_str()), "prev chain must survive the fast path");
1447        assert_eq!(db.get("docs", "x").unwrap().data["v"], 2);
1448        assert_eq!(db.get_as_of("docs", "x", v1.seq).unwrap().data["v"], 1);
1449    }
1450
1451    #[test]
1452    fn link_and_neighbors() {
1453        let db = Db::in_memory();
1454        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1455        db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
1456        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1457        db.put("trip",   "t2", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1458
1459        db.link("driver:d1", "handles", "trip:t1").unwrap();
1460        db.link("driver:d1", "handles", "trip:t2").unwrap();
1461        db.link("driver:d2", "handles", "trip:t1").unwrap();
1462
1463        let d1_trips = db.neighbors("driver:d1", "handles");
1464        assert_eq!(d1_trips.len(), 2);
1465        let ids: std::collections::HashSet<&str> = d1_trips.iter().map(|n| n.id.as_str()).collect();
1466        assert!(ids.contains("t1") && ids.contains("t2"));
1467
1468        let d2_trips = db.neighbors("driver:d2", "handles");
1469        assert_eq!(d2_trips.len(), 1);
1470        assert_eq!(d2_trips[0].id, "t1");
1471    }
1472
1473    #[test]
1474    fn link_stored_in_links_collection() {
1475        // Links are stored as __links__ documents, not as graph edges.
1476        // The __links__ collection is NQL-queryable and consistent with the PyO3 binding.
1477        let db = Db::in_memory();
1478        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1479        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1480        db.link("driver:d1", "handles", "trip:t1").unwrap();
1481        // Verify the __links__ document was created
1482        let link_doc = db.get("__links__", "driver:d1|handles|trip:t1");
1483        assert!(link_doc.is_some(), "__links__ doc should exist");
1484        let doc = link_doc.unwrap();
1485        assert_eq!(doc.data["_from"], "driver:d1");
1486        assert_eq!(doc.data["_rel"],  "handles");
1487        assert_eq!(doc.data["_to"],   "trip:t1");
1488        // neighbors() resolves to the target node
1489        let nb = db.neighbors("driver:d1", "handles");
1490        assert_eq!(nb.len(), 1);
1491        assert_eq!(nb[0].id, "t1");
1492    }
1493
1494    /// A lost id-index WAL must be recoverable: the objects carry coll/id/seq,
1495    /// so `repair()` can reconstruct every row, and the repaired database must
1496    /// reopen WARM with a valid head.
1497    ///
1498    /// Regression for 2.8.5, where the cold scan rebuilt seq_index, coll_tips,
1499    /// head and MANIFEST but never the id index — so a database in this state
1500    /// returned 0 rows from `list()` while `verify()` reported every object
1501    /// healthy, and `nedb-cli repair` printed success without fixing anything.
1502    #[test]
1503    fn repair_rebuilds_id_index_after_lost_wal() {
1504        let dir = tempdir().unwrap();
1505        {
1506            let db = Db::open(dir.path(), None).unwrap();
1507            for i in 0..25 {
1508                db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1509                    .unwrap();
1510            }
1511            db.put("rows", "r0", serde_json::json!({"i": 0, "v": 2}), vec![], None, None).unwrap();
1512            db.try_flush_all().unwrap();
1513        }
1514
1515        // Simulate the lost WAL: objects survive, the id index does not.
1516        std::fs::remove_dir_all(dir.path().join("indexes")).unwrap();
1517
1518        {
1519            let db = Db::open(dir.path(), None).unwrap();
1520            assert_eq!(db.list("rows").len(), 0, "precondition: rows unreachable");
1521            let (ok, bad) = db.verify();
1522            assert!(ok > 0 && bad.is_empty(), "objects must still be intact and verifying");
1523
1524            let written = db.repair().unwrap();
1525            assert_eq!(written, 25, "one entry per distinct (coll, id)");
1526            assert_eq!(db.list("rows").len(), 25, "every row must come back");
1527
1528            // The winner for a re-put id is the HIGHEST seq, matching put().
1529            let r0 = db.get("rows", "r0").expect("r0 present");
1530            assert_eq!(r0.data.get("v").and_then(|v| v.as_i64()), Some(2),
1531                "repair must restore the latest version, not an older one");
1532        }
1533
1534        // A repaired database must reopen warm with a real head.
1535        let db3 = Db::open(dir.path(), None).unwrap();
1536        assert_eq!(db3.list("rows").len(), 25);
1537        assert!(!db3.head().is_empty(), "repair must leave a valid MANIFEST head");
1538        assert!(db3.tip_collection("rows").is_some(), "tip_collection must resolve after repair");
1539    }
1540
1541    /// `since()` must never report "caught up" while the cursor is behind head.
1542    ///
1543    /// Regression for 2.8.5: on a warm boot the seq index is empty by design
1544    /// (the warm path skips the scan), so every seq lookup missed and `since()`
1545    /// returned zero nodes with `has_more = false` — identical to genuinely up
1546    /// to date. A consumer following the documented drain loop stopped one call
1547    /// in, on a database with every record unread.
1548    #[test]
1549    fn since_never_reports_caught_up_while_behind_head() {
1550        let dir = tempdir().unwrap();
1551        {
1552            let db = Db::open(dir.path(), None).unwrap();
1553            for i in 0..10 {
1554                db.put("rows", &format!("r{}", i), serde_json::json!({"i": i}), vec![], None, None)
1555                    .unwrap();
1556            }
1557            db.try_flush_all().unwrap();
1558        }
1559
1560        // Warm reopen: startup is "complete" in O(1) because the scan is skipped.
1561        let db2 = Db::open(dir.path(), None).unwrap();
1562        let st = db2.scan_status();
1563        assert!(st.tip_seq > 0, "log has entries");
1564        assert!(
1565            !st.seq_index_ready,
1566            "warm boot leaves the seq index cold — that is the honest signal"
1567        );
1568
1569        let batch = db2.since(0, 100);
1570        assert!(
1571            batch.to_seq < batch.head_seq,
1572            "cursor is behind the log head in this state"
1573        );
1574        assert!(
1575            batch.has_more,
1576            "has_more must be true while the cursor is behind head — otherwise the \
1577             consumer reads 'caught up' and stops with every record unread"
1578        );
1579
1580        // After a repair the index resolves and the drain actually completes.
1581        db2.repair().unwrap();
1582        assert!(db2.scan_status().seq_index_ready);
1583        let drained = db2.since(0, 100);
1584        assert!(!drained.has_more, "genuinely caught up reports has_more=false");
1585
1586        // KNOWN SHARP EDGE, pinned here deliberately: the cursor is EXCLUSIVE
1587        // and seqs start at 0, so `since(0, _)` returns (0, head] and the very
1588        // first write in a database (seq 0) is not reachable through any cursor
1589        // value. 10 writes therefore drain as 9 records. Changing the cursor
1590        // convention would break existing replication consumers, so this is
1591        // documented rather than silently altered — but a replica seeded from
1592        // since() alone starts one record short.
1593        assert_eq!(
1594            drained.nodes.len(),
1595            9,
1596            "since(0) is exclusive of seq 0 — see the sharp edge noted above"
1597        );
1598        assert!(
1599            drained.nodes.iter().all(|n| n.seq >= 1),
1600            "seq 0 is unreachable via since()"
1601        );
1602    }
1603
1604    #[test]
1605    fn link_missing_node_errors() {
1606        let db = Db::in_memory();
1607        db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
1608        assert!(db.link("driver:d1", "handles", "trip:ghost").is_err());
1609    }
1610
1611    #[test]
1612    fn link_durable_survives_reopen() {
1613        let dir = tempdir().unwrap();
1614        {
1615            let db = Db::open(dir.path(), None).unwrap();
1616            db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1617            db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1618            db.link("driver:d1", "handles", "trip:t1").unwrap();
1619        }
1620        let db2 = Db::open(dir.path(), None).unwrap();
1621        db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
1622        let trips = db2.neighbors("driver:d1", "handles");
1623        assert_eq!(trips.len(), 1);
1624        assert_eq!(trips[0].id, "t1");
1625    }
1626
1627    #[test]
1628    fn tip_survives_warm_restart() {
1629        // v2.5.43: tip() returns the last written object AND survives a warm restart.
1630        // On reopen the seq_index is cold (warm start skips the scan), so tip() must
1631        // resolve the last write via the MANIFEST tip_hash fallback — no scan.
1632        let dir = tempdir().unwrap();
1633        {
1634            let db = Db::open(dir.path(), None).unwrap();
1635            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1636            db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1637            db.flush_all(); // persists MANIFEST incl. tip_hash
1638            assert_eq!(db.tip().expect("tip in-session").id, "b2");
1639        }
1640        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1641        let db2 = Db::open(dir.path(), None).unwrap();
1642        assert!(db2.get_hash_by_seq(1).is_none(), "seq_index is cold on a warm boot");
1643        let tip = db2.tip().expect("tip() must survive a warm restart");
1644        assert_eq!(tip.id, "b2");
1645        assert_eq!(tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1646    }
1647
1648    #[test]
1649    fn tip_collection_survives_warm_restart() {
1650        // Same contract as tip(), per collection: itc-node-rs resumes headers /
1651        // blocks / l2_receipts independently, so each must be its own durable
1652        // resume point — not just the global tip.
1653        let dir = tempdir().unwrap();
1654        {
1655            let db = Db::open(dir.path(), None).unwrap();
1656            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1657            db.put("tx",     "t1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1658            let b2 = db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1659            db.flush_all(); // persists MANIFEST incl. coll_tips
1660            assert_eq!(db.tip_collection("blocks").unwrap().id, "b2");
1661            assert_eq!(db.tip_collection("blocks").unwrap().seq, b2.seq);
1662        }
1663        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1664        let db2 = Db::open(dir.path(), None).unwrap();
1665        assert!(db2.get_hash_by_seq(0).is_none(), "seq_index is cold on a warm boot");
1666        let blocks_tip = db2.tip_collection("blocks").expect("tip_collection must survive a warm restart");
1667        assert_eq!(blocks_tip.id, "b2");
1668        assert_eq!(blocks_tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1669        let tx_tip = db2.tip_collection("tx").expect("tx tip must also survive");
1670        assert_eq!(tx_tip.id, "t1");
1671        assert!(db2.tip_collection("absent").is_none());
1672    }
1673
1674    #[test]
1675    fn cold_scan_indexes_every_object_and_reports_completion() {
1676        // Regression guard for the cold-scan refactor: seq_index is now populated
1677        // DURING the parallel read pass (for live scan_status().indexed_count
1678        // progress — see cold_scan_background_arc), not in a second pass
1679        // afterward. This asserts the end state is unchanged: every written
1680        // object is indexed, tip()/tip_collection() are correct, and
1681        // scan_complete eventually reports true.
1682        let dir = tempdir().unwrap();
1683        let n = 25u64;
1684        {
1685            let db = Db::open(dir.path(), None).unwrap();
1686            for i in 0..n {
1687                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1688            }
1689            db.flush_all();
1690        }
1691        // Force a COLD start regardless of the MANIFEST nedb-v2 itself would
1692        // have written: delete it so startup_rebuild() takes the cold path and
1693        // start_cold_scan() actually spawns the background scan this test needs
1694        // to exercise.
1695        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1696
1697        let db = Db::open(dir.path(), None).unwrap();
1698        assert!(!db.scan_status().scan_complete, "should be cold immediately after open");
1699        let db = std::sync::Arc::new(db);
1700        Db::start_cold_scan(std::sync::Arc::clone(&db));
1701
1702        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1703        while !db.scan_status().scan_complete {
1704            assert!(std::time::Instant::now() < deadline, "cold scan did not complete in time");
1705            std::thread::sleep(std::time::Duration::from_millis(5));
1706        }
1707
1708        let status = db.scan_status();
1709        assert_eq!(status.indexed_count, n as usize, "every written object must be indexed");
1710        assert!(status.scan_complete);
1711
1712        let tip = db.tip().expect("tip resolves after cold scan");
1713        assert_eq!(tip.data.get("i").and_then(|v| v.as_u64()), Some(n - 1));
1714        let coll_tip = db.tip_collection("things").expect("tip_collection resolves after cold scan");
1715        assert_eq!(coll_tip.id, tip.id);
1716    }
1717
1718    /// Concurrent writers must settle the tip at the HIGHEST SEQ, and that tip
1719    /// must survive a warm restart. Before the seq-guarded tip fix, update_head
1720    /// was "last call wins": a slower thread carrying an OLDER seq could
1721    /// overwrite tip_hash after a newer write, and MANIFEST then persisted the
1722    /// stale tip for the next warm boot (flaky by nature — this pins the
1723    /// contract deterministically for the fixed code).
1724    #[test]
1725    fn concurrent_puts_tip_resolves_to_highest_seq_after_warm_restart() {
1726        let dir = tempdir().unwrap();
1727        let total: u64 = 100;
1728        {
1729            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1730            let mut handles = vec![];
1731            for t in 0..4u64 {
1732                let db2 = std::sync::Arc::clone(&db);
1733                handles.push(std::thread::spawn(move || {
1734                    for i in 0..25u64 {
1735                        db2.put("c", &format!("{}-{}", t, i),
1736                                serde_json::json!({"t": t, "i": i}),
1737                                vec![], None, None).unwrap();
1738                    }
1739                }));
1740            }
1741            for h in handles { h.join().unwrap(); }
1742            // In-session: tip must be the highest assigned seq.
1743            let expected = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
1744            assert_eq!(expected, total - 1, "exactly {} writes expected", total);
1745            assert_eq!(db.tip().expect("in-session tip").seq, expected);
1746            db.flush_all(); // persist MANIFEST incl. tip_hash
1747        }
1748        // Warm reopen: seq_index cold; tip() resolves via MANIFEST tip_hash.
1749        let db2 = Db::open(dir.path(), None).unwrap();
1750        let tip = db2.tip().expect("tip must survive warm restart after concurrent writes");
1751        assert_eq!(tip.seq, total - 1, "warm-boot tip must be the highest-seq write");
1752        // Per-collection tip: same contract.
1753        let ct = db2.tip_collection("c").expect("coll tip survives");
1754        assert_eq!(ct.seq, total - 1);
1755    }
1756
1757    /// Pre-2.5.43 MANIFESTs (no tip_hash) must warm-boot, NOT force a cold
1758    /// scan. The old "cold scan once to upgrade" policy was hours of random
1759    /// reads on multi-million-object seek-bound stores (itcd -dagv3), re-paid
1760    /// on every boot if the process exited before the scan finished. seq+head
1761    /// in the old MANIFEST are valid; tip()/tip_collection() return None until
1762    /// the first write+flush organically rewrites MANIFEST with a tip.
1763    #[test]
1764    fn pre_durable_tip_manifest_warm_boots_and_heals_lazily() {
1765        let dir = tempdir().unwrap();
1766        {
1767            let db = Db::open(dir.path(), None).unwrap();
1768            for i in 0..5u64 {
1769                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1770            }
1771            db.flush_all();
1772        }
1773        // Rewrite MANIFEST in the pre-2.5.43 shape: seq + head only.
1774        let manifest_path = dir.path().join("MANIFEST");
1775        let m: serde_json::Value =
1776            serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
1777        let old_format = serde_json::json!({ "seq": m["seq"], "head": m["head"] });
1778        std::fs::write(&manifest_path, serde_json::to_string(&old_format).unwrap()).unwrap();
1779
1780        // Reopen: must be WARM (startup_ready immediately — no cold scan gate).
1781        let db2 = Db::open(dir.path(), None).unwrap();
1782        assert!(db2.startup_ready.load(std::sync::atomic::Ordering::SeqCst),
1783                "pre-2.5.43 MANIFEST must warm-boot, not fall to a cold scan");
1784        // tip() unresolvable this boot — documented None, not a panic or scan.
1785        assert!(db2.tip().is_none(), "tip() is None until the manifest heals");
1786        // seq continuity: a new write gets a FRESH seq (no reuse).
1787        let n = db2.put("things", "next", serde_json::json!({"fresh": true}), vec![], None, None).unwrap();
1788        assert_eq!(n.seq, m["seq"].as_u64().unwrap(), "next write takes the persisted next-to-assign seq");
1789        db2.flush_all(); // organic upgrade: MANIFEST now carries tip_hash
1790        drop(db2);
1791
1792        // Healed: next boot is warm AND tip() resolves.
1793        let db3 = Db::open(dir.path(), None).unwrap();
1794        assert!(db3.startup_ready.load(std::sync::atomic::Ordering::SeqCst));
1795        let tip = db3.tip().expect("tip() must resolve after the organic upgrade");
1796        assert_eq!(tip.id, "next");
1797    }
1798
1799    /// Regression for the cold-scan MANIFEST seq off-by-one. The scan's old
1800    /// hand-rolled MANIFEST stored `seq: max_seq` (the last USED seq), but the
1801    /// warm boot loads `m.seq` as the NEXT-TO-ASSIGN counter — so a restart
1802    /// right after a quiet cold scan handed the next write the tip's seq:
1803    /// a DUPLICATE seq in the log (seq_index overwrite, wrong since() page).
1804    /// The scan now writes MANIFEST via flush_manifest(), which reads the live
1805    /// counter (max_seq + 1).
1806    #[test]
1807    fn manifest_after_cold_scan_does_not_reuse_tip_seq() {
1808        let dir = tempdir().unwrap();
1809        let old_tip_seq;
1810        {
1811            let db = Db::open(dir.path(), None).unwrap();
1812            for i in 0..5u64 {
1813                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1814            }
1815            db.flush_all();
1816            old_tip_seq = db.tip().unwrap().seq;
1817        }
1818        // Force a cold start: remove MANIFEST so the background scan runs and
1819        // writes a fresh MANIFEST itself.
1820        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1821        {
1822            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1823            Db::start_cold_scan(std::sync::Arc::clone(&db));
1824            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1825            while !db.scan_status().scan_complete {
1826                assert!(std::time::Instant::now() < deadline, "cold scan did not complete");
1827                std::thread::sleep(std::time::Duration::from_millis(5));
1828            }
1829            // No further writes — the scan's own MANIFEST is what the next boot sees.
1830        }
1831        // Warm reopen from the scan-written MANIFEST: the next write must get a
1832        // FRESH seq, never the tip's.
1833        let db3 = Db::open(dir.path(), None).unwrap();
1834        let tip_before = db3.tip().expect("tip survives scan-written MANIFEST");
1835        assert_eq!(tip_before.seq, old_tip_seq, "tip identity preserved across the scan");
1836        let new_node = db3.put("things", "next", serde_json::json!({"fresh": true}),
1837                               vec![], None, None).unwrap();
1838        assert!(new_node.seq > old_tip_seq,
1839                "new write reused seq {} (tip was {}) — duplicate seq in the log",
1840                new_node.seq, old_tip_seq);
1841    }
1842}