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.
Files changed (52) hide show
  1. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/PKG-INFO +101 -28
  2. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/README.md +100 -27
  3. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/pyproject.toml +1 -1
  4. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/__init__.py +1 -1
  5. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/engine.py +110 -0
  6. nedb_engine-6.0.0/python/nedb/namespace.py +126 -0
  7. nedb_engine-6.0.0/python/nedb/nedbd-v2 +0 -0
  8. nedb_engine-6.0.0/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  9. nedb_engine-6.0.0/python/nedb/nedbd-v2-darwin-x64 +0 -0
  10. nedb_engine-6.0.0/python/nedb/nedbd-v2.exe +0 -0
  11. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/server.py +20 -1
  12. nedb_engine-6.0.0/python/nedb/state_root.py +225 -0
  13. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_redis.py +33 -4
  14. nedb_engine-4.3.2/python/nedb/_native.abi3.so +0 -0
  15. nedb_engine-4.3.2/python/nedb/nedbd-v2 +0 -0
  16. nedb_engine-4.3.2/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  17. nedb_engine-4.3.2/python/nedb/nedbd-v2-darwin-x64 +0 -0
  18. nedb_engine-4.3.2/python/nedb/nedbd-v2.exe +0 -0
  19. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/.gitignore +0 -0
  20. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/COPYING-APACHE-2.0.txt +0 -0
  21. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/LICENSE +0 -0
  22. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/client/node/README.md +0 -0
  23. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/client/python/README.md +0 -0
  24. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/examples/agent-loop/README.md +0 -0
  25. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/examples/mini-chain/README.md +0 -0
  26. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/autoindex.py +0 -0
  27. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/__init__.py +0 -0
  28. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/dag.py +0 -0
  29. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/backends/redis_backend.py +0 -0
  30. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/cascade.py +0 -0
  31. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/client.py +0 -0
  32. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/concurrent.py +0 -0
  33. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/crypto.py +0 -0
  34. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/index.py +0 -0
  35. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/log.py +0 -0
  36. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/merkle.py +0 -0
  37. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/mongo.py +0 -0
  38. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/proof.py +0 -0
  39. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/query.py +0 -0
  40. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/redis_compat.py +0 -0
  41. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/relations.py +0 -0
  42. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/resp2.py +0 -0
  43. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/snapshot.py +0 -0
  44. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/sql.py +0 -0
  45. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/store.py +0 -0
  46. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_core.py +0 -0
  47. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_mongo.py +0 -0
  48. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_mysql.py +0 -0
  49. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_postgresql.py +0 -0
  50. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/python/nedb/wrap_sqlite.py +0 -0
  51. {nedb_engine-4.3.2 → nedb_engine-6.0.0}/rust/crates/nedb-wrap/README.md +0 -0
  52. {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: 4.3.2
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 4.0.0 is licensed under the **Business Source License 1.1**. If your organisation's annual
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, vendored with its licence intact, extended with NEDB's
64
- temporal and causal clauses. Two front-ends, one plan. NQL folded in, not deleted.
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
  [![neSQL on PyPI](https://img.shields.io/pypi/v/nesql?label=nesql%20·%20PyPI&color=a855f7)](https://pypi.org/project/nesql/)
67
69
  [![neSQL on crates.io](https://img.shields.io/crates/v/nesql?label=nesql%20·%20crates.io&color=a855f7)](https://crates.io/crates/nesql)
68
70
  [![neSQL on npm](https://img.shields.io/npm/v/nesql-engine?label=nesql-engine%20·%20npm&color=a855f7)](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 today | Refused, with the reason | neSQL |
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 | ✅ joins already exist in the executor |
227
- | `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ subqueries + set ops exist; windows arrive with the grammar |
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 goes away |
230
- | `AS OF SYSTEM TIME <seq>` | DDL, `TRUNCATE`, `GRANT`/`REVOKE` | ⛔️ still refused, and always will be |
231
- | `INSERT` / `UPDATE` / `DELETE`, all with `RETURNING` | an `INSERT` with no column list | ✅ |
232
- | `_caused_by` / `_valid_from` / `_valid_to` as INSERT columns | values that are expressions, not literals | ✅ |
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 is a translation artefact, and it is going away
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
- > We stopped translating. neSQL vendors PostgreSQL's **real grammar** — `gram.y`,
244
- > 19,513 lines and 492 keywords, from PostgreSQL 17.4, licence intact — and extends
245
- > it with the clauses NEDB needs, rather than rewriting SQL into a language that
246
- > cannot express it.
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* this was never free: `SYSTEM_TIME`, `PERIOD` and
249
- > `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres has no temporal
250
- > SQL at all. `AS OF SYSTEM TIME` is a CockroachDB extension, which means NEDB's
251
- > temporal clauses are additions to the vendored grammar rather than deviations
252
- > from it — the same road CockroachDB, Materialize and RisingWave took.
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
- > Names reserved, grammar vendored, executor foundations already shipping inside
259
- > this engine. Each package loads and answers `is_release() == false`, because a
260
- > package that imports cleanly and then lies is worse than one that isn't published.
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.** It gets folded in: two front-ends compiling to one
263
- > plan, so nothing translates and neither language is a second-class guest.
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 4.0.0 is licensed under the **Business Source License 1.1**. If your organisation's annual
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, vendored with its licence intact, extended with NEDB's
43
- temporal and causal clauses. Two front-ends, one plan. NQL folded in, not deleted.
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
  [![neSQL on PyPI](https://img.shields.io/pypi/v/nesql?label=nesql%20·%20PyPI&color=a855f7)](https://pypi.org/project/nesql/)
46
48
  [![neSQL on crates.io](https://img.shields.io/crates/v/nesql?label=nesql%20·%20crates.io&color=a855f7)](https://crates.io/crates/nesql)
47
49
  [![neSQL on npm](https://img.shields.io/npm/v/nesql-engine?label=nesql-engine%20·%20npm&color=a855f7)](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 today | Refused, with the reason | neSQL |
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 | ✅ joins already exist in the executor |
206
- | `WHERE` — the whole NQL predicate surface | subqueries, `UNION`, window functions | ✅ subqueries + set ops exist; windows arrive with the grammar |
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 goes away |
209
- | `AS OF SYSTEM TIME <seq>` | DDL, `TRUNCATE`, `GRANT`/`REVOKE` | ⛔️ still refused, and always will be |
210
- | `INSERT` / `UPDATE` / `DELETE`, all with `RETURNING` | an `INSERT` with no column list | ✅ |
211
- | `_caused_by` / `_valid_from` / `_valid_to` as INSERT columns | values that are expressions, not literals | ✅ |
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 is a translation artefact, and it is going away
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
- > We stopped translating. neSQL vendors PostgreSQL's **real grammar** — `gram.y`,
223
- > 19,513 lines and 492 keywords, from PostgreSQL 17.4, licence intact — and extends
224
- > it with the clauses NEDB needs, rather than rewriting SQL into a language that
225
- > cannot express it.
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* this was never free: `SYSTEM_TIME`, `PERIOD` and
228
- > `PORTION` appear **zero** times in PostgreSQL's grammar. Postgres has no temporal
229
- > SQL at all. `AS OF SYSTEM TIME` is a CockroachDB extension, which means NEDB's
230
- > temporal clauses are additions to the vendored grammar rather than deviations
231
- > from it — the same road CockroachDB, Materialize and RisingWave took.
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
- > Names reserved, grammar vendored, executor foundations already shipping inside
238
- > this engine. Each package loads and answers `is_release() == false`, because a
239
- > package that imports cleanly and then lies is worse than one that isn't published.
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.** It gets folded in: two front-ends compiling to one
242
- > plan, so nothing translates and neither language is a second-class guest.
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 = "4.3.2"
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"
@@ -106,4 +106,4 @@ __all__ = [
106
106
  "verify_proof", "fold_head",
107
107
  "_native", "__has_native__",
108
108
  ]
109
- __version__ = "4.3.2"
109
+ __version__ = "6.0.0"
@@ -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
@@ -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: Dict[str, int] = {}
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
- if self._db.log.ops:
393
- last = self._db.log.ops[-1]
394
- self._backend.append(json.dumps(last.to_dict()))
395
- self._backend.publish_ops([json.dumps(last.to_dict())])
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
File without changes
File without changes