nedb_engine/relation.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//! Reading one relation out of the store, without going through a query
6//! language to do it.
7//!
8//! # Why this module exists
9//!
10//! The SQL evaluator used to obtain its rows by BUILDING AN NQL STRING and
11//! parsing it back:
12//!
13//! ```text
14//! let mut q = format!("FROM {}", cname);
15//! if let Some(seq) = temporal.get(&key) { q.push_str(&format!(" AS OF {}", seq)); }
16//! ...
17//! crate::nql::query(db, &q)
18//! ```
19//!
20//! That is a translation. It is the same translation the project spent months
21//! removing, pointed the other way — and it carried the same class of defect,
22//! because a clause that fails to make it into the string is a clause that
23//! silently does not happen. The retry path in `pgwire` did exactly that: it
24//! rebuilt a shorter string by hand and dropped `VALID AS OF` and `SEARCH`
25//! while carefully preserving `AS OF`, because someone had been bitten by
26//! `AS OF` specifically and fixed that one.
27//!
28//! A struct cannot forget a field. This module is the scan expressed as data,
29//! executed by calling the store directly.
30//!
31//! # This is the fold, not a second implementation
32//!
33//! `matches_valid_as_of` and `node_contains_text` LIVE HERE NOW. They were
34//! private to `nql`, and NQL's executor calls into this module for them rather
35//! than keeping a copy. That ordering matters: two copies of "what does VALID
36//! AS OF mean" is the exact failure mode being removed, and it would be absurd
37//! to create one while removing one.
38//!
39//! What remains in `nql` is its parser and its predicate evaluator. When the
40//! last caller of those is gone, so is the file.
41
42use serde_json::Value;
43
44use crate::db::Db;
45use crate::store::Node;
46
47/// One relation to read, and the qualifiers that shape it.
48///
49/// Every field is a question the store can answer directly. There is no
50/// rendering step and nothing to escape — `SEARCH 'o''brien'` was a quoting
51/// problem when this was a string, and is not one now.
52#[derive(Debug, Clone, Default)]
53pub struct Scan {
54 /// The collection name.
55 pub coll: String,
56 /// `AS OF SYSTEM TIME <seq>` — system time, a sequence.
57 pub as_of: Option<u64>,
58 /// `VALID AS OF '<date>'` — application time, a date string.
59 pub valid_as_of: Option<String>,
60 /// `SEARCH '<text>'` — substring over the document's rendered fields.
61 pub search: Option<String>,
62 /// `TRACE <edge> [REVERSE]` — replace each row with its causal chain.
63 pub trace: Option<String>,
64 /// Walk effects rather than causes.
65 pub trace_reverse: bool,
66 /// `TRAVERSE <rel>` — replace each row with its one-hop neighbours.
67 pub traverse: Option<String>,
68 /// Chain length cap for `TRACE`.
69 ///
70 /// Explicit rather than defaulted at the call site. NQL took this from the
71 /// query's `LIMIT` and fell back to 1000 — which silently conflated "how
72 /// many rows do I want back" with "how deep may a causal chain go", two
73 /// unrelated numbers. They are separate here, and a truncated chain is a
74 /// thing the caller chose.
75 pub trace_limit: usize,
76}
77
78impl Scan {
79 pub fn new(coll: impl Into<String>) -> Self {
80 Scan { coll: coll.into(), trace_limit: DEFAULT_TRACE_LIMIT, ..Default::default() }
81 }
82
83 /// True when this scan asks for anything beyond the live collection.
84 pub fn is_plain(&self) -> bool {
85 self.as_of.is_none()
86 && self.valid_as_of.is_none()
87 && self.search.is_none()
88 && self.trace.is_none()
89 && self.traverse.is_none()
90 }
91}
92
93/// The cap NQL used, preserved so a migrated query answers identically.
94pub const DEFAULT_TRACE_LIMIT: usize = 1000;
95
96/// Is `node` valid at `date`?
97///
98/// Moved here from `nql`, unchanged, and now the only definition.
99///
100/// `valid_from` is inclusive and `valid_to` is EXCLUSIVE, which is what makes
101/// two adjacent validity windows tile without overlapping — a row ending
102/// `2026-01-01` and the next beginning `2026-01-01` yields exactly one answer
103/// on that date, not two and not zero.
104pub fn matches_valid_as_of(node: &Node, date: &str) -> bool {
105 let from_ok = node.valid_from.as_deref().map(|f| f <= date).unwrap_or(true);
106 let to_ok = node.valid_to.as_deref().map(|t| t > date).unwrap_or(true);
107 from_ok && to_ok
108}
109
110/// Full-text over the document's rendered JSON, case-insensitively.
111///
112/// Moved here from `nql`, unchanged, and now the only definition. It searches
113/// the SERIALISED document, so it matches field names as well as values —
114/// long-standing behaviour, preserved deliberately rather than quietly
115/// improved, because changing what `SEARCH` matches is a semantic change and
116/// this module's job is to not be one.
117pub fn node_contains_text(node: &Node, text: &str) -> bool {
118 node.data.to_string().to_lowercase().contains(&text.to_lowercase())
119}
120
121/// Read the relation.
122///
123/// The order is NQL's execution order, and it is load-bearing:
124///
125/// 1. **candidates** — every row, at a sequence or at the tip
126/// 2. **filters** — `VALID AS OF`, then `SEARCH`
127/// 3. **row-set transforms** — `TRACE`, then `TRAVERSE`
128///
129/// Filters before transforms is the part worth stating. Tracing first and
130/// filtering after would apply `SEARCH` to the CHAIN rather than to the roots
131/// the chain was grown from, which is a different question with a
132/// plausible-looking answer.
133pub fn read(db: &Db, scan: &Scan) -> Vec<Node> {
134 // A sequence reaches through the graveyard: a row deleted after `seq` was
135 // alive AT `seq`, and `list` only knows about the living. That is why this
136 // goes id-by-id rather than filtering `list`.
137 let candidates: Vec<Node> = match scan.as_of {
138 Some(seq) => db
139 .list_ids_including_deleted(&scan.coll)
140 .into_iter()
141 .filter_map(|id| db.get_as_of(&scan.coll, &id, seq))
142 .collect(),
143 None => db.list(&scan.coll),
144 };
145
146 let mut rows: Vec<Node> = candidates
147 .into_iter()
148 .filter(|n| {
149 scan.valid_as_of
150 .as_deref()
151 .map(|d| matches_valid_as_of(n, d))
152 .unwrap_or(true)
153 })
154 .filter(|n| {
155 scan.search
156 .as_deref()
157 .map(|t| node_contains_text(n, t))
158 .unwrap_or(true)
159 })
160 .collect();
161
162 if scan.trace.is_some() {
163 let limit = if scan.trace_limit == 0 { DEFAULT_TRACE_LIMIT } else { scan.trace_limit };
164 let mut traced: Vec<Node> = Vec::new();
165 for root in &rows {
166 traced.extend(db.trace(&root.hash, scan.trace_reverse, limit));
167 }
168 rows = traced;
169 }
170
171 if let Some(rel) = &scan.traverse {
172 let mut hopped: Vec<Node> = Vec::new();
173 for root in &rows {
174 hopped.extend(db.neighbors(&format!("{}:{}", root.coll, root.id), rel));
175 }
176 rows = hopped;
177 }
178
179 rows
180}
181
182/// Read the relation as query rows.
183pub fn read_json(db: &Db, scan: &Scan) -> Vec<Value> {
184 read(db, scan).iter().map(crate::nql::node_to_json).collect()
185}
186
187/// Resolve an `AS OF` marker to a real sequence number.
188///
189/// A bare integer passes through bit-for-bit; that is the backcompat
190/// contract. A wall-clock moment arrives with `WALL_CLOCK_FLAG` set and is
191/// resolved through [`Db::seq_at`] — the last sequence whose write-time is at
192/// or before the moment.
193///
194/// # Why this is a function and not a closure in one executor
195///
196/// It WAS a closure inside the SQL executor, and NQL had no resolution at all.
197/// That split is why `FROM orders AS OF "2026-01-01"` errored in Rust while
198/// the Python reference engine accepted it: one dialect, two implementations,
199/// and only one of them taught to read a timestamp.
200///
201/// Worse than the error was the fix that looked obvious — teaching the NQL
202/// PARSER to accept a datetime without also teaching its executor to resolve
203/// the flag. The marker would then reach `get_as_of` as a literal sequence
204/// near 2^63 and the query would answer confidently from the wrong point in
205/// history. An error is recoverable; a silently wrong answer about the past is
206/// the failure this codebase exists to prevent.
207///
208/// Returns the message TEXT rather than a typed error because the two callers
209/// surface it differently (a wire `ErrorResponse` and an `anyhow` bail), and
210/// both would convert a shared enum straight back to a string.
211pub fn resolve_as_of(db: &Db, marker: u64) -> Result<u64, String> {
212 if (marker & crate::wallclock::WALL_CLOCK_FLAG) == 0 {
213 return Ok(marker); // bare integer — a seq, untouched
214 }
215 let moment = crate::wallclock::WallClock::from_marker(marker)
216 .ok_or_else(|| "invalid wall-clock marker".to_string())?;
217 if !db.ts_index_ready() {
218 return Err(
219 "the write-time index is not ready on this boot (warm start defers it). \
220 Run `nedb-cli repair` or a cold scan, or AS OF a bare sequence number"
221 .to_string(),
222 );
223 }
224 match db.seq_at(moment.epoch_secs()) {
225 Some(seq) => Ok(seq),
226 None => {
227 let floor = db.history_floor();
228 // "Before anything existed" and "pruned away" read completely
229 // differently to an operator: one is routine, the other is the
230 // compaction tradeoff answering back.
231 if floor > 0 {
232 Err(format!(
233 "history at or before that moment is no longer available — \
234 the store was compacted past it (history floor {}). \
235 AS OF a bare sequence at or after the floor instead",
236 floor
237 ))
238 } else {
239 Err(format!(
240 "no writes at or before that moment in this database — \
241 nothing existed yet (the first write is at seq {}). \
242 A timestamp answers about the past; there is no past here yet",
243 db.seq.load(std::sync::atomic::Ordering::SeqCst)
244 ))
245 }
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 fn db() -> (tempfile::TempDir, Db) {
256 let dir = tempfile::tempdir().unwrap();
257 let db = Db::open(dir.path(), None).unwrap();
258 (dir, db)
259 }
260
261 #[test]
262 fn a_plain_scan_is_the_live_collection() {
263 let (_d, db) = db();
264 db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
265 db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
266 let rows = read(&db, &Scan::new("orders"));
267 assert_eq!(rows.len(), 2);
268 }
269
270 #[test]
271 fn as_of_reaches_a_row_that_was_deleted_later() {
272 // The reason the candidate set is built from
273 // `list_ids_including_deleted` rather than from `list`. A scan that
274 // started from the living would answer "it was never there", which is
275 // a different and wrong claim about the past.
276 let (_d, db) = db();
277 db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
278 let alive = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
279 db.delete("orders", "1").unwrap();
280
281 assert_eq!(read(&db, &Scan::new("orders")).len(), 0, "gone at the tip");
282 let past = Scan { as_of: Some(alive), ..Scan::new("orders") };
283 assert_eq!(read(&db, &past).len(), 1, "present at the sequence it was alive");
284 }
285
286 #[test]
287 fn search_filters_before_trace_grows_the_row_set() {
288 // Order matters: searching after the trace would test the CHAIN, not
289 // the roots, and quietly answer a different question.
290 let (_d, db) = db();
291 db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
292 db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
293
294 let s = Scan { search: Some("acme".into()), ..Scan::new("orders") };
295 let rows = read(&db, &s);
296 assert_eq!(rows.len(), 1);
297 assert_eq!(rows[0].id, "1");
298 }
299
300 #[test]
301 fn valid_to_is_exclusive_so_windows_tile() {
302 let (_d, db) = db();
303 db.put("p", "1", json!({"v": 1}), vec![],
304 Some("2026-01-01".into()), Some("2026-02-01".into())).unwrap();
305 db.put("p", "2", json!({"v": 2}), vec![],
306 Some("2026-02-01".into()), None).unwrap();
307
308 let on = |d: &str| {
309 let s = Scan { valid_as_of: Some(d.into()), ..Scan::new("p") };
310 read(&db, &s).into_iter().map(|n| n.id).collect::<Vec<_>>()
311 };
312 assert_eq!(on("2026-01-15"), vec!["1"]);
313 // The boundary: exactly one row, because `valid_to` is exclusive.
314 assert_eq!(on("2026-02-01"), vec!["2"]);
315 }
316
317 #[test]
318 fn a_scan_knows_whether_it_is_plain() {
319 assert!(Scan::new("t").is_plain());
320 assert!(!Scan { as_of: Some(1), ..Scan::new("t") }.is_plain());
321 assert!(!Scan { trace: Some("caused_by".into()), ..Scan::new("t") }.is_plain());
322 }
323}