Skip to main content

nedb_engine/
namespace.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//! Collection identity — what it means for a collection to EXIST.
6//!
7//! # Why this module exists
8//!
9//! Before this, "which collections exist" was not a fact about the database.
10//! It was a fact about the storage substrate, and the two substrates disagreed:
11//!
12//! ```text
13//! disk, flush between PUT and DELETE : ["orders"]
14//! disk, both inside one flush tick   : []
15//! memory                             : []
16//! ```
17//!
18//! All three are the same logical history — create a collection, then empty it.
19//! Disk mode answered by listing directories ([`crate::index::IdIndex::collections`]
20//! did a `read_dir`), and the WAL write buffer is keyed by `(coll, id)`, so a PUT
21//! followed by a DELETE before the 1-second flush ticker fires overwrites the
22//! buffered entry with its own tombstone. No directory is ever created. PUT,
23//! flush, DELETE leaves the directory behind forever, because the flush path only
24//! ever calls `remove_file` — it has no `remove_dir` in it at all.
25//!
26//! So the namespace was decided by a background timer. That is survivable for a
27//! `LIST COLLECTIONS` convenience call, and fatal for a state root: a root
28//! commits to a namespace, which is only meaningful if two replicas of the same
29//! history agree on what the namespace IS.
30//!
31//! # The rule
32//!
33//! A collection exists because a record says so, not because a directory is
34//! lying around. Creation is an event, the event is a node, and the node lives
35//! in the DAG like everything else. Emptying a collection does not destroy it;
36//! only an explicit drop does, and a drop is a tombstone rather than an absence.
37//!
38//! Putting the registry in the DAG rather than in a sidecar file is the boring
39//! choice and it pays three times: `since()` replicates collection creation to
40//! followers for free, `AS OF` answers "which collections existed at seq N"
41//! for free, and `verify()` covers the registry for free. A `COLLECTIONS` file
42//! would have needed all three written by hand.
43//!
44//! # Reserved names
45//!
46//! The registry has to live somewhere, and wherever it lives must not be
47//! user-writable — otherwise a client can forge the namespace by writing to it
48//! directly. The same reservation is what will later keep state-root records
49//! from being part of the state they describe, which is a decent sign it is the
50//! right primitive: one rule, used twice.
51
52use anyhow::{bail, Result};
53
54/// Everything under this prefix belongs to the engine. User writes are refused.
55pub const RESERVED_PREFIX: &str = "_nedb";
56
57/// The collection registry. Ids are collection names; the latest version of
58/// each says whether that collection is currently live.
59pub const COLLECTIONS: &str = "_nedb.collections";
60
61/// Persisted state roots. Ids are zero-padded sequence numbers so that the
62/// index's lexicographic id ordering is also numeric ordering.
63///
64/// Reserved for the reason the reservation exists at all: a root record that
65/// counted as part of the state would change the state it describes, so
66/// computing one would immediately invalidate it.
67pub const ROOTS: &str = "_nedb.roots";
68
69/// Engine metadata that is neither a collection record nor a root: the history
70/// floor lives here. Kept out of `ROOTS` so that collection stays homogeneous
71/// and `list_roots` never has to skip an entry it cannot parse -- an entry
72/// skipped silently is indistinguishable from one that failed to parse.
73pub const META: &str = "_nedb.meta";
74
75/// Zero-padded so lexicographic ordering is numeric ordering. `u64::MAX` is 20
76/// digits.
77pub fn seq_id(seq: u64) -> String {
78    format!("{:020}", seq)
79}
80
81/// Is this name part of the engine's own namespace?
82pub fn is_reserved(coll: &str) -> bool {
83    coll == RESERVED_PREFIX || coll.starts_with(&format!("{}.", RESERVED_PREFIX))
84}
85
86/// Refuse a write the caller is not allowed to make.
87///
88/// Named for what it does to the caller, not for what it returns, because the
89/// only correct response at every call site is to stop.
90pub fn refuse_reserved(coll: &str) -> Result<()> {
91    if is_reserved(coll) {
92        bail!(
93            "collection {:?} is reserved: everything under {:?} is engine-owned, and \
94             letting a client write there would let it forge the namespace the state \
95             root commits to",
96            coll, RESERVED_PREFIX
97        );
98    }
99    Ok(())
100}
101
102/// Is this a name a collection can durably HAVE?
103///
104/// Two separate concerns land here.
105///
106/// The first is that a collection name becomes a directory name on disk
107/// (`indexes/{coll}/{shard}/{id}`), so a name containing a path separator or a
108/// `..` component does not address a collection at all — it addresses somewhere
109/// else on the filesystem. That has to be refused at the entry point rather than
110/// sanitised, because a silently rewritten name is a different collection than
111/// the one the caller asked for, and they would never be told.
112///
113/// The second is that a state root commits to these names. A name that cannot
114/// round-trip identically through every storage path is not an identity.
115pub fn validate_name(coll: &str) -> Result<()> {
116    if coll.is_empty() {
117        bail!("collection name is empty");
118    }
119    if coll.len() > 255 {
120        bail!("collection name is {} bytes; the limit is 255", coll.len());
121    }
122    if coll == "." || coll == ".." {
123        bail!("collection name {:?} is a filesystem path component, not a name", coll);
124    }
125    if coll.contains('/') || coll.contains('\\') {
126        bail!(
127            "collection name {:?} contains a path separator — on disk a collection \
128             name IS a directory name, so this does not name a collection, it names \
129             a location",
130            coll
131        );
132    }
133    if coll.contains('\0') {
134        bail!("collection name contains a NUL byte");
135    }
136    // A leading or trailing space survives JSON and dies in a shell, a URL, and
137    // half the tools that will ever read a root. Refuse rather than trim: trimming
138    // means the collection you created is not the collection you named.
139    if coll != coll.trim() {
140        bail!("collection name {:?} has leading or trailing whitespace", coll);
141    }
142    Ok(())
143}
144
145/// A name that may be written to: valid AND not engine-owned.
146pub fn validate_writable(coll: &str) -> Result<()> {
147    validate_name(coll)?;
148    refuse_reserved(coll)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn the_registry_itself_is_reserved() {
157        assert!(is_reserved(COLLECTIONS));
158        assert!(refuse_reserved(COLLECTIONS).is_err());
159    }
160
161    #[test]
162    fn reservation_is_by_namespace_not_by_leading_underscore() {
163        // Users get to keep their underscores. Only OUR prefix is taken.
164        assert!(!is_reserved("_private"));
165        assert!(!is_reserved("_nedbish"));
166        assert!(is_reserved("_nedb"));
167        assert!(is_reserved("_nedb.roots"));
168    }
169
170    #[test]
171    fn a_name_that_escapes_the_data_directory_is_refused() {
172        for escape in ["../etc", "a/b", "..\\windows", "/absolute", ".."] {
173            assert!(
174                validate_name(escape).is_err(),
175                "{:?} must not be usable as a collection name", escape
176            );
177        }
178    }
179
180    #[test]
181    fn ordinary_names_survive() {
182        for ok in ["orders", "itsl_ops", "blocks-v2", "Ünicode", "a.b"] {
183            validate_writable(ok).unwrap_or_else(|e| panic!("{:?} refused: {}", ok, e));
184        }
185    }
186
187    #[test]
188    fn whitespace_is_refused_rather_than_trimmed() {
189        // Trimming would mean the collection you created is not the one you named.
190        assert!(validate_name(" orders").is_err());
191        assert!(validate_name("orders ").is_err());
192        assert!(validate_name("ord ers").is_ok(), "an interior space is a real name");
193    }
194}