nedb-engine 2.6.0__tar.gz → 2.6.2__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.0 → nedb_engine-2.6.2}/PKG-INFO +1 -1
  2. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/pyproject.toml +1 -1
  3. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/__init__.py +3 -3
  4. nedb_engine-2.6.2/python/nedb/_native.abi3.so +0 -0
  5. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/concurrent.py +9 -0
  6. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/engine.py +102 -0
  7. nedb_engine-2.6.2/python/nedb/nedbd-v2 +0 -0
  8. nedb_engine-2.6.2/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  9. nedb_engine-2.6.2/python/nedb/nedbd-v2-darwin-x64 +0 -0
  10. nedb_engine-2.6.2/python/nedb/nedbd-v2.exe +0 -0
  11. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/server.py +37 -19
  12. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/store.py +10 -0
  13. nedb_engine-2.6.0/python/nedb/_native.abi3.so +0 -0
  14. nedb_engine-2.6.0/python/nedb/nedbd-v2 +0 -0
  15. nedb_engine-2.6.0/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  16. nedb_engine-2.6.0/python/nedb/nedbd-v2-darwin-x64 +0 -0
  17. nedb_engine-2.6.0/python/nedb/nedbd-v2.exe +0 -0
  18. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/.gitignore +0 -0
  19. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/LICENSE +0 -0
  20. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/README.md +0 -0
  21. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/client/node/README.md +0 -0
  22. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/client/python/README.md +0 -0
  23. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/examples/mini-chain/README.md +0 -0
  24. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/autoindex.py +0 -0
  25. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/backends/__init__.py +0 -0
  26. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/backends/redis_backend.py +0 -0
  27. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/cascade.py +0 -0
  28. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/crypto.py +0 -0
  29. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/index.py +0 -0
  30. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/log.py +0 -0
  31. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/merkle.py +0 -0
  32. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/mongo.py +0 -0
  33. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/proof.py +0 -0
  34. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/query.py +0 -0
  35. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/redis_compat.py +0 -0
  36. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/relations.py +0 -0
  37. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/resp2.py +0 -0
  38. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/snapshot.py +0 -0
  39. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/sql.py +0 -0
  40. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/python/nedb/wrap_redis.py +0 -0
  41. {nedb_engine-2.6.0 → nedb_engine-2.6.2}/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.0
3
+ Version: 2.6.2
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.0"
13
+ version = "2.6.2"
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.0"
92
+ __version__ = "2.6.2"
@@ -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
 
@@ -207,6 +214,8 @@ class Sequencer:
207
214
  return db.put_file(*intent.args, **intent.kwargs)
208
215
  if k == "checkpoint":
209
216
  return db.checkpoint()
217
+ if k == "tx":
218
+ return db.tx(*intent.args, **intent.kwargs)
210
219
  raise ValueError(f"unknown write kind: {k}")
211
220
 
212
221
  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
@@ -35,7 +35,11 @@ HTTP API (all JSON):
35
35
  GET /v1/databases/<name>/files/<filename>/root?version=N&tier=warm — Merkle root (anchorable)
36
36
  POST /v1/databases/<name>/proof {hash} — Merkle inclusion proof (verifiable offline)
37
37
  POST /v1/databases/<name>/checkpoint — on-demand checkpoint
38
- GET /v1/databases/<name>/batch — batch writes (array of {op,coll,id,doc})
38
+ POST /v1/databases/<name>/batch — ATOMIC multi-op tx: {ops: [{op, coll, id,
39
+ doc?, if_seq?, idem?, client?}], client?}
40
+ all-or-nothing; if_seq CAS preconditions
41
+ (N = exact version, -1 = must-not-exist);
42
+ 409 {error: "precondition_failed", failures}
39
43
  """
40
44
  from __future__ import annotations
41
45
 
@@ -52,6 +56,7 @@ from . import __version__
52
56
  from .engine import NEDB
53
57
  from .concurrent import Sequencer
54
58
  from .log import ReplayError
59
+ from .engine import PreconditionFailed
55
60
 
56
61
  NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}")
57
62
 
@@ -525,30 +530,43 @@ def make_handler(manager: Manager, token: Optional[str]):
525
530
  "size": len(data), "tier": tier})
526
531
  return
527
532
 
528
- # POST /v1/databases/<name>/batch — multiple ops in one request
529
- # Body: {ops: [{op:"put"|"del"|"link", coll, id, doc?, frm?, rel?, to?}, ...]}
533
+ # POST /v1/databases/<name>/batch — ATOMIC multi-op transaction.
534
+ # Body: {ops: [{op:"put"|"del"|"link", coll, id, doc?, frm?, rel?,
535
+ # to?, if_seq?, idem?, client?, caused_by?, ttl_s?}, ...],
536
+ # client?: default client for ops that don't set one}
537
+ #
538
+ # All-or-nothing: every `if_seq` precondition is validated against
539
+ # current state FIRST (under the Sequencer's single committer
540
+ # thread — nothing can interleave); if any fails, HTTP 409 with
541
+ # the structured failure list and NOTHING is applied.
542
+ #
543
+ # `if_seq` contract (the Lua-atomics replacement):
544
+ # if_seq = N doc's last_seq must be exactly N (CAS)
545
+ # if_seq = -1 doc must not exist (create-once)
546
+ # omitted unconditional
547
+ #
548
+ # Per-op `idem` and `client` are forwarded to the op log (idem
549
+ # keys dedupe engine-side; client attributes provenance).
530
550
  if method == "POST" and len(parts) == 4 and parts[:2] == ["v1", "databases"] and parts[3] == "batch":
531
551
  db = manager.require(parts[2])
532
552
  b = self._body()
533
553
  ops_list = b.get("ops") or []
534
554
  if not isinstance(ops_list, list) or not ops_list:
535
555
  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})
556
+ default_client = str(b.get("client") or "http")
557
+ try:
558
+ out = db.tx(ops_list, default_client=default_client)
559
+ except PreconditionFailed as e:
560
+ self._send(409, {"error": "precondition_failed",
561
+ "failures": e.failures,
562
+ "seq": db.seq})
563
+ return
564
+ except ReplayError as e:
565
+ raise HttpError(409, str(e))
566
+ except (KeyError, TypeError, ValueError) as e:
567
+ raise HttpError(400, f"bad op: {e}")
568
+ self._send(200, {"results": out["results"], "count": out["count"],
569
+ "atomic": True, "seq": db.seq, "head": db.head})
552
570
  return
553
571
 
554
572
  raise HttpError(404, "no such route")
@@ -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