nedb-engine 4.3.2__tar.gz → 6.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/PKG-INFO +101 -28
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/README.md +100 -27
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/pyproject.toml +1 -1
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/__init__.py +1 -1
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/engine.py +110 -0
- nedb_engine-6.0.0/python/nedb/namespace.py +126 -0
- nedb_engine-6.0.0/python/nedb/nedbd-v2 +0 -0
- nedb_engine-6.0.0/python/nedb/nedbd-v2-darwin-arm64 +0 -0
- nedb_engine-6.0.0/python/nedb/nedbd-v2-darwin-x64 +0 -0
- nedb_engine-6.0.0/python/nedb/nedbd-v2.exe +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/server.py +20 -1
- nedb_engine-6.0.0/python/nedb/state_root.py +225 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_redis.py +33 -4
- nedb_engine-4.3.2/python/nedb/_native.abi3.so +0 -0
- nedb_engine-4.3.2/python/nedb/nedbd-v2 +0 -0
- nedb_engine-4.3.2/python/nedb/nedbd-v2-darwin-arm64 +0 -0
- nedb_engine-4.3.2/python/nedb/nedbd-v2-darwin-x64 +0 -0
- nedb_engine-4.3.2/python/nedb/nedbd-v2.exe +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/.gitignore +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/COPYING-APACHE-2.0.txt +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/LICENSE +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/client/node/README.md +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/client/python/README.md +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/examples/agent-loop/README.md +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/examples/mini-chain/README.md +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/autoindex.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/__init__.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/dag.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/redis_backend.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/cascade.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/client.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/concurrent.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/crypto.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/index.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/log.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/merkle.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/mongo.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/proof.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/query.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/redis_compat.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/relations.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/resp2.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/snapshot.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/sql.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/store.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_core.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_mongo.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_mysql.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_postgresql.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_sqlite.py +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/rust/crates/nedb-wrap/README.md +0 -0
- {nedb_engine-4.3.2 → nedb_engine-6.0.0}/rust/nedb-v2/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nedb-engine
|
|
3
|
-
Version:
|
|
3
|
+
Version: 6.0.0
|
|
4
4
|
Summary: NEDB — a versioned, self-compressing, time-traveling embedded database (replay-protected, idempotent, relational, searchable) with durable AOF persistence and a server daemon (nedbd).
|
|
5
5
|
Project-URL: Homepage, https://github.com/aiassistsecure/nedb
|
|
6
6
|
Project-URL: Repository, https://github.com/aiassistsecure/nedb
|
|
@@ -39,7 +39,7 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
|
|
|
39
39
|
**[Studio → studio.interchained.org](https://studio.interchained.org)** · **[nedb.aiassist.net](https://nedb.aiassist.net)**
|
|
40
40
|
|
|
41
41
|
> ## 🟢 Free in production under $1M revenue
|
|
42
|
-
> NEDB
|
|
42
|
+
> NEDB is licensed under the **Business Source License 1.1** (since 4.0.0). If your organisation's annual
|
|
43
43
|
> revenue is **under USD $1,000,000**, you may use it in production — commercially, embedded, in
|
|
44
44
|
> closed-source software — with **no permission needed and no royalty**. At **$1M or more**, you
|
|
45
45
|
> need an additional use grant from Interchained LLC: **licensing@interchained.org**.
|
|
@@ -59,14 +59,74 @@ endpoint already answers `psql`, SQLAlchemy Core **and** ORM, asyncpg and
|
|
|
59
59
|
node-postgres against a live store — but it gets there by *translating* SQL into
|
|
60
60
|
NQL, and a translation can only reach as far as the target language's shape.
|
|
61
61
|
|
|
62
|
-
**[neSQL](https://github.com/Eth-Interchained/neSQL)** removes the translation
|
|
63
|
-
PostgreSQL's real grammar
|
|
64
|
-
|
|
62
|
+
**[neSQL](https://github.com/Eth-Interchained/neSQL)** removes the translation.
|
|
63
|
+
PostgreSQL's real grammar (`gram.y`, 19,513 lines, 492 keywords, vendored from
|
|
64
|
+
17.4 at [`vendor/postgresql/`](vendor/postgresql/) with its licence intact),
|
|
65
|
+
extended with NEDB's temporal and causal clauses. **Two front-ends, one plan.
|
|
66
|
+
NQL folded in, not deleted.**
|
|
65
67
|
|
|
66
68
|
[](https://pypi.org/project/nesql/)
|
|
67
69
|
[](https://crates.io/crates/nesql)
|
|
68
70
|
[](https://www.npmjs.com/package/nesql-engine)
|
|
69
71
|
|
|
72
|
+
### Available now, opt-in: `NEDBD_SQL_ENGINE=1`
|
|
73
|
+
|
|
74
|
+
The SQL engine is in this release and it is **off by default**. Turn it on and a
|
|
75
|
+
user collection is answered by a real SQL evaluator instead of a translation —
|
|
76
|
+
every one of these works, and every one is refused *by name* without it:
|
|
77
|
+
|
|
78
|
+
```sql
|
|
79
|
+
SELECT o._id, d.name FROM orders o JOIN drivers d ON o.driver = d._id;
|
|
80
|
+
SELECT status, sum(total), avg(total) FROM orders GROUP BY status;
|
|
81
|
+
SELECT _id FROM orders WHERE driver IN (SELECT _id FROM drivers);
|
|
82
|
+
SELECT DISTINCT status FROM orders;
|
|
83
|
+
SELECT _id FROM orders UNION SELECT _id FROM drivers;
|
|
84
|
+
SELECT status, array_agg(_id ORDER BY total DESC) FROM orders GROUP BY status;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`sum(total), avg(total)` in one grouped row is the one worth pointing at. An NQL
|
|
88
|
+
grouped row carries the group key, `count`, and **one** named aggregate — so that
|
|
89
|
+
query was never slow, it was *unrepresentable*. No translator can fix a row
|
|
90
|
+
model, which is the whole reason neSQL exists.
|
|
91
|
+
|
|
92
|
+
**And NQL's own verbs are now SQL clauses**, so they compose with all of the
|
|
93
|
+
above rather than living on a separate path:
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
-- full-text search from NQL, a join from SQL, one statement
|
|
97
|
+
SELECT o._id, d.name
|
|
98
|
+
FROM orders SEARCH 'acme' o
|
|
99
|
+
JOIN drivers d ON o.driver = d._id;
|
|
100
|
+
|
|
101
|
+
-- one relation in the past, joined against another at the tip
|
|
102
|
+
SELECT h.total, n.total
|
|
103
|
+
FROM orders AS OF SYSTEM TIME 412 h
|
|
104
|
+
JOIN audit n ON h._id = n._id;
|
|
105
|
+
|
|
106
|
+
SELECT _id FROM orders VALID AS OF '2026-01-01';
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
There is **one implementation** of each verb — the SQL side parses them and the
|
|
110
|
+
NQL engine still executes them — so neither language is a reimplementation of
|
|
111
|
+
the other. `AS OF SYSTEM TIME`, `VALID AS OF` and `SEARCH` are **unreserved
|
|
112
|
+
keywords**: a collection aliased `search`, or a column named `valid`, keeps
|
|
113
|
+
working exactly as before.
|
|
114
|
+
|
|
115
|
+
**Why it is opt-in rather than the default**, stated plainly because the reason
|
|
116
|
+
is the interesting part. A parity harness runs the same corpus through both
|
|
117
|
+
engines and asserts identical answers — 44 checks, in CI, and it is what earns
|
|
118
|
+
the flag being flipped rather than a benchmark. It already found two real
|
|
119
|
+
divergences: `SELECT *` returned its columns in a different order on each
|
|
120
|
+
engine, and the SQL evaluator built its column list from the **first row alone**,
|
|
121
|
+
so a field only later documents carried silently did not appear at all.
|
|
122
|
+
|
|
123
|
+
Both are fixed. But 44 checks over six documents proves agreement on the shapes
|
|
124
|
+
we thought to test, and the evaluator still materialises each relation — the
|
|
125
|
+
`WHERE` is pushed into the scan, which narrows *what* is read but not *whether*.
|
|
126
|
+
Flipping the default changes the read path of every existing deployment, and
|
|
127
|
+
"correct on six rows" is not "safe on six million". So it ships as a flag, with
|
|
128
|
+
the bar for changing that written down.
|
|
129
|
+
|
|
70
130
|
---
|
|
71
131
|
|
|
72
132
|
## New in 3.3.0 — the query language grew up
|
|
@@ -221,46 +281,59 @@ all the work in this sentence. Every refusal below traces to the same cause:
|
|
|
221
281
|
NQL is the engine's native language, so SQL has to be rewritten into it, and a
|
|
222
282
|
rewrite can only ever reach as far as the target language's shape.
|
|
223
283
|
|
|
224
|
-
| Supported
|
|
284
|
+
| Supported on the default path | Refused there, with the reason | `NEDBD_SQL_ENGINE=1` |
|
|
225
285
|
| --- | --- | --- |
|
|
226
|
-
| `*`, a column list, `COUNT(*)`, `SUM`/`AVG`/`MIN`/`MAX(col)` | `JOIN` — NQL is single-collection | ✅
|
|
227
|
-
| `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ subqueries
|
|
228
|
-
| `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`, `OFFSET` | expressions in the select list | ✅ |
|
|
229
|
-
| one named aggregate per grouped row | `sum(x), avg(x)` in one query — NQL's grouped row holds *one* | ✅ the row-model cap
|
|
230
|
-
|
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
286
|
+
| `*`, a column list, `COUNT(*)`, `SUM`/`AVG`/`MIN`/`MAX(col)` | `JOIN` — NQL is single-collection | ✅ **works** (nested-loop + hash) |
|
|
287
|
+
| `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ **subqueries, `EXISTS`, `UNION`/`INTERSECT`/`EXCEPT` work**; window functions arrive with the grammar |
|
|
288
|
+
| `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`, `OFFSET` | expressions in the select list | ✅ **works** |
|
|
289
|
+
| one named aggregate per grouped row | `sum(x), avg(x)` in one query — NQL's grouped row holds *one* | ✅ **works** — the row-model cap is gone |
|
|
290
|
+
| — | `DISTINCT`, `array_agg(x ORDER BY y)`, derived tables | ✅ **works** |
|
|
291
|
+
| `AS OF SYSTEM TIME <seq>`, `VALID AS OF`, `SEARCH` | — | ✅ **also works**, and composes with joins and aggregates |
|
|
292
|
+
| `TRACE`, `TRAVERSE`, `LINK` | — | ↩︎ still answered by the NQL path; SQL has no spelling for them yet |
|
|
293
|
+
| `INSERT` / `UPDATE` / `DELETE`, all with `RETURNING` | an `INSERT` with no column list | writes always take the NQL path |
|
|
294
|
+
| `_caused_by` / `_valid_from` / `_valid_to` as INSERT columns | values that are expressions, not literals | as above |
|
|
295
|
+
| — | DDL, `TRUNCATE`, `GRANT`/`REVOKE` | ⛔️ **refused on both**, and always will be |
|
|
233
296
|
|
|
234
297
|
The `⛔️` row is the one that is not a limitation. `TRUNCATE` is refused because
|
|
235
298
|
NEDB is append-only *so that history cannot be discarded* — that is the product,
|
|
236
299
|
not a gap — and DDL is refused because collections are created by the first write
|
|
237
300
|
to them. Those answers do not change.
|
|
238
301
|
|
|
239
|
-
### Every other row on that table
|
|
302
|
+
### Every other row on that table was a translation artefact — and one flag removes them
|
|
240
303
|
|
|
241
304
|
> ### 🆕 [**neSQL**](https://github.com/Eth-Interchained/neSQL) — PostgreSQL's grammar, NEDB's memory
|
|
242
305
|
>
|
|
243
|
-
>
|
|
244
|
-
>
|
|
245
|
-
>
|
|
246
|
-
>
|
|
306
|
+
> Those refusals were never the engine's limits. `sqlselect.rs` has had
|
|
307
|
+
> nested-loop and hash joins, subqueries, `EXISTS`, quantified comparisons, set
|
|
308
|
+
> operations, `array_agg(x ORDER BY y)` and derived tables for some time — they
|
|
309
|
+
> were simply unreachable *through a translator*, because the translator's
|
|
310
|
+
> target was NQL. Set `NEDBD_SQL_ENGINE=1` and they are reachable.
|
|
311
|
+
>
|
|
312
|
+
> neSQL vendors PostgreSQL's **real grammar** — `gram.y`, 19,513 lines and 492
|
|
313
|
+
> keywords, from 17.4, licence intact — and extends it with the clauses NEDB
|
|
314
|
+
> needs, rather than rewriting SQL into a language that cannot express it.
|
|
247
315
|
>
|
|
248
|
-
> It is worth knowing *why*
|
|
249
|
-
> `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres
|
|
250
|
-
> SQL at all
|
|
251
|
-
> temporal clauses are additions to the vendored grammar
|
|
252
|
-
> from it
|
|
316
|
+
> It is worth knowing *why* the temporal clauses were never free: `SYSTEM_TIME`,
|
|
317
|
+
> `PERIOD` and `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres
|
|
318
|
+
> has no temporal SQL at all, and `AS OF SYSTEM TIME` is a CockroachDB
|
|
319
|
+
> extension — so NEDB's temporal clauses are additions *to* the vendored grammar
|
|
320
|
+
> rather than deviations *from* it. The same road CockroachDB, Materialize and
|
|
321
|
+
> RisingWave took. What the grammar does hand over free: `WITH RECURSIVE`,
|
|
322
|
+
> window functions, `GROUPING SETS` and `MERGE`.
|
|
253
323
|
>
|
|
254
324
|
> ```bash
|
|
255
325
|
> pip install nesql · cargo add nesql · npm install nesql-engine
|
|
256
326
|
> ```
|
|
257
327
|
>
|
|
258
|
-
>
|
|
259
|
-
>
|
|
260
|
-
>
|
|
328
|
+
> Those three are **reserved names**, not a product: each loads and answers
|
|
329
|
+
> `is_release() == false`, because a package that imports cleanly and then lies
|
|
330
|
+
> is worse than one that isn't published. The engine is what ships today, and
|
|
331
|
+
> neSQL will be this same engine under its own name.
|
|
261
332
|
>
|
|
262
|
-
> **NQL is not being deleted
|
|
263
|
-
>
|
|
333
|
+
> **NQL is not being deleted, and it is not being wrapped.** Its verbs are SQL
|
|
334
|
+
> clauses now — `AS OF SYSTEM TIME`, `VALID AS OF`, `SEARCH` — parsed by the SQL
|
|
335
|
+
> side and executed by the NQL engine. One implementation, two front-ends,
|
|
336
|
+
> neither one a second-class guest.
|
|
264
337
|
|
|
265
338
|
Every refusal names the boundary instead of saying "syntax error", and a
|
|
266
339
|
grouped query that projects a column SQL would reject gets Postgres's own
|
|
@@ -18,7 +18,7 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
|
|
|
18
18
|
**[Studio → studio.interchained.org](https://studio.interchained.org)** · **[nedb.aiassist.net](https://nedb.aiassist.net)**
|
|
19
19
|
|
|
20
20
|
> ## 🟢 Free in production under $1M revenue
|
|
21
|
-
> NEDB
|
|
21
|
+
> NEDB is licensed under the **Business Source License 1.1** (since 4.0.0). If your organisation's annual
|
|
22
22
|
> revenue is **under USD $1,000,000**, you may use it in production — commercially, embedded, in
|
|
23
23
|
> closed-source software — with **no permission needed and no royalty**. At **$1M or more**, you
|
|
24
24
|
> need an additional use grant from Interchained LLC: **licensing@interchained.org**.
|
|
@@ -38,14 +38,74 @@ endpoint already answers `psql`, SQLAlchemy Core **and** ORM, asyncpg and
|
|
|
38
38
|
node-postgres against a live store — but it gets there by *translating* SQL into
|
|
39
39
|
NQL, and a translation can only reach as far as the target language's shape.
|
|
40
40
|
|
|
41
|
-
**[neSQL](https://github.com/Eth-Interchained/neSQL)** removes the translation
|
|
42
|
-
PostgreSQL's real grammar
|
|
43
|
-
|
|
41
|
+
**[neSQL](https://github.com/Eth-Interchained/neSQL)** removes the translation.
|
|
42
|
+
PostgreSQL's real grammar (`gram.y`, 19,513 lines, 492 keywords, vendored from
|
|
43
|
+
17.4 at [`vendor/postgresql/`](vendor/postgresql/) with its licence intact),
|
|
44
|
+
extended with NEDB's temporal and causal clauses. **Two front-ends, one plan.
|
|
45
|
+
NQL folded in, not deleted.**
|
|
44
46
|
|
|
45
47
|
[](https://pypi.org/project/nesql/)
|
|
46
48
|
[](https://crates.io/crates/nesql)
|
|
47
49
|
[](https://www.npmjs.com/package/nesql-engine)
|
|
48
50
|
|
|
51
|
+
### Available now, opt-in: `NEDBD_SQL_ENGINE=1`
|
|
52
|
+
|
|
53
|
+
The SQL engine is in this release and it is **off by default**. Turn it on and a
|
|
54
|
+
user collection is answered by a real SQL evaluator instead of a translation —
|
|
55
|
+
every one of these works, and every one is refused *by name* without it:
|
|
56
|
+
|
|
57
|
+
```sql
|
|
58
|
+
SELECT o._id, d.name FROM orders o JOIN drivers d ON o.driver = d._id;
|
|
59
|
+
SELECT status, sum(total), avg(total) FROM orders GROUP BY status;
|
|
60
|
+
SELECT _id FROM orders WHERE driver IN (SELECT _id FROM drivers);
|
|
61
|
+
SELECT DISTINCT status FROM orders;
|
|
62
|
+
SELECT _id FROM orders UNION SELECT _id FROM drivers;
|
|
63
|
+
SELECT status, array_agg(_id ORDER BY total DESC) FROM orders GROUP BY status;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`sum(total), avg(total)` in one grouped row is the one worth pointing at. An NQL
|
|
67
|
+
grouped row carries the group key, `count`, and **one** named aggregate — so that
|
|
68
|
+
query was never slow, it was *unrepresentable*. No translator can fix a row
|
|
69
|
+
model, which is the whole reason neSQL exists.
|
|
70
|
+
|
|
71
|
+
**And NQL's own verbs are now SQL clauses**, so they compose with all of the
|
|
72
|
+
above rather than living on a separate path:
|
|
73
|
+
|
|
74
|
+
```sql
|
|
75
|
+
-- full-text search from NQL, a join from SQL, one statement
|
|
76
|
+
SELECT o._id, d.name
|
|
77
|
+
FROM orders SEARCH 'acme' o
|
|
78
|
+
JOIN drivers d ON o.driver = d._id;
|
|
79
|
+
|
|
80
|
+
-- one relation in the past, joined against another at the tip
|
|
81
|
+
SELECT h.total, n.total
|
|
82
|
+
FROM orders AS OF SYSTEM TIME 412 h
|
|
83
|
+
JOIN audit n ON h._id = n._id;
|
|
84
|
+
|
|
85
|
+
SELECT _id FROM orders VALID AS OF '2026-01-01';
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
There is **one implementation** of each verb — the SQL side parses them and the
|
|
89
|
+
NQL engine still executes them — so neither language is a reimplementation of
|
|
90
|
+
the other. `AS OF SYSTEM TIME`, `VALID AS OF` and `SEARCH` are **unreserved
|
|
91
|
+
keywords**: a collection aliased `search`, or a column named `valid`, keeps
|
|
92
|
+
working exactly as before.
|
|
93
|
+
|
|
94
|
+
**Why it is opt-in rather than the default**, stated plainly because the reason
|
|
95
|
+
is the interesting part. A parity harness runs the same corpus through both
|
|
96
|
+
engines and asserts identical answers — 44 checks, in CI, and it is what earns
|
|
97
|
+
the flag being flipped rather than a benchmark. It already found two real
|
|
98
|
+
divergences: `SELECT *` returned its columns in a different order on each
|
|
99
|
+
engine, and the SQL evaluator built its column list from the **first row alone**,
|
|
100
|
+
so a field only later documents carried silently did not appear at all.
|
|
101
|
+
|
|
102
|
+
Both are fixed. But 44 checks over six documents proves agreement on the shapes
|
|
103
|
+
we thought to test, and the evaluator still materialises each relation — the
|
|
104
|
+
`WHERE` is pushed into the scan, which narrows *what* is read but not *whether*.
|
|
105
|
+
Flipping the default changes the read path of every existing deployment, and
|
|
106
|
+
"correct on six rows" is not "safe on six million". So it ships as a flag, with
|
|
107
|
+
the bar for changing that written down.
|
|
108
|
+
|
|
49
109
|
---
|
|
50
110
|
|
|
51
111
|
## New in 3.3.0 — the query language grew up
|
|
@@ -200,46 +260,59 @@ all the work in this sentence. Every refusal below traces to the same cause:
|
|
|
200
260
|
NQL is the engine's native language, so SQL has to be rewritten into it, and a
|
|
201
261
|
rewrite can only ever reach as far as the target language's shape.
|
|
202
262
|
|
|
203
|
-
| Supported
|
|
263
|
+
| Supported on the default path | Refused there, with the reason | `NEDBD_SQL_ENGINE=1` |
|
|
204
264
|
| --- | --- | --- |
|
|
205
|
-
| `*`, a column list, `COUNT(*)`, `SUM`/`AVG`/`MIN`/`MAX(col)` | `JOIN` — NQL is single-collection | ✅
|
|
206
|
-
| `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ subqueries
|
|
207
|
-
| `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`, `OFFSET` | expressions in the select list | ✅ |
|
|
208
|
-
| one named aggregate per grouped row | `sum(x), avg(x)` in one query — NQL's grouped row holds *one* | ✅ the row-model cap
|
|
209
|
-
|
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
265
|
+
| `*`, a column list, `COUNT(*)`, `SUM`/`AVG`/`MIN`/`MAX(col)` | `JOIN` — NQL is single-collection | ✅ **works** (nested-loop + hash) |
|
|
266
|
+
| `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ **subqueries, `EXISTS`, `UNION`/`INTERSECT`/`EXCEPT` work**; window functions arrive with the grammar |
|
|
267
|
+
| `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`, `OFFSET` | expressions in the select list | ✅ **works** |
|
|
268
|
+
| one named aggregate per grouped row | `sum(x), avg(x)` in one query — NQL's grouped row holds *one* | ✅ **works** — the row-model cap is gone |
|
|
269
|
+
| — | `DISTINCT`, `array_agg(x ORDER BY y)`, derived tables | ✅ **works** |
|
|
270
|
+
| `AS OF SYSTEM TIME <seq>`, `VALID AS OF`, `SEARCH` | — | ✅ **also works**, and composes with joins and aggregates |
|
|
271
|
+
| `TRACE`, `TRAVERSE`, `LINK` | — | ↩︎ still answered by the NQL path; SQL has no spelling for them yet |
|
|
272
|
+
| `INSERT` / `UPDATE` / `DELETE`, all with `RETURNING` | an `INSERT` with no column list | writes always take the NQL path |
|
|
273
|
+
| `_caused_by` / `_valid_from` / `_valid_to` as INSERT columns | values that are expressions, not literals | as above |
|
|
274
|
+
| — | DDL, `TRUNCATE`, `GRANT`/`REVOKE` | ⛔️ **refused on both**, and always will be |
|
|
212
275
|
|
|
213
276
|
The `⛔️` row is the one that is not a limitation. `TRUNCATE` is refused because
|
|
214
277
|
NEDB is append-only *so that history cannot be discarded* — that is the product,
|
|
215
278
|
not a gap — and DDL is refused because collections are created by the first write
|
|
216
279
|
to them. Those answers do not change.
|
|
217
280
|
|
|
218
|
-
### Every other row on that table
|
|
281
|
+
### Every other row on that table was a translation artefact — and one flag removes them
|
|
219
282
|
|
|
220
283
|
> ### 🆕 [**neSQL**](https://github.com/Eth-Interchained/neSQL) — PostgreSQL's grammar, NEDB's memory
|
|
221
284
|
>
|
|
222
|
-
>
|
|
223
|
-
>
|
|
224
|
-
>
|
|
225
|
-
>
|
|
285
|
+
> Those refusals were never the engine's limits. `sqlselect.rs` has had
|
|
286
|
+
> nested-loop and hash joins, subqueries, `EXISTS`, quantified comparisons, set
|
|
287
|
+
> operations, `array_agg(x ORDER BY y)` and derived tables for some time — they
|
|
288
|
+
> were simply unreachable *through a translator*, because the translator's
|
|
289
|
+
> target was NQL. Set `NEDBD_SQL_ENGINE=1` and they are reachable.
|
|
290
|
+
>
|
|
291
|
+
> neSQL vendors PostgreSQL's **real grammar** — `gram.y`, 19,513 lines and 492
|
|
292
|
+
> keywords, from 17.4, licence intact — and extends it with the clauses NEDB
|
|
293
|
+
> needs, rather than rewriting SQL into a language that cannot express it.
|
|
226
294
|
>
|
|
227
|
-
> It is worth knowing *why*
|
|
228
|
-
> `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres
|
|
229
|
-
> SQL at all
|
|
230
|
-
> temporal clauses are additions to the vendored grammar
|
|
231
|
-
> from it
|
|
295
|
+
> It is worth knowing *why* the temporal clauses were never free: `SYSTEM_TIME`,
|
|
296
|
+
> `PERIOD` and `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres
|
|
297
|
+
> has no temporal SQL at all, and `AS OF SYSTEM TIME` is a CockroachDB
|
|
298
|
+
> extension — so NEDB's temporal clauses are additions *to* the vendored grammar
|
|
299
|
+
> rather than deviations *from* it. The same road CockroachDB, Materialize and
|
|
300
|
+
> RisingWave took. What the grammar does hand over free: `WITH RECURSIVE`,
|
|
301
|
+
> window functions, `GROUPING SETS` and `MERGE`.
|
|
232
302
|
>
|
|
233
303
|
> ```bash
|
|
234
304
|
> pip install nesql · cargo add nesql · npm install nesql-engine
|
|
235
305
|
> ```
|
|
236
306
|
>
|
|
237
|
-
>
|
|
238
|
-
>
|
|
239
|
-
>
|
|
307
|
+
> Those three are **reserved names**, not a product: each loads and answers
|
|
308
|
+
> `is_release() == false`, because a package that imports cleanly and then lies
|
|
309
|
+
> is worse than one that isn't published. The engine is what ships today, and
|
|
310
|
+
> neSQL will be this same engine under its own name.
|
|
240
311
|
>
|
|
241
|
-
> **NQL is not being deleted
|
|
242
|
-
>
|
|
312
|
+
> **NQL is not being deleted, and it is not being wrapped.** Its verbs are SQL
|
|
313
|
+
> clauses now — `AS OF SYSTEM TIME`, `VALID AS OF`, `SEARCH` — parsed by the SQL
|
|
314
|
+
> side and executed by the NQL engine. One implementation, two front-ends,
|
|
315
|
+
> neither one a second-class guest.
|
|
243
316
|
|
|
244
317
|
Every refusal names the boundary instead of saying "syntax error", and a
|
|
245
318
|
grouped query that projects a column SQL would reject gets Postgres's own
|
|
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "nedb-engine"
|
|
13
|
-
version = "
|
|
13
|
+
version = "6.0.0"
|
|
14
14
|
description = "NEDB — a versioned, self-compressing, time-traveling embedded database (replay-protected, idempotent, relational, searchable) with durable AOF persistence and a server daemon (nedbd)."
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.8"
|
|
@@ -18,6 +18,7 @@ from typing import Any, Dict, List, Optional
|
|
|
18
18
|
|
|
19
19
|
from .cascade import BlobStore
|
|
20
20
|
from . import snapshot as _snap
|
|
21
|
+
from . import namespace as _ns
|
|
21
22
|
from . import crypto as _crypto
|
|
22
23
|
from .index import Indexes, tokenize
|
|
23
24
|
from .log import Op, OpLog, ReplayError, GENESIS, blake, canon # noqa: F401 (re-exported)
|
|
@@ -117,6 +118,10 @@ class NEDB:
|
|
|
117
118
|
# caller (the concurrent Sequencer) issues ONE fsync per batch via flush()
|
|
118
119
|
# — group commit. Default False keeps embedded/direct use durable per-op.
|
|
119
120
|
self._defer_sync = False
|
|
121
|
+
# Collections already known to be registered. A CACHE, never the
|
|
122
|
+
# answer: `collections()` reads the registry. A stale cache can only
|
|
123
|
+
# cause a redundant registry check, never a wrong namespace.
|
|
124
|
+
self._known_collections: set = set()
|
|
120
125
|
# Encryption: resolve TMK (arg > env) → load/create DEK → None if no TMK
|
|
121
126
|
self._dek: Optional[bytes] = None
|
|
122
127
|
resolved_tmk = _crypto.resolve_tmk(tmk)
|
|
@@ -472,6 +477,106 @@ class NEDB:
|
|
|
472
477
|
return False # already expired
|
|
473
478
|
return True
|
|
474
479
|
|
|
480
|
+
# --- collection identity ------------------------------------------------
|
|
481
|
+
#
|
|
482
|
+
# Mirrors rust/nedb-v2/src/db.rs. See python/nedb/namespace.py for why the
|
|
483
|
+
# reference engine carries this too, and for the parity failure that made
|
|
484
|
+
# it necessary.
|
|
485
|
+
|
|
486
|
+
def _ensure_collection(self, coll: str) -> None:
|
|
487
|
+
"""Record that a collection exists, if that is not already recorded.
|
|
488
|
+
|
|
489
|
+
Idempotent, and cheap after a collection's first write: a set hit. On a
|
|
490
|
+
miss it consults the registry itself before writing, so reopening a
|
|
491
|
+
database does not re-register everything in it.
|
|
492
|
+
|
|
493
|
+
ORDER MATTERS. The registry record is appended BEFORE the user's write,
|
|
494
|
+
exactly as `Db::put` does it in Rust — `validate` then `ensure` then the
|
|
495
|
+
write. The two engines assign the same sequence numbers to the same
|
|
496
|
+
writes only if they spend them in the same order, and the cross-engine
|
|
497
|
+
parity corpus compares absolute sequences.
|
|
498
|
+
"""
|
|
499
|
+
if coll in self._known_collections:
|
|
500
|
+
return
|
|
501
|
+
existing = self.store.get(f"{_ns.COLLECTIONS}:{coll}")
|
|
502
|
+
if existing is not None:
|
|
503
|
+
# Registered already. Revive it if it was dropped and is being
|
|
504
|
+
# written to again — a write is an unambiguous assertion that the
|
|
505
|
+
# caller means for this collection to exist.
|
|
506
|
+
if existing.get("dropped"):
|
|
507
|
+
self._write_collection_record(coll, False)
|
|
508
|
+
self._known_collections.add(coll)
|
|
509
|
+
return
|
|
510
|
+
self._write_collection_record(coll, False)
|
|
511
|
+
self._known_collections.add(coll)
|
|
512
|
+
|
|
513
|
+
def _write_collection_record(self, coll: str, dropped: bool) -> None:
|
|
514
|
+
"""Append a registry record.
|
|
515
|
+
|
|
516
|
+
Creation and drop are the same shape because they are the same kind of
|
|
517
|
+
event: an assertion, at a sequence, about whether a name is live. Goes
|
|
518
|
+
through the ordinary log so replication, AS OF and verification cover
|
|
519
|
+
it without being taught about it.
|
|
520
|
+
"""
|
|
521
|
+
key = f"{_ns.COLLECTIONS}:{coll}"
|
|
522
|
+
doc = {"_id": coll, "name": coll, "dropped": dropped, "at_seq": self.seq}
|
|
523
|
+
nonce = self._next("engine")
|
|
524
|
+
op, created = self._log_append(
|
|
525
|
+
"engine", nonce, "put",
|
|
526
|
+
{"key": key, "coll": _ns.COLLECTIONS, "id": coll, "doc": doc},
|
|
527
|
+
)
|
|
528
|
+
if created:
|
|
529
|
+
apply_op(self.store, self.relations, self.indexes, op, self.cause_map)
|
|
530
|
+
|
|
531
|
+
def collections(self) -> List[str]:
|
|
532
|
+
"""Every collection that currently exists.
|
|
533
|
+
|
|
534
|
+
THE authoritative answer. An emptied-but-created collection is present
|
|
535
|
+
here; that is the whole point. Derived from recorded events rather than
|
|
536
|
+
from which document keys happen to be live, so a collection does not
|
|
537
|
+
cease to exist because its last row was deleted.
|
|
538
|
+
"""
|
|
539
|
+
live = []
|
|
540
|
+
prefix = f"{_ns.COLLECTIONS}:"
|
|
541
|
+
for key in self.store.keys(prefix):
|
|
542
|
+
rec = self.store.get(key)
|
|
543
|
+
if rec is None or rec.get("dropped"):
|
|
544
|
+
continue
|
|
545
|
+
name = rec.get("name")
|
|
546
|
+
if name is not None and not _ns.is_reserved(name):
|
|
547
|
+
live.append(name)
|
|
548
|
+
return sorted(live)
|
|
549
|
+
|
|
550
|
+
def collections_as_of(self, as_of: int) -> List[str]:
|
|
551
|
+
"""Which collections existed as of a sequence. Versioned for free,
|
|
552
|
+
because the registry is ordinary documents in the log."""
|
|
553
|
+
live = []
|
|
554
|
+
prefix = f"{_ns.COLLECTIONS}:"
|
|
555
|
+
for key in self.store.keys(prefix):
|
|
556
|
+
rec = self.store.get(key, as_of)
|
|
557
|
+
if rec is None or rec.get("dropped"):
|
|
558
|
+
continue
|
|
559
|
+
name = rec.get("name")
|
|
560
|
+
if name is not None and not _ns.is_reserved(name):
|
|
561
|
+
live.append(name)
|
|
562
|
+
return sorted(live)
|
|
563
|
+
|
|
564
|
+
def drop_collection(self, coll: str) -> bool:
|
|
565
|
+
"""Drop a collection: record that the name is no longer live.
|
|
566
|
+
|
|
567
|
+
A TOMBSTONE, not an erasure — the same contract `delete` has for
|
|
568
|
+
documents. Documents are left where they are; quietly destroying
|
|
569
|
+
history behind a namespace operation is exactly the behaviour the
|
|
570
|
+
engine refuses to have.
|
|
571
|
+
"""
|
|
572
|
+
_ns.validate_writable(coll)
|
|
573
|
+
rec = self.store.get(f"{_ns.COLLECTIONS}:{coll}")
|
|
574
|
+
if rec is None or rec.get("dropped"):
|
|
575
|
+
return False
|
|
576
|
+
self._write_collection_record(coll, True)
|
|
577
|
+
self._known_collections.discard(coll)
|
|
578
|
+
return True
|
|
579
|
+
|
|
475
580
|
# --- mutations ----------------------------------------------------------
|
|
476
581
|
def put(self, coll: str, id: str, doc: dict, client: str = "local",
|
|
477
582
|
nonce: Optional[int] = None, idem: Optional[str] = None,
|
|
@@ -481,6 +586,8 @@ class NEDB:
|
|
|
481
586
|
confidence: Optional[float] = None,
|
|
482
587
|
valid_from: Optional[str] = None,
|
|
483
588
|
valid_to: Optional[str] = None) -> dict:
|
|
589
|
+
_ns.validate_writable(coll)
|
|
590
|
+
self._ensure_collection(coll)
|
|
484
591
|
key = f"{coll}:{id}"
|
|
485
592
|
doc = dict(doc)
|
|
486
593
|
doc.setdefault("_id", id)
|
|
@@ -504,6 +611,9 @@ class NEDB:
|
|
|
504
611
|
|
|
505
612
|
def delete(self, coll: str, id: str, client: str = "local",
|
|
506
613
|
nonce: Optional[int] = None, idem: Optional[str] = None) -> None:
|
|
614
|
+
# Validated but NOT registered — deleting from a collection that does
|
|
615
|
+
# not exist should not bring it into existence. Matches `Db::delete`.
|
|
616
|
+
_ns.validate_writable(coll)
|
|
507
617
|
key = f"{coll}:{id}"
|
|
508
618
|
nonce = self._next(client) if nonce is None else nonce
|
|
509
619
|
op, created = self._log_append(client, nonce, "delete",
|
|
@@ -0,0 +1,126 @@
|
|
|
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 for the Python reference engine.
|
|
6
|
+
|
|
7
|
+
This mirrors `rust/nedb-v2/src/namespace.rs` rule for rule, and it exists for
|
|
8
|
+
the same reason the Rust one does: a collection has to exist because a record
|
|
9
|
+
says so, not because storage happens to have something lying around.
|
|
10
|
+
|
|
11
|
+
# Why the reference engine needs this too
|
|
12
|
+
|
|
13
|
+
NEDB ships two independent NQL implementations. When the Rust engine started
|
|
14
|
+
registering collections, the two stopped agreeing about sequence numbers — the
|
|
15
|
+
same writes into a fresh database landed one position apart, because Rust spent
|
|
16
|
+
a sequence on the registry record and Python did not. The cross-engine parity
|
|
17
|
+
corpus caught it immediately:
|
|
18
|
+
|
|
19
|
+
parity after a delete: FROM t AS OF 1
|
|
20
|
+
python [(('_id','a'), ('t','66'))]
|
|
21
|
+
rust [(('_id','a'), ('t','55'))]
|
|
22
|
+
|
|
23
|
+
Rust at `AS OF 1` was Python at `AS OF 0`. The correct response was not to move
|
|
24
|
+
the test's goalposts. If collection existence is a logical fact in the engine,
|
|
25
|
+
it is a logical fact in the reference, and the two agree again because both are
|
|
26
|
+
right rather than because one stopped looking.
|
|
27
|
+
|
|
28
|
+
The Python engine had the same underlying defect in a different shape, too:
|
|
29
|
+
collections were derived by splitting live document keys, so a collection whose
|
|
30
|
+
last document was deleted simply ceased to exist — indistinguishable from one
|
|
31
|
+
that had never been created.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
RESERVED_PREFIX = "_nedb"
|
|
35
|
+
|
|
36
|
+
#: The collection registry. Ids are collection names; the newest version of
|
|
37
|
+
#: each says whether that collection is currently live.
|
|
38
|
+
COLLECTIONS = "_nedb.collections"
|
|
39
|
+
|
|
40
|
+
#: Engine metadata that is neither a collection record nor a root.
|
|
41
|
+
META = "_nedb.meta"
|
|
42
|
+
|
|
43
|
+
#: Persisted state roots.
|
|
44
|
+
ROOTS = "_nedb.roots"
|
|
45
|
+
|
|
46
|
+
MAX_NAME_BYTES = 255
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReservedCollection(ValueError):
|
|
50
|
+
"""A write was aimed at the engine's own namespace."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidCollectionName(ValueError):
|
|
54
|
+
"""A name a collection cannot durably have."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_reserved(coll: str) -> bool:
|
|
58
|
+
"""Is this name part of the engine's own namespace?"""
|
|
59
|
+
return coll == RESERVED_PREFIX or coll.startswith(RESERVED_PREFIX + ".")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def refuse_reserved(coll: str) -> None:
|
|
63
|
+
"""Refuse a write the caller is not allowed to make."""
|
|
64
|
+
if is_reserved(coll):
|
|
65
|
+
raise ReservedCollection(
|
|
66
|
+
f"collection {coll!r} is reserved: everything under "
|
|
67
|
+
f"{RESERVED_PREFIX!r} is engine-owned, and letting a client write "
|
|
68
|
+
f"there would let it forge the namespace the state root commits to"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_name(coll: str) -> None:
|
|
73
|
+
"""Is this a name a collection can durably HAVE?
|
|
74
|
+
|
|
75
|
+
Refuses rather than sanitises. A silently rewritten name is a different
|
|
76
|
+
collection than the one the caller asked for, and they would never be told.
|
|
77
|
+
|
|
78
|
+
The Python engine keys documents as ``coll:id``, so a colon in a collection
|
|
79
|
+
name would make ``a:b`` and ``a`` + id ``b`` the same key — an aliasing bug
|
|
80
|
+
the Rust engine cannot have, since it keys by a pair. Refused here for the
|
|
81
|
+
same reason a path separator is refused there: the name has to survive its
|
|
82
|
+
own storage layer intact.
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(coll, str):
|
|
85
|
+
raise InvalidCollectionName(
|
|
86
|
+
f"collection name must be a string, got {type(coll).__name__}"
|
|
87
|
+
)
|
|
88
|
+
if coll == "":
|
|
89
|
+
raise InvalidCollectionName("collection name is empty")
|
|
90
|
+
n = len(coll.encode("utf-8"))
|
|
91
|
+
if n > MAX_NAME_BYTES:
|
|
92
|
+
raise InvalidCollectionName(
|
|
93
|
+
f"collection name is {n} bytes; the limit is {MAX_NAME_BYTES}"
|
|
94
|
+
)
|
|
95
|
+
if coll in (".", ".."):
|
|
96
|
+
raise InvalidCollectionName(
|
|
97
|
+
f"collection name {coll!r} is a filesystem path component, not a name"
|
|
98
|
+
)
|
|
99
|
+
if "/" in coll or "\\" in coll:
|
|
100
|
+
raise InvalidCollectionName(
|
|
101
|
+
f"collection name {coll!r} contains a path separator — on disk a "
|
|
102
|
+
f"collection name is a directory name, so this does not name a "
|
|
103
|
+
f"collection, it names a location"
|
|
104
|
+
)
|
|
105
|
+
if ":" in coll:
|
|
106
|
+
raise InvalidCollectionName(
|
|
107
|
+
f"collection name {coll!r} contains a colon, which is the key "
|
|
108
|
+
f"separator — 'a:b' and collection 'a' id 'b' would be one key"
|
|
109
|
+
)
|
|
110
|
+
if "\0" in coll:
|
|
111
|
+
raise InvalidCollectionName("collection name contains a NUL byte")
|
|
112
|
+
if coll != coll.strip():
|
|
113
|
+
raise InvalidCollectionName(
|
|
114
|
+
f"collection name {coll!r} has leading or trailing whitespace"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_writable(coll: str) -> None:
|
|
119
|
+
"""A name that may be written to: valid AND not engine-owned."""
|
|
120
|
+
validate_name(coll)
|
|
121
|
+
refuse_reserved(coll)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def seq_id(seq: int) -> str:
|
|
125
|
+
"""Zero-padded so lexicographic ordering is numeric ordering."""
|
|
126
|
+
return f"{seq:020d}"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -61,6 +61,7 @@ from typing import Any, Dict, List, Optional
|
|
|
61
61
|
from urllib.parse import urlparse, parse_qs, unquote
|
|
62
62
|
|
|
63
63
|
from . import __version__
|
|
64
|
+
from . import namespace as _ns
|
|
64
65
|
from .engine import NEDB
|
|
65
66
|
from .concurrent import Sequencer
|
|
66
67
|
from .log import ReplayError
|
|
@@ -180,9 +181,27 @@ class Manager:
|
|
|
180
181
|
|
|
181
182
|
@staticmethod
|
|
182
183
|
def collection_counts(db: NEDB) -> Dict[str, int]:
|
|
183
|
-
counts
|
|
184
|
+
"""Live row counts per collection, for the public surface.
|
|
185
|
+
|
|
186
|
+
Two things this must get right, and the second one is why it is not a
|
|
187
|
+
one-line key split any more.
|
|
188
|
+
|
|
189
|
+
Engine-owned collections are EXCLUDED. `_nedb.*` is bookkeeping; it
|
|
190
|
+
leaked into `/v1/databases/<name>` as a collection with rows, which
|
|
191
|
+
both named an internal detail publicly and inflated the row count. A
|
|
192
|
+
deploy test comparing seeded rows against reported rows is what caught
|
|
193
|
+
it.
|
|
194
|
+
|
|
195
|
+
And the collection LIST comes from the registry rather than from which
|
|
196
|
+
document keys happen to be live, so a collection that was created and
|
|
197
|
+
then emptied still reports — with zero rows. It exists; it is empty.
|
|
198
|
+
Those are different facts and the API should not merge them.
|
|
199
|
+
"""
|
|
200
|
+
counts: Dict[str, int] = {c: 0 for c in db.collections()}
|
|
184
201
|
for key in db.store.keys(""):
|
|
185
202
|
coll = key.split(":", 1)[0]
|
|
203
|
+
if _ns.is_reserved(coll):
|
|
204
|
+
continue
|
|
186
205
|
counts[coll] = counts.get(coll, 0) + 1
|
|
187
206
|
return counts
|
|
188
207
|
|
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
"""state_root_v1 — the Python reference implementation.
|
|
6
|
+
|
|
7
|
+
This is deliberately a SECOND implementation, written from the format rules
|
|
8
|
+
rather than translated from the Rust. Its whole job is to disagree if the
|
|
9
|
+
format has a hole in it: a spec that only one program implements is not a
|
|
10
|
+
spec, it is that program's behaviour written down.
|
|
11
|
+
|
|
12
|
+
The rules it implements, in full:
|
|
13
|
+
|
|
14
|
+
hash BLAKE2b-512, first 32 bytes
|
|
15
|
+
domains every hash input starts with a distinct ASCII tag
|
|
16
|
+
lengths every variable-length field is preceded by u64 little-endian
|
|
17
|
+
optionals one presence byte, then the value if present
|
|
18
|
+
ordering leaves sorted by key BYTES (collection, then id)
|
|
19
|
+
unicode none applied; names commit as the exact UTF-8 they arrived as
|
|
20
|
+
odd leaf promoted unchanged, never duplicated (CVE-2012-2459)
|
|
21
|
+
leaf count committed in the subtree root alongside the fold
|
|
22
|
+
empty H(tag), a constant, never zero
|
|
23
|
+
field order documents commit in their own key order, not sorted
|
|
24
|
+
|
|
25
|
+
Run `python3 -m nedb.state_root` to check against the committed vectors.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import struct
|
|
33
|
+
from typing import Any, Iterable, Mapping, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
TAG_EMPTY = b"nedb:state_root_v1:empty"
|
|
36
|
+
TAG_NODE = b"nedb:state_root_v1:node"
|
|
37
|
+
TAG_NS_LEAF = b"nedb:state_root_v1:namespace_leaf"
|
|
38
|
+
TAG_NS_ROOT = b"nedb:state_root_v1:namespace_root"
|
|
39
|
+
TAG_REC_LEAF = b"nedb:state_root_v1:record_leaf"
|
|
40
|
+
TAG_REC_ROOT = b"nedb:state_root_v1:records_root"
|
|
41
|
+
TAG_STATE_ROOT = b"nedb:state_root_v1:state_root"
|
|
42
|
+
|
|
43
|
+
V_NULL, V_FALSE, V_TRUE, V_I64, V_U64, V_F64, V_STR, V_ARR, V_OBJ = range(9)
|
|
44
|
+
|
|
45
|
+
I64_MIN, I64_MAX = -(2**63), 2**63 - 1
|
|
46
|
+
U64_MAX = 2**64 - 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _h(*parts: bytes) -> bytes:
|
|
50
|
+
d = hashlib.blake2b()
|
|
51
|
+
for p in parts:
|
|
52
|
+
d.update(p)
|
|
53
|
+
return d.digest()[:32]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _lp(b: bytearray, raw: bytes) -> None:
|
|
57
|
+
b += struct.pack("<Q", len(raw))
|
|
58
|
+
b += raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lp_opt(b: bytearray, s: Optional[str]) -> None:
|
|
62
|
+
if s is None:
|
|
63
|
+
b.append(0)
|
|
64
|
+
else:
|
|
65
|
+
b.append(1)
|
|
66
|
+
_lp(b, s.encode("utf-8"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_value(b: bytearray, v: Any) -> None:
|
|
70
|
+
"""Canonical encoding of a JSON value.
|
|
71
|
+
|
|
72
|
+
Numbers commit by REPRESENTATION, not by mathematical value, so 1 and 1.0
|
|
73
|
+
differ. Python makes that distinction naturally (int vs float); the Rust
|
|
74
|
+
side gets it from serde_json's parsed number type. That the two agree is
|
|
75
|
+
not automatic, which is exactly why it is in the vectors.
|
|
76
|
+
"""
|
|
77
|
+
if v is None:
|
|
78
|
+
b.append(V_NULL)
|
|
79
|
+
elif v is True:
|
|
80
|
+
b.append(V_TRUE)
|
|
81
|
+
elif v is False:
|
|
82
|
+
b.append(V_FALSE)
|
|
83
|
+
elif isinstance(v, int):
|
|
84
|
+
# bool is a subclass of int, handled above.
|
|
85
|
+
if I64_MIN <= v <= I64_MAX:
|
|
86
|
+
b.append(V_I64)
|
|
87
|
+
b += struct.pack("<q", v)
|
|
88
|
+
elif 0 <= v <= U64_MAX:
|
|
89
|
+
b.append(V_U64)
|
|
90
|
+
b += struct.pack("<Q", v)
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"integer out of 64-bit range: {v}")
|
|
93
|
+
elif isinstance(v, float):
|
|
94
|
+
if v != v:
|
|
95
|
+
raise ValueError("NaN cannot be committed to a state root")
|
|
96
|
+
b.append(V_F64)
|
|
97
|
+
# -0.0 == 0.0, so they must commit identically.
|
|
98
|
+
b += struct.pack("<d", 0.0 if v == 0.0 else v)
|
|
99
|
+
elif isinstance(v, str):
|
|
100
|
+
b.append(V_STR)
|
|
101
|
+
_lp(b, v.encode("utf-8"))
|
|
102
|
+
elif isinstance(v, (list, tuple)):
|
|
103
|
+
b.append(V_ARR)
|
|
104
|
+
b += struct.pack("<Q", len(v))
|
|
105
|
+
for it in v:
|
|
106
|
+
encode_value(b, it)
|
|
107
|
+
elif isinstance(v, Mapping):
|
|
108
|
+
b.append(V_OBJ)
|
|
109
|
+
b += struct.pack("<Q", len(v))
|
|
110
|
+
# Insertion order, NOT sorted. Python dicts preserve it; the vectors
|
|
111
|
+
# are loaded with object_pairs_hook=dict to keep the file's order.
|
|
112
|
+
for k, val in v.items():
|
|
113
|
+
_lp(b, k.encode("utf-8"))
|
|
114
|
+
encode_value(b, val)
|
|
115
|
+
else:
|
|
116
|
+
raise TypeError(f"not a JSON value: {type(v).__name__}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def namespace_leaf(name: str) -> bytes:
|
|
120
|
+
b = bytearray()
|
|
121
|
+
_lp(b, name.encode("utf-8"))
|
|
122
|
+
return _h(TAG_NS_LEAF, bytes(b))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def record_leaf(
|
|
126
|
+
coll: str,
|
|
127
|
+
id_: str,
|
|
128
|
+
data: Any,
|
|
129
|
+
valid_from: Optional[str] = None,
|
|
130
|
+
valid_to: Optional[str] = None,
|
|
131
|
+
) -> bytes:
|
|
132
|
+
b = bytearray()
|
|
133
|
+
_lp(b, coll.encode("utf-8"))
|
|
134
|
+
_lp(b, id_.encode("utf-8"))
|
|
135
|
+
_lp_opt(b, valid_from)
|
|
136
|
+
_lp_opt(b, valid_to)
|
|
137
|
+
encode_value(b, data)
|
|
138
|
+
return _h(TAG_REC_LEAF, bytes(b))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fold(level: list[bytes]) -> bytes:
|
|
142
|
+
if not level:
|
|
143
|
+
return _h(TAG_EMPTY)
|
|
144
|
+
while len(level) > 1:
|
|
145
|
+
nxt = []
|
|
146
|
+
for i in range(0, len(level) - 1, 2):
|
|
147
|
+
nxt.append(_h(TAG_NODE, level[i], level[i + 1]))
|
|
148
|
+
if len(level) % 2:
|
|
149
|
+
nxt.append(level[-1]) # promote; duplicating is CVE-2012-2459
|
|
150
|
+
level = nxt
|
|
151
|
+
return level[0]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _subtree(tag: bytes, leaves: list[bytes]) -> bytes:
|
|
155
|
+
return _h(tag, struct.pack("<Q", len(leaves)), _fold(leaves))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def namespace_root(collections: Iterable[str]) -> bytes:
|
|
159
|
+
names = sorted(set(collections), key=lambda s: s.encode("utf-8"))
|
|
160
|
+
return _subtree(TAG_NS_ROOT, [namespace_leaf(n) for n in names])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def records_root(records: Sequence[Mapping[str, Any]]) -> bytes:
|
|
164
|
+
ordered = sorted(
|
|
165
|
+
records,
|
|
166
|
+
key=lambda r: (r["coll"].encode("utf-8"), r["id"].encode("utf-8")),
|
|
167
|
+
)
|
|
168
|
+
leaves = [
|
|
169
|
+
record_leaf(
|
|
170
|
+
r["coll"], r["id"], r["data"],
|
|
171
|
+
r.get("valid_from"), r.get("valid_to"),
|
|
172
|
+
)
|
|
173
|
+
for r in ordered
|
|
174
|
+
]
|
|
175
|
+
return _subtree(TAG_REC_ROOT, leaves)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def compute(collections: Iterable[str], records: Sequence[Mapping[str, Any]]) -> dict:
|
|
179
|
+
colls = list(collections)
|
|
180
|
+
ns = namespace_root(colls)
|
|
181
|
+
rec = records_root(records)
|
|
182
|
+
return {
|
|
183
|
+
"version": "state_root_v1",
|
|
184
|
+
"namespace_root": ns.hex(),
|
|
185
|
+
"records_root": rec.hex(),
|
|
186
|
+
"state_root": _h(TAG_STATE_ROOT, ns, rec).hex(),
|
|
187
|
+
"collection_count": len(set(colls)),
|
|
188
|
+
"record_count": len(records),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Vector check ──────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def check_vectors(path: str) -> tuple[int, list[str]]:
|
|
195
|
+
"""Returns (cases checked, failures). Never raises on a mismatch — the
|
|
196
|
+
caller decides what a mismatch means, and a mismatch is data, not a crash.
|
|
197
|
+
"""
|
|
198
|
+
with open(path, encoding="utf-8") as fh:
|
|
199
|
+
doc = json.load(fh)
|
|
200
|
+
failures = []
|
|
201
|
+
for case in doc["cases"]:
|
|
202
|
+
got = compute(case["collections"], case["records"])
|
|
203
|
+
want = case["expect"]
|
|
204
|
+
for field in ("namespace_root", "records_root", "state_root",
|
|
205
|
+
"collection_count", "record_count"):
|
|
206
|
+
if got[field] != want[field]:
|
|
207
|
+
failures.append(
|
|
208
|
+
f"{case['name']}.{field}: python={got[field]!r} "
|
|
209
|
+
f"rust={want[field]!r}"
|
|
210
|
+
)
|
|
211
|
+
return len(doc["cases"]), failures
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
import os
|
|
216
|
+
import sys
|
|
217
|
+
|
|
218
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
219
|
+
default = os.path.join(here, "..", "..", "vectors", "state_root_v1.json")
|
|
220
|
+
target = sys.argv[1] if len(sys.argv) > 1 else default
|
|
221
|
+
n, bad = check_vectors(target)
|
|
222
|
+
for line in bad:
|
|
223
|
+
print("MISMATCH", line)
|
|
224
|
+
print(f"{n - len({b.split('.')[0] for b in bad})}/{n} cases agree with Rust")
|
|
225
|
+
sys.exit(1 if bad else 0)
|
|
@@ -319,6 +319,11 @@ class NEDBSurface:
|
|
|
319
319
|
# raise AttributeError on every install lacking the native wheel,
|
|
320
320
|
# i.e. exactly the universal py3-none-any path.
|
|
321
321
|
self._nedbd_mode: bool = False
|
|
322
|
+
# How many log ops the backend has already been handed. Set before any
|
|
323
|
+
# branch can call _reload(), because the early-return path (an empty
|
|
324
|
+
# stream) never reaches the assignment there — and an unset mark would
|
|
325
|
+
# be an AttributeError on the first write of a brand-new database.
|
|
326
|
+
self._persisted_ops: int = 0
|
|
322
327
|
|
|
323
328
|
if nedbd_url:
|
|
324
329
|
# Route all NEDB operations to a running nedbd server
|
|
@@ -383,16 +388,40 @@ class NEDBSurface:
|
|
|
383
388
|
apply_op(self._db.store, self._db.relations,
|
|
384
389
|
self._db.indexes, op, self._db.cause_map)
|
|
385
390
|
self._db._nonce = dict(self._db.log._last_nonce)
|
|
391
|
+
# Whatever was just restored is already in the stream. Without this the
|
|
392
|
+
# next write would re-append the entire replayed history.
|
|
393
|
+
self._persisted_ops = len(self._db.log.ops) if hasattr(self._db, "log") else 0
|
|
386
394
|
|
|
387
395
|
def _persist_last_op(self) -> None:
|
|
396
|
+
"""Persist every op appended since the last call.
|
|
397
|
+
|
|
398
|
+
It used to persist only `ops[-1]`, on the assumption that one API
|
|
399
|
+
call appends exactly one op. That assumption broke the moment a
|
|
400
|
+
single `put` could append two — a collection's first write now emits
|
|
401
|
+
its registry record and then the document — and the failure was
|
|
402
|
+
silent in the worst way: the registry op was never written to the
|
|
403
|
+
stream, so a replay rebuilt a log whose `prev` links pointed at an op
|
|
404
|
+
that was not there, and `verify()` returned False on a database that
|
|
405
|
+
had never been touched by anything but NEDB itself.
|
|
406
|
+
|
|
407
|
+
A high-water mark instead of a count, so this stays correct no matter
|
|
408
|
+
how many ops any future engine operation appends. The mark only ever
|
|
409
|
+
advances over ops actually handed to the backend, so a failure part
|
|
410
|
+
way through leaves the rest to be retried rather than skipped.
|
|
411
|
+
"""
|
|
388
412
|
if self._nedbd_mode:
|
|
389
413
|
return # nedbd persists atomically on each HTTP call
|
|
390
414
|
if not hasattr(self._db, "log"):
|
|
391
415
|
return # DAG backend persists itself (WAL + MANIFEST, atexit flush)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
416
|
+
total = len(self._db.log.ops)
|
|
417
|
+
if total <= self._persisted_ops:
|
|
418
|
+
return
|
|
419
|
+
pending = self._db.log.ops[self._persisted_ops:total]
|
|
420
|
+
encoded = [json.dumps(op.to_dict()) for op in pending]
|
|
421
|
+
for line in encoded:
|
|
422
|
+
self._backend.append(line)
|
|
423
|
+
self._backend.publish_ops(encoded)
|
|
424
|
+
self._persisted_ops = total
|
|
396
425
|
|
|
397
426
|
# ── Collection registration ───────────────────────────────────────────────
|
|
398
427
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|