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}
81
82pub struct Db {
83    pub objects:        ObjectStore,
84    pub id_index:       IdIndex,
85    pub sorted_indexes: SortedIndexes,
86    pub graph:          GraphStore,
87    pub root:           PathBuf,
88    /// Advisory exclusive lock on the data directory (`LOCK` file), held for
89    /// the Db's lifetime. One process owns a durable store at a time — a
90    /// second opener gets a loud refusal instead of silent split-brain (two
91    /// engines with independent in-memory state on one dir: cross-process
92    /// writes invisible, CAS races — the 2026-07-20 aias multi-worker session
93    /// bug, caught live). Released automatically on drop AND on any process
94    /// death including SIGKILL, because the flock dies with the fd. `None`
95    /// for in-memory databases and under NEDB_SHARED_OPEN=1 (operator
96    /// override for tooling that accepts the risk).
97    _dir_lock:          Option<std::fs::File>,
98    /// Dirty flag — set true when head changes, cleared after manifest flush.
99    /// Decouples flush_manifest from the hot write path so concurrent writes
100    /// don't serialise on 2× file I/O per PUT.
101    manifest_dirty:     Arc<AtomicBool>,
102    pub seq:            AtomicU64,
103    /// Cached Merkle head — updated incrementally on every write (O(1)).
104    head:               RwLock<String>,
105    /// `(seq, object hash)` of the most recent write (highest seq). Mirrors `head`
106    /// but holds the tip's content hash, so `tip()` can resolve the last node O(1)
107    /// on a warm boot when the in-memory `seq_index` is still cold. The seq rides
108    /// along so concurrent writers can settle the tip by HIGHEST SEQ rather than
109    /// arrival order (a slow older put must never clobber a newer tip). Only the
110    /// hash is persisted in MANIFEST — format unchanged.
111    tip_hash:           RwLock<(u64, String)>,
112    /// Per-collection tip: `coll -> (seq, object hash)` of the highest-seq node in
113    /// that collection. Kept current on every write (`update_head`, seq-guarded),
114    /// restored from MANIFEST on warm boot, rebuilt by the cold scan — so
115    /// `tip_collection()` is O(1) and durable across restarts in every startup
116    /// regime, by construction.
117    coll_tip_hash:      Arc<DashMap<String, (u64, String)>>,
118    /// True once startup is fully ready (MANIFEST loaded or cold scan complete).
119    /// Warm starts set this true before returning from open().
120    /// Cold starts set this true in the background thread when scan completes.
121    /// Writes are held with 503 until this is true; reads always proceed.
122    pub startup_ready:  Arc<AtomicBool>,
123    /// Seq → hash lookup for v1 compatibility. Populated by put(), put_batch(),
124    /// and the cold-scan background pass. Only covers nodes from the current
125    /// process session + cold-scan; older seqs not in this map cannot be resolved.
126    seq_index:          Arc<DashMap<u64, String>>,
127}
128
129impl Db {
130    /// Create a pure in-memory database — no disk I/O, no migration, instant startup.
131    /// Perfect for tests, hot-cache layers, and ephemeral sessions.
132    /// All data is lost when the Db is dropped.
133    pub fn in_memory() -> Self {
134        Self {
135            objects:        ObjectStore::in_memory(),
136            id_index:       IdIndex::in_memory(),
137            sorted_indexes: SortedIndexes::new(),
138            graph:          GraphStore::in_memory(),
139            root:           std::path::PathBuf::from(":memory:"),
140            _dir_lock:      None,
141            seq:            AtomicU64::new(0),
142            head:           RwLock::new(String::new()),
143            tip_hash:       RwLock::new((0, String::new())),
144            coll_tip_hash:  Arc::new(DashMap::new()),
145            startup_ready:  Arc::new(AtomicBool::new(true)),  // always ready
146            manifest_dirty: Arc::new(AtomicBool::new(false)),
147            seq_index:      Arc::new(DashMap::new()),
148        }
149    }
150
151    /// Acquire the exclusive advisory lock on a durable data directory.
152    /// Refuses (with the holder's pid when known) rather than allowing a
153    /// second live engine on the same files. NEDB_SHARED_OPEN=1 skips the
154    /// guard entirely — for tooling that knowingly accepts split-brain risk.
155    fn acquire_dir_lock(db_root: &Path) -> Result<Option<std::fs::File>> {
156        if std::env::var("NEDB_SHARED_OPEN").map(|v| v.trim() == "1").unwrap_or(false) {
157            return Ok(None);
158        }
159        use fs2::FileExt as _;
160        use std::io::Write as _;
161        let lock_path = db_root.join("LOCK");
162        let lock_file = std::fs::OpenOptions::new()
163            .create(true).read(true).write(true).open(&lock_path)?;
164        if lock_file.try_lock_exclusive().is_err() {
165            let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
166            let holder = holder.trim();
167            anyhow::bail!(
168                "data directory {:?} is locked by another process{} — refusing a \
169                 split-brain open: a second engine on the same files cannot see this \
170                 process's writes (invisible sessions, CAS races). Stop the other \
171                 process, or set NEDB_SHARED_OPEN=1 only if you accept that risk.",
172                db_root,
173                if holder.is_empty() { String::new() } else { format!(" (pid {holder})") }
174            );
175        }
176        // Best-effort: record our pid for the next contender's error message.
177        let _ = lock_file.set_len(0);
178        let _ = writeln!(&lock_file, "{}", std::process::id());
179        let _ = lock_file.sync_all();
180        Ok(Some(lock_file))
181    }
182
183    /// Open (or create) a database. Runs v1→v2 migration automatically if log.aof is present.
184    pub fn open(db_root: &Path, dek: Option<Dek>) -> Result<Self> {
185        std::fs::create_dir_all(db_root)?;
186
187        // Split-brain guard FIRST — refuse before touching any store state.
188        let dir_lock = Self::acquire_dir_lock(db_root)?;
189
190        let objects        = ObjectStore::new(db_root, dek.clone())?;
191        let id_index       = IdIndex::new(db_root)?;
192        let sorted_indexes = SortedIndexes::new();
193        let graph          = GraphStore::new(db_root)?;
194
195        let mut db = Self {
196            objects,
197            id_index,
198            sorted_indexes,
199            graph,
200            root: db_root.to_path_buf(),
201            _dir_lock: dir_lock,
202            seq:  AtomicU64::new(0),
203            head: RwLock::new(String::new()),
204            tip_hash: RwLock::new((0, String::new())),
205            coll_tip_hash: Arc::new(DashMap::new()),
206            startup_ready:  Arc::new(AtomicBool::new(false)),
207            manifest_dirty: Arc::new(AtomicBool::new(false)),
208            seq_index:      Arc::new(DashMap::new()),
209        };
210
211        // Auto-migrate v1 → v2 if needed (pass DEK so encrypted AOFs convert correctly)
212        migrate::migrate_if_needed(
213            db_root,
214            &db.objects,
215            &db.id_index,
216            &db.sorted_indexes,
217            &db.graph,
218            dek.as_ref(),
219        )?;
220
221        // Fast startup: load seq+head from MANIFEST if no sorted indexes need rebuilding.
222        // Falls back to full object scan only when necessary (first open, or post-migration).
223        db.startup_rebuild()?;
224
225        Ok(db)
226    }
227
228    /// Smart startup:
229    /// - Warm (MANIFEST exists): O(1) load → startup_ready = true immediately.
230    /// - Cold (no MANIFEST): start server immediately, run scan in background thread.
231    ///   Writes return 503 until scan completes; reads always proceed.
232    fn startup_rebuild(&mut self) -> Result<()> {
233        let manifest_path = self.root.join("MANIFEST");
234        let needs_index_rebuild = !self.sorted_indexes.is_empty();
235
236        // Warm path: MANIFEST + no sorted indexes to rebuild → instant start
237        if manifest_path.exists() && !needs_index_rebuild {
238            if let Some(m) = fs::read_to_string(&manifest_path)
239                .ok()
240                .and_then(|s| serde_json::from_str::<Manifest>(&s).ok())
241            {
242                // Self-heal: MANIFEST with an empty or short head is corrupt/stale.
243                // Fall through to cold scan so the head is rebuilt correctly from objects.
244                if m.head.len() < 8 {
245                    eprintln!("  [nedbd] MANIFEST head invalid (len={}), self-healing via cold scan", m.head.len());
246                } else {
247                    // Pre-2.5.43 MANIFEST (no persisted tip): warm-boot ANYWAY.
248                    //
249                    // The old policy forced a full cold scan "once to upgrade" —
250                    // on multi-million-object embedded stores (itcd -dagv3:
251                    // 1.7M+ objects per database) that scan is hours of random
252                    // reads on seek-bound media, it races the host's own boot
253                    // I/O, and if the process exits before it completes the
254                    // NEXT boot pays it again — a permanent boot tax for
255                    // exactly the deployments that can least afford it. And it
256                    // buys nothing that can't heal lazily: seq + head in the
257                    // old MANIFEST are perfectly valid, and flush_manifest
258                    // writes tip_hash + coll_tips from live state, so the very
259                    // first write + flush after boot upgrades the MANIFEST
260                    // organically. Until then tip()/tip_collection() simply
261                    // return None on this boot — exactly their documented
262                    // behavior for an unresolvable tip — and every other read
263                    // and write path is unaffected.
264                    if m.tip_hash.is_empty() {
265                        eprintln!("  [nedbd] MANIFEST predates durable tip() — warm boot; tip()/tip_collection() heal on first flush (no forced scan)");
266                    }
267                    self.seq.store(m.seq, Ordering::SeqCst); // m.seq is already the next-to-assign counter
268                    *self.head.write() = m.head.clone();
269                    // The tip's seq is the last ASSIGNED seq (m.seq is next-to-assign).
270                    *self.tip_hash.write() = (m.seq.saturating_sub(1), m.tip_hash.clone());
271                    for (coll, hash) in &m.coll_tips {
272                        // Per-coll seqs aren't persisted (MANIFEST format unchanged);
273                        // seed 0 — every future write has seq >= m.seq > 0 and wins,
274                        // and nothing older than the persisted tip can ever arrive
275                        // because the seq counter resumes at m.seq.
276                        self.coll_tip_hash.insert(coll.clone(), (0, hash.clone()));
277                    }
278                    self.startup_ready.store(true, Ordering::SeqCst);
279                    println!("  [nedbd] warm start — seq={} head={}... tip={}...",
280                        m.seq, &m.head[..8],
281                        if m.tip_hash.is_empty() { "(pre-2.5.43, heals on flush)" }
282                        else { &m.tip_hash[..8.min(m.tip_hash.len())] });
283                    return Ok(());
284                }
285            } else {
286                eprintln!("  [nedbd] MANIFEST corrupt or missing, falling back to cold scan");
287            }
288        }
289
290        // Cold path: mark as not ready, return immediately.
291        // The actual background scan is started by Db::start_cold_scan(arc)
292        // which is called from Manager::open_all() AFTER Arc::new(db) — when
293        // the Db is heap-allocated and its field addresses are permanently stable.
294        // Capturing field addresses here would cause UB: Db moves on return.
295        println!("  [nedbd] cold start — background scan will start after heap allocation");
296        Ok(())
297    }
298
299    /// Call this from Manager::open_all() after Arc::new(db).
300    /// Spawns the cold scan background thread with stable heap addresses.
301    /// No-op if startup is already complete (warm start).
302    pub fn start_cold_scan(self_arc: Arc<Self>) {
303        if self_arc.startup_ready.load(Ordering::SeqCst) {
304            return; // warm start — already ready
305        }
306        // Fast path: if the database is empty (new or just created), skip the
307        // background thread entirely. No objects to scan = instant startup.
308        if self_arc.objects.all_hashes().next().is_none() {
309            self_arc.startup_ready.store(true, Ordering::SeqCst);
310            return;
311        }
312        println!("  [nedbd] cold start — background scan starting, server accepting reads now");
313        std::thread::spawn(move || {
314            let db = self_arc;
315            cold_scan_background_arc(db);
316        });
317    }
318
319    /// Write a document. Returns the new node with its content hash set.
320    pub fn put(
321        &self,
322        coll: &str,
323        id: &str,
324        data: Value,
325        caused_by: Vec<String>,
326        valid_from: Option<String>,
327        valid_to:   Option<String>,
328    ) -> Result<Node> {
329        let seq  = self.seq.fetch_add(1, Ordering::SeqCst);
330        let prev = self.id_index.get(coll, id);
331
332        // Remove old node from sorted indexes (it's being superseded).
333        // Skip the old-object disk read entirely when no sorted index exists —
334        // the read (open + BLAKE2b verify + optional AES-GCM decrypt + JSON
335        // parse) was pure waste in the common unindexed case, ~2x read
336        // amplification on every update (the itcd chainstate shape).
337        if !self.sorted_indexes.is_empty() {
338            if let Some(old_hash) = &prev {
339                if let Ok(old_node) = self.objects.read(old_hash) {
340                    if let Value::Object(ref obj) = old_node.data {
341                        for (field, value) in obj {
342                            self.sorted_indexes.remove(coll, field, value, old_hash);
343                        }
344                    }
345                }
346            }
347        }
348
349        let mut node = Node {
350            id:         id.to_string(),
351            coll:       coll.to_string(),
352            seq,
353            data:       data.clone(),
354            prev,
355            caused_by:  caused_by.clone(),
356            ts:         now(),
357            valid_from,
358            valid_to,
359            hash:       String::new(),
360        };
361
362        // Write to object store (atomic, content-addressed)
363        let hash = self.objects.write(&mut node)?;
364        self.seq_index.insert(seq, hash.clone());
365
366        // Update id index (atomic file)
367        self.id_index.set(coll, id, &hash)?;
368
369        // Update sorted indexes
370        if let Value::Object(ref obj) = data {
371            for (field, value) in obj {
372                if self.sorted_indexes.has(coll, field) {
373                    self.sorted_indexes.insert(coll, field, value, &hash);
374                }
375            }
376        }
377
378        // Write causal graph edges
379        for cause in &caused_by {
380            self.graph.add_edge(&hash, "caused_by", cause)?;
381            self.graph.add_edge(cause, "caused_by_rev", &hash)?;
382        }
383
384        // Update running Merkle head: O(1) chain, no full recompute.
385        // new_head = BLAKE2b(prev_head || seq_bytes || new_object_hash)
386        self.update_head(coll, seq, &hash);
387
388        Ok(node)
389    }
390
391    /// Batch put: write N documents in parallel, preserving monotonic seq ordering.
392    /// Pre-allocates N seq numbers atomically, then parallelises object writes and
393    /// id-index updates via Rayon. Each op is independent — safe to parallelise.
394    /// Returns nodes in input order with assigned seq numbers.
395    pub fn put_batch(
396        &self,
397        ops: Vec<(String, String, Value, Vec<String>, Option<String>, Option<String>)>,
398        // (coll, id, data, caused_by, valid_from, valid_to)
399    ) -> Result<Vec<Node>> {
400        use rayon::prelude::*;
401
402        if ops.is_empty() { return Ok(vec![]); }
403        let n = ops.len() as u64;
404
405        // Pre-allocate N consecutive seq numbers — preserves ordering under concurrency
406        let base_seq = self.seq.fetch_add(n, Ordering::SeqCst);
407        let ts = now();
408
409        // Build nodes with assigned seq numbers
410        let index_live = !self.sorted_indexes.is_empty();
411        let mut nodes: Vec<Node> = ops.into_iter().enumerate().map(|(i, (coll, id, data, caused_by, valid_from, valid_to))| {
412            let prev = self.id_index.get(&coll, &id);
413            // Parity with put(): drop the superseded version's values from any
414            // sorted indexes, so top-k never returns stale hashes after a batch
415            // update. Without this, batch updates left the old version's index
416            // entries in place — ORDER BY surfaced superseded rows alongside
417            // current ones. Only pay the old-object read when an index exists.
418            if index_live {
419                if let Some(old_hash) = &prev {
420                    if let Ok(old_node) = self.objects.read(old_hash) {
421                        if let Value::Object(ref obj) = old_node.data {
422                            for (field, value) in obj {
423                                self.sorted_indexes.remove(&coll, field, value, old_hash);
424                            }
425                        }
426                    }
427                }
428            }
429            Node {
430                id, coll, seq: base_seq + i as u64,
431                data, prev, caused_by,
432                ts, valid_from, valid_to,
433                hash: String::new(),
434            }
435        }).collect();
436
437        // Parallel object writes (content-addressed, idempotent, safe to parallelise)
438        let write_errors: Vec<anyhow::Error> = nodes.par_iter_mut()
439            .filter_map(|node| self.objects.write(node).err())
440            .collect();
441        if let Some(e) = write_errors.into_iter().next() { return Err(e); }
442
443        // Parallel id-index updates
444        let index_errors: Vec<anyhow::Error> = nodes.par_iter()
445            .filter_map(|node| self.id_index.set(&node.coll, &node.id, &node.hash).err())
446            .collect();
447        if let Some(e) = index_errors.into_iter().next() { return Err(e); }
448
449        // Sorted indexes + causal graph (sequential — small overhead, usually no indexes)
450        for node in &nodes {
451            self.seq_index.insert(node.seq, node.hash.clone());
452            if let Value::Object(ref obj) = node.data {
453                for (field, value) in obj {
454                    if self.sorted_indexes.has(&node.coll, field) {
455                        self.sorted_indexes.insert(&node.coll, field, value, &node.hash);
456                    }
457                }
458            }
459            for cause in &node.caused_by {
460                self.graph.add_edge(&node.hash, "caused_by", cause).ok();
461                self.graph.add_edge(cause, "caused_by_rev", &node.hash).ok();
462            }
463        }
464
465        // Single Merkle head update for the whole batch (chain all hashes)
466        for node in &nodes {
467            self.update_head(&node.coll, node.seq, &node.hash);
468        }
469
470        Ok(nodes)
471    }
472
473    /// Update the running Merkle head with a new write. O(1); no file I/O — the
474    /// background ticker flushes MANIFEST.
475    ///
476    /// Concurrency contract (this function is reached by parallel `put()`s —
477    /// the server runs puts on blocking threads):
478    /// - The head chain is extended under ONE write lock held across the whole
479    ///   read-modify-write. The old read-then-write shape let two concurrent
480    ///   writers both read the same prev head; one contribution was silently
481    ///   dropped from the chain — a corrupted tamper-evidence primitive. The
482    ///   chain is arrival-ordered under concurrency (a seq-ordered canonical
483    ///   head is tracked as follow-up work); what this lock guarantees is that
484    ///   EVERY write is committed into the chain exactly once.
485    /// - Tip pointers settle by HIGHEST SEQ, not arrival order: concurrent
486    ///   puts can reach here out of seq order, and "last call wins" could
487    ///   persist a stale tip into MANIFEST for the next warm boot.
488    fn update_head(&self, coll: &str, seq: u64, new_hash: &str) {
489        use blake2::{Blake2b512, Digest};
490        {
491            let mut head = self.head.write();
492            let mut h = Blake2b512::new();
493            h.update(head.as_bytes());
494            h.update(seq.to_le_bytes());
495            h.update(new_hash.as_bytes());
496            *head = hex::encode(&h.finalize()[..32]);
497        }
498        {
499            let mut tip = self.tip_hash.write();
500            if seq >= tip.0 {
501                *tip = (seq, new_hash.to_string());
502            }
503        }
504        self.coll_tip_hash
505            .entry(coll.to_string())
506            .and_modify(|t| {
507                if seq >= t.0 {
508                    *t = (seq, new_hash.to_string());
509                }
510            })
511            .or_insert_with(|| (seq, new_hash.to_string()));
512        // Mark dirty — background ticker will flush to MANIFEST (no I/O on write path)
513        self.manifest_dirty.store(true, Ordering::Release);
514    }
515
516    /// Flush both the id-index WAL and MANIFEST. Used on graceful shutdown.
517    pub fn flush_all(&self) {
518        self.id_index.flush_write_buf();
519        // v3: fsync the active segment (no-op for loose/in-memory stores).
520        // One durability point per batch instead of one fsync per object.
521        if let Err(e) = self.objects.sync() {
522            eprintln!("nedb: segment sync failed: {}", e);
523        }
524        self.flush_manifest();
525    }
526
527    /// Compact the v3 packed object store: keep the CURRENT version of every
528    /// document (from the id-index) and reclaim everything else. No-op unless
529    /// running with the v3 segment substrate (`--dag-v3` / NEDB_DAG_V3).
530    ///
531    /// This is a PRUNING operation: superseded/historical object versions are
532    /// dropped, so AS OF / TRACE over pruned versions is discarded — that is
533    /// what reclaims the space. Flushes first so all data is durable on disk
534    /// before the old segments are deleted.
535    pub fn compact(&self) -> Result<crate::segment::CompactStats> {
536        self.flush_all();
537        let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
538        for coll in self.id_index.collections() {
539            for id in self.id_index.list_ids(&coll) {
540                if let Some(h) = self.id_index.get(&coll, &id) {
541                    live.insert(h);
542                }
543            }
544        }
545        self.objects.compact(&live)
546    }
547
548    /// Flush MANIFEST to disk if dirty. No-op for in-memory databases.
549    pub fn flush_manifest_if_dirty(&self) {
550        if self.root == std::path::PathBuf::from(":memory:") { return; }
551        if self.manifest_dirty.compare_exchange(
552            true, false, Ordering::AcqRel, Ordering::Relaxed
553        ).is_ok() {
554            self.flush_manifest();
555        }
556    }
557
558    /// Atomically persist current seq+head to MANIFEST. No-op for in-memory databases.
559    pub fn flush_manifest(&self) {
560        if self.root == std::path::PathBuf::from(":memory:") { return; }
561        let seq  = self.seq.load(Ordering::SeqCst);
562        let head = self.head.read().clone();
563        let tip_hash = self.tip_hash.read().1.clone();
564        let coll_tips: std::collections::HashMap<String, String> = self.coll_tip_hash
565            .iter()
566            .map(|kv| (kv.key().clone(), kv.value().1.clone()))
567            .collect();
568        let m = Manifest { seq, head, tip_hash, coll_tips };
569        if let Ok(json) = serde_json::to_string(&m) {
570            let path = self.root.join("MANIFEST");
571            let tmp  = self.root.join("MANIFEST.tmp");
572            // fsync the tmp file BEFORE the rename: rename-without-fsync can
573            // leave a zero-length/partial MANIFEST at the final path after
574            // power loss (ext4 delayed allocation). The startup self-heal
575            // (invalid head -> cold scan) catches that, but a full rescan is
576            // exactly the cost MANIFEST exists to avoid. One fsync per flush,
577            // and flushes are already off the hot write path (ticker-driven).
578            let wrote = (|| -> std::io::Result<()> {
579                use std::io::Write;
580                let mut f = fs::File::create(&tmp)?;
581                f.write_all(json.as_bytes())?;
582                f.sync_all()
583            })();
584            if wrote.is_ok() && fs::rename(&tmp, &path).is_ok() {
585                // Make the rename itself durable (directory entry). Unix-only;
586                // on Windows directory handles don't support this and the
587                // rename is already journaled by NTFS.
588                #[cfg(unix)]
589                if let Ok(dir) = fs::File::open(&self.root) {
590                    let _ = dir.sync_all();
591                }
592            }
593        }
594    }
595
596    /// Start a background thread that flushes both the id-index WAL and MANIFEST
597    /// every `interval_ms` milliseconds.
598    /// Call this after Arc::new(db) — the Arc keeps Db alive for the thread's lifetime.
599    /// Flush cadence for EMBEDDED durable handles (the napi and pyo3 `open()` paths).
600    ///
601    /// `nedbd` has always run the manifest ticker at 1 s, so a server flushes the id-index WAL and
602    /// MANIFEST every second and a hard kill loses at most a second of acknowledged writes. The
603    /// embedded bindings did not start a ticker at all: their WAL was flushed only by the exit hooks
604    /// (SIGINT/SIGTERM/atexit) — so an embedded app killed with SIGKILL, OOM-killed, or cut by power
605    /// lost EVERY write since open, with no bound. Found by CHALK / Sports-Rater on 2026-09-04
606    /// (acknowledged fan writes gone after `kill -9`). Since 2.8.5 the bindings start the ticker on
607    /// durable open with this cadence — parity with nedbd.
608    ///
609    /// `NEDB_FLUSH_MS` overrides: an integer of milliseconds (min 50), or `0` / `off` to disable
610    /// (only for hosts that own their own flush cadence). Unset → 1000.
611    pub fn embedded_flush_interval_ms() -> Option<u64> {
612        match std::env::var("NEDB_FLUSH_MS") {
613            Err(_) => Some(1000),
614            Ok(v) => {
615                let v = v.trim().to_ascii_lowercase();
616                if v.is_empty() { return Some(1000); }
617                if v == "0" || v == "off" || v == "false" || v == "no" { return None; }
618                match v.parse::<u64>() {
619                    Ok(ms) => Some(ms.max(50)),
620                    Err(_) => { eprintln!("nedb: NEDB_FLUSH_MS={:?} is not a number — using 1000", v); Some(1000) }
621                }
622            }
623        }
624    }
625
626    pub fn start_manifest_ticker(self_arc: Arc<Self>, interval_ms: u64) {
627        let db = self_arc;
628        std::thread::spawn(move || {
629            loop {
630                std::thread::sleep(std::time::Duration::from_millis(interval_ms));
631                // Flush id-index WAL to disk (parallel Rayon writes)
632                db.id_index.flush_write_buf();
633                // Segment bytes must be durable BEFORE a MANIFEST that
634                // references them: otherwise power loss can leave MANIFEST
635                // pointing at a tip whose object bytes were still in the page
636                // cache — the torn tail is truncated on reopen and the warm
637                // boot resolves a tip that no longer exists, with the seq
638                // counter ahead of durable data. Order: sync segments, then
639                // MANIFEST. Gated on the dirty flag so an idle database pays
640                // no per-tick fsync. (flush_all already used this order; the
641                // ticker now matches it.)
642                if db.manifest_dirty.load(Ordering::Acquire) {
643                    if let Err(e) = db.objects.sync() {
644                        eprintln!("nedb: segment sync failed: {}", e);
645                    }
646                    db.flush_manifest_if_dirty();
647                }
648            }
649        });
650    }
651
652    /// Return the current Merkle head string. O(1) — read from cache.
653    pub fn head(&self) -> String {
654        self.head.read().clone()
655    }
656
657    /// Delete a document — writes a tombstone node and removes the id from the index.
658    /// The object history is preserved in the DAG; only the live id pointer is cleared.
659    pub fn delete(&self, coll: &str, id: &str) -> Result<bool> {
660        let prev = match self.id_index.get(coll, id) {
661            None => return Ok(false),   // already gone
662            Some(h) => h,
663        };
664        let seq = self.seq.fetch_add(1, Ordering::SeqCst);
665        let mut tombstone = Node {
666            id:         format!("_del_{}", id),
667            coll:       coll.to_string(),
668            seq,
669            data:       serde_json::json!({"_deleted": id, "_prev": prev}),
670            prev:       Some(prev),
671            caused_by:  vec![],
672            ts:         now(),
673            valid_from: None,
674            valid_to:   None,
675            hash:       String::new(),
676        };
677        let hash = self.objects.write(&mut tombstone)?;
678        self.update_head(coll, seq, &hash);
679        // Remove the live id pointer — doc is now invisible to queries and list()
680        self.id_index.remove(coll, id)?;
681        Ok(true)
682    }
683
684    /// Get the current version of a document by id.
685    pub fn get(&self, coll: &str, id: &str) -> Option<Node> {
686        let hash = self.id_index.get(coll, id)?;
687        self.objects.read(&hash).ok()
688    }
689
690    /// Get a specific version of a document by object hash.
691    pub fn get_by_hash(&self, hash: &str) -> Option<Node> {
692        self.objects.read(hash).ok()
693    }
694
695    /// Get a document AS OF a specific sequence number.
696    /// Walks the version chain (prev links) backward until seq <= target.
697    pub fn get_as_of(&self, coll: &str, id: &str, target_seq: u64) -> Option<Node> {
698        let hash = self.id_index.get(coll, id)?;
699        let mut current = self.objects.read(&hash).ok()?;
700        loop {
701            if current.seq <= target_seq {
702                return Some(current);
703            }
704            let prev_hash = current.prev.as_deref()?;
705            current = self.objects.read(prev_hash).ok()?;
706        }
707    }
708
709    /// List all documents in a collection, returning current versions.
710    pub fn list(&self, coll: &str) -> Vec<Node> {
711        self.id_index
712            .list_ids(coll)
713            .into_iter()
714            .filter_map(|id| self.get(coll, &id))
715            .collect()
716    }
717
718    /// ORDER BY field ASC LIMIT n — uses sorted index if available, else falls back to full scan.
719    pub fn order_by_asc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
720        if self.sorted_indexes.has(coll, field) {
721            self.sorted_indexes
722                .top_k_asc(coll, field, limit)
723                .into_iter()
724                .filter_map(|h| self.objects.read(&h).ok())
725                .collect()
726        } else {
727            let mut docs = self.list(coll);
728            docs.sort_by(|a, b| {
729                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
730                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
731                av.cmp(&bv)
732            });
733            docs.truncate(limit);
734            docs
735        }
736    }
737
738    /// ORDER BY field DESC LIMIT n
739    pub fn order_by_desc(&self, coll: &str, field: &str, limit: usize) -> Vec<Node> {
740        if self.sorted_indexes.has(coll, field) {
741            self.sorted_indexes
742                .top_k_desc(coll, field, limit)
743                .into_iter()
744                .filter_map(|h| self.objects.read(&h).ok())
745                .collect()
746        } else {
747            let mut docs = self.list(coll);
748            docs.sort_by(|a, b| {
749                let av = a.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
750                let bv = b.data.get(field).map(OrderedValue::from).unwrap_or(OrderedValue::Null);
751                bv.cmp(&av)
752            });
753            docs.truncate(limit);
754            docs
755        }
756    }
757
758    /// TRACE caused_by — walk causal graph from a node.
759    pub fn trace(&self, hash: &str, reverse: bool, limit: usize) -> Vec<Node> {
760        self.graph
761            .trace(hash, "caused_by", reverse, limit)
762            .into_iter()
763            .filter_map(|h| self.objects.read(&h).ok())
764            .collect()
765    }
766
767    /// Verify tamper-evidence of all objects.
768    pub fn verify(&self) -> (usize, Vec<String>) {
769        self.objects.verify_all()
770    }
771
772    /// Create a sorted index for a (coll, field) pair.
773    pub fn create_sorted_index(&self, coll: &str, field: &str) {
774        self.sorted_indexes.ensure(coll, field);
775        // Backfill from existing objects
776        for id in self.id_index.list_ids(coll) {
777            if let Some(node) = self.get(coll, &id) {
778                if let Value::Object(ref obj) = node.data {
779                    if let Some(value) = obj.get(field) {
780                        self.sorted_indexes.insert(coll, field, value, &node.hash);
781                    }
782                }
783            }
784        }
785    }
786
787    /// Resolve a sequence number to its content hash (v1 compatibility).
788    /// Only covers nodes written in the current process session + cold-scan nodes.
789    pub fn get_hash_by_seq(&self, seq: u64) -> Option<String> {
790        self.seq_index.get(&seq).map(|r| r.clone())
791    }
792
793    /// The tip — the most recently written node (highest seq), or `None` if the
794    /// database is empty. O(1): `self.seq` is the next-to-assign counter, so the
795    /// latest write sits at `seq - 1`; we resolve it through the same
796    /// seq_index → object-store path a normal read uses, so the returned Node is
797    /// byte-identical to one fetched by id or hash (it carries its own seq, hash,
798    /// causal links, and valid-time). This is the cheap "give me the latest write"
799    /// primitive — the head of the log, not an aggregate.
800    pub fn tip(&self) -> Option<Node> {
801        let next = self.seq.load(Ordering::SeqCst);
802        if next == 0 {
803            return None; // nothing written yet
804        }
805        // Fast path: resolve the head seq through the in-memory seq index
806        // (populated by this session's writes or by the cold scan).
807        if let Some(hash) = self.get_hash_by_seq(next - 1) {
808            return self.get_by_hash(&hash);
809        }
810        // Warm-boot fallback: the seq index is still cold (warm start skips the
811        // scan), but the tip's object hash was persisted in MANIFEST and restored
812        // on open. O(1), no scan — this is what makes tip() survive a restart.
813        let th = self.tip_hash.read().1.clone();
814        if !th.is_empty() {
815            return self.get_by_hash(&th);
816        }
817        None
818    }
819
820    /// The collection-local tip — the most recent write into `coll` (highest seq in
821    /// that collection), or `None` if the collection has no writes. O(1): resolves
822    /// through `coll_tip_hash`, a dedicated per-collection map kept current on every
823    /// write (`update_head`), restored from MANIFEST on warm boot, and rebuilt by the
824    /// cold scan — durable across restarts by construction, same contract as `tip()`
825    /// for the global head. Conceptually a different index than the global `tip()`
826    /// (global head vs collection head), kept as a separate method so each is
827    /// explicit — parity with the Python reference's `tip(coll)`. Lets a consumer
828    /// resume one chain (e.g. blocks / tx / utxo) without pulling global tip and
829    /// filtering.
830    pub fn tip_collection(&self, coll: &str) -> Option<Node> {
831        let hash = self.coll_tip_hash.get(coll)?.1.clone();
832        self.get_by_hash(&hash)
833    }
834
835    /// Changefeed page: up to `limit` nodes written AFTER `after_seq` (EXCLUSIVE),
836    /// ascending by seq, wrapped in a `SinceBatch` cursor envelope. `after_seq` is
837    /// the cursor you last applied (a prior `tip()` seq or `to_seq`). `limit` bounds
838    /// the page — `0` means DEFAULT_SINCE_LIMIT, so the engine primitive can never
839    /// materialize an unbounded batch even when embedders call it directly (the
840    /// safety is here, not only in the HTTP layer). Drain by paging while
841    /// `has_more`, advancing your cursor to `to_seq`, then hand off to the live
842    /// `subscribe` edge. The append-only log IS the changefeed, so this is an
843    /// O(page) walk; unresolved seqs (outside seq_index coverage — see
844    /// `scan_status()`) are skipped rather than faked.
845    pub fn since(&self, after_seq: u64, limit: usize) -> SinceBatch {
846        let next = self.seq.load(Ordering::SeqCst);          // head + 1
847        let head_seq = next.saturating_sub(1);
848        let cap = if limit == 0 { DEFAULT_SINCE_LIMIT } else { limit };
849        let mut nodes: Vec<Node> = Vec::new();
850        let mut to_seq = after_seq;
851        let mut hit_limit = false;
852        let mut s = after_seq.saturating_add(1);
853        while s < next {
854            if nodes.len() >= cap { hit_limit = true; break; }
855            if let Some(hash) = self.get_hash_by_seq(s) {
856                if let Some(node) = self.get_by_hash(&hash) {
857                    to_seq = node.seq;
858                    nodes.push(node);
859                }
860            }
861            s += 1;
862        }
863        SinceBatch { nodes, from_seq: after_seq, to_seq, head_seq, has_more: hit_limit }
864    }
865
866    /// Replication readiness — see `ScanStatus`. `scan_complete` gates safe
867    /// historical catch-up: a consumer pulling an old cursor right after a cold
868    /// start must wait for it, or `since()` may hand back a partial page that looks
869    /// like "caught up". Computes the indexed range by scanning the in-memory seq
870    /// index (O(index)) — intended for periodic status polls, not the per-write
871    /// hot path.
872    pub fn scan_status(&self) -> ScanStatus {
873        let next = self.seq.load(Ordering::SeqCst);
874        let mut min = u64::MAX;
875        let mut max = 0u64;
876        let mut count = 0usize;
877        for kv in self.seq_index.iter() {
878            let s = *kv.key();
879            if s < min { min = s; }
880            if s > max { max = s; }
881            count += 1;
882        }
883        if count == 0 { min = 0; }
884        ScanStatus {
885            scan_complete:   self.startup_ready.load(Ordering::SeqCst),
886            tip_seq:         next.saturating_sub(1),
887            indexed_seq_min: min,
888            indexed_seq_max: max,
889            indexed_count:   count,
890        }
891    }
892
893    /// Add an explicit named relation edge between two documents.
894    /// Add an explicit named relation between two "coll:id" nodes.
895    /// Relations stored as __links__ documents — NQL-queryable, time-travelable,
896    /// consistent with the PyO3 binding which uses the same __links__ convention.
897    pub fn link(&self, frm: &str, rel: &str, to: &str) -> Result<()> {
898        let (frm_coll, frm_id) = frm.split_once(':')
899            .ok_or_else(|| anyhow::anyhow!("link frm must be 'coll:id', got: {}", frm))?;
900        let (to_coll, to_id) = to.split_once(':')
901            .ok_or_else(|| anyhow::anyhow!("link to must be 'coll:id', got: {}", to))?;
902        if self.id_index.get(frm_coll, frm_id).is_none() {
903            anyhow::bail!("link: frm not found: {}", frm);
904        }
905        if self.id_index.get(to_coll, to_id).is_none() {
906            anyhow::bail!("link: to not found: {}", to);
907        }
908        let link_id = format!("{}|{}|{}", frm, rel, to);
909        let doc = serde_json::json!({"_from": frm, "_rel": rel, "_to": to});
910        self.put("__links__", &link_id, doc, vec![], None, None)?;
911        Ok(())
912    }
913
914    /// Remove a named relation (deletes the __links__ document).
915    pub fn unlink(&self, frm: &str, rel: &str, to: &str) -> Result<bool> {
916        let link_id = format!("{}|{}|{}", frm, rel, to);
917        self.delete("__links__", &link_id)
918    }
919
920    /// Get neighbor nodes via a named relation.
921    /// Queries __links__ — consistent with the PyO3 binding.
922    pub fn neighbors(&self, frm: &str, rel: &str) -> Vec<Node> {
923        self.id_index
924            .list_ids("__links__")
925            .into_iter()
926            .filter_map(|id| self.get("__links__", &id))
927            .filter(|node| {
928                node.data.get("_from").and_then(|v| v.as_str()) == Some(frm)
929                    && node.data.get("_rel").and_then(|v| v.as_str()) == Some(rel)
930            })
931            .filter_map(|node| {
932                let to = node.data.get("_to")?.as_str()?;
933                let (to_coll, to_id) = to.split_once(':')?;
934                self.get(to_coll, to_id)
935            })
936            .collect()
937    }
938}
939
940impl Drop for Db {
941    /// Flush buffered state when the database is closed so a write-then-drop
942    /// sequence is durable without an explicit `flush_all()`.
943    ///
944    /// `IdIndex::set` only stages updates in the in-memory WAL `write_buf`;
945    /// disk persistence happens in `flush_write_buf()`, normally driven by the
946    /// manifest ticker. A short-lived `Db` (a library user's `{ let db =
947    /// Db::open(p)?; db.put(..)?; }` block, or a test) has no ticker, so without
948    /// this its writes would be silently lost on reopen. Flushing on drop
949    /// mirrors the flush-on-close contract of other embedded stores (sled,
950    /// RocksDB).
951    ///
952    /// In production this is a harmless safety net, not the primary durability
953    /// path: the manifest ticker thread holds an `Arc<Db>` for the process
954    /// lifetime, so `Drop` only fires once every owning handle is gone. No-op
955    /// for in-memory databases (`flush_all` short-circuits on `:memory:`).
956    fn drop(&mut self) {
957        self.flush_all();
958    }
959}
960
961/// Background cold-scan worker. Takes Arc<Db> — safe, Db is on the heap.
962fn cold_scan_background_arc(db: Arc<Db>) {
963    use rayon::prelude::*;
964    use blake2::{Blake2b512, Digest};
965
966    let objects        = &db.objects;
967    let head           = &db.head;
968    let seq_atomic     = &db.seq;
969    let sorted_indexes = &db.sorted_indexes;
970    let seq_index      = &db.seq_index;
971    let ready_flag     = Arc::clone(&db.startup_ready);
972
973    let hashes: Vec<String> = objects.all_hashes().collect();
974    let total = hashes.len();
975
976    if total == 0 {
977        ready_flag.store(true, Ordering::SeqCst);
978        return;
979    }
980
981    println!("  [nedbd] background scan — {} objects...", total);
982    let t0 = std::time::Instant::now();
983    let step = (total / 10).max(1000);
984
985    // Populate the seq index AS objects are read here, not in a second pass
986    // afterward: this loop is the slow, disk-I/O-bound phase (verifying and
987    // parsing every object), and it can run for minutes on a multi-million
988    // object store. `scan_status().indexed_count` reads `seq_index`'s size, so
989    // inserting here — not after `.collect()` — is what makes that a real, live
990    // progress signal through the phase that actually takes the time, instead
991    // of reporting a flat 0 until this whole pass finishes. Safe: DashMap
992    // supports concurrent inserts, and every parallel worker here inserts a
993    // disjoint key (each object has its own seq).
994    let nodes: Vec<Node> = hashes.par_iter()
995        .enumerate()
996        .filter_map(|(i, h)| {
997            if i > 0 && i % step == 0 {
998                let pct     = i * 100 / total;
999                let elapsed = t0.elapsed().as_secs_f32();
1000                let rate    = i as f32 / elapsed;
1001                let eta     = (total - i) as f32 / rate;
1002                eprint!("\r  [nedbd]   {:>3}%  {:>8} / {:>8}  ({:>8.0}/s  eta {:.0}s)   ",
1003                    pct, i, total, rate, eta);
1004            }
1005            let node = objects.read(h).ok()?;
1006            seq_index.insert(node.seq, node.hash.clone());
1007            Some(node)
1008        })
1009        .collect();
1010
1011    eprintln!("\r  [nedbd]   100%  {:>8} / {:>8}  ({:.1}s)                        ",
1012        total, total, t0.elapsed().as_secs_f32());
1013
1014    let max_seq = nodes.iter().map(|n| n.seq).max().unwrap_or(0);
1015    seq_atomic.store(max_seq + 1, Ordering::SeqCst);
1016
1017    // Per-collection tip: highest-seq node's hash, per coll. `nodes` is NOT
1018    // seq-ordered here (it comes from an unordered object-hash scan), so this
1019    // must track the max explicitly — unlike the live write path's "last call
1020    // wins" (which relies on ascending call order that a scan doesn't have).
1021    let mut coll_max: std::collections::HashMap<String, (u64, String)> = std::collections::HashMap::new();
1022
1023    for node in &nodes {
1024        // seq_index was already populated above, during the read pass.
1025        coll_max.entry(node.coll.clone())
1026            .and_modify(|(s, h)| if node.seq > *s { *s = node.seq; *h = node.hash.clone(); })
1027            .or_insert_with(|| (node.seq, node.hash.clone()));
1028        if let Value::Object(ref obj) = node.data {
1029            for (field, value) in obj {
1030                if sorted_indexes.has(&node.coll, field) {
1031                    sorted_indexes.insert(&node.coll, field, value, &node.hash);
1032                }
1033            }
1034        }
1035    }
1036
1037    for (coll, (seq, hash)) in coll_max {
1038        db.coll_tip_hash.insert(coll, (seq, hash));
1039    }
1040
1041    // Compute Merkle head from sorted hashes
1042    let mut sorted_hashes = hashes;
1043    sorted_hashes.sort();
1044    let mut h = Blake2b512::new();
1045    h.update(max_seq.to_le_bytes());
1046    for hash_str in &sorted_hashes {
1047        h.update(hash_str.as_bytes());
1048    }
1049    let new_head = hex::encode(&h.finalize()[..32]);
1050    *head.write() = new_head;
1051
1052    // Tip = the highest-seq object we indexed. Persist its hash so tip() resolves
1053    // O(1) on the next warm boot, before any scan repopulates the seq index.
1054    let tip_hash = db.seq_index.iter()
1055        .max_by_key(|kv| *kv.key())
1056        .map(|kv| kv.value().clone())
1057        .unwrap_or_default();
1058    *db.tip_hash.write() = (max_seq, tip_hash);
1059
1060    // Write MANIFEST through the one canonical writer. The hand-rolled write
1061    // this replaces stored `seq: max_seq` (the last USED seq) — but the warm
1062    // boot loads `m.seq` as the NEXT-TO-ASSIGN counter, so a restart right
1063    // after a quiet cold scan handed the next write the tip's seq: a duplicate
1064    // seq in the log (seq_index overwrite, wrong since() page). flush_manifest
1065    // reads the live counter (already max_seq + 1) — correct by construction.
1066    db.flush_manifest();
1067
1068    // Signal server: writes can now proceed
1069    ready_flag.store(true, Ordering::SeqCst);
1070    println!("  [nedbd] background scan complete — seq={} objects={} MANIFEST written", max_seq, total);
1071}
1072
1073fn now() -> f64 {
1074    std::time::SystemTime::now()
1075        .duration_since(std::time::UNIX_EPOCH)
1076        .map(|d| d.as_secs_f64())
1077        .unwrap_or(0.0)
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use tempfile::tempdir;
1084
1085    #[test]
1086    fn put_and_get() {
1087        let dir = tempdir().unwrap();
1088        let db = Db::open(dir.path(), None).unwrap();
1089        db.put(
1090            "blocks", "618000",
1091            serde_json::json!({"height": 618000, "hash": "0000abc"}),
1092            vec![], None, None,
1093        ).unwrap();
1094        let node = db.get("blocks", "618000").unwrap();
1095        assert_eq!(node.id, "618000");
1096        assert_eq!(node.data["height"], 618000);
1097    }
1098
1099    #[test]
1100    fn order_by_with_sorted_index() {
1101        let dir = tempdir().unwrap();
1102        let db = Db::open(dir.path(), None).unwrap();
1103        db.create_sorted_index("blocks", "height");
1104        for h in [3u64, 1, 5, 2, 4] {
1105            db.put("blocks", &h.to_string(),
1106                serde_json::json!({"height": h}),
1107                vec![], None, None).unwrap();
1108        }
1109        let asc = db.order_by_asc("blocks", "height", 3);
1110        let heights: Vec<u64> = asc.iter()
1111            .filter_map(|n| n.data["height"].as_u64())
1112            .collect();
1113        assert_eq!(heights, vec![1, 2, 3]);
1114    }
1115
1116    #[test]
1117    fn causal_trace() {
1118        let dir = tempdir().unwrap();
1119        let db = Db::open(dir.path(), None).unwrap();
1120        let a = db.put("ops", "a", serde_json::json!({"op": "create"}), vec![], None, None).unwrap();
1121        let b = db.put("ops", "b", serde_json::json!({"op": "transfer"}), vec![a.hash.clone()], None, None).unwrap();
1122        let c = db.put("ops", "c", serde_json::json!({"op": "burn"}), vec![b.hash.clone()], None, None).unwrap();
1123
1124        let trace = db.trace(&c.hash, false, 10);
1125        assert_eq!(trace.len(), 3);  // c → b → a
1126    }
1127
1128    #[test]
1129    fn as_of() {
1130        let dir = tempdir().unwrap();
1131        let db = Db::open(dir.path(), None).unwrap();
1132        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1133        let _v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1134
1135        let at_v1 = db.get_as_of("docs", "x", v1.seq).unwrap();
1136        assert_eq!(at_v1.data["v"], 1);
1137        let current = db.get("docs", "x").unwrap();
1138        assert_eq!(current.data["v"], 2);
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests_v2 {
1144    use super::*;
1145    use tempfile::tempdir;
1146
1147    #[test]
1148    fn seq_index_populated_on_put() {
1149        let db = Db::in_memory();
1150        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1151        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1152        assert_eq!(db.get_hash_by_seq(a.seq), Some(a.hash.clone()));
1153        assert_eq!(db.get_hash_by_seq(b.seq), Some(b.hash.clone()));
1154        assert_eq!(db.get_hash_by_seq(9999), None);
1155    }
1156
1157    #[test]
1158    fn tip_and_since() {
1159        let db = Db::in_memory();
1160        // Empty db: no tip, empty changefeed.
1161        assert!(db.tip().is_none());
1162        assert!(db.since(0, 0).nodes.is_empty());
1163
1164        let a = db.put("item", "a", serde_json::json!({"x": 1}), vec![], None, None).unwrap();
1165        let b = db.put("item", "b", serde_json::json!({"x": 2}), vec![], None, None).unwrap();
1166
1167        // tip() = the most recent write (highest seq), returned as a full node.
1168        let t = db.tip().expect("tip after writes");
1169        assert_eq!(t.seq, b.seq);
1170        assert_eq!(t.id, "b");
1171        assert_eq!(t.hash, b.hash);
1172
1173        // since(after_seq, limit) — EXCLUSIVE cursor, bounded page + envelope.
1174        let after_a = db.since(a.seq, 0);
1175        assert_eq!(after_a.nodes.len(), 1);
1176        assert_eq!(after_a.nodes[0].id, "b");
1177        assert_eq!(after_a.from_seq, a.seq);
1178        assert_eq!(after_a.to_seq, b.seq);
1179        assert_eq!(after_a.head_seq, b.seq);
1180        assert!(!after_a.has_more);
1181
1182        // Nothing written after the tip.
1183        assert!(db.since(b.seq, 0).nodes.is_empty());
1184
1185        // `limit` bounds the page and sets has_more; resume from to_seq.
1186        let c = db.put("item", "c", serde_json::json!({"x": 3}), vec![], None, None).unwrap();
1187        let page = db.since(a.seq, 1);             // (a..] capped at 1 -> [b], more pending
1188        assert_eq!(page.nodes.len(), 1);
1189        assert_eq!(page.nodes[0].id, "b");
1190        assert_eq!(page.to_seq, b.seq);
1191        assert!(page.has_more);
1192        let page2 = db.since(page.to_seq, 1);      // resume from b -> [c], done
1193        assert_eq!(page2.nodes.len(), 1);
1194        assert_eq!(page2.nodes[0].id, "c");
1195        assert_eq!(page2.to_seq, c.seq);
1196        assert!(!page2.has_more);
1197    }
1198
1199    #[test]
1200    fn tip_collection_per_chain() {
1201        // The ITC sync-client case: separate chains in separate collections; a
1202        // consumer resumes ONE without pulling global tip and filtering.
1203        let db = Db::in_memory();
1204        assert!(db.tip_collection("blocks").is_none());
1205
1206        db.put("blocks", "b0", serde_json::json!({"h": 0}), vec![], None, None).unwrap();
1207        db.put("tx",     "t0", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1208        let b1 = db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1209        let t1 = db.put("tx",     "t1", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1210
1211        // global tip = latest write overall (t1)
1212        assert_eq!(db.tip().unwrap().id, "t1");
1213        // collection-local tips = latest write in each collection
1214        let bt = db.tip_collection("blocks").expect("blocks tip");
1215        assert_eq!(bt.id, "b1");
1216        assert_eq!(bt.seq, b1.seq);
1217        assert_eq!(db.tip_collection("tx").unwrap().seq, t1.seq);
1218        assert!(db.tip_collection("absent").is_none());
1219    }
1220
1221    #[test]
1222    fn seq_index_survives_batch() {
1223        let db = Db::in_memory();
1224        let nodes = db.put_batch(vec![
1225            ("item".into(), "x".into(), serde_json::json!({"v": 1}), vec![], None, None),
1226            ("item".into(), "y".into(), serde_json::json!({"v": 2}), vec![], None, None),
1227        ]).unwrap();
1228        for node in &nodes {
1229            assert_eq!(db.get_hash_by_seq(node.seq), Some(node.hash.clone()));
1230        }
1231    }
1232
1233    /// Regression: put_batch must remove the superseded version's sorted-index
1234    /// entries, exactly like put() does. Old behavior left the old hashes in
1235    /// the BTree — ORDER BY returned superseded rows alongside current ones
1236    /// (they resolve fine through the content-addressed store, which made the
1237    /// stale rows look legitimate).
1238    #[test]
1239    fn put_batch_removes_superseded_sorted_index_entries() {
1240        let db = Db::in_memory();
1241        db.create_sorted_index("blocks", "height");
1242        db.put("blocks", "x", serde_json::json!({"height": 1}), vec![], None, None).unwrap();
1243        db.put_batch(vec![
1244            ("blocks".into(), "x".into(), serde_json::json!({"height": 99}), vec![], None, None),
1245        ]).unwrap();
1246
1247        let asc = db.order_by_asc("blocks", "height", 10);
1248        assert_eq!(asc.len(), 1, "stale index entry for the superseded version must be gone");
1249        assert_eq!(asc[0].data["height"], 99);
1250        assert_eq!(asc[0].id, "x");
1251    }
1252
1253    /// Updates without any sorted index must keep full version-chain semantics
1254    /// (guards the new skip-old-object-read fast path in put()).
1255    #[test]
1256    fn update_without_indexes_preserves_chain() {
1257        let db = Db::in_memory();
1258        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1259        let v2 = db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
1260        assert_eq!(v2.prev.as_deref(), Some(v1.hash.as_str()), "prev chain must survive the fast path");
1261        assert_eq!(db.get("docs", "x").unwrap().data["v"], 2);
1262        assert_eq!(db.get_as_of("docs", "x", v1.seq).unwrap().data["v"], 1);
1263    }
1264
1265    #[test]
1266    fn link_and_neighbors() {
1267        let db = Db::in_memory();
1268        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1269        db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
1270        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1271        db.put("trip",   "t2", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1272
1273        db.link("driver:d1", "handles", "trip:t1").unwrap();
1274        db.link("driver:d1", "handles", "trip:t2").unwrap();
1275        db.link("driver:d2", "handles", "trip:t1").unwrap();
1276
1277        let d1_trips = db.neighbors("driver:d1", "handles");
1278        assert_eq!(d1_trips.len(), 2);
1279        let ids: std::collections::HashSet<&str> = d1_trips.iter().map(|n| n.id.as_str()).collect();
1280        assert!(ids.contains("t1") && ids.contains("t2"));
1281
1282        let d2_trips = db.neighbors("driver:d2", "handles");
1283        assert_eq!(d2_trips.len(), 1);
1284        assert_eq!(d2_trips[0].id, "t1");
1285    }
1286
1287    #[test]
1288    fn link_stored_in_links_collection() {
1289        // Links are stored as __links__ documents, not as graph edges.
1290        // The __links__ collection is NQL-queryable and consistent with the PyO3 binding.
1291        let db = Db::in_memory();
1292        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1293        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1294        db.link("driver:d1", "handles", "trip:t1").unwrap();
1295        // Verify the __links__ document was created
1296        let link_doc = db.get("__links__", "driver:d1|handles|trip:t1");
1297        assert!(link_doc.is_some(), "__links__ doc should exist");
1298        let doc = link_doc.unwrap();
1299        assert_eq!(doc.data["_from"], "driver:d1");
1300        assert_eq!(doc.data["_rel"],  "handles");
1301        assert_eq!(doc.data["_to"],   "trip:t1");
1302        // neighbors() resolves to the target node
1303        let nb = db.neighbors("driver:d1", "handles");
1304        assert_eq!(nb.len(), 1);
1305        assert_eq!(nb[0].id, "t1");
1306    }
1307
1308    #[test]
1309    fn link_missing_node_errors() {
1310        let db = Db::in_memory();
1311        db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
1312        assert!(db.link("driver:d1", "handles", "trip:ghost").is_err());
1313    }
1314
1315    #[test]
1316    fn link_durable_survives_reopen() {
1317        let dir = tempdir().unwrap();
1318        {
1319            let db = Db::open(dir.path(), None).unwrap();
1320            db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
1321            db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
1322            db.link("driver:d1", "handles", "trip:t1").unwrap();
1323        }
1324        let db2 = Db::open(dir.path(), None).unwrap();
1325        db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
1326        let trips = db2.neighbors("driver:d1", "handles");
1327        assert_eq!(trips.len(), 1);
1328        assert_eq!(trips[0].id, "t1");
1329    }
1330
1331    #[test]
1332    fn tip_survives_warm_restart() {
1333        // v2.5.43: tip() returns the last written object AND survives a warm restart.
1334        // On reopen the seq_index is cold (warm start skips the scan), so tip() must
1335        // resolve the last write via the MANIFEST tip_hash fallback — no scan.
1336        let dir = tempdir().unwrap();
1337        {
1338            let db = Db::open(dir.path(), None).unwrap();
1339            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1340            db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1341            db.flush_all(); // persists MANIFEST incl. tip_hash
1342            assert_eq!(db.tip().expect("tip in-session").id, "b2");
1343        }
1344        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1345        let db2 = Db::open(dir.path(), None).unwrap();
1346        assert!(db2.get_hash_by_seq(1).is_none(), "seq_index is cold on a warm boot");
1347        let tip = db2.tip().expect("tip() must survive a warm restart");
1348        assert_eq!(tip.id, "b2");
1349        assert_eq!(tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1350    }
1351
1352    #[test]
1353    fn tip_collection_survives_warm_restart() {
1354        // Same contract as tip(), per collection: itc-node-rs resumes headers /
1355        // blocks / l2_receipts independently, so each must be its own durable
1356        // resume point — not just the global tip.
1357        let dir = tempdir().unwrap();
1358        {
1359            let db = Db::open(dir.path(), None).unwrap();
1360            db.put("blocks", "b1", serde_json::json!({"h": 1}), vec![], None, None).unwrap();
1361            db.put("tx",     "t1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
1362            let b2 = db.put("blocks", "b2", serde_json::json!({"h": 2}), vec![], None, None).unwrap();
1363            db.flush_all(); // persists MANIFEST incl. coll_tips
1364            assert_eq!(db.tip_collection("blocks").unwrap().id, "b2");
1365            assert_eq!(db.tip_collection("blocks").unwrap().seq, b2.seq);
1366        }
1367        // Warm reopen: MANIFEST present -> no cold scan -> seq_index cold.
1368        let db2 = Db::open(dir.path(), None).unwrap();
1369        assert!(db2.get_hash_by_seq(0).is_none(), "seq_index is cold on a warm boot");
1370        let blocks_tip = db2.tip_collection("blocks").expect("tip_collection must survive a warm restart");
1371        assert_eq!(blocks_tip.id, "b2");
1372        assert_eq!(blocks_tip.data.get("h").and_then(|v| v.as_i64()), Some(2));
1373        let tx_tip = db2.tip_collection("tx").expect("tx tip must also survive");
1374        assert_eq!(tx_tip.id, "t1");
1375        assert!(db2.tip_collection("absent").is_none());
1376    }
1377
1378    #[test]
1379    fn cold_scan_indexes_every_object_and_reports_completion() {
1380        // Regression guard for the cold-scan refactor: seq_index is now populated
1381        // DURING the parallel read pass (for live scan_status().indexed_count
1382        // progress — see cold_scan_background_arc), not in a second pass
1383        // afterward. This asserts the end state is unchanged: every written
1384        // object is indexed, tip()/tip_collection() are correct, and
1385        // scan_complete eventually reports true.
1386        let dir = tempdir().unwrap();
1387        let n = 25u64;
1388        {
1389            let db = Db::open(dir.path(), None).unwrap();
1390            for i in 0..n {
1391                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1392            }
1393            db.flush_all();
1394        }
1395        // Force a COLD start regardless of the MANIFEST nedb-v2 itself would
1396        // have written: delete it so startup_rebuild() takes the cold path and
1397        // start_cold_scan() actually spawns the background scan this test needs
1398        // to exercise.
1399        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1400
1401        let db = Db::open(dir.path(), None).unwrap();
1402        assert!(!db.scan_status().scan_complete, "should be cold immediately after open");
1403        let db = std::sync::Arc::new(db);
1404        Db::start_cold_scan(std::sync::Arc::clone(&db));
1405
1406        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1407        while !db.scan_status().scan_complete {
1408            assert!(std::time::Instant::now() < deadline, "cold scan did not complete in time");
1409            std::thread::sleep(std::time::Duration::from_millis(5));
1410        }
1411
1412        let status = db.scan_status();
1413        assert_eq!(status.indexed_count, n as usize, "every written object must be indexed");
1414        assert!(status.scan_complete);
1415
1416        let tip = db.tip().expect("tip resolves after cold scan");
1417        assert_eq!(tip.data.get("i").and_then(|v| v.as_u64()), Some(n - 1));
1418        let coll_tip = db.tip_collection("things").expect("tip_collection resolves after cold scan");
1419        assert_eq!(coll_tip.id, tip.id);
1420    }
1421
1422    /// Concurrent writers must settle the tip at the HIGHEST SEQ, and that tip
1423    /// must survive a warm restart. Before the seq-guarded tip fix, update_head
1424    /// was "last call wins": a slower thread carrying an OLDER seq could
1425    /// overwrite tip_hash after a newer write, and MANIFEST then persisted the
1426    /// stale tip for the next warm boot (flaky by nature — this pins the
1427    /// contract deterministically for the fixed code).
1428    #[test]
1429    fn concurrent_puts_tip_resolves_to_highest_seq_after_warm_restart() {
1430        let dir = tempdir().unwrap();
1431        let total: u64 = 100;
1432        {
1433            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1434            let mut handles = vec![];
1435            for t in 0..4u64 {
1436                let db2 = std::sync::Arc::clone(&db);
1437                handles.push(std::thread::spawn(move || {
1438                    for i in 0..25u64 {
1439                        db2.put("c", &format!("{}-{}", t, i),
1440                                serde_json::json!({"t": t, "i": i}),
1441                                vec![], None, None).unwrap();
1442                    }
1443                }));
1444            }
1445            for h in handles { h.join().unwrap(); }
1446            // In-session: tip must be the highest assigned seq.
1447            let expected = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
1448            assert_eq!(expected, total - 1, "exactly {} writes expected", total);
1449            assert_eq!(db.tip().expect("in-session tip").seq, expected);
1450            db.flush_all(); // persist MANIFEST incl. tip_hash
1451        }
1452        // Warm reopen: seq_index cold; tip() resolves via MANIFEST tip_hash.
1453        let db2 = Db::open(dir.path(), None).unwrap();
1454        let tip = db2.tip().expect("tip must survive warm restart after concurrent writes");
1455        assert_eq!(tip.seq, total - 1, "warm-boot tip must be the highest-seq write");
1456        // Per-collection tip: same contract.
1457        let ct = db2.tip_collection("c").expect("coll tip survives");
1458        assert_eq!(ct.seq, total - 1);
1459    }
1460
1461    /// Pre-2.5.43 MANIFESTs (no tip_hash) must warm-boot, NOT force a cold
1462    /// scan. The old "cold scan once to upgrade" policy was hours of random
1463    /// reads on multi-million-object seek-bound stores (itcd -dagv3), re-paid
1464    /// on every boot if the process exited before the scan finished. seq+head
1465    /// in the old MANIFEST are valid; tip()/tip_collection() return None until
1466    /// the first write+flush organically rewrites MANIFEST with a tip.
1467    #[test]
1468    fn pre_durable_tip_manifest_warm_boots_and_heals_lazily() {
1469        let dir = tempdir().unwrap();
1470        {
1471            let db = Db::open(dir.path(), None).unwrap();
1472            for i in 0..5u64 {
1473                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1474            }
1475            db.flush_all();
1476        }
1477        // Rewrite MANIFEST in the pre-2.5.43 shape: seq + head only.
1478        let manifest_path = dir.path().join("MANIFEST");
1479        let m: serde_json::Value =
1480            serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
1481        let old_format = serde_json::json!({ "seq": m["seq"], "head": m["head"] });
1482        std::fs::write(&manifest_path, serde_json::to_string(&old_format).unwrap()).unwrap();
1483
1484        // Reopen: must be WARM (startup_ready immediately — no cold scan gate).
1485        let db2 = Db::open(dir.path(), None).unwrap();
1486        assert!(db2.startup_ready.load(std::sync::atomic::Ordering::SeqCst),
1487                "pre-2.5.43 MANIFEST must warm-boot, not fall to a cold scan");
1488        // tip() unresolvable this boot — documented None, not a panic or scan.
1489        assert!(db2.tip().is_none(), "tip() is None until the manifest heals");
1490        // seq continuity: a new write gets a FRESH seq (no reuse).
1491        let n = db2.put("things", "next", serde_json::json!({"fresh": true}), vec![], None, None).unwrap();
1492        assert_eq!(n.seq, m["seq"].as_u64().unwrap(), "next write takes the persisted next-to-assign seq");
1493        db2.flush_all(); // organic upgrade: MANIFEST now carries tip_hash
1494        drop(db2);
1495
1496        // Healed: next boot is warm AND tip() resolves.
1497        let db3 = Db::open(dir.path(), None).unwrap();
1498        assert!(db3.startup_ready.load(std::sync::atomic::Ordering::SeqCst));
1499        let tip = db3.tip().expect("tip() must resolve after the organic upgrade");
1500        assert_eq!(tip.id, "next");
1501    }
1502
1503    /// Regression for the cold-scan MANIFEST seq off-by-one. The scan's old
1504    /// hand-rolled MANIFEST stored `seq: max_seq` (the last USED seq), but the
1505    /// warm boot loads `m.seq` as the NEXT-TO-ASSIGN counter — so a restart
1506    /// right after a quiet cold scan handed the next write the tip's seq:
1507    /// a DUPLICATE seq in the log (seq_index overwrite, wrong since() page).
1508    /// The scan now writes MANIFEST via flush_manifest(), which reads the live
1509    /// counter (max_seq + 1).
1510    #[test]
1511    fn manifest_after_cold_scan_does_not_reuse_tip_seq() {
1512        let dir = tempdir().unwrap();
1513        let old_tip_seq;
1514        {
1515            let db = Db::open(dir.path(), None).unwrap();
1516            for i in 0..5u64 {
1517                db.put("things", &i.to_string(), serde_json::json!({"i": i}), vec![], None, None).unwrap();
1518            }
1519            db.flush_all();
1520            old_tip_seq = db.tip().unwrap().seq;
1521        }
1522        // Force a cold start: remove MANIFEST so the background scan runs and
1523        // writes a fresh MANIFEST itself.
1524        std::fs::remove_file(dir.path().join("MANIFEST")).unwrap();
1525        {
1526            let db = std::sync::Arc::new(Db::open(dir.path(), None).unwrap());
1527            Db::start_cold_scan(std::sync::Arc::clone(&db));
1528            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1529            while !db.scan_status().scan_complete {
1530                assert!(std::time::Instant::now() < deadline, "cold scan did not complete");
1531                std::thread::sleep(std::time::Duration::from_millis(5));
1532            }
1533            // No further writes — the scan's own MANIFEST is what the next boot sees.
1534        }
1535        // Warm reopen from the scan-written MANIFEST: the next write must get a
1536        // FRESH seq, never the tip's.
1537        let db3 = Db::open(dir.path(), None).unwrap();
1538        let tip_before = db3.tip().expect("tip survives scan-written MANIFEST");
1539        assert_eq!(tip_before.seq, old_tip_seq, "tip identity preserved across the scan");
1540        let new_node = db3.put("things", "next", serde_json::json!({"fresh": true}),
1541                               vec![], None, None).unwrap();
1542        assert!(new_node.seq > old_tip_seq,
1543                "new write reused seq {} (tip was {}) — duplicate seq in the log",
1544                new_node.seq, old_tip_seq);
1545    }
1546}