nedb-engine 2.6.1__tar.gz → 2.6.3__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 (41) hide show
  1. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/PKG-INFO +1 -1
  2. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/pyproject.toml +1 -1
  3. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/__init__.py +3 -3
  4. nedb_engine-2.6.3/python/nedb/_native.abi3.so +0 -0
  5. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/concurrent.py +16 -0
  6. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/engine.py +102 -0
  7. nedb_engine-2.6.3/python/nedb/nedbd-v2 +0 -0
  8. nedb_engine-2.6.3/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  9. nedb_engine-2.6.3/python/nedb/nedbd-v2-darwin-x64 +0 -0
  10. nedb_engine-2.6.3/python/nedb/nedbd-v2.exe +0 -0
  11. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/server.py +66 -21
  12. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/store.py +10 -0
  13. nedb_engine-2.6.1/python/nedb/_native.abi3.so +0 -0
  14. nedb_engine-2.6.1/python/nedb/nedbd-v2 +0 -0
  15. nedb_engine-2.6.1/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  16. nedb_engine-2.6.1/python/nedb/nedbd-v2-darwin-x64 +0 -0
  17. nedb_engine-2.6.1/python/nedb/nedbd-v2.exe +0 -0
  18. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/.gitignore +0 -0
  19. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/LICENSE +0 -0
  20. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/README.md +0 -0
  21. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/client/node/README.md +0 -0
  22. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/client/python/README.md +0 -0
  23. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/examples/mini-chain/README.md +0 -0
  24. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/autoindex.py +0 -0
  25. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/backends/__init__.py +0 -0
  26. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/backends/redis_backend.py +0 -0
  27. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/cascade.py +0 -0
  28. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/crypto.py +0 -0
  29. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/index.py +0 -0
  30. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/log.py +0 -0
  31. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/merkle.py +0 -0
  32. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/mongo.py +0 -0
  33. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/proof.py +0 -0
  34. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/query.py +0 -0
  35. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/redis_compat.py +0 -0
  36. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/relations.py +0 -0
  37. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/resp2.py +0 -0
  38. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/snapshot.py +0 -0
  39. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/sql.py +0 -0
  40. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/python/nedb/wrap_redis.py +0 -0
  41. {nedb_engine-2.6.1 → nedb_engine-2.6.3}/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.1
3
+ Version: 2.6.3
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
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
10
10
 
11
11
  [project]
12
12
  name = "nedb-engine"
13
- version = "2.6.1"
13
+ version = "2.6.3"
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"
@@ -16,7 +16,7 @@ as ``nedb._native`` (``nedb.__has_native__`` reports whether it loaded).
16
16
  """
17
17
  from __future__ import annotations
18
18
 
19
- from .engine import NEDB
19
+ from .engine import NEDB, PreconditionFailed
20
20
  from .log import Op, OpLog, ReplayError
21
21
  from .query import Query, parse_nql
22
22
  from .snapshot import save_snapshot, load_snapshot
@@ -79,7 +79,7 @@ except ImportError: # pure-Python install (sdist / unsupported platform)
79
79
  del _types, _sys, _NativeStub, _native_stub
80
80
 
81
81
  __all__ = [
82
- "NEDB", "OpLog", "Op", "ReplayError", "Query", "parse_nql",
82
+ "NEDB", "PreconditionFailed", "OpLog", "Op", "ReplayError", "Query", "parse_nql",
83
83
  "save_snapshot", "load_snapshot",
84
84
  "sql_exec", "sql_to_nql", "SQLError", "SQLUnsupportedError",
85
85
  "RedisCompat", "RedisError", "RedisUnsupportedError",
@@ -89,4 +89,4 @@ __all__ = [
89
89
  "verify_proof", "fold_head",
90
90
  "_native", "__has_native__",
91
91
  ]
92
- __version__ = "2.6.1"
92
+ __version__ = "2.6.3"
@@ -90,6 +90,13 @@ class Sequencer:
90
90
  def put(self, coll: str, id: str, doc: dict, **kw: Any) -> Any:
91
91
  return self._submit("put", coll, id, doc, **kw)
92
92
 
93
+ def tx(self, ops: list, **kw: Any) -> Any:
94
+ """Atomic multi-op transaction (see NEDB.tx). Submitted as ONE
95
+ intent, so the committer validates preconditions and applies all ops
96
+ with nothing interleaving — the single-writer thread IS the
97
+ serialization point, exactly as Redis's single thread was for Lua."""
98
+ return self._submit("tx", ops, **kw)
99
+
93
100
  def delete(self, coll: str, id: str, **kw: Any) -> Any:
