Skip to main content

nedb_engine/
index.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Index store for NEDB v2.
6//!
7//! Two index types:
8//!
9//! 1. **ID index** (`indexes/{coll}/id/{doc_id}` → object hash)
10//!    Atomic file-per-document. Reading is a single `fs::read_to_string`.
11//!    Writing is atomic (write .tmp → rename). Parallel reads are lock-free.
12//!
13//! 2. **Sorted index** (`indexes/{coll}/{field}.sorted` → in-memory BTreeMap)
14//!    Rebuilt from object store on startup. Persisted as a compact binary
15//!    file for fast cold start. Used for ORDER BY field ASC/DESC LIMIT n.
16
17use std::collections::BTreeMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use anyhow::Result;
22use dashmap::DashMap;
23use serde_json::Value;
24
25/// Ordered JSON value for BTree indexes (null < bool < number < string < array < object).
26#[derive(Debug, Clone, PartialEq)]
27pub enum OrderedValue {
28    Null,
29    Bool(bool),
30    Number(f64),   // NaN-safe comparison via total_cmp
31    Str(String),
32    Array(Vec<OrderedValue>),
33    Object,        // objects are all equal in ordering (sort by insertion order falls back to hash)
34}
35
36impl Eq for OrderedValue {}
37
38impl PartialOrd for OrderedValue {
39    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
40        Some(self.cmp(other))
41    }
42}
43
44impl Ord for OrderedValue {
45    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
46        use OrderedValue::*;
47        use std::cmp::Ordering::*;
48        match (self, other) {
49            (Null, Null)       => Equal,
50            (Null, _)          => Less,
51            (_, Null)          => Greater,
52            (Bool(a), Bool(b)) => a.cmp(b),
53            (Bool(_), _)       => Less,
54            (_, Bool(_))       => Greater,
55            (Number(a), Number(b)) => a.total_cmp(b),
56            (Number(_), _)     => Less,
57            (_, Number(_))     => Greater,
58            (Str(a), Str(b))   => a.cmp(b),
59            (Str(_), _)        => Less,
60            (_, Str(_))        => Greater,
61            (Array(a), Array(b)) => a.cmp(b),
62            (Array(_), _)      => Less,
63            (_, Array(_))      => Greater,
64            (Object, Object)   => Equal,
65        }
66    }
67}
68
69impl From<&Value> for OrderedValue {
70    fn from(v: &Value) -> Self {
71        match v {
72            Value::Null        => OrderedValue::Null,
73            Value::Bool(b)     => OrderedValue::Bool(*b),
74            Value::Number(n)   => OrderedValue::Number(n.as_f64().unwrap_or(f64::NAN)),
75            Value::String(s)   => OrderedValue::Str(s.clone()),
76            Value::Array(a)    => OrderedValue::Array(a.iter().map(|x| x.into()).collect()),
77            Value::Object(_)   => OrderedValue::Object,
78        }
79    }
80}
81
82/// Compute a 2-char hex shard prefix from a document id.
83/// Distributes files across 256 subdirectories to avoid flat-directory
84/// slowdown on ext4/xfs when a collection has >50k documents.
85fn id_shard(id: &str) -> String {
86    // FNV-1a 32-bit — fast, no crypto needed, deterministic
87    let mut hash: u32 = 2166136261;
88    for b in id.bytes() {
89        hash ^= b as u32;
90        hash = hash.wrapping_mul(16777619);
91    }
92    format!("{:02x}", hash & 0xff)
93}
94
95/// Encode a document id into a filesystem-safe leaf filename.
96///
97/// The id-index stores one file per document, and the id is the filename. Raw
98/// ids work on case-sensitive POSIX filesystems, but ids containing bytes that
99/// are illegal in Windows filenames (`: | / \ < > " ? *`, control chars) — most
100/// notably link ids like `driver:d1|handles|trip:t1` — cannot be written there,
101/// so the write silently fails and the entry is lost on reopen.
102///
103/// We percent-escape every byte that isn't unreserved (`A-Z a-z 0-9 - _ .`).
104/// `%` itself is escaped so decoding is unambiguous. Safe ids (block heights,
105/// hex hashes, utxo keys) are all-unreserved and return UNCHANGED, so existing
106/// chainstate paths are byte-for-byte identical and the hot path is unaffected.
107fn encode_id(id: &str) -> String {
108    fn is_unreserved(b: u8) -> bool {
109        b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')
110    }
111    if id.bytes().all(is_unreserved) {
112        return id.to_string();
113    }
114    let mut out = String::with_capacity(id.len() + 8);
115    for &b in id.as_bytes() {
116        if is_unreserved(b) {
117            out.push(b as char);
118        } else {
119            out.push_str(&format!("%{:02X}", b));
120        }
121    }
122    out
123}
124
125/// Inverse of `encode_id`. A name with no `%` (a safe id, or a legacy raw id
126/// written by an older version on a POSIX filesystem) is returned unchanged, so
127/// `list_ids` recovers the right id for both new and pre-upgrade files.
128fn decode_id(name: &str) -> String {
129    if !name.contains('%') {
130        return name.to_string();
131    }
132    fn hexval(b: u8) -> Option<u8> {
133        match b {
134            b'0'..=b'9' => Some(b - b'0'),
135            b'A'..=b'F' => Some(b - b'A' + 10),
136            b'a'..=b'f' => Some(b - b'a' + 10),
137            _ => None,
138        }
139    }
140    let bytes = name.as_bytes();
141    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
142    let mut i = 0;
143    while i < bytes.len() {
144        if bytes[i] == b'%' && i + 2 < bytes.len() {
145            if let (Some(hi), Some(lo)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
146                out.push(hi * 16 + lo);
147                i += 3;
148                continue;
149            }
150        }
151        out.push(bytes[i]);
152        i += 1;
153    }
154    String::from_utf8_lossy(&out).into_owned()
155}
156
157/// Per-document ID index — atomic file-per-doc, sharded across 256 subdirs.
158///
159/// Write path: updates go to `write_buf` (DashMap, zero I/O, lock-free).
160/// Background ticker calls `flush_write_buf()` every 1s — Rayon-parallel disk writes.
161/// Read path: `write_buf` checked first (latest value), then disk.
162/// This eliminates per-PUT `fs::rename` from the hot path, fixing concurrent write contention.
163pub struct IdIndex {
164    root:      PathBuf,
165    /// In-memory store: (coll, id) → hash. None = disk-backed (normal mode).
166    mem:       Option<Arc<dashmap::DashMap<(String, String), String>>>,
167    /// WAL write buffer — disk-backed mode buffers here, flushed to disk periodically.
168    write_buf: Arc<dashmap::DashMap<(String, String), Option<String>>>,  // None = tombstone
169}
170
171impl IdIndex {
172    pub fn new(db_root: &Path) -> Result<Self> {
173        let root = db_root.join("indexes");
174        fs::create_dir_all(&root)?;
175        Ok(Self { root, mem: None, write_buf: Arc::new(dashmap::DashMap::new()) })
176    }
177
178    /// Create a pure in-memory id index — no disk I/O.
179    pub fn in_memory() -> Self {
180        Self {
181            root:      PathBuf::from(":memory:"),
182            mem:       Some(Arc::new(dashmap::DashMap::new())),
183            write_buf: Arc::new(dashmap::DashMap::new()),
184        }
185    }
186
187    /// Flush the WAL write buffer to disk in parallel. Called by the background ticker.
188    /// No-op for in-memory databases. Safe to call concurrently with writes.
189    /// Flush the in-memory WAL to disk, reporting I/O failure to the caller.
190    /// Every entry is attempted; the first error is returned after the pass.
191    ///
192    /// DURABILITY INVARIANT: an entry is dropped from `write_buf` ONLY when its
193    /// disk write actually succeeded. A failed write (ENOSPC, EIO, EROFS) leaves
194    /// the entry buffered so the next tick retries it.
195    ///
196    /// Before 2.8.6 this cleared the buffer unconditionally, which silently and
197    /// permanently discarded acknowledged writes whenever a flush hit a full
198    /// disk: `put()` had already returned `Ok` and the content-addressed object
199    /// was durable (so `verify()` still counted it), but no id-index entry ever
200    /// reached disk — so the row was simply absent on reopen, with no error
201    /// anywhere. Reproduced on a full 22 MiB filesystem: 30 rows acknowledged,
202    /// `verify()` reported 30 healthy objects, `list()` returned 0.
203    pub fn try_flush_write_buf(&self) -> std::io::Result<()> {
204        if self.mem.is_some() || self.write_buf.is_empty() { return Ok(()); }
205        use rayon::prelude::*;
206        // Drain all pending entries and write them in parallel
207        let entries: Vec<((String, String), Option<String>)> = self.write_buf
208            .iter()
209            .map(|e| (e.key().clone(), e.value().clone()))
210            .collect();
211        let results: Vec<std::io::Result<()>> = entries.par_iter()
212            .map(|((coll, id), hash_opt)| -> std::io::Result<()> {
213                match hash_opt {
214                    Some(hash) => {
215                        // Write/update: tmp → rename
216                        let path = self.path(coll, id);
217                        if let Some(parent) = path.parent() {
218                            fs::create_dir_all(parent)?;
219                        }
220                        let tmp = path.with_extension("tmp");
221                        if let Err(e) = fs::write(&tmp, hash) {
222                            // A partial/empty tmp must not be left behind on a
223                            // full disk — it consumes the very space needed to
224                            // retry, and it is not a valid index leaf.
225                            let _ = fs::remove_file(&tmp);
226                            return Err(e);
227                        }
228                        if let Err(e) = fs::rename(&tmp, &path) {
229                            let _ = fs::remove_file(&tmp);
230                            return Err(e);
231                        }
232                        Ok(())
233                    }
234                    None => {
235                        // Tombstone: remove the file (encoded leaf + legacy raw if distinct).
236                        // Already-absent is success — the desired end state holds.
237                        let path = self.path(coll, id);
238                        match fs::remove_file(&path) {
239                            Ok(()) => {}
240                            Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
241                            Err(e) => return Err(e),
242                        }
243                        let raw = self.raw_path(coll, id);
244                        if raw != path {
245                            match fs::remove_file(&raw) {
246                                Ok(()) => {}
247                                Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
248                                Err(e) => return Err(e),
249                            }
250                        }
251                        Ok(())
252                    }
253                }
254            })
255            .collect();
256
257        // Clear flushed entries — but ONLY when the write SUCCEEDED, and only
258        // when the buffered value is still the exact value we flushed. An
259        // unconditional remove() here would delete a NEWER value written between
260        // the snapshot above and this point: that write would never reach disk
261        // (the file holds the stale hash we just wrote) and get() would serve the
262        // old version once the buffer check misses — a silent lost update.
263        // remove_if closes the race; a newer value simply stays buffered and
264        // flushes on the next tick.
265        let mut first_err: Option<std::io::Error> = None;
266        for ((key, flushed_val), result) in entries.iter().zip(results.iter()) {
267            match result {
268                Ok(()) => {
269                    self.write_buf.remove_if(key, |_, current| current == flushed_val);
270                }
271                Err(e) => {
272                    if first_err.is_none() {
273                        first_err = Some(std::io::Error::new(
274                            e.kind(),
275                            format!("id-index leaf {}/{}: {}", key.0, key.1, e),
276                        ));
277                    }
278                }
279            }
280        }
281        match first_err {
282            Some(e) => Err(e),
283            None => Ok(()),
284        }
285    }
286
287    /// Back-compat wrapper around [`try_flush_write_buf`]: flushes and logs.
288    /// Prefer the `try_` form — a swallowed flush error is a lost write.
289    pub fn flush_write_buf(&self) {
290        if let Err(e) = self.try_flush_write_buf() {
291            eprintln!(
292                "nedb: id-index flush failed ({}) — affected writes are RETAINED in the WAL and will be retried on the next flush",
293                e
294            );
295        }
296    }
297
298    fn path(&self, coll: &str, id: &str) -> PathBuf {
299        // Shard across 256 subdirectories using first 2 hex chars of a simple
300        // hash of the id. Prevents flat-directory slowdown (ext4 htree degrades
301        // past ~50k files per directory) for large collections like kv.
302        // Format: indexes/{coll}/id/{shard}/{encode_id(id)}
303        // Shard on the RAW id (stable across versions); only the leaf filename
304        // is encoded so it is legal on every filesystem (incl. Windows).
305        let shard = id_shard(id);
306        self.root.join(coll).join("id").join(&shard).join(encode_id(id))
307    }
308
309    /// Legacy path: the raw id as the leaf filename (pre-`encode_id`). Used only
310    /// as a read/cleanup fallback so id-index entries written by older versions
311    /// on POSIX filesystems stay readable after upgrade. On Windows a raw path
312    /// with illegal chars simply fails to open (→ treated as absent).
313    fn raw_path(&self, coll: &str, id: &str) -> PathBuf {
314        let shard = id_shard(id);
315        self.root.join(coll).join("id").join(&shard).join(id)
316    }
317
318    /// Get the current object hash for a document.
319    /// Checks WAL write buffer first (most recent), then disk.
320    pub fn get(&self, coll: &str, id: &str) -> Option<String> {
321        if let Some(ref mem) = self.mem {
322            return mem.get(&(coll.to_string(), id.to_string())).map(|v| v.clone());
323        }
324        // Check WAL buffer first — may have an unflushed write or tombstone
325        let key = (coll.to_string(), id.to_string());
326        if let Some(entry) = self.write_buf.get(&key) {
327            return entry.value().clone();  // None = tombstoned
328        }
329        // Fall through to disk: encoded filename first, then the legacy raw
330        // filename (pre-upgrade data). For safe ids the two paths are identical,
331        // so this is a single read on the hot path.
332        let p = self.path(coll, id);
333        let content = match fs::read_to_string(&p) {
334            Ok(c) => c,
335            Err(_) => {
336                let raw = self.raw_path(coll, id);
337                if raw == p { return None; }
338                fs::read_to_string(&raw).ok()?
339            }
340        };
341        let h = content.trim().to_string();
342        if h.is_empty() { None } else { Some(h) }
343    }
344
345    /// Set the current object hash for a document.
346    /// Disk mode: writes to WAL buffer only (zero I/O on hot path).
347    /// Background ticker flushes WAL to disk every 1s via Rayon.
348    pub fn set(&self, coll: &str, id: &str, hash: &str) -> Result<()> {
349        if let Some(ref mem) = self.mem {
350            mem.insert((coll.to_string(), id.to_string()), hash.to_string());
351            return Ok(());
352        }
353        // WAL: buffer the update, no disk I/O here
354        self.write_buf.insert(
355            (coll.to_string(), id.to_string()),
356            Some(hash.to_string()),
357        );
358        Ok(())
359    }
360
361    /// List all doc IDs in a collection (memory map or disk + WAL merge).
362    pub fn list_ids(&self, coll: &str) -> Vec<String> {
363        if let Some(ref mem) = self.mem {
364            // DashMap iteration order is also unspecified — sort here too, so
365            // memory mode and disk mode agree.
366            let mut ids: Vec<String> = mem.iter()
367                .filter(|e| e.key().0 == coll)
368                .map(|e| e.key().1.clone())
369                .collect();
370            ids.sort_unstable();
371            return ids;
372        }
373        // Read from disk then overlay WAL (adds buffered writes, removes tombstones)
374        let id_root = self.root.join(coll).join("id");
375        // Each entry in id_root is a 2-char hex shard dir
376        let mut ids: Vec<String> = fs::read_dir(&id_root)
377            .into_iter()
378            .flatten()
379            .filter_map(|e| e.ok())
380            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
381            .flat_map(|shard_dir| {
382                fs::read_dir(shard_dir.path())
383                    .into_iter()
384                    .flatten()
385                    .filter_map(|e| e.ok())
386                    .filter_map(|e| {
387                        let name = e.file_name().to_string_lossy().to_string();
388                        if name.ends_with(".tmp") { return None; }
389                        // Decode the on-disk filename back to the document id
390                        // (encoded for new files; identity for legacy/safe ids).
391                        Some(decode_id(&name))
392                    })
393                    .collect::<Vec<_>>()
394            })
395            .collect::<std::collections::HashSet<_>>()
396            .into_iter()
397            // Overlay WAL: add buffered writes, remove tombstones
398            .chain(
399                self.write_buf.iter()
400                    .filter(|e| e.key().0 == coll && e.value().is_some())
401                    .map(|e| e.key().1.clone())
402            )
403            .collect::<std::collections::HashSet<_>>()
404            .into_iter()
405            .filter(|id| {
406                // Exclude WAL tombstones
407                self.write_buf.get(&(coll.to_string(), id.clone()))
408                    .map(|v| v.is_some())
409                    .unwrap_or(true)
410            })
411            .collect::<Vec<_>>();
412
413        // Deterministic order. The dedup above runs through HashSet, and Rust
414        // seeds its hasher randomly PER PROCESS — so without this the same query
415        // over unchanged data returns rows in a different order on every restart:
416        //
417        //   run 1:  o5 o8 o7 o4 o6 ...
418        //   run 2:  o7 o5 o2 o6 o8 ...
419        //
420        // Cosmetic for a full scan, but not for `LIMIT 5` with no ORDER BY,
421        // which then returns an arbitrary 5 of 8 and calls it an answer. It also
422        // makes any snapshot/diff test flaky for reasons that look like data
423        // corruption.
424        //
425        // Sorted by id, which for the common case of sequential ids is also
426        // insertion order. Callers that want a different order say ORDER BY.
427        ids.sort_unstable();
428        ids
429    }
430
431    /// Remove the id index entry for a document (tombstone / delete).
432    /// Disk mode: writes a tombstone to the WAL buffer; flushed to disk on next ticker.
433    pub fn remove(&self, coll: &str, id: &str) -> Result<()> {
434        if let Some(ref mem) = self.mem {
435            mem.remove(&(coll.to_string(), id.to_string()));
436            return Ok(());
437        }
438        // WAL tombstone: None value means "delete this file on flush"
439        self.write_buf.insert((coll.to_string(), id.to_string()), None);
440        Ok(())
441    }
442
443    /// List all known collections.
444    ///
445    /// Overlays the WAL, exactly as `ids()` does. A collection whose first write
446    /// is still sitting in `write_buf` has no directory on disk yet, so a
447    /// read_dir-only implementation reports it as absent for up to a full flush
448    /// tick.
449    ///
450    /// That was a real bug, and a nasty one because it was invisible to a human
451    /// at a terminal: type a PUT, type a query, and the 1s ticker has already
452    /// fired in between. Only an automated caller — one that writes and reads in
453    /// the same millisecond — ever sees the empty list. It surfaced through
454    /// `/cast`, which checks the generated collection against this list and
455    /// returned "collection does not exist" for a collection that had just been
456    /// written successfully.
457    ///
458    /// Tombstoned entries are excluded, but only when the collection has no
459    /// surviving documents anywhere — a delete of one document must not hide the
460    /// whole collection.
461    pub fn collections(&self) -> Vec<String> {
462        if let Some(ref mem) = self.mem {
463            let mut colls: Vec<String> = mem.iter()
464                .map(|e| e.key().0.clone())
465                .collect::<std::collections::HashSet<_>>()
466                .into_iter().collect();
467            colls.sort();
468            return colls;
469        }
470
471        let mut set: std::collections::HashSet<String> = fs::read_dir(&self.root)
472            .into_iter()
473            .flatten()
474            .filter_map(|e| e.ok())
475            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
476            .map(|e| e.file_name().to_string_lossy().to_string())
477            .collect();
478
479        // Overlay WAL: a buffered live write makes its collection visible now.
480        for e in self.write_buf.iter() {
481            if e.value().is_some() {
482                set.insert(e.key().0.clone());
483            }
484        }
485
486        let mut colls: Vec<String> = set.into_iter().collect();
487        colls.sort();
488        colls
489    }
490}
491
492/// In-memory sorted index per (collection, field).
493/// Rebuilt from object store on startup. O(log n) ORDER BY queries.
494pub struct SortedIndexes {
495    /// (coll, field) → BTreeMap<value, Vec<hash>>
496    inner: DashMap<(String, String), BTreeMap<OrderedValue, Vec<String>>>,
497}
498
499impl SortedIndexes {
500    pub fn new() -> Self {
501        Self { inner: DashMap::new() }
502    }
503
504    /// Register a field as sorted-indexed for a collection.
505    /// Must be called before any puts for that field to be indexed.
506    pub fn ensure(&self, coll: &str, field: &str) {
507        self.inner
508            .entry((coll.to_string(), field.to_string()))
509            .or_default();
510    }
511
512    /// Insert (or update) a value → hash mapping.
513    pub fn insert(&self, coll: &str, field: &str, value: &Value, hash: &str) {
514        let key = (coll.to_string(), field.to_string());
515        if let Some(mut idx) = self.inner.get_mut(&key) {
516            let ov = OrderedValue::from(value);
517            idx.entry(ov)
518               .or_default()
519               .push(hash.to_string());
520        }
521    }
522
523    /// Remove a hash from the index (on overwrite/delete of a doc version).
524    pub fn remove(&self, coll: &str, field: &str, value: &Value, hash: &str) {
525        let key = (coll.to_string(), field.to_string());
526        if let Some(mut idx) = self.inner.get_mut(&key) {
527            let ov = OrderedValue::from(value);
528            if let Some(hashes) = idx.get_mut(&ov) {
529                hashes.retain(|h| h != hash);
530                if hashes.is_empty() { idx.remove(&ov); }
531            }
532        }
533    }
534
535    /// Return the top-k hashes ordered by field ASC.
536    pub fn top_k_asc(&self, coll: &str, field: &str, k: usize) -> Vec<String> {
537        let key = (coll.to_string(), field.to_string());
538        self.inner.get(&key).map(|idx| {
539            idx.values().flat_map(|v| v.iter().cloned()).take(k).collect()
540        }).unwrap_or_default()
541    }
542
543    /// Return the top-k hashes ordered by field DESC.
544    pub fn top_k_desc(&self, coll: &str, field: &str, k: usize) -> Vec<String> {
545        let key = (coll.to_string(), field.to_string());
546        self.inner.get(&key).map(|idx| {
547            idx.values().rev().flat_map(|v| v.iter().cloned()).take(k).collect()
548        }).unwrap_or_default()
549    }
550
551    /// Hashes whose indexed value falls within the given bounds.
552    ///
553    /// The BTreeMap already orders by value, so a bounded predicate is a
554    /// range walk rather than a full collection scan. `None` for either bound
555    /// means unbounded on that side, which serves a one-sided inequality
556    /// (`fee > 10`) as well as a two-sided `BETWEEN`.
557    ///
558    /// Documents where the field is ABSENT are not in this index at all, and
559    /// are therefore not returned. That is correct for every predicate this
560    /// serves: a missing field compares as null, which satisfies no ordering
561    /// comparison.
562    pub fn range(
563        &self,
564        coll: &str,
565        field: &str,
566        low: Option<&Value>,
567        high: Option<&Value>,
568        low_incl: bool,
569        high_incl: bool,
570    ) -> Vec<String> {
571        use std::ops::Bound;
572        let key = (coll.to_string(), field.to_string());
573        self.inner.get(&key).map(|idx| {
574            let lo = match low {
575                None => Bound::Unbounded,
576                Some(v) => {
577                    let ov = OrderedValue::from(v);
578                    if low_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
579                }
580            };
581            let hi = match high {
582                None => Bound::Unbounded,
583                Some(v) => {
584                    let ov = OrderedValue::from(v);
585                    if high_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
586                }
587            };
588            idx.range((lo, hi)).flat_map(|(_, v)| v.iter().cloned()).collect()
589        }).unwrap_or_default()
590    }
591
592    /// Hashes whose indexed value equals `value` — an O(log n) point lookup,
593    /// used for `=` and for each arm of an `IN (...)` list.
594    pub fn exact(&self, coll: &str, field: &str, value: &Value) -> Vec<String> {
595        let key = (coll.to_string(), field.to_string());
596        self.inner.get(&key).map(|idx| {
597            idx.get(&OrderedValue::from(value)).cloned().unwrap_or_default()
598        }).unwrap_or_default()
599    }
600
601    /// How many hashes a range covers, without materialising them.
602    ///
603    /// Lets the planner compare two candidate indexes and pick the more
604    /// selective one, rather than committing to whichever field it saw first.
605    pub fn range_len(
606        &self,
607        coll: &str,
608        field: &str,
609        low: Option<&Value>,
610        high: Option<&Value>,
611        low_incl: bool,
612        high_incl: bool,
613    ) -> usize {
614        use std::ops::Bound;
615        let key = (coll.to_string(), field.to_string());
616        self.inner.get(&key).map(|idx| {
617            let lo = match low {
618                None => Bound::Unbounded,
619                Some(v) => {
620                    let ov = OrderedValue::from(v);
621                    if low_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
622                }
623            };
624            let hi = match high {
625                None => Bound::Unbounded,
626                Some(v) => {
627                    let ov = OrderedValue::from(v);
628                    if high_incl { Bound::Included(ov) } else { Bound::Excluded(ov) }
629                }
630            };
631            idx.range((lo, hi)).map(|(_, v)| v.len()).sum()
632        }).unwrap_or(0)
633    }
634
635    /// Check if a sorted index exists for a (coll, field) pair.
636    pub fn has(&self, coll: &str, field: &str) -> bool {
637        self.inner.contains_key(&(coll.to_string(), field.to_string()))
638    }
639
640    /// True if no sorted indexes have been registered yet.
641    pub fn is_empty(&self) -> bool {
642        self.inner.is_empty()
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use tempfile::tempdir;
650
651    /// A failed flush must RETAIN the entry for retry, never discard it.
652    ///
653    /// Regression for the 2.8.5 silent-loss bug: `flush_write_buf` cleared every
654    /// snapshotted key regardless of whether its disk write succeeded, so a
655    /// flush that hit ENOSPC/EACCES permanently dropped acknowledged writes.
656    /// The fault is injected by planting a regular FILE where the collection
657    /// directory must go, so `create_dir_all` fails with NotADirectory. That is
658    /// deliberately independent of file permissions: containers frequently run
659    /// as root or hold `cap_dac_override`, where a chmod-0555 fixture is
660    /// silently writable and the test would pass without exercising anything.
661    #[test]
662    fn failed_flush_retains_entries_for_retry() {
663        let dir = tempdir().unwrap();
664        let idx = IdIndex::new(dir.path()).unwrap();
665
666        idx.set("rows", "a", "hash_a").unwrap();
667        idx.set("rows", "b", "hash_b").unwrap();
668
669        // Block the leaf path: `indexes/rows` is a file, so the index cannot
670        // create `indexes/rows/id/<shard>/` beneath it.
671        let blocker = dir.path().join("indexes").join("rows");
672        fs::write(&blocker, b"not a directory").unwrap();
673
674        let result = idx.try_flush_write_buf();
675        assert!(
676            result.is_err(),
677            "flush must report the I/O failure, got Ok — callers cannot detect lost writes"
678        );
679
680        // THE INVARIANT: the entries are still buffered, so a later flush retries.
681        assert_eq!(
682            idx.write_buf.len(),
683            2,
684            "failed flush discarded buffered writes — acknowledged data would be lost"
685        );
686
687        // Clear the fault and retry: the writes must now land.
688        fs::remove_file(&blocker).unwrap();
689        idx.try_flush_write_buf()
690            .expect("retry after the fault clears must succeed");
691        assert!(idx.write_buf.is_empty(), "successful flush must drain the WAL");
692
693        // Reopen from disk only — proves the retry actually persisted.
694        let idx2 = IdIndex::new(dir.path()).unwrap();
695        assert_eq!(idx2.get("rows", "a").as_deref(), Some("hash_a"));
696        assert_eq!(idx2.get("rows", "b").as_deref(), Some("hash_b"));
697    }
698
699    /// A successful flush still drains the buffer and reports Ok.
700    /// Guards the false-positive direction: the new error path must not make
701    /// healthy flushes look like failures.
702    #[test]
703    fn successful_flush_reports_ok_and_drains() {
704        let dir = tempdir().unwrap();
705        let idx = IdIndex::new(dir.path()).unwrap();
706        for i in 0..64 {
707            idx.set("rows", &format!("id{}", i), &format!("h{}", i)).unwrap();
708        }
709        idx.try_flush_write_buf().expect("healthy flush must be Ok");
710        assert!(idx.write_buf.is_empty());
711        let idx2 = IdIndex::new(dir.path()).unwrap();
712        assert_eq!(idx2.get("rows", "id63").as_deref(), Some("h63"));
713    }
714
715    #[test]
716    fn id_index_roundtrip() {
717        let dir = tempdir().unwrap();
718        let idx = IdIndex::new(dir.path()).unwrap();
719        idx.set("blocks", "618000", "abcdef1234").unwrap();
720        assert_eq!(idx.get("blocks", "618000"), Some("abcdef1234".to_string()));
721    }
722
723    #[test]
724    fn encode_decode_id_bijective() {
725        // Safe ids pass through unchanged (chainstate paths stay identical).
726        for safe in ["618000", "utxo-000000042", "abc_DEF.123", "deadBEEF"] {
727            assert_eq!(encode_id(safe), safe, "safe id must be identity");
728            assert_eq!(decode_id(&encode_id(safe)), safe);
729        }
730        // FS-unsafe ids (link ids, paths) round-trip and contain no illegal
731        // Windows filename chars once encoded.
732        for weird in ["driver:d1|handles|trip:t1", "a/b\\c", "x<y>z?\"*", "100%done"] {
733            let enc = encode_id(weird);
734            assert!(
735                !enc.chars().any(|c| matches!(c,
736                    ':' | '|' | '/' | '\\' | '<' | '>' | '?' | '"' | '*')),
737                "encoded leaf must be filesystem-safe: {}", enc);
738            assert_eq!(decode_id(&enc), weird, "encode/decode must round-trip");
739        }
740    }
741
742    #[test]
743    fn id_index_fs_unsafe_id_survives_disk_roundtrip() {
744        // Regression: link ids contain ':' and '|', illegal in Windows filenames.
745        // They must persist to the on-disk id-index and read back after reopen.
746        let dir = tempdir().unwrap();
747        let weird = "driver:d1|handles|trip:t1";
748        {
749            let idx = IdIndex::new(dir.path()).unwrap();
750            idx.set("__links__", weird, "deadbeefcafe").unwrap();
751            idx.flush_write_buf(); // persist WAL → disk (encoded leaf filename)
752        }
753        // Cold reopen: nothing in the WAL, must come from disk.
754        let idx2 = IdIndex::new(dir.path()).unwrap();
755        assert_eq!(idx2.get("__links__", weird), Some("deadbeefcafe".to_string()),
756                   "FS-unsafe id must be readable from disk after reopen");
757        assert_eq!(idx2.list_ids("__links__"), vec![weird.to_string()],
758                   "list_ids must decode the on-disk filename back to the id");
759    }
760
761    #[test]
762    fn ordered_value_ordering() {
763        use OrderedValue::*;
764        assert!(Null < Bool(false));
765        assert!(Bool(false) < Bool(true));
766        assert!(Bool(true) < Number(0.0));
767        assert!(Number(1.0) < Number(2.0));
768        assert!(Number(2.0) < Str("a".to_string()));
769        assert!(Str("a".to_string()) < Str("b".to_string()));
770    }
771
772    #[test]
773    fn sorted_index_top_k() {
774        let idx = SortedIndexes::new();
775        idx.ensure("blocks", "height");
776        idx.insert("blocks", "height", &serde_json::json!(3), "hash3");
777        idx.insert("blocks", "height", &serde_json::json!(1), "hash1");
778        idx.insert("blocks", "height", &serde_json::json!(2), "hash2");
779        let asc = idx.top_k_asc("blocks", "height", 2);
780        assert_eq!(asc, vec!["hash1", "hash2"]);
781        let desc = idx.top_k_desc("blocks", "height", 2);
782        assert_eq!(desc, vec!["hash3", "hash2"]);
783    }
784
785    /// Regression stress test for the flush_write_buf lost-update race.
786    ///
787    /// Old behavior: flush snapshotted the buffer, wrote files in parallel, then
788    /// UNCONDITIONALLY removed each snapshotted key. A set() landing between the
789    /// snapshot and the remove was deleted from the buffer without ever being
790    /// flushed — disk kept the stale hash and (with no later write to re-insert
791    /// the key) the newer value was lost forever.
792    ///
793    /// Shape: every key is written exactly twice (v1 then v2) while a flusher
794    /// thread spins. Under the old code, keys whose v1 was snapshotted and whose
795    /// v2 arrived during the parallel disk-write phase get their v2 dropped by
796    /// the unconditional remove — the final assert catches them on disk at v1.
797    /// With remove_if, a superseded snapshot entry leaves the newer value
798    /// buffered for the next flush, so every key must read v2 at the end.
799    /// (Probabilistic by nature, but the race window — thousands of parallel
800    /// file writes — is wide; with 2000 keys the old code fails reliably.)
801    #[test]
802    fn flush_never_drops_a_concurrent_newer_write() {
803        use std::sync::Arc;
804        use std::sync::atomic::{AtomicBool, Ordering};
805
806        let dir = tempdir().unwrap();
807        let idx = Arc::new(IdIndex::new(dir.path()).unwrap());
808        let stop = Arc::new(AtomicBool::new(false));
809        const N: usize = 2000;
810
811        let flusher = {
812            let idx = Arc::clone(&idx);
813            let stop = Arc::clone(&stop);
814            std::thread::spawn(move || {
815                while !stop.load(Ordering::Relaxed) {
816                    idx.flush_write_buf();
817                }
818            })
819        };
820
821        // v1 for every key, then v2 for every key — the flusher races both passes.
822        for i in 0..N {
823            idx.set("c", &format!("k{}", i), "v1").unwrap();
824        }
825        for i in 0..N {
826            idx.set("c", &format!("k{}", i), "v2").unwrap();
827        }
828
829        stop.store(true, Ordering::Relaxed);
830        flusher.join().unwrap();
831        // Drain anything still buffered (remove_if leaves superseded entries in).
832        idx.flush_write_buf();
833        idx.flush_write_buf();
834
835        // Every key must be v2 — from this handle AND from a cold reopen (disk).
836        for i in 0..N {
837            let k = format!("k{}", i);
838            assert_eq!(idx.get("c", &k), Some("v2".to_string()),
839                       "key {} lost its newer write (buffer path)", k);
840        }
841        let cold = IdIndex::new(dir.path()).unwrap();
842        for i in 0..N {
843            let k = format!("k{}", i);
844            assert_eq!(cold.get("c", &k), Some("v2".to_string()),
845                       "key {} lost its newer write (disk path)", k);
846        }
847    }
848
849    /// Regression: a collection must be visible the instant it is written, not
850    /// one flush tick later.
851    ///
852    /// Old behavior: `collections()` did a bare `read_dir` of the object root. A
853    /// brand-new collection lives only in `write_buf` until the 1s ticker fires,
854    /// so it was reported as ABSENT for up to a full second after a successful
855    /// write. Every other read path (`get`, `list_ids`) already overlaid the WAL;
856    /// this one silently did not.
857    ///
858    /// Why it hid for so long: a human at a terminal cannot reproduce it. Typing
859    /// a PUT and then a query leaves hundreds of milliseconds in between, and the
860    /// ticker has already run. Only a caller that writes and reads within the
861    /// same millisecond sees the empty list — which is exactly what an automated
862    /// test does. It surfaced through `/cast`, which validates the model's chosen
863    /// collection against this list and rejected a collection that had just been
864    /// written.
865    ///
866    /// NOTE the deliberate absence of any flush below. Calling flush_write_buf()
867    /// here would make this test pass against the OLD code and assert nothing.
868    #[test]
869    fn collections_are_visible_before_flush() {
870        let dir = tempdir().unwrap();
871        let idx = IdIndex::new(dir.path()).unwrap();
872
873        idx.set("orders", "o1", "hash1").unwrap();
874        let colls = idx.collections();
875        assert!(
876            colls.contains(&"orders".to_string()),
877            "collection invisible before flush: {colls:?}"
878        );
879
880        // Still correct once it does reach disk — no duplicates from the overlay.
881        idx.flush_write_buf();
882        let after = idx.collections();
883        assert_eq!(after, vec!["orders".to_string()], "after flush: {after:?}");
884
885        // Second collection, same story, and the first must not vanish.
886        idx.set("stylists", "s1", "hash2").unwrap();
887        let both = idx.collections();
888        assert_eq!(both, vec!["orders".to_string(), "stylists".to_string()],
889                   "expected both collections, got {both:?}");
890    }
891
892    /// Regression: `list_ids` must return a STABLE order.
893    ///
894    /// The dedup path runs through `HashSet`, and Rust seeds its hasher randomly
895    /// per process. Observed on a real daemon — same query, same data, three
896    /// consecutive runs:
897    ///
898    /// ```text
899    ///   o5 o8 o7 o4 o6 ...
900    ///   o7 o5 o2 o6 o8 ...
901    ///   o6 o1 o5 o4 o7 ...
902    /// ```
903    ///
904    /// Cosmetic on a full scan. NOT cosmetic for `LIMIT 5` with no `ORDER BY`,
905    /// which then hands back an arbitrary 5 of 8 as though it were an answer.
906    ///
907    /// NOTE the id set below. Sequential ids (`o1`..`o8`) can land in a
908    /// consistent order by chance, which would let this pass against the old
909    /// code. These are deliberately hash-scattered strings, and 24 of them, so a
910    /// single unsorted run being accidentally sorted is vanishingly unlikely.
911    #[test]
912    fn list_ids_order_is_stable() {
913        let dir = tempdir().unwrap();
914        let idx = IdIndex::new(dir.path()).unwrap();
915
916        let ids: Vec<String> = (0..24).map(|i| format!("zq{:x}-{}", i * 7919, i)).collect();
917        for id in &ids {
918            idx.set("orders", id, "h").unwrap();
919        }
920
921        // Buffered (pre-flush) and on-disk (post-flush) must BOTH be sorted, and
922        // must agree with each other — a flush is not a reordering event.
923        let mut want = ids.clone();
924        want.sort_unstable();
925
926        let before = idx.list_ids("orders");
927        assert_eq!(before, want, "unsorted before flush");
928
929        idx.flush_write_buf();
930        let after = idx.list_ids("orders");
931        assert_eq!(after, want, "unsorted after flush");
932        assert_eq!(before, after, "flush changed the order");
933
934        // Repeat reads within a process must not drift either.
935        for _ in 0..5 {
936            assert_eq!(idx.list_ids("orders"), want, "order varied between reads");
937        }
938    }
939
940    /// A tombstoned document must not resurrect its collection.
941    #[test]
942    fn collections_excludes_tombstone_only_writes() {
943        let dir = tempdir().unwrap();
944        let idx = IdIndex::new(dir.path()).unwrap();
945        idx.remove("ghosts", "g1").unwrap();
946        let colls = idx.collections();
947        assert!(!colls.contains(&"ghosts".to_string()),
948                "a tombstone conjured a collection: {colls:?}");
949    }
950
951}