nitrodb 2.4.3__py3-none-any.whl

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/proof.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ nedb.proof — Merkle inclusion proofs for the hash-chained op log.
3
+
4
+ Every NEDB write produces an Op whose ``hash`` field is the chain head after that op
5
+ (``op.hash = blake2b(prev_head.utf8 + canon(body))`` — see ``nedb.log``). The chain
6
+ head therefore commits cryptographically to the entire write history.
7
+
8
+ This module derives a *parallel*, compact Merkle commitment chain over the same
9
+ op-hash sequence so a client can verify — offline, with no server round-trip and
10
+ without re-running the engine — that a specific op-hash was at a specific seq in
11
+ the log, and that the log's head followed from chaining all subsequent op-hashes
12
+ on top of it.
13
+
14
+ The chain step used by both ``verify_proof`` (client) and the server endpoint is::
15
+
16
+ head_next = blake2b(bytes.fromhex(head_prev) || bytes.fromhex(op_hash)).hexdigest()
17
+
18
+ starting from the genesis head (the all-zero 32-byte string). Folding over the
19
+ full ordered list of op-hashes yields a deterministic head; tamper with any op
20
+ hash (or reorder ops) and the fold no longer matches. The Op's own ``hash`` field
21
+ already commits to its body (payload, seq, nonce, ts, …) under the engine's chain
22
+ formula, so this fold is a complete commitment to the write history through the
23
+ op-hashes alone — exactly the property a Merkle inclusion proof needs.
24
+
25
+ The fold's terminal value (``proof["head"]``) is a *derived* head, distinct from
26
+ ``db.head`` (the engine's UTF-8-hex chain) but a deterministic function of the
27
+ same log. The server computes the derived head over the live log when issuing
28
+ each proof, so any divergence between client fold and server fold means the
29
+ proof was tampered with in transit.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ from typing import Dict, List
35
+
36
+ # Genesis head matches the engine: all-zero 32-byte string in hex.
37
+ GENESIS = "0" * 64
38
+
39
+
40
+ def _blake(data: bytes) -> str:
41
+ """BLAKE2b-256 over raw bytes, returned as hex — same primitive the engine uses."""
42
+ return hashlib.blake2b(data, digest_size=32).hexdigest()
43
+
44
+
45
+ def _chain_step(head_hex: str, op_hash_hex: str) -> str:
46
+ """One Merkle-chain step on raw bytes (decoded from hex).
47
+
48
+ Mirrors the user-facing spec exactly:
49
+ new_head = blake2b(prev_head_bytes || op_hash_bytes)
50
+ """
51
+ return _blake(bytes.fromhex(head_hex) + bytes.fromhex(op_hash_hex))
52
+
53
+
54
+ def fold_head(op_hashes: List[str], start: str = GENESIS) -> str:
55
+ """Fold an ordered list of op-hashes into a Merkle-chain head.
56
+
57
+ Used by the server to produce ``proof["head"]`` for any given log state and
58
+ by tests to sanity-check that the fold is order-sensitive and deterministic.
59
+ """
60
+ head = start
61
+ for h in op_hashes:
62
+ head = _chain_step(head, h)
63
+ return head
64
+
65
+
66
+ def verify_proof(proof: Dict) -> bool:
67
+ """Verify a Merkle inclusion proof client-side — no server, no engine needed.
68
+
69
+ ``proof`` shape::
70
+
71
+ {
72
+ "hash": "<64-hex>", # the document op's content hash (op.hash)
73
+ "seq": N, # sequence number of this op in the log
74
+ "prev_head": "<64-hex>", # chain head BEFORE this op (= op.prev_hash)
75
+ "subsequent": ["<64-hex>"], # op-hashes of every op AFTER this one
76
+ "head": "<64-hex>", # claimed final (derived) chain head
77
+ }
78
+
79
+ Verification (matches the spec):
80
+ 1. ``head_at_seq = blake2b(prev_head_bytes || hash_bytes)``
81
+ 2. For each ``sub_hash`` in subsequent:
82
+ ``head = blake2b(head_bytes || sub_hash_bytes)``
83
+ 3. ``final head == proof["head"]``
84
+
85
+ Returns True iff every step is internally consistent. Tampering with any
86
+ field — flipping a byte of ``hash``, reordering ``subsequent``, swapping
87
+ ``prev_head``, etc. — will flip the fold and make this return False.
88
+ """
89
+ if not isinstance(proof, dict):
90
+ return False
91
+
92
+ # Required keys with the right shapes.
93
+ required = ("hash", "seq", "prev_head", "subsequent", "head")
94
+ if not all(k in proof for k in required):
95
+ return False
96
+ if not isinstance(proof["seq"], int) or proof["seq"] < 0:
97
+ return False
98
+ if not isinstance(proof["subsequent"], list):
99
+ return False
100
+
101
+ # Hex shape check on the 64-char fields. Anything malformed → not verified.
102
+ def _is_hex32(s: object) -> bool:
103
+ if not isinstance(s, str) or len(s) != 64:
104
+ return False
105
+ try:
106
+ bytes.fromhex(s)
107
+ except ValueError:
108
+ return False
109
+ return True
110
+
111
+ if not _is_hex32(proof["hash"]): return False
112
+ if not _is_hex32(proof["prev_head"]): return False
113
+ if not _is_hex32(proof["head"]): return False
114
+ for s in proof["subsequent"]:
115
+ if not _is_hex32(s):
116
+ return False
117
+
118
+ # 1. Re-derive the chain head AT this op's seq from prev_head + this op's hash.
119
+ head = _chain_step(proof["prev_head"], proof["hash"])
120
+
121
+ # 2. Fold each subsequent op-hash on top to advance the chain to the tail.
122
+ for sub in proof["subsequent"]:
123
+ head = _chain_step(head, sub)
124
+
125
+ # 3. The fold must equal the claimed final head bit-for-bit.
126
+ return head == proof["head"]
nedb/query.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ nedb.query — NQL (the NEDB Query Language) parser + the fluent query builder.
3
+
4
+ Both the text form and the fluent builder compile to the SAME plan dict, so the two
5
+ front-ends share identical semantics. In the production engine the parser lives once
6
+ in Rust; Python and Node get the exact same grammar for free.
7
+
8
+ NQL grammar (keywords case-insensitive):
9
+
10
+ FROM <collection>
11
+ [ AS OF <seq> ]
12
+ [ WHERE <field> <op> <value> (AND <field> <op> <value>)* ]
13
+ [ SEARCH "<text>" ]
14
+ [ ORDER BY <field> [ASC|DESC] ]
15
+ [ TRAVERSE <relation> ]
16
+ [ LIMIT <n> ]
17
+
18
+ op := = | != | < | <= | > | >=
19
+ value := number | "string" | 'string' | true | false | null
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any, List, Optional, Tuple
25
+
26
+ _TOKEN_RE = re.compile(
27
+ r"""\s+
28
+ | "(?P<dq>[^"]*)"
29
+ | '(?P<sq>[^']*)'
30
+ | (?P<num>-?\d+(?:\.\d+)?)
31
+ | (?P<op><=|>=|!=|=|<|>)
32
+ | (?P<word>[A-Za-z_][A-Za-z0-9_]*)
33
+ """,
34
+ re.VERBOSE,
35
+ )
36
+
37
+ _KEYWORDS = {"from", "as", "of", "where", "and", "search", "order", "by",
38
+ "asc", "desc", "traverse", "trace", "reverse", "limit",
39
+ "valid", "true", "false", "null", "group", "count", "sum", "avg", "min", "max"}
40
+
41
+
42
+ def _lex(text: str) -> List[Tuple[str, Any]]:
43
+ toks: List[Tuple[str, Any]] = []
44
+ pos = 0
45
+ while pos < len(text):
46
+ m = _TOKEN_RE.match(text, pos)
47
+ if not m:
48
+ raise SyntaxError(f"NQL: cannot tokenize near: {text[pos:pos+20]!r}")
49
+ pos = m.end()
50
+ if m.group("dq") is not None:
51
+ toks.append(("str", m.group("dq")))
52
+ elif m.group("sq") is not None:
53
+ toks.append(("str", m.group("sq")))
54
+ elif m.group("num") is not None:
55
+ n = m.group("num")
56
+ toks.append(("num", float(n) if "." in n else int(n)))
57
+ elif m.group("op") is not None:
58
+ toks.append(("op", m.group("op")))
59
+ elif m.group("word") is not None:
60
+ w = m.group("word")
61
+ lw = w.lower()
62
+ toks.append(("kw", lw) if lw in _KEYWORDS else ("word", w))
63
+ # whitespace -> skip
64
+ return toks
65
+
66
+
67
+ def empty_plan(coll: str) -> dict:
68
+ return {"from": coll, "as_of": None, "where": [], "search": None,
69
+ "order_by": None, "traverse": None, "limit": None,
70
+ "group_by": None, "aggregate": None,
71
+ "trace": None, "trace_reverse": False,
72
+ "valid_as_of": None}
73
+
74
+
75
+ def parse_nql(text: str) -> dict:
76
+ toks = _lex(text)
77
+ i = 0
78
+
79
+ def peek():
80
+ return toks[i] if i < len(toks) else (None, None)
81
+
82
+ def expect_kw(kw):
83
+ nonlocal i
84
+ t, v = peek()
85
+ if t != "kw" or v != kw:
86
+ raise SyntaxError(f"NQL: expected '{kw.upper()}', got {v!r}")
87
+ i += 1
88
+
89
+ def value():
90
+ nonlocal i
91
+ t, v = peek()
92
+ if t in ("num", "str"):
93
+ i += 1
94
+ return v
95
+ if t == "kw" and v in ("true", "false", "null"):
96
+ i += 1
97
+ return {"true": True, "false": False, "null": None}[v]
98
+ if t == "word":
99
+ i += 1
100
+ return v
101
+ raise SyntaxError(f"NQL: expected value, got {v!r}")
102
+
103
+ expect_kw("from")
104
+ t, v = peek()
105
+ if t not in ("word", "kw"):
106
+ raise SyntaxError("NQL: expected collection after FROM")
107
+ i += 1
108
+ plan = empty_plan(v)
109
+
110
+ # AS OF <seq>
111
+ if peek() == ("kw", "as"):
112
+ i += 1
113
+ expect_kw("of")
114
+ t, v = peek()
115
+ if t != "num":
116
+ raise SyntaxError("NQL: AS OF expects an integer seq")
117
+ i += 1
118
+ plan["as_of"] = int(v)
119
+
120
+ # VALID AS OF <date> — bi-temporal valid-time filter
121
+ # Syntax: VALID AS OF "2024-02-15" (ISO 8601 date or datetime string)
122
+ # Can appear before or after WHERE; "valid" is the disambiguating keyword.
123
+ if peek() == ("kw", "valid"):
124
+ i += 1
125
+ # expect AS OF
126
+ t, v = peek()
127
+ if t == "kw" and v == "as":
128
+ i += 1
129
+ t, v = peek()
130
+ if t == "kw" and v == "of":
131
+ i += 1
132
+ t, v = peek()
133
+ if t != "str":
134
+ raise SyntaxError("NQL: VALID AS OF expects a quoted date string")
135
+ i += 1
136
+ plan["valid_as_of"] = v
137
+
138
+ # WHERE ... AND ...
139
+ if peek() == ("kw", "where"):
140
+ i += 1
141
+ while True:
142
+ t, field = peek()
143
+ if t not in ("word", "kw"):
144
+ raise SyntaxError("NQL: expected field in WHERE")
145
+ i += 1
146
+ t, op = peek()
147
+ if t != "op":
148
+ raise SyntaxError("NQL: expected operator in WHERE")
149
+ i += 1
150
+ plan["where"].append((field, op, value()))
151
+ if peek() == ("kw", "and"):
152
+ i += 1
153
+ continue
154
+ break
155
+
156
+ # VALID AS OF <date> — also accepted after WHERE (second position)
157
+ if peek() == ("kw", "valid") and plan["valid_as_of"] is None:
158
+ i += 1
159
+ for kw in ("as", "of"):
160
+ if peek()[1] == kw: i += 1
161
+ t, v = peek()
162
+ if t != "str":
163
+ raise SyntaxError("NQL: VALID AS OF expects a quoted date string")
164
+ i += 1
165
+ plan["valid_as_of"] = v
166
+
167
+ # SEARCH "text"
168
+ if peek() == ("kw", "search"):
169
+ i += 1
170
+ t, v = peek()
171
+ if t != "str":
172
+ raise SyntaxError("NQL: SEARCH expects a quoted string")
173
+ i += 1
174
+ plan["search"] = v
175
+
176
+ # ORDER BY field [ASC|DESC]
177
+ if peek() == ("kw", "order"):
178
+ i += 1
179
+ expect_kw("by")
180
+ t, field = peek()
181
+ if t not in ("word", "kw"):
182
+ raise SyntaxError("NQL: expected field after ORDER BY")
183
+ i += 1
184
+ direction = "ASC"
185
+ if peek() == ("kw", "asc"):
186
+ i += 1
187
+ elif peek() == ("kw", "desc"):
188
+ i += 1
189
+ direction = "DESC"
190
+ plan["order_by"] = (field, direction)
191
+
192
+ # TRAVERSE relation
193
+ if peek() == ("kw", "traverse"):
194
+ i += 1
195
+ t, rel = peek()
196
+ if t not in ("word", "kw"):
197
+ raise SyntaxError("NQL: expected relation after TRAVERSE")
198
+ i += 1
199
+ plan["traverse"] = rel
200
+
201
+ # TRACE <field> [REVERSE]
202
+ # Walks the causal provenance graph.
203
+ # TRACE caused_by → backward: which ops caused these documents?
204
+ # TRACE caused_by REVERSE → forward: which documents did these ops cause?
205
+ if peek() == ("kw", "trace"):
206
+ i += 1
207
+ t, tf = peek()
208
+ if t not in ("word", "kw"):
209
+ raise SyntaxError("NQL: expected field name after TRACE")
210
+ i += 1
211
+ plan["trace"] = tf
212
+ if peek() == ("kw", "reverse"):
213
+ i += 1
214
+ plan["trace_reverse"] = True
215
+
216
+ # LIMIT n
217
+ if peek() == ("kw", "limit"):
218
+ i += 1
219
+ t, v = peek()
220
+ if t != "num":
221
+ raise SyntaxError("NQL: LIMIT expects an integer")
222
+ i += 1
223
+ plan["limit"] = int(v)
224
+
225
+ # GROUP BY field [COUNT | SUM field | AVG field | MIN field | MAX field]
226
+ if peek() == ("kw", "group"):
227
+ i += 1
228
+ expect_kw("by")
229
+ t, field = peek()
230
+ if t not in ("word", "kw"):
231
+ raise SyntaxError("NQL: expected field after GROUP BY")
232
+ i += 1
233
+ plan["group_by"] = field
234
+ # optional aggregate after GROUP BY
235
+ t, agg = peek()
236
+ if t == "kw" and agg in ("count", "sum", "avg", "min", "max"):
237
+ i += 1
238
+ if agg == "count":
239
+ plan["aggregate"] = ("count", None)
240
+ else:
241
+ t2, agg_field = peek()
242
+ if t2 not in ("word", "kw"):
243
+ raise SyntaxError(f"NQL: {agg.upper()} expects a field name")
244
+ i += 1
245
+ plan["aggregate"] = (agg, agg_field)
246
+
247
+ if i != len(toks):
248
+ raise SyntaxError(f"NQL: unexpected trailing tokens: {toks[i:]}")
249
+ return plan
250
+
251
+
252
+ def cmp(a, op, b) -> bool:
253
+ try:
254
+ if op == "=":
255
+ return a == b
256
+ if op == "!=":
257
+ return a != b
258
+ if a is None:
259
+ return False
260
+ if op == "<":
261
+ return a < b
262
+ if op == "<=":
263
+ return a <= b
264
+ if op == ">":
265
+ return a > b
266
+ if op == ">=":
267
+ return a >= b
268
+ except TypeError:
269
+ return False
270
+ return False
271
+
272
+
273
+ class Query:
274
+ """Fluent builder that compiles to the same plan dict as NQL text."""
275
+
276
+ def __init__(self, engine, coll: str) -> None:
277
+ self._engine = engine
278
+ self.plan = empty_plan(coll)
279
+
280
+ def as_of(self, seq: int) -> "Query":
281
+ self.plan["as_of"] = seq
282
+ return self
283
+
284
+ def where(self, field: str, op: str, value: Any) -> "Query":
285
+ self.plan["where"].append((field, op, value))
286
+ return self
287
+
288
+ def search(self, text: str) -> "Query":
289
+ self.plan["search"] = text
290
+ return self
291
+
292
+ def order_by(self, field: str, desc: bool = False) -> "Query":
293
+ self.plan["order_by"] = (field, "DESC" if desc else "ASC")
294
+ return self
295
+
296
+ def traverse(self, rel: str) -> "Query":
297
+ self.plan["traverse"] = rel
298
+ return self
299
+
300
+ def limit(self, n: int) -> "Query":
301
+ self.plan["limit"] = n
302
+ return self
303
+
304
+ def run(self) -> List[dict]:
305
+ return self._engine.execute(self.plan)