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.
Files changed (39) hide show
  1. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/PKG-INFO +45 -1
  2. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/README.md +44 -0
  3. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/pyproject.toml +1 -1
  4. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/__init__.py +6 -1
  5. nedb_engine-2.7.0/python/nedb/_native.abi3.so +0 -0
  6. nedb_engine-2.7.0/python/nedb/client.py +411 -0
  7. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/concurrent.py +7 -0
  8. nedb_engine-2.7.0/python/nedb/nedbd-v2 +0 -0
  9. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  10. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2-darwin-x64 +0 -0
  11. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/nedbd-v2.exe +0 -0
  12. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/server.py +29 -2
  13. nedb_engine-2.6.2/python/nedb/_native.abi3.so +0 -0
  14. nedb_engine-2.6.2/python/nedb/nedbd-v2 +0 -0
  15. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/.gitignore +0 -0
  16. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/LICENSE +0 -0
  17. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/client/node/README.md +0 -0
  18. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/client/python/README.md +0 -0
  19. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/examples/mini-chain/README.md +0 -0
  20. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/autoindex.py +0 -0
  21. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/backends/__init__.py +0 -0
  22. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/backends/redis_backend.py +0 -0
  23. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/cascade.py +0 -0
  24. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/crypto.py +0 -0
  25. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/engine.py +0 -0
  26. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/index.py +0 -0
  27. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/log.py +0 -0
  28. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/merkle.py +0 -0
  29. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/mongo.py +0 -0
  30. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/proof.py +0 -0
  31. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/query.py +0 -0
  32. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/redis_compat.py +0 -0
  33. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/relations.py +0 -0
  34. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/resp2.py +0 -0
  35. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/snapshot.py +0 -0
  36. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/sql.py +0 -0
  37. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/store.py +0 -0
  38. {nedb_engine-2.6.2 → nedb_engine-2.7.0}/python/nedb/wrap_redis.py +0 -0
  39. {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.6.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.6.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.6.2"
97
+ __version__ = "2.7.0"
@@ -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
@@ -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
File without changes
File without changes