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}