94
101
  return self._submit("delete", coll, id, **kw)
95
102
 
@@ -108,6 +115,11 @@ class Sequencer:
108
115
  def checkpoint(self) -> Any:
109
116
  return self._submit("checkpoint")
110
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
+
111
123
  # ── read API: concurrent, snapshot-isolated at committed_seq ───────────────
112
124
  def query(self, nql: str) -> List[dict]:
113
125
  plan = parse_nql(nql)
@@ -207,6 +219,10 @@ class Sequencer:
207
219
  return db.put_file(*intent.args, **intent.kwargs)
208
220
  if k == "checkpoint":
209
221
  return db.checkpoint()
222
+ if k == "tx":
223
+ return db.tx(*intent.args, **intent.kwargs)
224
+ if k == "sweep":
225
+ return db.sweep()
210
226
  raise ValueError(f"unknown write kind: {k}")
211
227
 
212
228
  def close(self) -> None:
@@ -24,6 +24,23 @@ from .relations import Relations
24
24
  from .store import MVCCStore
25
25
 
26
26
 
27
+ class PreconditionFailed(Exception):
28
+ """An `if_seq` compare-and-set precondition did not hold; the transaction
29
+ was rejected as a unit and NOTHING was applied. `.failures` lists every
30
+ failed check as {index, coll, id, expected, actual}."""
31
+
32
+ def __init__(self, failures: List[dict]):
33
+ self.failures = failures
34
+ super().__init__(
35
+ f"{len(failures)} precondition(s) failed: "
36
+ + "; ".join(
37
+ f"[{f['index']}] {f['coll']}:{f['id']} expected seq "
38
+ f"{f['expected']}, actual {f['actual']}"
39
+ for f in failures
40
+ )
41
+ )
42
+
43
+
27
44
  def apply_op(store: MVCCStore, relations: Relations, indexes: Indexes, op: Op,
28
45
  cause_map: Optional[Dict[int, List[int]]] = None) -> None:
29
46
  """Deterministically fold one op into materialized state."""
@@ -484,6 +501,91 @@ class NEDB:
484
501
  return self._check_ttl(coll, id, doc)
485
502
  return doc # time-travel reads never trigger lazy expiry
486
503
 
504
+ def last_seq(self, coll: str, id: str) -> Optional[int]:
505
+ """Version anchor for compare-and-set: the seq of the most recent op
506
+ (put OR delete) that touched this document, or None if never written."""
507
+ return self.store.last_seq(f"{coll}:{id}")
508
+
509
+ def tx(self, ops: List[dict], default_client: str = "local") -> dict:
510
+ """Atomic multi-op transaction with optional per-op CAS preconditions.
511
+
512
+ Each op is a dict:
513
+ {"op": "put", "coll", "id", "doc", "if_seq"?, "idem"?, "client"?,
514
+ "caused_by"?, "ttl_s"?}
515
+ {"op": "del", "coll", "id", "if_seq"?, "idem"?, "client"?}
516
+ {"op": "link", "frm", "rel", "to", "client"?}
517
+
518
+ `if_seq` semantics (the Lua-atomics replacement contract):
519
+ if_seq = N -> the doc's last_seq must be exactly N (the caller
520
+ read version N and nothing has touched it since)
521
+ if_seq = -1 -> the doc must NOT currently exist (create-once)
522
+ omitted/None -> unconditional
523
+
524
+ Two phases: ALL preconditions are validated against current state
525
+ first; if any fails, PreconditionFailed is raised and NOTHING is
526
+ applied. Only then are the ops applied in order (each becoming a
527
+ normal chained, hash-sealed op-log entry).
528
+
529
+ Concurrency contract: this method performs no locking of its own.
530
+ It is atomic when executed by a single writer — which is exactly how
531
+ the daemon runs it (one intent on the Sequencer's committer thread,
532
+ the same single-writer property that made Redis Lua scripts atomic).
533
+ Embedded multi-threaded callers must route through Sequencer.tx().
534
+
535
+ Durability: the Sequencer group-commits, so a tx submitted as one
536
+ intent shares a single fsync boundary — a crash before it loses the
537
+ whole tx together, never a prefix of it.
538
+
539
+ Precondition reads use the raw store (no TTL side effects): an
540
+ expired-but-unswept doc still counts as existing for if_seq == -1;
541
+ call sweep() first where create-once interacts with TTLs.
542
+ """
543
+ failures: List[dict] = []
544
+ for i, op in enumerate(ops):
545
+ want = op.get("if_seq")
546
+ if want is None:
547
+ continue
548
+ kind = str(op.get("op", "put")).lower()
549
+ if kind not in ("put", "del", "delete"):
550
+ continue # links carry no per-doc version
551
+ coll, rid = str(op["coll"]), str(op["id"])
552
+ cur = self.store.last_seq(f"{coll}:{rid}")
553
+ want = int(want)
554
+ if want == -1:
555
+ if self.store.get(f"{coll}:{rid}") is not None:
556
+ failures.append({"index": i, "coll": coll, "id": rid,
557
+ "expected": -1, "actual": cur})
558
+ elif cur != want:
559
+ failures.append({"index": i, "coll": coll, "id": rid,
560
+ "expected": want, "actual": cur})
561
+ if failures:
562
+ raise PreconditionFailed(failures)
563
+
564
+ results: List[dict] = []
565
+ for op in ops:
566
+ kind = str(op.get("op", "put")).lower()
567
+ client = str(op.get("client") or default_client)
568
+ if kind == "put":
569
+ stored = self.put(str(op["coll"]), str(op["id"]),
570
+ dict(op.get("doc") or {}),
571
+ client=client,
572
+ idem=op.get("idem"),
573
+ ttl_s=op.get("ttl_s"),
574
+ caused_by=op.get("caused_by"))
575
+ results.append({"op": "put", "id": op["id"], "seq": self.seq,
576
+ "doc": stored})
577
+ elif kind in ("del", "delete"):
578
+ self.delete(str(op["coll"]), str(op["id"]),
579
+ client=client, idem=op.get("idem"))
580
+ results.append({"op": "del", "id": op["id"], "seq": self.seq})
581
+ elif kind == "link":
582
+ self.link(str(op["frm"]), str(op["rel"]), str(op["to"]),
583
+ client=client)
584
+ results.append({"op": "link", "seq": self.seq})
585
+ else:
586
+ results.append({"op": kind, "error": "unknown op"})
587
+ return {"results": results, "count": len(results), "seq": self.seq}
588
+
487
589
  def expire(self, coll: str, id: str, ttl_s: float) -> bool:
