nedb-engine 2.6.2__tar.gz → 2.7.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-2.6.2 → nedb_engine-2.7.0}/PKG-INFO +45 -1
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/README.md +44 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/pyproject.toml +1 -1
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/__init__.py +6 -1
- nedb_engine-2.7.0/python/nedb/_native.abi3.so +0 -0
- nedb_engine-2.7.0/python/nedb/client.py +411 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/concurrent.py +7 -0
- nedb_engine-2.7.0/python/nedb/nedbd-v2 +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2-darwin-arm64 +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2-darwin-x64 +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2.exe +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/server.py +29 -2
- nedb_engine-2.6.2/python/nedb/_native.abi3.so +0 -0
- nedb_engine-2.6.2/python/nedb/nedbd-v2 +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/.gitignore +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/LICENSE +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/client/node/README.md +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/client/python/README.md +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/examples/mini-chain/README.md +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/autoindex.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/backends/__init__.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/backends/redis_backend.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/cascade.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/crypto.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/engine.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/index.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/log.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/merkle.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/mongo.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/proof.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/query.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/redis_compat.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/relations.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/resp2.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/snapshot.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/sql.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/store.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/wrap_redis.py +0 -0
- {nedb_engine-2.6.2 → nedb_engine-2.7.0}/rust/nedb-v2/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nedb-engine
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.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
|
|
@@ -176,6 +176,50 @@ MongoClient(db)["users"].find({"status": "active"}).sort("age", -1).to_list()
|
|
|
176
176
|
|
|
177
177
|
---
|
|
178
178
|
|
|
179
|
+
## Official Python client — talk to nedbd over HTTP
|
|
180
|
+
|
|
181
|
+
Running the daemon? `nedb.client.NedbClient` is the official client for its
|
|
182
|
+
HTTP API — extracted from the battle-tested clients that ran a production
|
|
183
|
+
Redis→NEDB mainnet migration, speaking the full route surface: queries,
|
|
184
|
+
atomic CAS transactions, TTL, indexes, relations, Merkle proofs, and the
|
|
185
|
+
Mongo-compat endpoint. Env-var defaults (`NEDBD_URL`, `NEDBD_TOKEN`,
|
|
186
|
+
`NEDB_DB`) mirror the daemon's own.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from nedb import NedbClient, PreconditionFailed, op_put
|
|
190
|
+
|
|
191
|
+
c = NedbClient("http://127.0.0.1:7070", db="app", token="s3cret")
|
|
192
|
+
c.ensure_database()
|
|
193
|
+
|
|
194
|
+
c.put("users", "u1", {"id": "u1", "email": "a@b.c"}, idem="signup-u1")
|
|
195
|
+
c.query('FROM users WHERE email = "a@b.c"') # full NQL rides through
|
|
196
|
+
c.query("FROM users AS OF 41") # time-travel included
|
|
197
|
+
|
|
198
|
+
# Atomic all-or-nothing transaction with engine-checked preconditions —
|
|
199
|
+
# the primitive that replaces Redis Lua scripts (if_seq: N = CAS, -1 = create-once)
|
|
200
|
+
doc = c.get_doc("users", "u1") # docs carry _seq
|
|
201
|
+
c.tx([op_put("users", "u1", {**doc, "plan": "pro"}, if_seq=doc["_seq"])])
|
|
202
|
+
|
|
203
|
+
# Contested writes: retry ONLY on PreconditionFailed, capped backoff
|
|
204
|
+
def bump():
|
|
205
|
+
d = c.get_doc("counters", "hits") or {"n": 0}
|
|
206
|
+
return c.tx([op_put("counters", "hits", {"n": d.get("n", 0) + 1},
|
|
207
|
+
if_seq=d.get("_seq", -1))])
|
|
208
|
+
c.cas_retry(bump)
|
|
209
|
+
|
|
210
|
+
# Integrity, verifiable WITHOUT trusting the server
|
|
211
|
+
proof = c.proof(c.log(limit=1)[0]["hash"])
|
|
212
|
+
from nedb import verify_proof; verify_proof(proof) # -> True, locally
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
A CAS miss raises the **same `PreconditionFailed`** (with the same
|
|
216
|
+
`.failures` shape) the embedded engine raises — code written against
|
|
217
|
+
`NEDB.tx` ports to the HTTP client without changing its except-clauses.
|
|
218
|
+
Typed errors throughout: `NedbAuthError`, `NedbNotFound`, `NedbBadRequest`,
|
|
219
|
+
`NedbConflict`, `CasExhausted`.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
179
223
|
## Redis layer-2 — wrap_redis()
|
|
180
224
|
|
|
181
225
|
Already running on Redis? Wrap your connection in one line and gain NEDB features *alongside* your existing Redis app — no migration required.
|
|
@@ -155,6 +155,50 @@ MongoClient(db)["users"].find({"status": "active"}).sort("age", -1).to_list()
|
|
|
155
155
|
|
|
156
156
|
---
|
|
157
157
|
|
|
158
|
+
## Official Python client — talk to nedbd over HTTP
|
|
159
|
+
|
|
160
|
+
Running the daemon? `nedb.client.NedbClient` is the official client for its
|
|
161
|
+
HTTP API — extracted from the battle-tested clients that ran a production
|
|
162
|
+
Redis→NEDB mainnet migration, speaking the full route surface: queries,
|
|
163
|
+
atomic CAS transactions, TTL, indexes, relations, Merkle proofs, and the
|
|
164
|
+
Mongo-compat endpoint. Env-var defaults (`NEDBD_URL`, `NEDBD_TOKEN`,
|
|
165
|
+
`NEDB_DB`) mirror the daemon's own.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from nedb import NedbClient, PreconditionFailed, op_put
|
|
169
|
+
|
|
170
|
+
c = NedbClient("http://127.0.0.1:7070", db="app", token="s3cret")
|
|
171
|
+
c.ensure_database()
|
|
172
|
+
|
|
173
|
+
c.put("users", "u1", {"id": "u1", "email": "a@b.c"}, idem="signup-u1")
|
|
174
|
+
c.query('FROM users WHERE email = "a@b.c"') # full NQL rides through
|
|
175
|
+
c.query("FROM users AS OF 41") # time-travel included
|
|
176
|
+
|
|
177
|
+
# Atomic all-or-nothing transaction with engine-checked preconditions —
|
|
178
|
+
# the primitive that replaces Redis Lua scripts (if_seq: N = CAS, -1 = create-once)
|
|
179
|
+
doc = c.get_doc("users", "u1") # docs carry _seq
|
|
180
|
+
c.tx([op_put("users", "u1", {**doc, "plan": "pro"}, if_seq=doc["_seq"])])
|
|
181
|
+
|
|
182
|
+
# Contested writes: retry ONLY on PreconditionFailed, capped backoff
|
|
183
|
+
def bump():
|
|
184
|
+
d = c.get_doc("counters", "hits") or {"n": 0}
|
|
185
|
+
return c.tx([op_put("counters", "hits", {"n": d.get("n", 0) + 1},
|
|
186
|
+
if_seq=d.get("_seq", -1))])
|
|
187
|
+
c.cas_retry(bump)
|
|
188
|
+
|
|
189
|
+
# Integrity, verifiable WITHOUT trusting the server
|
|
190
|
+
proof = c.proof(c.log(limit=1)[0]["hash"])
|
|
191
|
+
from nedb import verify_proof; verify_proof(proof) # -> True, locally
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A CAS miss raises the **same `PreconditionFailed`** (with the same
|
|
195
|
+
`.failures` shape) the embedded engine raises — code written against
|
|
196
|
+
`NEDB.tx` ports to the HTTP client without changing its except-clauses.
|
|
197
|
+
Typed errors throughout: `NedbAuthError`, `NedbNotFound`, `NedbBadRequest`,
|
|
198
|
+
`NedbConflict`, `CasExhausted`.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
158
202
|
## Redis layer-2 — wrap_redis()
|
|
159
203
|
|
|
160
204
|
Already running on Redis? Wrap your connection in one line and gain NEDB features *alongside* your existing Redis app — no migration required.
|
|
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "nedb-engine"
|
|
13
|
-
version = "2.
|
|
13
|
+
version = "2.7.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,11 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
from .engine import NEDB, PreconditionFailed
|
|
20
20
|
from .log import Op, OpLog, ReplayError
|
|
21
|
+
from .client import (
|
|
22
|
+
NedbClient, op_put, op_del,
|
|
23
|
+
NedbError, NedbAuthError, NedbNotFound, NedbBadRequest,
|
|
24
|
+
NedbConflict, CasExhausted,
|
|
25
|
+
)
|
|
21
26
|
from .query import Query, parse_nql
|
|
22
27
|
from .snapshot import save_snapshot, load_snapshot
|
|
23
28
|
from .crypto import resolve_tmk, rewrap_dek
|
|
@@ -89,4 +94,4 @@ __all__ = [
|
|
|
89
94
|
"verify_proof", "fold_head",
|
|
90
95
|
"_native", "__has_native__",
|
|
91
96
|
]
|
|
92
|
-
__version__ = "2.
|
|
97
|
+
__version__ = "2.7.0"
|
|
Binary file
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NedbClient — the official Python client for nedbd's HTTP API.
|
|
3
|
+
|
|
4
|
+
Extracted from production: this client is the promotion of the two
|
|
5
|
+
battle-tested hand-rolled clients that ran the AiAS mainnet migration
|
|
6
|
+
(the storage adapter's ``NedbdClient`` and the backfill's ``Nedbd``),
|
|
7
|
+
unified and extended to speak the daemon's full route surface. Design
|
|
8
|
+
choices carry their scars:
|
|
9
|
+
|
|
10
|
+
* **Typed errors, one vocabulary.** A CAS miss raises the SAME
|
|
11
|
+
:class:`~nedb.engine.PreconditionFailed` the embedded engine raises,
|
|
12
|
+
with the same ``.failures`` shape — code written against ``NEDB.tx``
|
|
13
|
+
ports to the HTTP client without changing its except-clauses.
|
|
14
|
+
* **No transparent write retries.** Writes are only safe to repeat when
|
|
15
|
+
the caller supplies an ``idem`` key (the daemon dedupes replays into
|
|
16
|
+
silent no-ops), so the client never retries on its own. The one
|
|
17
|
+
sanctioned loop is :meth:`NedbClient.cas_retry`, which retries ONLY
|
|
18
|
+
on ``PreconditionFailed`` with capped exponential backoff — the same
|
|
19
|
+
discipline the AiAS Lua-parity guards shipped with.
|
|
20
|
+
* **Env-var defaults** (``NEDBD_URL``, ``NEDBD_TOKEN``, ``NEDB_DB``)
|
|
21
|
+
mirror the daemon's own configuration surface, so
|
|
22
|
+
``NedbClient()`` with no arguments does the right thing next to a
|
|
23
|
+
conventionally-configured nedbd.
|
|
24
|
+
|
|
25
|
+
Quick start::
|
|
26
|
+
|
|
27
|
+
from nedb.client import NedbClient, op_put
|
|
28
|
+
|
|
29
|
+
c = NedbClient("http://127.0.0.1:7070", db="app")
|
|
30
|
+
c.ensure_database()
|
|
31
|
+
c.put("users", "u1", {"id": "u1", "email": "a@b.c"})
|
|
32
|
+
c.query('FROM users WHERE email = "a@b.c"')
|
|
33
|
+
|
|
34
|
+
# atomic CAS transaction — all-or-nothing, engine-checked
|
|
35
|
+
doc = c.get_doc("users", "u1")
|
|
36
|
+
c.tx([op_put("users", "u1", {**doc, "plan": "pro"},
|
|
37
|
+
if_seq=doc["_seq"])])
|
|
38
|
+
|
|
39
|
+
# contested writes: retry only on precondition failures
|
|
40
|
+
def bump():
|
|
41
|
+
d = c.get_doc("counters", "hits") or {"n": 0}
|
|
42
|
+
return c.tx([op_put("counters", "hits", {"n": int(d.get("n", 0)) + 1},
|
|
43
|
+
if_seq=d.get("_seq", -1))])
|
|
44
|
+
c.cas_retry(bump)
|
|
45
|
+
|
|
46
|
+
Not yet wrapped (use :meth:`NedbClient.request` directly if needed):
|
|
47
|
+
the files API (``POST/GET …/files``) — its tier/version semantics get a
|
|
48
|
+
dedicated helper in a follow-up release.
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import time
|
|
55
|
+
import urllib.error
|
|
56
|
+
import urllib.request
|
|
57
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TypeVar
|
|
58
|
+
|
|
59
|
+
from .engine import PreconditionFailed # one exception type, both transports
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"NedbClient", "op_put", "op_del",
|
|
63
|
+
"NedbError", "NedbAuthError", "NedbNotFound", "NedbBadRequest",
|
|
64
|
+
"NedbConflict", "CasExhausted", "PreconditionFailed",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
T = TypeVar("T")
|
|
68
|
+
|
|
69
|
+
DEFAULT_URL = "http://localhost:7070"
|
|
70
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
71
|
+
CAS_MAX_RETRIES = 16 # matches the AiAS guard budget
|
|
72
|
+
CAS_BACKOFF_BASE_S = 0.01 # grows 1.5x per attempt …
|
|
73
|
+
CAS_BACKOFF_CAP_S = 0.2 # … capped here
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── errors ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class NedbError(RuntimeError):
|
|
79
|
+
"""Base error for daemon interactions. ``status`` is the HTTP status
|
|
80
|
+
(``None`` for transport failures); ``payload`` is the parsed error body."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str, status: Optional[int] = None,
|
|
83
|
+
payload: Optional[dict] = None):
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.status = status
|
|
86
|
+
self.payload = payload or {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NedbAuthError(NedbError):
|
|
90
|
+
"""401/403 — missing or invalid bearer token."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NedbNotFound(NedbError):
|
|
94
|
+
"""404 — unknown database, route, or op hash."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NedbBadRequest(NedbError):
|
|
98
|
+
"""400 — malformed request or NQL error."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NedbConflict(NedbError):
|
|
102
|
+
"""409 that is NOT a CAS precondition failure — e.g. a nonce replay
|
|
103
|
+
(``ReplayError`` server-side). Preconditions raise
|
|
104
|
+
:class:`~nedb.engine.PreconditionFailed` instead."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CasExhausted(NedbError):
|
|
108
|
+
""":meth:`NedbClient.cas_retry` lost the race ``max_retries`` times in a
|
|
109
|
+
row. At sane write volumes this indicates a stuck loop or a hot-doc
|
|
110
|
+
pathology, not load."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── tx op builders ───────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def op_put(coll: str, doc_id: str, doc: dict, *,
|
|
116
|
+
if_seq: Optional[int] = None,
|
|
117
|
+
ttl_s: Optional[float] = None,
|
|
118
|
+
caused_by: Optional[List[int]] = None,
|
|
119
|
+
evidence: Optional[str] = None,
|
|
120
|
+
confidence: Optional[float] = None,
|
|
121
|
+
valid_from: Optional[str] = None,
|
|
122
|
+
valid_to: Optional[str] = None) -> Dict[str, Any]:
|
|
123
|
+
"""A ``put`` op for :meth:`NedbClient.tx`.
|
|
124
|
+
|
|
125
|
+
``if_seq`` semantics (checked atomically across the whole tx):
|
|
126
|
+
* ``N`` — the doc's current ``_seq`` must equal N (CAS update)
|
|
127
|
+
* ``-1`` — the doc must NOT exist (create-once)
|
|
128
|
+
* ``None`` — unconditional
|
|
129
|
+
"""
|
|
130
|
+
op: Dict[str, Any] = {"op": "put", "coll": coll, "id": doc_id, "doc": doc}
|
|
131
|
+
if if_seq is not None:
|
|
132
|
+
op["if_seq"] = if_seq
|
|
133
|
+
if ttl_s is not None:
|
|
134
|
+
op["ttl_s"] = ttl_s
|
|
135
|
+
if caused_by is not None:
|
|
136
|
+
op["caused_by"] = caused_by
|
|
137
|
+
if evidence is not None:
|
|
138
|
+
op["evidence"] = evidence
|
|
139
|
+
if confidence is not None:
|
|
140
|
+
op["confidence"] = confidence
|
|
141
|
+
if valid_from is not None:
|
|
142
|
+
op["valid_from"] = valid_from
|
|
143
|
+
if valid_to is not None:
|
|
144
|
+
op["valid_to"] = valid_to
|
|
145
|
+
return op
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def op_del(coll: str, doc_id: str, *,
|
|
149
|
+
if_seq: Optional[int] = None) -> Dict[str, Any]:
|
|
150
|
+
"""A ``del`` op for :meth:`NedbClient.tx` (``if_seq`` as in
|
|
151
|
+
:func:`op_put`)."""
|
|
152
|
+
op: Dict[str, Any] = {"op": "del", "coll": coll, "id": doc_id}
|
|
153
|
+
if if_seq is not None:
|
|
154
|
+
op["if_seq"] = if_seq
|
|
155
|
+
return op
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── the client ───────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
class NedbClient:
|
|
161
|
+
"""Synchronous client for one nedbd daemon, scoped to one database.
|
|
162
|
+
|
|
163
|
+
Thread-safe: holds no mutable state beyond configuration, and every
|
|
164
|
+
request is an independent HTTP call (nedbd's Sequencer provides the
|
|
165
|
+
single-committer serialization server-side).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, url: Optional[str] = None, db: Optional[str] = None,
|
|
169
|
+
token: Optional[str] = None,
|
|
170
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
171
|
+
client_tag: str = "nedb-client"):
|
|
172
|
+
self.url = (url or os.getenv("NEDBD_URL", DEFAULT_URL)).rstrip("/")
|
|
173
|
+
self.db = db or os.getenv("NEDB_DB", "default")
|
|
174
|
+
self.token = token if token is not None else os.getenv("NEDBD_TOKEN")
|
|
175
|
+
self.timeout = timeout
|
|
176
|
+
self.client_tag = client_tag
|
|
177
|
+
|
|
178
|
+
# ── transport ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def request(self, method: str, path: str, body: Optional[dict] = None,
|
|
181
|
+
timeout: Optional[float] = None) -> dict:
|
|
182
|
+
"""Raw authenticated request. Public on purpose: routes this client
|
|
183
|
+
doesn't wrap yet stay reachable without giving up auth/error typing."""
|
|
184
|
+
req = urllib.request.Request(
|
|
185
|
+
f"{self.url}{path}",
|
|
186
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
187
|
+
method=method)
|
|
188
|
+
req.add_header("Content-Type", "application/json")
|
|
189
|
+
if self.token:
|
|
190
|
+
req.add_header("Authorization", f"Bearer {self.token}")
|
|
191
|
+
try:
|
|
192
|
+
with urllib.request.urlopen(
|
|
193
|
+
req, timeout=self.timeout if timeout is None else timeout
|
|
194
|
+
) as resp:
|
|
195
|
+
return json.loads(resp.read().decode())
|
|
196
|
+
except urllib.error.HTTPError as e:
|
|
197
|
+
raw = e.read().decode(errors="replace")
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(raw)
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
payload = {"error": raw[:300]}
|
|
202
|
+
self._raise_typed(e.code, payload, method, path)
|
|
203
|
+
except urllib.error.URLError as e:
|
|
204
|
+
raise NedbError(
|
|
205
|
+
f"nedbd unreachable at {self.url}: {e.reason}") from e
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _raise_typed(status: int, payload: dict, method: str,
|
|
209
|
+
path: str) -> None:
|
|
210
|
+
msg = str(payload.get("error") or payload)[:300]
|
|
211
|
+
if status == 409 and payload.get("error") == "precondition_failed":
|
|
212
|
+
exc = PreconditionFailed(payload.get("failures") or [])
|
|
213
|
+
exc.seq = payload.get("seq") # server seq at rejection time
|
|
214
|
+
raise exc
|
|
215
|
+
if status in (401, 403):
|
|
216
|
+
raise NedbAuthError(msg, status, payload)
|
|
217
|
+
if status == 404:
|
|
218
|
+
raise NedbNotFound(msg, status, payload)
|
|
219
|
+
if status == 400:
|
|
220
|
+
raise NedbBadRequest(msg, status, payload)
|
|
221
|
+
if status == 409:
|
|
222
|
+
raise NedbConflict(msg, status, payload)
|
|
223
|
+
raise NedbError(f"nedbd {method} {path} -> {status}: {msg}",
|
|
224
|
+
status, payload)
|
|
225
|
+
|
|
226
|
+
def _dbp(self, action: str = "") -> str:
|
|
227
|
+
tail = f"/{action}" if action else ""
|
|
228
|
+
return f"/v1/databases/{self.db}{tail}"
|
|
229
|
+
|
|
230
|
+
# ── server / database management ─────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def health(self) -> dict:
|
|
233
|
+
"""``GET /health`` — unauthenticated liveness + daemon version."""
|
|
234
|
+
return self.request("GET", "/health")
|
|
235
|
+
|
|
236
|
+
def wait_ready(self, timeout: float = 10.0, interval: float = 0.2) -> dict:
|
|
237
|
+
"""Poll ``/health`` until the daemon answers (startup helper)."""
|
|
238
|
+
deadline = time.time() + timeout
|
|
239
|
+
last: Optional[Exception] = None
|
|
240
|
+
while time.time() < deadline:
|
|
241
|
+
try:
|
|
242
|
+
return self.health()
|
|
243
|
+
except NedbError as e: # noqa: PERF203 — startup poll
|
|
244
|
+
last = e
|
|
245
|
+
time.sleep(interval)
|
|
246
|
+
raise NedbError(f"nedbd not ready after {timeout}s: {last}")
|
|
247
|
+
|
|
248
|
+
def databases(self) -> List[dict]:
|
|
249
|
+
return self.request("GET", "/v1/databases").get("databases", [])
|
|
250
|
+
|
|
251
|
+
def database_detail(self, name: Optional[str] = None) -> dict:
|
|
252
|
+
return self.request("GET", f"/v1/databases/{name or self.db}")
|
|
253
|
+
|
|
254
|
+
def create_database(self, name: Optional[str] = None,
|
|
255
|
+
init: Optional[dict] = None) -> dict:
|
|
256
|
+
body: Dict[str, Any] = {"name": name or self.db}
|
|
257
|
+
if init is not None:
|
|
258
|
+
body["init"] = init
|
|
259
|
+
return self.request("POST", "/v1/databases", body)
|
|
260
|
+
|
|
261
|
+
def ensure_database(self, name: Optional[str] = None) -> bool:
|
|
262
|
+
"""Create the database iff missing (idempotent). True if created."""
|
|
263
|
+
want = name or self.db
|
|
264
|
+
if want in (self.health().get("databases") or []):
|
|
265
|
+
return False
|
|
266
|
+
self.create_database(want)
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def drop_database(self, name: str) -> dict:
|
|
270
|
+
"""``DELETE /v1/databases/{name}`` — destructive; the name is
|
|
271
|
+
REQUIRED (never defaults to ``self.db``) so a drop is always spelled
|
|
272
|
+
out at the call site."""
|
|
273
|
+
return self.request("DELETE", f"/v1/databases/{name}")
|
|
274
|
+
|
|
275
|
+
# ── reads ────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def query(self, nql: str) -> List[dict]:
|
|
278
|
+
"""Run NQL, return rows. (AS OF / VALID AS OF / TRACE / ORDER BY /
|
|
279
|
+
LIMIT / GROUP BY all ride through — the daemon owns the grammar.)"""
|
|
280
|
+
return self.query_full(nql).get("rows", [])
|
|
281
|
+
|
|
282
|
+
def query_full(self, nql: str) -> dict:
|
|
283
|
+
"""Run NQL, return the full envelope ``{rows, count, seq, head}``."""
|
|
284
|
+
return self.request("POST", self._dbp("query"), {"nql": nql})
|
|
285
|
+
|
|
286
|
+
def get_doc(self, coll: str, doc_id: str) -> Optional[dict]:
|
|
287
|
+
"""Fetch one doc by id (returns ``None`` when absent). Docs carry
|
|
288
|
+
``_id`` and ``_seq`` — feed ``_seq`` to ``op_put(if_seq=…)``."""
|
|
289
|
+
if '"' in coll or '"' in str(doc_id):
|
|
290
|
+
raise NedbBadRequest(f"unquotable identifier: {coll}:{doc_id}")
|
|
291
|
+
rows = self.query(f'FROM {coll} WHERE _id = "{doc_id}"')
|
|
292
|
+
return rows[0] if rows else None
|
|
293
|
+
|
|
294
|
+
def count(self, coll: str) -> int:
|
|
295
|
+
return int(self.query_full(f"FROM {coll}").get("count", 0))
|
|
296
|
+
|
|
297
|
+
# ── writes ───────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
def put(self, coll: str, doc_id: str, doc: dict, *,
|
|
300
|
+
idem: Optional[str] = None,
|
|
301
|
+
ttl_s: Optional[float] = None,
|
|
302
|
+
nonce: Optional[int] = None,
|
|
303
|
+
caused_by: Optional[List[int]] = None,
|
|
304
|
+
evidence: Optional[str] = None,
|
|
305
|
+
confidence: Optional[float] = None,
|
|
306
|
+
valid_from: Optional[str] = None,
|
|
307
|
+
valid_to: Optional[str] = None,
|
|
308
|
+
client: Optional[str] = None) -> dict:
|
|
309
|
+
"""Single unconditional put → ``{ok, doc, seq, head}``.
|
|
310
|
+
|
|
311
|
+
``idem`` makes the write replay-safe: the daemon dedupes a repeated
|
|
312
|
+
key into a silent no-op returning the ORIGINAL result — the property
|
|
313
|
+
the AiAS backfill's restart-safety is built on. For compare-and-set
|
|
314
|
+
preconditions use :meth:`tx` (``if_seq`` is transactional-only).
|
|
315
|
+
"""
|
|
316
|
+
body: Dict[str, Any] = {"coll": coll, "id": doc_id, "doc": doc,
|
|
317
|
+
"client": client or self.client_tag}
|
|
318
|
+
for k, v in (("idem", idem), ("ttl_s", ttl_s), ("nonce", nonce),
|
|
319
|
+
("caused_by", caused_by), ("evidence", evidence),
|
|
320
|
+
("confidence", confidence), ("valid_from", valid_from),
|
|
321
|
+
("valid_to", valid_to)):
|
|
322
|
+
if v is not None:
|
|
323
|
+
body[k] = v
|
|
324
|
+
return self.request("POST", self._dbp("put"), body)
|
|
325
|
+
|
|
326
|
+
def delete(self, coll: str, doc_id: str) -> dict:
|
|
327
|
+
"""``DELETE …/rows/{coll}/{id}`` → ``{ok, seq, head}``."""
|
|
328
|
+
return self.request(
|
|
329
|
+
"DELETE", self._dbp(f"rows/{coll}/{doc_id}"))
|
|
330
|
+
|
|
331
|
+
def tx(self, ops: List[Dict[str, Any]], *,
|
|
332
|
+
client: Optional[str] = None) -> dict:
|
|
333
|
+
"""Atomic all-or-nothing transaction (``POST …/batch``).
|
|
334
|
+
|
|
335
|
+
Validates every ``if_seq`` precondition first, then applies every op
|
|
336
|
+
inside one group-commit — the engine primitive that replaced Redis
|
|
337
|
+
Lua scripts. Raises :class:`~nedb.engine.PreconditionFailed` (with
|
|
338
|
+
``.failures`` and ``.seq``) when any check misses; NOTHING is applied.
|
|
339
|
+
Build ops with :func:`op_put` / :func:`op_del`.
|
|
340
|
+
"""
|
|
341
|
+
return self.request("POST", self._dbp("batch"),
|
|
342
|
+
{"ops": ops, "client": client or self.client_tag})
|
|
343
|
+
|
|
344
|
+
def cas_retry(self, fn: Callable[[], T], *,
|
|
345
|
+
max_retries: int = CAS_MAX_RETRIES,
|
|
346
|
+
backoff_base: float = CAS_BACKOFF_BASE_S,
|
|
347
|
+
backoff_cap: float = CAS_BACKOFF_CAP_S) -> T:
|
|
348
|
+
"""Run ``fn`` (read → build ops → :meth:`tx`), retrying ONLY on
|
|
349
|
+
:class:`PreconditionFailed` with capped exponential backoff. Every
|
|
350
|
+
other error propagates untouched. This is the guard discipline from
|
|
351
|
+
the AiAS migration, packaged."""
|
|
352
|
+
for attempt in range(max_retries):
|
|
353
|
+
try:
|
|
354
|
+
return fn()
|
|
355
|
+
except PreconditionFailed:
|
|
356
|
+
if attempt == max_retries - 1:
|
|
357
|
+
break
|
|
358
|
+
time.sleep(min(backoff_base * (1.5 ** attempt), backoff_cap))
|
|
359
|
+
raise CasExhausted(
|
|
360
|
+
f"CAS retry budget exhausted after {max_retries} attempts")
|
|
361
|
+
|
|
362
|
+
# ── indexes / relations ──────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def create_index(self, coll: str, field: str, kind: str = "eq") -> dict:
|
|
365
|
+
"""Idempotent server-side."""
|
|
366
|
+
return self.request("POST", self._dbp("index"),
|
|
367
|
+
{"coll": coll, "field": field, "kind": kind})
|
|
368
|
+
|
|
369
|
+
def ensure_indexes(self, pairs: Iterable[Tuple[str, str]]) -> int:
|
|
370
|
+
n = 0
|
|
371
|
+
for coll, field in pairs:
|
|
372
|
+
self.create_index(coll, field)
|
|
373
|
+
n += 1
|
|
374
|
+
return n
|
|
375
|
+
|
|
376
|
+
def link(self, frm: str, rel: str, to: str) -> dict:
|
|
377
|
+
return self.request("POST", self._dbp("link"),
|
|
378
|
+
{"frm": frm, "rel": rel, "to": to})
|
|
379
|
+
|
|
380
|
+
def neighbors(self, node: str, rel: str) -> List[str]:
|
|
381
|
+
return self.request("POST", self._dbp("neighbors"),
|
|
382
|
+
{"node": node, "rel": rel}).get("nodes", [])
|
|
383
|
+
|
|
384
|
+
# ── integrity / ops ──────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
def verify(self) -> dict:
|
|
387
|
+
"""Recompute the hash chain server-side → ``{ok, seq, head}``."""
|
|
388
|
+
return self.request("GET", self._dbp("verify"))
|
|
389
|
+
|
|
390
|
+
def checkpoint(self) -> dict:
|
|
391
|
+
return self.request("POST", self._dbp("checkpoint"))
|
|
392
|
+
|
|
393
|
+
def sweep(self) -> int:
|
|
394
|
+
"""Expire TTL'd docs now; returns the number swept."""
|
|
395
|
+
return int(self.request("POST", self._dbp("sweep")).get("swept", 0))
|
|
396
|
+
|
|
397
|
+
def log(self, limit: int = 50) -> List[dict]:
|
|
398
|
+
"""Most-recent-first op log (each op carries its chain ``hash``)."""
|
|
399
|
+
return self.request(
|
|
400
|
+
"GET", self._dbp(f"log?limit={int(limit)}")).get("log", [])
|
|
401
|
+
|
|
402
|
+
def proof(self, op_hash: str) -> dict:
|
|
403
|
+
"""Merkle inclusion proof for an op hash. Verify locally with
|
|
404
|
+
:func:`nedb.proof.verify_proof` — no server trust required."""
|
|
405
|
+
return self.request("POST", self._dbp("proof"), {"hash": op_hash})
|
|
406
|
+
|
|
407
|
+
def mongo(self, collection: str, op: str, **kwargs: Any) -> dict:
|
|
408
|
+
"""Thin passthrough to the Mongo-compat endpoint
|
|
409
|
+
(``find/findOne/count/insertOne/…``)."""
|
|
410
|
+
return self.request("POST", self._dbp("mongo"),
|
|
411
|
+
{"collection": collection, "op": op, **kwargs})
|
|
@@ -115,6 +115,11 @@ class Sequencer:
|
|
|
115
115
|
def checkpoint(self) -> Any:
|
|
116
116
|
return self._submit("checkpoint")
|
|
117
117
|
|
|
118
|
+
def sweep(self) -> Any:
|
|
119
|
+
"""TTL sweep as ONE committer intent — sweep appends delete ops, so it
|
|
120
|
+
must ride the single writer, never race it from another thread."""
|
|
121
|
+
return self._submit("sweep")
|
|
122
|
+
|
|
118
123
|
# ── read API: concurrent, snapshot-isolated at committed_seq ───────────────
|
|
119
124
|
def query(self, nql: str) -> List[dict]:
|
|
120
125
|
plan = parse_nql(nql)
|
|
@@ -216,6 +221,8 @@ class Sequencer:
|
|
|
216
221
|
return db.checkpoint()
|
|
217
222
|
if k == "tx":
|
|
218
223
|
return db.tx(*intent.args, **intent.kwargs)
|
|
224
|
+
if k == "sweep":
|
|
225
|
+
return db.sweep()
|
|
219
226
|
raise ValueError(f"unknown write kind: {k}")
|
|
220
227
|
|
|
221
228
|
def close(self) -> None:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -12,6 +12,7 @@ Config (env):
|
|
|
12
12
|
NEDBD_PORT bind port (default 7070)
|
|
13
13
|
NEDBD_DATA data root directory (default ./nedb-data)
|
|
14
14
|
NEDBD_TOKEN bearer token (optional; if set, every /v1 route requires it)
|
|
15
|
+
NEDBD_SWEEP_S TTL sweep interval s (default 30; 0 disables the sweeper)
|
|
15
16
|
|
|
16
17
|
Run:
|
|
17
18
|
nedbd # console script (pip install nedb-engine)
|
|
@@ -24,7 +25,7 @@ HTTP API (all JSON):
|
|
|
24
25
|
GET /v1/databases/<name>
|
|
25
26
|
DELETE /v1/databases/<name>
|
|
26
27
|
POST /v1/databases/<name>/query {nql}
|
|
27
|
-
POST /v1/databases/<name>/put {coll, id, doc, client?, nonce?, idem?}
|
|
28
|
+
POST /v1/databases/<name>/put {coll, id, doc, client?, nonce?, idem?, ttl_s?}
|
|
28
29
|
POST /v1/databases/<name>/index {coll, field, kind}
|
|
29
30
|
POST /v1/databases/<name>/link {frm, rel, to}
|
|
30
31
|
DELETE /v1/databases/<name>/rows/<coll>/<id>
|
|
@@ -35,6 +36,8 @@ HTTP API (all JSON):
|
|
|
35
36
|
GET /v1/databases/<name>/files/<filename>/root?version=N&tier=warm — Merkle root (anchorable)
|
|
36
37
|
POST /v1/databases/<name>/proof {hash} — Merkle inclusion proof (verifiable offline)
|
|
37
38
|
POST /v1/databases/<name>/checkpoint — on-demand checkpoint
|
|
39
|
+
POST /v1/databases/<name>/sweep — delete TTL-expired docs now (also runs
|
|
40
|
+
automatically every NEDBD_SWEEP_S, default 30)
|
|
38
41
|
POST /v1/databases/<name>/batch — ATOMIC multi-op tx: {ops: [{op, coll, id,
|
|
39
42
|
doc?, if_seq?, idem?, client?}], client?}
|
|
40
43
|
all-or-nothing; if_seq CAS preconditions
|
|
@@ -327,7 +330,7 @@ def make_handler(manager: Manager, token: Optional[str]):
|
|
|
327
330
|
if not coll or rid is None or not isinstance(doc, dict):
|
|
328
331
|
raise HttpError(400, "coll, id, and doc are required")
|
|
329
332
|
_scalar = ("client", "nonce", "idem", "evidence", "confidence",
|
|
330
|
-
"valid_from", "valid_to")
|
|
333
|
+
"valid_from", "valid_to", "ttl_s")
|
|
331
334
|
kw = {k: b[k] for k in _scalar if b.get(k) is not None}
|
|
332
335
|
# caused_by may live at the top level of the request body
|
|
333
336
|
# OR inside doc (natural for clients embedding it in the document).
|
|
@@ -369,6 +372,10 @@ def make_handler(manager: Manager, token: Optional[str]):
|
|
|
369
372
|
head = db.checkpoint()
|
|
370
373
|
self._send(200, {"ok": True, "head": head, "seq": db.seq})
|
|
371
374
|
return
|
|
375
|
+
if method == "POST" and action == "sweep":
|
|
376
|
+
swept = db.sweep()
|
|
377
|
+
self._send(200, {"ok": True, "swept": swept, "seq": db.seq})
|
|
378
|
+
return
|
|
372
379
|
if method == "GET" and action == "log":
|
|
373
380
|
limit = int(query.get("limit", ["50"])[0])
|
|
374
381
|
ops = [o.to_dict() for o in db.log.ops[-limit:]][::-1]
|
|
@@ -913,6 +920,26 @@ def main() -> None:
|
|
|
913
920
|
token = args.token
|
|
914
921
|
resp2_port = args.resp2_port
|
|
915
922
|
manager = Manager(data)
|
|
923
|
+
|
|
924
|
+
# TTL sweeper: engine expiry is lazy (as_of=None gets) but daemon reads are
|
|
925
|
+
# snapshot-pinned, so expired docs would linger forever without an active
|
|
926
|
+
# sweep. Runs every NEDBD_SWEEP_S seconds (default 30; 0 disables) through
|
|
927
|
+
# each Sequencer's committer intent — single-writer discipline preserved.
|
|
928
|
+
sweep_s = float(os.environ.get("NEDBD_SWEEP_S", "30"))
|
|
929
|
+
if sweep_s > 0:
|
|
930
|
+
def _sweeper():
|
|
931
|
+
import time as _t
|
|
932
|
+
while True:
|
|
933
|
+
_t.sleep(sweep_s)
|
|
934
|
+
for _name in list(manager._open.keys()):
|
|
935
|
+
try:
|
|
936
|
+
n = manager._open[_name].sweep()
|
|
937
|
+
if n:
|
|
938
|
+
_log(f" [nedbd] ttl sweep [{_name}]: {n} expired", level=1)
|
|
939
|
+
except Exception as _e: # noqa: BLE001
|
|
940
|
+
_log(f" [nedbd] ttl sweep [{_name}] error: {_e}", level=1)
|
|
941
|
+
threading.Thread(target=_sweeper, name="nedbd-ttl-sweeper", daemon=True).start()
|
|
942
|
+
|
|
916
943
|
httpd = ThreadingHTTPServer((host, port), make_handler(manager, token))
|
|
917
944
|
auth = "on" if token else "off"
|
|
918
945
|
BANNER = f"""\
|
|
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
|