nedb-engine 3.2.1 → 3.3.0
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.
- package/README.md +253 -3
- package/nedb.darwin-arm64.node +0 -0
- package/nedb.darwin-x64.node +0 -0
- package/nedb.linux-arm64-gnu.node +0 -0
- package/nedb.linux-arm64-musl.node +0 -0
- package/nedb.linux-x64-gnu.node +0 -0
- package/nedb.linux-x64-musl.node +0 -0
- package/nedb.win32-x64-msvc.node +0 -0
- package/nedbd-v2-darwin-arm64 +0 -0
- package/nedbd-v2-darwin-x64 +0 -0
- package/nedbd-v2-linux-arm64 +0 -0
- package/nedbd-v2-linux-arm64-musl +0 -0
- package/nedbd-v2-linux-x64 +0 -0
- package/nedbd-v2-linux-x64-musl +0 -0
- package/nedbd-v2-win-x64.exe +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,78 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
|
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
|
+
## New in 3.3.0 — the query language grew up
|
|
30
|
+
|
|
31
|
+
`WHERE` was six operators wide (`= != > < >= <=`) joined by an implicit `AND`.
|
|
32
|
+
It now takes a full boolean expression, in **both** engines, and the clauses
|
|
33
|
+
around it run in SQL's order.
|
|
34
|
+
|
|
35
|
+
```sql
|
|
36
|
+
FROM jobs
|
|
37
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
38
|
+
AND miner IS NOT NULL
|
|
39
|
+
AND NOT (region LIKE "eu-%")
|
|
40
|
+
GROUP BY region SUM fee
|
|
41
|
+
HAVING sum_fee > 10000
|
|
42
|
+
ORDER BY sum_fee DESC
|
|
43
|
+
LIMIT 20 OFFSET 40
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| added | |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `IN (…)` / `NOT IN (…)` | set membership |
|
|
49
|
+
| `BETWEEN a AND b` / `NOT BETWEEN` | inclusive both ends, as in SQL |
|
|
50
|
+
| `LIKE` / `NOT LIKE` / `ILIKE` | `%` any run, `_` any one char |
|
|
51
|
+
| `IS NULL` / `IS NOT NULL` | matches absent **and** explicitly-null |
|
|
52
|
+
| `AND` / `OR` / `NOT` / `(…)` | `AND` binds tighter; parens nest to any depth |
|
|
53
|
+
| `OFFSET n` | pagination; pairs with `LIMIT` |
|
|
54
|
+
| `ORDER BY a, b DESC` | multi-key, per-key direction |
|
|
55
|
+
| `HAVING <predicate>` | filters the aggregated rows |
|
|
56
|
+
| `COUNT` / `SUM f` / `AVG f` / `MIN f` / `MAX f` | whole-result aggregate, one row |
|
|
57
|
+
|
|
58
|
+
**Indexed range scans.** `=`, `IN`, `BETWEEN` and the inequalities are served
|
|
59
|
+
from a sorted index when one covers the field — a point lookup on 20,000 rows
|
|
60
|
+
goes from 137 ms to 0.01 ms, a 1%-selective `BETWEEN` from 186 ms to 1.1 ms.
|
|
61
|
+
See [**Indexes**](#indexes) for the measured table and the three cases that
|
|
62
|
+
deliberately decline the index.
|
|
63
|
+
|
|
64
|
+
**Nine silent defects fixed.** None of them crashed; they all returned a
|
|
65
|
+
confident wrong answer with HTTP 200. The worst:
|
|
66
|
+
|
|
67
|
+
- `GROUP BY status MAX fee` aggregated the *group* field, not the target —
|
|
68
|
+
answering `1.0` where the real maxima were 40 and 30.
|
|
69
|
+
- `LIMIT 2 GROUP BY status COUNT` truncated the aggregate's **input**, so
|
|
70
|
+
twelve rows across three statuses reported counts summing to 2.
|
|
71
|
+
- `ORDER BY count DESC` on grouped rows sorted the raw documents on a field
|
|
72
|
+
that only exists after grouping — silently inert.
|
|
73
|
+
- `FROM jobs OFFSET 2` and a misspelled `ORDRE BY fee` were silently **dropped**
|
|
74
|
+
and a different query answered. Unknown clauses are now a parse error.
|
|
75
|
+
- `SUM` ran through `f64`, losing integer precision above 2^53 — a real problem
|
|
76
|
+
for satoshi amounts and block heights. Integer inputs now stay in `i64`.
|
|
77
|
+
- An ordering comparison against a missing field was true in the Rust engine
|
|
78
|
+
(`WHERE fee < 5` returned rows with no `fee` at all) and false in the Python
|
|
79
|
+
reference. Now false in both, matching SQL.
|
|
80
|
+
- A field named like a keyword (`count`, `min`, `value`, `status`) was
|
|
81
|
+
unaddressable, because both lexers canonicalised case at field positions.
|
|
82
|
+
|
|
83
|
+
**Compatibility.** Verified by building a `nedbd` from the released v3.2.2 tag
|
|
84
|
+
and diffing every answer: **40 of 45 legacy queries byte-identical, zero
|
|
85
|
+
regressions.** The differences are the three bug fixes above, each documented
|
|
86
|
+
in `tests/test_backcompat.py`. `scripts/compare_engine_answers.py` reproduces
|
|
87
|
+
the comparison against any released binary.
|
|
88
|
+
|
|
89
|
+
**The DAG is untouched.** `tests/test_dag_preserved.py` runs the entire new
|
|
90
|
+
query surface against a live chain and asserts head, seq and `verify()` are
|
|
91
|
+
unchanged afterwards — and that `verify()` still returns *false* when the log
|
|
92
|
+
is tampered with. Reads are reads.
|
|
93
|
+
|
|
94
|
+
**Cross-engine parity is now gated.** Nothing previously checked that the
|
|
95
|
+
Python reference and the Rust core agreed, which is how they had drifted apart
|
|
96
|
+
in five places. Two suites now run the same battery through both and assert
|
|
97
|
+
identical answers.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
29
101
|
## New in 3.2.0 — wrap the databases you already run
|
|
30
102
|
|
|
31
103
|
NEDB adds **tamper-evident causal provenance to a database you already have**, in one line, without
|
|
@@ -271,6 +343,12 @@ db.query('FROM users WHERE status = "active" ORDER BY age ASC')
|
|
|
271
343
|
db.query('FROM users SEARCH "rust"')
|
|
272
344
|
db.query('FROM users GROUP BY status COUNT')
|
|
273
345
|
|
|
346
|
+
# Full boolean predicates — IN, BETWEEN, LIKE, IS NULL, OR, NOT, parentheses
|
|
347
|
+
db.query('FROM users WHERE status IN ("active", "trialing")')
|
|
348
|
+
db.query('FROM users WHERE age BETWEEN 25 AND 40')
|
|
349
|
+
db.query('FROM users WHERE bio LIKE "%rust%" AND NOT (status = "retired")')
|
|
350
|
+
db.query('FROM users WHERE (age < 25 OR age > 60) AND bio IS NOT NULL')
|
|
351
|
+
|
|
274
352
|
# Time-travel — AS OF any past sequence
|
|
275
353
|
snap = db.seq
|
|
276
354
|
db.put("users", "alice", {"name": "Alice", "age": 32, "status": "retired"})
|
|
@@ -495,13 +573,185 @@ redis-cli -p 6380 SELECT shop EVAL 'FROM beliefs TRACE caused_by' 0
|
|
|
495
573
|
FROM <collection>
|
|
496
574
|
[ AS OF <seq> ] transaction time (when was it written?)
|
|
497
575
|
[ VALID AS OF "<date>" ] valid time (when was it true in the world?)
|
|
498
|
-
[ WHERE <
|
|
576
|
+
[ WHERE <predicate> ] full boolean predicate, see below
|
|
499
577
|
[ SEARCH "<text>" ] full-text search
|
|
500
|
-
[ ORDER BY <field> [ASC|DESC] ]
|
|
501
578
|
[ TRAVERSE <relation> ] graph traversal
|
|
502
579
|
[ TRACE caused_by [REVERSE] ] causal provenance (why? / what did this cause?)
|
|
503
|
-
[ LIMIT <n> ]
|
|
504
580
|
[ GROUP BY <field> [COUNT|SUM f|AVG f|MIN f|MAX f] ]
|
|
581
|
+
[ COUNT | SUM f | AVG f | MIN f | MAX f ] whole-result aggregate, one row
|
|
582
|
+
[ HAVING <predicate> ] filters the AGGREGATED rows
|
|
583
|
+
[ ORDER BY <field> [ASC|DESC] (, ...) ]
|
|
584
|
+
[ LIMIT <n> ] [ OFFSET <n> ]
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Clauses are evaluated in SQL's order, whatever order you write them in:
|
|
588
|
+
|
|
589
|
+
```
|
|
590
|
+
FROM → WHERE → GROUP BY → HAVING → ORDER BY → OFFSET → LIMIT
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
That matters, and before 3.3.0 it was wrong. `LIMIT` truncated the *input* to
|
|
594
|
+
an aggregate rather than the result, so `LIMIT 5 GROUP BY status COUNT` over
|
|
595
|
+
twelve rows reported counts summing to 5 — it said only five rows existed when
|
|
596
|
+
twelve did. `ORDER BY` ran before grouping, so sorting on `count` or `sum_fee`
|
|
597
|
+
silently did nothing. `VALID AS OF` was applied after `LIMIT` in the Python
|
|
598
|
+
engine, so a limited bi-temporal query returned fewer valid rows than exist.
|
|
599
|
+
|
|
600
|
+
### Predicates
|
|
601
|
+
|
|
602
|
+
`WHERE` takes a full boolean expression. `AND` binds tighter than `OR`;
|
|
603
|
+
parentheses override, and nest to any depth.
|
|
604
|
+
|
|
605
|
+
```
|
|
606
|
+
<predicate> := <or>
|
|
607
|
+
<or> := <and> [OR <and>]*
|
|
608
|
+
<and> := <not> [AND <not>]*
|
|
609
|
+
<not> := [NOT] <primary>
|
|
610
|
+
<primary> := "(" <predicate> ")" | <comparison>
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
| Comparison | Example |
|
|
614
|
+
| --- | --- |
|
|
615
|
+
| `= != < <= > >=` | `WHERE height > 600000` |
|
|
616
|
+
| `IN (…)` / `NOT IN (…)` | `WHERE status IN ("open", "pending")` |
|
|
617
|
+
| `BETWEEN a AND b` | `WHERE height BETWEEN 100 AND 200` — inclusive, as in SQL |
|
|
618
|
+
| `NOT BETWEEN a AND b` | `WHERE fee NOT BETWEEN 10 AND 20` |
|
|
619
|
+
| `LIKE` / `NOT LIKE` | `WHERE miner LIKE "Acme%"` — `%` any run, `_` any one char |
|
|
620
|
+
| `ILIKE` | `WHERE miner ILIKE "acme%"` — case-insensitive |
|
|
621
|
+
| `IS NULL` / `IS NOT NULL` | `WHERE miner IS NULL` — matches absent **and** explicitly-null |
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
db.query('''FROM jobs
|
|
625
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
626
|
+
AND miner IS NOT NULL
|
|
627
|
+
AND NOT (region LIKE "eu-%")
|
|
628
|
+
ORDER BY fee DESC LIMIT 20''')
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Filterable metadata fields: `_id`, `_coll`, `_hash`, `_seq`.
|
|
632
|
+
|
|
633
|
+
`_id = "x"` is an O(1) index lookup rather than a scan — but only when it is a
|
|
634
|
+
genuine conjunct. Under an `OR` it cannot constrain the result set, so the
|
|
635
|
+
planner correctly declines the fast path there.
|
|
636
|
+
|
|
637
|
+
### Indexes
|
|
638
|
+
|
|
639
|
+
`=`, `IN (...)`, `BETWEEN` and the one-sided inequalities are served from a
|
|
640
|
+
sorted index when one covers the field, turning a full collection scan into a
|
|
641
|
+
bounded range walk:
|
|
642
|
+
|
|
643
|
+
```python
|
|
644
|
+
db.create_index("blocks", "height", "sorted")
|
|
645
|
+
db.query("FROM blocks WHERE height BETWEEN 600000 AND 600100")
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Measured on 20,000 rows with `scripts/bench_index_range.py` — two identical
|
|
649
|
+
databases, one indexed, one not:
|
|
650
|
+
|
|
651
|
+
| Query | Scan | Indexed | Speedup |
|
|
652
|
+
| --- | --- | --- | --- |
|
|
653
|
+
| `WHERE fee = 10000` | 137 ms | 0.01 ms | 17,000× |
|
|
654
|
+
| `WHERE fee IN (a, b, c)` | 185 ms | 0.02 ms | 9,700× |
|
|
655
|
+
| `WHERE fee BETWEEN …` (1% of rows) | 186 ms | 1.1 ms | 170× |
|
|
656
|
+
| `WHERE fee BETWEEN …` (10% of rows) | 188 ms | 13 ms | 14× |
|
|
657
|
+
| unindexed field (control) | 188 ms | 187 ms | 1.0× |
|
|
658
|
+
|
|
659
|
+
The planner asks the index how many rows each candidate range covers and takes
|
|
660
|
+
the narrowest, so `WHERE region = "eu" AND height BETWEEN 600000 AND 600001`
|
|
661
|
+
uses the height index rather than whichever field it saw first. Same-field
|
|
662
|
+
bounds are merged, so `height > 100 AND height < 200` is one walk.
|
|
663
|
+
|
|
664
|
+
An index is only ever used to NARROW candidates — the full predicate is
|
|
665
|
+
re-evaluated on whatever comes back, so the answer never depends on whether an
|
|
666
|
+
index exists. Three cases deliberately decline it:
|
|
667
|
+
|
|
668
|
+
- **`IS NULL`.** A document whose field is absent is not in that field's
|
|
669
|
+
index, so an index scan would return the exact complement of the answer.
|
|
670
|
+
- **Anything under `OR` or `NOT`.** A disjunct does not constrain the result
|
|
671
|
+
set; narrowing on one arm would silently drop the rows the other arm matched.
|
|
672
|
+
- **`AS OF`.** The sorted index holds current versions only (a superseded hash
|
|
673
|
+
is dropped on overwrite), so it cannot answer a historical query.
|
|
674
|
+
|
|
675
|
+
> **Predicates over NULL follow SQL's three-valued logic.** An ordering
|
|
676
|
+
> comparison (`<` `<=` `>` `>=`, and therefore `BETWEEN`) against a missing or
|
|
677
|
+
> null field is never true — `WHERE fee < 5` will not return a row that has no
|
|
678
|
+
> `fee` at all. `LIKE` is false in *both* polarities, so a null row appears in
|
|
679
|
+
> neither `LIKE` nor `NOT LIKE`. `=` and `!=` do operate on null, so
|
|
680
|
+
> `WHERE fee = NULL` selects rows where the field is absent or null, and
|
|
681
|
+
> `WHERE fee != 5` includes them. Use `IS NULL` / `IS NOT NULL` to test
|
|
682
|
+
> presence explicitly.
|
|
683
|
+
|
|
684
|
+
### Unknown clauses are errors
|
|
685
|
+
|
|
686
|
+
A query containing a clause the engine does not implement is **rejected**, not
|
|
687
|
+
silently reinterpreted. Before 3.3.0 the parser skipped tokens it did not
|
|
688
|
+
recognise, so `FROM jobs OFFSET 2` returned un-offset rows with HTTP 200 and a
|
|
689
|
+
misspelled `ORDRE BY fee` returned unsorted rows — the engine answered a
|
|
690
|
+
*different query* than the one asked, and said nothing. Both now return
|
|
691
|
+
HTTP 400 with the offending token.
|
|
692
|
+
|
|
693
|
+
### Aggregates
|
|
694
|
+
|
|
695
|
+
`SUM`/`AVG`/`MIN`/`MAX` take the field to aggregate; `COUNT` (the default when
|
|
696
|
+
no aggregate is given) takes none.
|
|
697
|
+
|
|
698
|
+
```python
|
|
699
|
+
db.query('FROM items GROUP BY cat MAX price')
|
|
700
|
+
# → [{"cat": "x", "count": 3, "max_price": 10.0, "value": 10.0}, …]
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
`count` is the group size. The aggregate only considers rows whose target field
|
|
704
|
+
is numeric, so a group of 5 where 2 carry a numeric `price` reports `count: 5`
|
|
705
|
+
and averages over 2. An aggregate with no numeric input is `null`, never `0`.
|
|
706
|
+
The `value` key is a back-compatible alias for the aggregate result.
|
|
707
|
+
|
|
708
|
+
Integer inputs give integer results — `SUM`/`MIN`/`MAX` stay in 64-bit
|
|
709
|
+
integers rather than passing through a float, so a sum over satoshi amounts or
|
|
710
|
+
block heights above 2^53 is exact. `AVG` is always fractional.
|
|
711
|
+
|
|
712
|
+
Groups come back sorted by key unless you say otherwise, so results are stable
|
|
713
|
+
run to run and identical across engines.
|
|
714
|
+
|
|
715
|
+
Drop the `GROUP BY` for a whole-result aggregate, which returns exactly one row:
|
|
716
|
+
|
|
717
|
+
```python
|
|
718
|
+
db.query('FROM orders COUNT') # → [{"count": 1049, "value": 1049}]
|
|
719
|
+
db.query('FROM orders WHERE status = "paid" COUNT')
|
|
720
|
+
db.query('FROM orders SUM total') # → [{"count": 1049, "sum_total": 88123, …}]
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`COUNT` of an empty result is one row holding `0` — a caller asking "how many?"
|
|
724
|
+
always gets a number. `SUM` of an empty result is `null`.
|
|
725
|
+
|
|
726
|
+
### HAVING
|
|
727
|
+
|
|
728
|
+
`WHERE` filters rows before they are grouped; `HAVING` filters the groups.
|
|
729
|
+
|
|
730
|
+
```python
|
|
731
|
+
db.query('FROM orders GROUP BY region SUM total HAVING sum_total > 10000')
|
|
732
|
+
db.query('FROM orders GROUP BY region COUNT HAVING count BETWEEN 5 AND 50')
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`HAVING` runs through the same evaluator as `WHERE`, so it gets the whole
|
|
736
|
+
predicate surface — `IN`, `BETWEEN`, `LIKE`, `OR`, `NOT`, parentheses — rather
|
|
737
|
+
than a poorer second copy.
|
|
738
|
+
|
|
739
|
+
### Sorting and paging
|
|
740
|
+
|
|
741
|
+
```python
|
|
742
|
+
db.query('FROM orders ORDER BY region, total DESC') # ties broken by the next key
|
|
743
|
+
db.query('FROM orders ORDER BY total DESC LIMIT 20 OFFSET 40')
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
`OFFSET` skips rows of the result and pairs with `LIMIT` for pagination. An
|
|
747
|
+
offset past the end is an empty page, not an error.
|
|
748
|
+
|
|
749
|
+
A field whose name collides with a reserved word is still addressable — a
|
|
750
|
+
document may legitimately have a `count`, `min`, `value` or `status` field, and
|
|
751
|
+
`WHERE count > 3` reads that field rather than the aggregate:
|
|
752
|
+
|
|
753
|
+
```python
|
|
754
|
+
db.query('FROM metrics WHERE count > 3 ORDER BY count DESC')
|
|
505
755
|
```
|
|
506
756
|
|
|
507
757
|
Combine both time axes:
|
package/nedb.darwin-arm64.node
CHANGED
|
Binary file
|
package/nedb.darwin-x64.node
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/nedb.linux-x64-gnu.node
CHANGED
|
Binary file
|
package/nedb.linux-x64-musl.node
CHANGED
|
Binary file
|
package/nedb.win32-x64-msvc.node
CHANGED
|
Binary file
|
package/nedbd-v2-darwin-arm64
CHANGED
|
Binary file
|
package/nedbd-v2-darwin-x64
CHANGED
|
Binary file
|
package/nedbd-v2-linux-arm64
CHANGED
|
Binary file
|
|
Binary file
|
package/nedbd-v2-linux-x64
CHANGED
|
Binary file
|
package/nedbd-v2-linux-x64-musl
CHANGED
|
Binary file
|
package/nedbd-v2-win-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nedb-engine",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "NEDB \u2014 hash-chained, time-traveling, bi-temporal embedded database with Rust native core. SQL, Redis, MongoDB adapters. Causal Write Provenance. RESP2 wire protocol.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|