488
590
  """Set or update the TTL on an existing document. Returns False if not found."""
489
591
  doc = self.store.get(f"{coll}:{id}")
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,7 +36,13 @@ 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
38
- GET /v1/databases/<name>/batchbatch writes (array of {op,coll,id,doc})
39
+ POST /v1/databases/<name>/sweepdelete TTL-expired docs now (also runs
40
+ automatically every NEDBD_SWEEP_S, default 30)
41
+ POST /v1/databases/<name>/batch — ATOMIC multi-op tx: {ops: [{op, coll, id,
42
+ doc?, if_seq?, idem?, client?}], client?}
43
+ all-or-nothing; if_seq CAS preconditions
44
+ (N = exact version, -1 = must-not-exist);
45
+ 409 {error: "precondition_failed", failures}
39
46
  """
40
47
  from __future__ import annotations
41
48
 
@@ -52,6 +59,7 @@ from . import __version__
52
59
  from .engine import NEDB
53
60
  from .concurrent import Sequencer
54
61
  from .log import ReplayError
62
+ from .engine import PreconditionFailed
55
63
 
56
64
  NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}")
57
65
 
@@ -322,7 +330,7 @@ def make_handler(manager: Manager, token: Optional[str]):
322
330
  if not coll or rid is None or not isinstance(doc, dict):
323
331
  raise HttpError(400, "coll, id, and doc are required")
324
332
  _scalar = ("client", "nonce", "idem", "evidence", "confidence",
325
- "valid_from", "valid_to")
333
+ "valid_from", "valid_to", "ttl_s")
326
334
  kw = {k: b[k] for k in _scalar if b.get(k) is not None}
327
335
  # caused_by may live at the top level of the request body
328
336
  # OR inside doc (natural for clients embedding it in the document).
@@ -364,6 +372,10 @@ def make_handler(manager: Manager, token: Optional[str]):
364
372
  head = db.checkpoint()
365
373
  self._send(200, {"ok": True, "head": head, "seq": db.seq})
366
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
367
379
  if method == "GET" and action == "log":
368
380
  limit = int(query.get("limit", ["50"])[0])
369
381
  ops = [o.to_dict() for o in db.log.ops[-limit:]][::-1]
@@ -525,30 +537,43 @@ def make_handler(manager: Manager, token: Optional[str]):
525
537
  "size": len(data), "tier": tier})
526
538
  return
527
539
 
528
- # POST /v1/databases/<name>/batch — multiple ops in one request
529
- # Body: {ops: [{op:"put"|"del"|"link", coll, id, doc?, frm?, rel?, to?}, ...]}
540
+ # POST /v1/databases/<name>/batch — ATOMIC multi-op transaction.
541
+ # Body: {ops: [{op:"put"|"del"|"link", coll, id, doc?, frm?, rel?,
542
+ # to?, if_seq?, idem?, client?, caused_by?, ttl_s?}, ...],
543
+ # client?: default client for ops that don't set one}
544
+ #
545
+ # All-or-nothing: every `if_seq` precondition is validated against
546
+ # current state FIRST (under the Sequencer's single committer
547
+ # thread — nothing can interleave); if any fails, HTTP 409 with
548
+ # the structured failure list and NOTHING is applied.
549
+ #
550
+ # `if_seq` contract (the Lua-atomics replacement):
551
+ # if_seq = N doc's last_seq must be exactly N (CAS)
552
+ # if_seq = -1 doc must not exist (create-once)
553
+ # omitted unconditional
554
+ #
555
+ # Per-op `idem` and `client` are forwarded to the op log (idem
556
+ # keys dedupe engine-side; client attributes provenance).
530
557
  if method == "POST" and len(parts) == 4 and parts[:2] == ["v1", "databases"] and parts[3] == "batch":
531
558
  db = manager.require(parts[2])
532
559
  b = self._body()
533
560
  ops_list = b.get("ops") or []
534
561
  if not isinstance(ops_list, list) or not ops_list:
535
562
  raise HttpError(400, "ops array is required")
536
- results = []
537
- for op in ops_list:
538
- kind = str(op.get("op", "put")).lower()
539
- if kind == "put":
540
- doc = db.put(str(op["coll"]), str(op["id"]), dict(op.get("doc") or {}))
541
- results.append({"op": "put", "id": op["id"], "seq": db.seq})
542
- elif kind == "del":
543
- db.delete(str(op["coll"]), str(op["id"]))
544
- results.append({"op": "del", "id": op["id"], "seq": db.seq})
545
- elif kind == "link":
546
- db.link(str(op["frm"]), str(op["rel"]), str(op["to"]))
547
- results.append({"op": "link", "seq": db.seq})
548
- else:
549
- results.append({"op": kind, "error": "unknown op"})
550
- self._send(200, {"results": results, "count": len(results),
551
- "seq": db.seq, "head": db.head})
563
+ default_client = str(b.get("client") or "http")
564
+ try:
565
+ out = db.tx(ops_list, default_client=default_client)
566
+ except PreconditionFailed as e:
567
+ self._send(409, {"error": "precondition_failed",
568
+ "failures": e.failures,
569
+ "seq": db.seq})
570
+ return
571
+ except ReplayError as e:
572
+ raise HttpError(409, str(e))
573
+ except (KeyError, TypeError, ValueError) as e:
574
+ raise HttpError(400, f"bad op: {e}")
575
+ self._send(200, {"results": out["results"], "count": out["count"],
576
+ "atomic": True, "seq": db.seq, "head": db.head})
552
577
  return
553
578
 
554
579
  raise HttpError(404, "no such route")
@@ -895,6 +920,26 @@ def main() -> None:
895
920
  token = args.token
896
921
  resp2_port = args.resp2_port
897
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
+
898
943
  httpd = ThreadingHTTPServer((host, port), make_handler(manager, token))
899
944
  auth = "on" if token else "off"
900
945
  BANNER = f"""\
@@ -40,6 +40,16 @@ class MVCCStore:
40
40
  _, val = chain[i]
41
41
  return None if val is TOMB else val
42
42
 
43
+ def last_seq(self, key: str) -> Optional[int]:
44
+ """Seq of the most recent op that touched this key (including a
45
+ delete's tombstone), or None if the key has never been written.
46
+
47
+ This is the version anchor for compare-and-set (`if_seq`): a CAS
48
+ taken against version N correctly fails after ANY later write or
49
+ delete, because that op advanced the key's last_seq past N."""
50
+ seqs = self._seqs.get(key)
51
+ return seqs[-1] if seqs else None
52
+
43
53
  def keys(self, prefix: str = "", as_of: Optional[int] = None) -> List[str]:
44
54
  # Lock-free read: take an atomic snapshot of the key set, then resolve
45
55
  # versions by `as_of`. Under the concurrent Sequencer a single committer
Binary file
Binary file
File without changes
File without changes
File without changes