@jarenjs/db 0.67.0 → 0.72.2
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/ARCHITECTURE.md +6 -4
- package/README.md +41 -8
- package/docs/HOSTS.md +5 -0
- package/docs/JOBS-FORMAT.md +14 -1
- package/docs/MIGRATION-FORMAT.md +78 -26
- package/docs/MODEL-FORMAT.md +12 -3
- package/package.json +4 -4
- package/src/algebra.js +4 -1
- package/src/cli.js +89 -68
- package/src/dag-job.js +17 -12
- package/src/derive.js +2 -1
- package/src/dialect.js +3 -1
- package/src/dialects/postgres.js +5 -0
- package/src/dialects/sqlite.js +6 -2
- package/src/document-files.js +76 -20
- package/src/document-steps.js +94 -120
- package/src/documents.js +24 -6
- package/src/drivers/node.js +1 -1
- package/src/drivers/postgres.js +15 -4
- package/src/emit.js +4 -0
- package/src/jobs.js +42 -0
- package/src/migrate.js +44 -5
- package/src/plan.js +40 -4
- package/src/store.js +25 -14
- package/types/index.d.ts +32 -7
- package/types/node.d.ts +9 -3
package/ARCHITECTURE.md
CHANGED
|
@@ -258,9 +258,11 @@ re-run unnamed rather than raised.
|
|
|
258
258
|
|
|
259
259
|
Three normalizations, each forced by the wire rather than chosen:
|
|
260
260
|
`int8` and `numeric` arrive as strings (they can exceed a double) and
|
|
261
|
-
become JavaScript numbers
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
become JavaScript numbers. An `int8` outside the safe integer range
|
|
262
|
+
refuses with `JD2005`; allocated collection inserts decode `RETURNING`
|
|
263
|
+
inside a transaction so a refused key rolls its row back. PostgreSQL
|
|
264
|
+
sequences still advance on rollback. `json`/`jsonb` arrive parsed, because
|
|
265
|
+
the client's type parsers are the host's configuration, and the row decoder reads text; and a
|
|
264
266
|
JavaScript boolean is bound as 1 or 0, because a boolean member is 1 or
|
|
265
267
|
0 in this mapping.
|
|
266
268
|
|
|
@@ -529,7 +531,7 @@ member as an array or an object **and nothing else** — §8.14 answers
|
|
|
529
531
|
| `{$le\|$lt: [{$distance: [<path>, <literal>]}, r]}` | the same four comparisons against `circleBounds(probe, r)` | implied | every position within `r` metres lies inside the circle's box, which the kernel computes on the same sphere and the same `EARTH_RADIUS` the engine measures with — so the two cannot disagree by model. `$ge`/`$gt` is NOT promoted: no box narrows "farther than r" |
|
|
530
532
|
| `{$starts-with: [{$geohash: [<path>, k]}, "<cell>"]}`, cell length ≤ k | `<c> IN ("<cell>")` or `<c> >= "<cell>" AND <c> < successor` | **exact** | the column HOLDS `$geohash(row, k)`, and geohash is a prefix code, so a prefix test on the expression is the same test on the column |
|
|
531
533
|
| the same, cell length > k | the cell truncated to k | implied | the column can only confirm its own first k characters |
|
|
532
|
-
| `{$exists: {$index-of: [{$geohash-neighbours: "<cell>"}, {$geohash: [<path>, k]}]}}`, cell length = k | `<c> IN (…the nine cells…)` | **
|
|
534
|
+
| `{$exists: {$index-of: [{$geohash-neighbours: "<cell>"}, {$geohash: [<path>, k]}]}}`, cell length = k | `<c> IS NULL OR <c> IN (…the nine cells…)` | **inexact** | Unbounded members remain candidates so the row predicate preserves the empty-search-item error. An explicit `$exists` guard excludes them safely. Nine cells cover neighbours across a cell boundary; one prefix does not. |
|
|
533
535
|
|
|
534
536
|
**The proof is a proof about BOXES, not about SQL**, so the physical
|
|
535
537
|
mapping (MODEL-FORMAT §2.1, `physical`) does not enter it: the same box
|
package/README.md
CHANGED
|
@@ -297,7 +297,9 @@ no continuation to emit.
|
|
|
297
297
|
`$within`, a `$bbox-intersects`, a bounded `$distance` or a geohash
|
|
298
298
|
probe over such a collection narrows **in SQLite** through the index
|
|
299
299
|
and refines **in the engine**. `$bbox-intersects` and a geohash cell
|
|
300
|
-
test are exact and need no refinement;
|
|
300
|
+
prefix test are exact and need no refinement; neighbourhood membership
|
|
301
|
+
retains unbounded candidates and preserves the engine's empty-item error.
|
|
302
|
+
`$within` and a bounded
|
|
301
303
|
`$distance` push a bounding box the truth table proves they imply,
|
|
302
304
|
and the exact predicate re-runs over the narrowed candidates —
|
|
303
305
|
`explain().prefilters` says which, over what columns, and whether it
|
|
@@ -431,8 +433,10 @@ how.scanNarrative;
|
|
|
431
433
|
The region arrives as a bound parameter: a GeoJSON object is not a
|
|
432
434
|
value any database can bind, so what binds is one edge of its box per
|
|
433
435
|
slot, computed at bind time from the same kernel the stored columns
|
|
434
|
-
came from. `$bbox-intersects` and a geohash
|
|
435
|
-
|
|
436
|
+
came from. `$bbox-intersects` and a geohash prefix no longer than the
|
|
437
|
+
indexed cell are exact and need no refinement; neighbourhood membership
|
|
438
|
+
refines to preserve errors on
|
|
439
|
+
unbounded candidates. `$within` and a bounded `$distance` push the box
|
|
436
440
|
they provably imply and re-run the exact predicate over the narrowed
|
|
437
441
|
candidates. A circle that reaches a pole or crosses the antimeridian
|
|
438
442
|
pushes **nothing** — there is no single box to push — and the answer is
|
|
@@ -688,6 +692,15 @@ opinion: <!--fact:vector.ceiling-->5.142 ns per vector component — one query r
|
|
|
688
692
|
Past that this design is the wrong tool and no margin changes it; what
|
|
689
693
|
lies beyond is an approximate index, and this store does not have one.
|
|
690
694
|
|
|
695
|
+
The [labelled retrieval comparison](../../benchmark/README.md#labelled-recall-and-repeated-refinement)
|
|
696
|
+
also scores an optional storage adapter with approximate candidates against
|
|
697
|
+
exact cosine on cached live embeddings. The AI ledger reports the adapter's
|
|
698
|
+
algorithm, exhaustive flag and returned candidate count, validates candidate
|
|
699
|
+
identity/vector shape, and re-scores candidates itself. The DB ledger recipe
|
|
700
|
+
continues to normalize as an exact adapter. No ANN runtime dependency is added.
|
|
701
|
+
|
|
702
|
+
Index decision: <!--fact:recall.annDecision-->0/6 contender rows cleared all bars; retain exact. Required exact-top-10 recall ≥ 0.95, p95 speedup ≥ 2×, and a measured exact p95 ≥ 100 ms. The largest reference corpus contains 5183 documents; scale beyond it remains unmeasured.<!--/fact-->
|
|
703
|
+
|
|
691
704
|
**One document, three executors, proven to agree.** As with the spatial
|
|
692
705
|
family, the k-nearest shapes of a committed corpus
|
|
693
706
|
(`test/json/fixtures/vector-corpus.json`) run through the JavaScript
|
|
@@ -1028,6 +1041,10 @@ shutdown limits, the browser persistence matrix and measured latency/memory loss
|
|
|
1028
1041
|
and `worker.stop()` quiesces every claim, renewal, checkpoint and
|
|
1029
1042
|
settlement before it resolves, so closing the store releases the
|
|
1030
1043
|
database file deterministically.
|
|
1044
|
+
DAG jobs inspect persisted workflow/input/task identity before reading
|
|
1045
|
+
checkpoint values. `jobs.reset(id, { expectedGeneration })` explicitly
|
|
1046
|
+
discards an inactive run's checkpoints and restarts it under a new fence;
|
|
1047
|
+
live leases and stale observations refuse (JOBS-FORMAT §10).
|
|
1031
1048
|
- **The browser** (`@jarenjs/db/wasm`): the same store, the same
|
|
1032
1049
|
queries, the same live updates run on the official SQLite wasm build
|
|
1033
1050
|
over a probed persistence ladder: isolated SharedArrayBuffer OPFS,
|
|
@@ -1072,13 +1089,13 @@ disagree.
|
|
|
1072
1089
|
<!--fact:live.eventTimeTable-->
|
|
1073
1090
|
| view | maintained | re-run | ratio |
|
|
1074
1091
|
|---|---:|---:|---:|
|
|
1075
|
-
| bucket (60 s ladder, mean), 1000 rows |
|
|
1076
|
-
| rolling (5 min window, mean), 1000 rows |
|
|
1077
|
-
| bucket (60 s ladder, mean), 10000 rows | 136 µs |
|
|
1078
|
-
| rolling (5 min window, mean), 10000 rows | 13.
|
|
1092
|
+
| bucket (60 s ladder, mean), 1000 rows | 123 µs | 911 µs | 7.4× |
|
|
1093
|
+
| rolling (5 min window, mean), 1000 rows | 411 µs | 3.67 ms | 8.9× |
|
|
1094
|
+
| bucket (60 s ladder, mean), 10000 rows | 136 µs | 8.12 ms | 59.6× |
|
|
1095
|
+
| rolling (5 min window, mean), 10000 rows | 13.4 ms | 60.9 ms | 4.5× |
|
|
1079
1096
|
<!--/fact-->
|
|
1080
1097
|
|
|
1081
|
-
The gain is <!--fact:live.eventTimeBand-->
|
|
1098
|
+
The gain is <!--fact:live.eventTimeBand-->59.6× for the bucket and 4.5× for the rolling at 10,000 readings<!--/fact-->. A bucket
|
|
1082
1099
|
view is nearly flat in the series length, because a write folds one
|
|
1083
1100
|
bucket again and the rest of the ladder is untouched. A rolling view is
|
|
1084
1101
|
not, and the table says so: its answer is one row per reading, so the
|
|
@@ -1335,3 +1352,19 @@ Every subpath a consumer can import, derived from the manifest by
|
|
|
1335
1352
|
| `@jarenjs/db/node-worker` | JavaScript | declared |
|
|
1336
1353
|
| `@jarenjs/db/node-pool` | JavaScript | declared |
|
|
1337
1354
|
<!--/fact-->
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
The [durable AI ledger recipe](../ai/README.md#a-durable-ledger-over-jarenjsdb)
|
|
1358
|
+
exposes optional atomic namespace mutation as well as its base storage and rank
|
|
1359
|
+
capabilities. It reads and publishes through one immediate transaction, using the
|
|
1360
|
+
transaction's synchronous collection facade so another SQLite connection on the
|
|
1361
|
+
same event loop cannot block an awaiting writer. A rejected callback changes
|
|
1362
|
+
neither records nor counters; ledger id minting and guarded multi-record updates
|
|
1363
|
+
use that boundary.
|
|
1364
|
+
|
|
1365
|
+
Document-only migrations also accept named files:
|
|
1366
|
+
`jaren-db documents --migrations migrations --in users=users.json --in events=events.jsonl --out migrated.json`.
|
|
1367
|
+
The output is one atomically published collection bundle; read it with
|
|
1368
|
+
`--format collections`. Assertion plans expose provider, ordered-fold and
|
|
1369
|
+
bounded materialization strategies through `onAssertionPlan`.
|
|
1370
|
+
See [MIGRATION-FORMAT §6 and §11](docs/MIGRATION-FORMAT.md).
|
package/docs/HOSTS.md
CHANGED
|
@@ -6,6 +6,11 @@ capabilities are observed when the connection opens. The common oracle and
|
|
|
6
6
|
lifecycle corpus is `test/db/store-hosts.test.js`; it checks values and coded
|
|
7
7
|
errors across Node, worker, pool, wasm sessions and wasm journal fallback.
|
|
8
8
|
|
|
9
|
+
Concurrent PostgreSQL first opens can race while creating the same collection
|
|
10
|
+
or entity table. Each initialization transaction retries one catalog-creation
|
|
11
|
+
collision after rollback, then re-reads and verifies the winning shape. Ordinary
|
|
12
|
+
constraint failures remain errors, and a repeated collision refuses the open.
|
|
13
|
+
|
|
9
14
|
## Node workers
|
|
10
15
|
|
|
11
16
|
```js
|
package/docs/JOBS-FORMAT.md
CHANGED
|
@@ -390,7 +390,10 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
|
|
|
390
390
|
|
|
391
391
|
**The legacy rule is deterministic, and never reads unknown as equal.**
|
|
392
392
|
A run checkpointed by a release that did not record task identity has
|
|
393
|
-
no `taskVersionsHash`.
|
|
393
|
+
no `taskVersionsHash`. Identity inspection reads only the reserved metadata
|
|
394
|
+
value and probes whether other rows exist; it does not load or parse node
|
|
395
|
+
values. Missing metadata with saved values refuses `JD2069`. Refused
|
|
396
|
+
legacy upgrades leave the original identity untouched. If it recorded no node value yet, there is
|
|
394
397
|
nothing that could be replayed wrongly, so the identity is upgraded in
|
|
395
398
|
place and the run proceeds. If it DID record values, the implementation
|
|
396
399
|
that produced them cannot be confirmed and the resume is refused,
|
|
@@ -471,6 +474,16 @@ a cancellation policy, a retry policy are the host's.
|
|
|
471
474
|
and counts it under `stats().cancellations`; an attempt held by
|
|
472
475
|
another process meets the fence at its next settling call and records
|
|
473
476
|
a loss, as any superseded attempt does.
|
|
477
|
+
- **`reset(id, { expectedGeneration })`** — explicitly discard one inactive
|
|
478
|
+
run's checkpoints and restart its attempts at zero. Read the job's
|
|
479
|
+
`leaseGeneration` first and pass that observation. One transaction
|
|
480
|
+
conditionally updates the job, increments its fence generation and deletes
|
|
481
|
+
its checkpoint rows. A concurrent generation change refuses `JD2066`, a
|
|
482
|
+
live lease `JD2068`, an unknown or completed job `JD2065`; no refusal
|
|
483
|
+
discards values. The next claim recomputes the workflow under current
|
|
484
|
+
identity. Reset does not undo external task effects; their idempotency
|
|
485
|
+
keys remain the host's responsibility. It is root-only administration,
|
|
486
|
+
with the same signal/deadline options as requeue.
|
|
474
487
|
- **`requeue(id)`** — returns a failed, dead, cancelled or lease-expired
|
|
475
488
|
job to `pending` at the clock's instant (the queue's own order). The
|
|
476
489
|
attempt history is KEPT: `attempts` counts every claim the job ever
|
package/docs/MIGRATION-FORMAT.md
CHANGED
|
@@ -107,6 +107,39 @@ and the column entry carries the width the value is packed to:
|
|
|
107
107
|
|
|
108
108
|
## 3. Planning and the widening/narrowing rule
|
|
109
109
|
|
|
110
|
+
### Repairing a legacy spatial member expression
|
|
111
|
+
|
|
112
|
+
SQLite derived spatial columns read members as JSON values, preserving strings
|
|
113
|
+
and booleans as well as geometry objects and coordinate arrays. A legacy column
|
|
114
|
+
declared with `json(jsonb_extract(...))` has different SQL and remains a
|
|
115
|
+
`JD0002` at open; opening never rewrites an existing table. Rebuild those
|
|
116
|
+
derived columns through an ordinary migration. The intermediate model below
|
|
117
|
+
removes only spatial indexes; the two plans are combined into one transaction,
|
|
118
|
+
including any R\*Tree trigger removal and backfill:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
const withoutSpatial = structuredClone(model);
|
|
122
|
+
for (const collection of Object.values(withoutSpatial.collections ?? {})) {
|
|
123
|
+
collection.indexes = (collection.indexes ?? [])
|
|
124
|
+
.filter((index) => !['geohash', 'bbox'].includes(index.derive));
|
|
125
|
+
}
|
|
126
|
+
const remove = planMigration(model, withoutSpatial, { dialect: sqliteDialect }).migration;
|
|
127
|
+
const restore = planMigration(withoutSpatial, model, { dialect: sqliteDialect }).migration;
|
|
128
|
+
const repair = {
|
|
129
|
+
...remove, id: 'spatial-member-json', to: restore.to,
|
|
130
|
+
steps: [...remove.steps, ...restore.steps],
|
|
131
|
+
};
|
|
132
|
+
await migrate({ driver, path }, [...previousMigrations, repair], { baseline, model });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Keep the historical baseline and full migration list. With no previous migrations,
|
|
136
|
+
`baseline` is `model`. The logical model hash stays the same; the explicit
|
|
137
|
+
artifact changes the physical expression. Shadow verification and final shape
|
|
138
|
+
validation run normally, stored documents remain intact, and replay skips the
|
|
139
|
+
recorded repair. The stored-column mapping does not need this repair.
|
|
140
|
+
|
|
141
|
+
### Model differences
|
|
142
|
+
|
|
110
143
|
`planMigration(fromModel, toModel, { dialect, id, derived })` produces
|
|
111
144
|
`{ migration, report }` by diffing the two models' PHYSICAL plans. The
|
|
112
145
|
from-model is the previous model — the previous model FILE, or, under
|
|
@@ -235,25 +268,34 @@ hash of the `baseline` model when no migration has run.
|
|
|
235
268
|
|
|
236
269
|
| strategy | which assertions | what it costs |
|
|
237
270
|
|---|---|---|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
271
|
+
| provider | a collection `$count` that the existing query planner proves native without a typed intermediate schema | one aggregate result, with no assertion document fetch |
|
|
272
|
+
| per-document | an independent, unwindowed FLWOR over `$[*]`, with the default empty-sequence expectation | one keyset batch; fails at the first violation |
|
|
273
|
+
| fold | `$count`, `$sum`, `$avg`, `$min`, `$max` over a partition-independent operand; sequence EBV of an independent FLWOR | one batch plus fixed accumulator state |
|
|
274
|
+
| fold with bounds | `$distinct` over such an operand with an explicit positive `maxDistinct` | one batch plus unique items under cardinality and byte bounds |
|
|
275
|
+
| materialize | global or positional operands, nested scans, unsupported shapes, or unbounded distinct | the collection under `assertionBounds` |
|
|
276
|
+
|
|
277
|
+
Folds feed the operand's sequence items, in original order, into the
|
|
278
|
+
query engine's shared accumulator. Floating-point addition is **not
|
|
279
|
+
associative**: combining batch totals changes answers. The ordered state
|
|
280
|
+
preserves the whole-query result, including singleton arrays, negative
|
|
281
|
+
zero, NaN, mixed-type refusals and Unicode code-point ordering. Sequence
|
|
282
|
+
EBV is checked once across all batches, including an empty source.
|
|
283
|
+
`$[0]`, root-dependent filters and positional bindings never qualify as
|
|
284
|
+
independent merely because they occur inside an aggregate.
|
|
285
|
+
|
|
286
|
+
`classifyAssertion(query, { expect, maxDistinct })` exposes the portable
|
|
287
|
+
strategy. `onAssertionPlan(plan)` reports migration, step, collection,
|
|
288
|
+
strategy, shape, reason and bounds before the assertion reads documents;
|
|
289
|
+
Store dry-run query statements also expose strategy and reason. Provider
|
|
290
|
+
promotion reuses the query planner with no assumed typed columns. Typed
|
|
291
|
+
SQL aggregates still need a trustworthy intermediate schema and proof
|
|
292
|
+
that their numeric/order semantics match; otherwise the ordered fold runs.
|
|
251
293
|
|
|
252
294
|
**A materializing assertion is bounded.** `options.assertionBounds`
|
|
253
295
|
defaults to `{ maxRows: 100000, maxBytes: 67108864 }` and is crossed
|
|
254
296
|
BEFORE the excess is held — the walk stops at the row that would break
|
|
255
297
|
it, refusing `JD2007` (rows) or `JD2076` (bytes) and naming the two
|
|
256
|
-
assertion
|
|
298
|
+
assertion strategies that are answered in batches instead. `null` on either
|
|
257
299
|
member removes that bound, which a caller must ask for: an unbounded
|
|
258
300
|
read nobody declared is exactly what this classification removes. This
|
|
259
301
|
is a deliberate behavior change — a migration that used to read a very
|
|
@@ -265,14 +307,11 @@ hash of the `baseline` model when no migration has run.
|
|
|
265
307
|
transformed | derived | asserted }`, one event per batch), and never
|
|
266
308
|
hold the whole collection in memory. A PER-DOCUMENT assertion — a
|
|
267
309
|
FLWOR over `$[*]` whose `$where` and `$return` read only the binding
|
|
268
|
-
— walks the same batches and fails
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
early, before the data grows. A cross-document assertion that is one
|
|
274
|
-
associative aggregate no longer costs that read at all — see the
|
|
275
|
-
classification table above.
|
|
310
|
+
— with the default empty expectation walks the same batches and fails
|
|
311
|
+
fast. Other assertions follow the classification table above.
|
|
312
|
+
`batchSize` must be a positive safe integer. `maxDistinct` opts into a
|
|
313
|
+
distinct fold and bounds retained unique items; `maxBytes` applies to
|
|
314
|
+
those items. Duplicates do not consume additional cardinality.
|
|
276
315
|
- A transform MUST NOT change a caller-keyed document's key member —
|
|
277
316
|
the key column would go stale; the run refuses (`JD0023`).
|
|
278
317
|
|
|
@@ -510,10 +549,23 @@ jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place
|
|
|
510
549
|
line-delimited, everything else one JSON array) unless `--format` /
|
|
511
550
|
`--out-format` says otherwise, and stdio defaults to JSONL. Input and
|
|
512
551
|
output encodings are independent, so this is also the converter.
|
|
513
|
-
- **
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
552
|
+
- **Named sources share one collection bundle.** Repeat
|
|
553
|
+
`--in users=users.json --in events=events.jsonl --out migrated.json`.
|
|
554
|
+
Output defaults to `--out-format collections`: a standard JSON object
|
|
555
|
+
mapping collection names to arrays, including empty collections.
|
|
556
|
+
Publication is one atomic rename after every collection succeeds.
|
|
557
|
+
Multiple source files cannot use `--in-place`, because separate
|
|
558
|
+
renames cannot give this guarantee. Single-source `--collection`
|
|
559
|
+
still asserts the collection name and refuses a mismatch.
|
|
560
|
+
- **A bundle can be read explicitly** with `--format collections`, then
|
|
561
|
+
replaced using `--in-place --yes`. Bundle input is materialized under
|
|
562
|
+
a whole-file `maxBytes` ceiling before parsing and per-collection
|
|
563
|
+
`maxRows` validation after parsing. JSON and JSONL single-collection
|
|
564
|
+
sources remain streaming. `--max-rows`, `--max-bytes`, and
|
|
565
|
+
`--max-distinct` configure assertion admission too; the first two
|
|
566
|
+
accept `none` to remove their limit deliberately. A materializing
|
|
567
|
+
file run admits rows before retaining them. Physical-step and query
|
|
568
|
+
compilation refusals precede every input read.
|
|
517
569
|
- **`--out` writes elsewhere; `--in-place` replaces the input and
|
|
518
570
|
needs `--yes`.** Either way the documents land in a sibling
|
|
519
571
|
temporary that is renamed over the target only once every document
|
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -379,7 +379,7 @@ rather than degrading silently.
|
|
|
379
379
|
|
|
380
380
|
| capability | value | mapping | drivers |
|
|
381
381
|
|---|---|---|---|
|
|
382
|
-
| `deterministicIndexableFunctions` | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(
|
|
382
|
+
| `deterministicIndexableFunctions` | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(("doc" -> '<path>'))` | `node`, `wasm` |
|
|
383
383
|
| `deterministicIndexableFunctions` | `false` | a **stored column** the store writes on every insert, upsert and patch, computed in JavaScript from the same kernel call | `bun` |
|
|
384
384
|
| `rtree` | `false` | a `derive: 'bbox'` column set that declared `physical: 'rtree'` (§2.1) is planned, created and verified as the **B-tree over its four columns**, and `explain().prefilters[].via` reports `'columns'` beside `store.capabilities.rtree === false` | any build without `ENABLE_RTREE` |
|
|
385
385
|
|
|
@@ -451,9 +451,18 @@ no positions at all, or one whose coordinates are not positions, which
|
|
|
451
451
|
is what a non-finite coordinate becomes: JSON cannot carry `NaN`, so it
|
|
452
452
|
arrives as `null` and is no longer a number.
|
|
453
453
|
|
|
454
|
+
A string also has no position: an untyped member can store WKT or even the
|
|
455
|
+
string `"[1,2]"` without interpreting it as a coordinate array. Virtual and
|
|
456
|
+
stored columns both derive `NULL`. Existing SQLite expressions that passed raw
|
|
457
|
+
SQL strings to `json()` need the explicit column repair described in
|
|
458
|
+
[MIGRATION-FORMAT §3](MIGRATION-FORMAT.md#repairing-a-legacy-spatial-member-expression).
|
|
459
|
+
|
|
454
460
|
The consequence is stated here rather than discovered later: **a row
|
|
455
|
-
whose derived column is `NULL` is not found by
|
|
456
|
-
that column.**
|
|
461
|
+
whose derived column is `NULL` is not found by ordinary predicates pushed to
|
|
462
|
+
that column.** Neighbourhood membership retains those rows as candidates and
|
|
463
|
+
re-runs its predicate: `$index-of` raises for an empty search item, so discarding
|
|
464
|
+
the row would hide that error. Guard the geohash with `$exists` before the
|
|
465
|
+
membership test when unbounded rows should be excluded. For the other spatial predicates the planner promotes
|
|
457
466
|
(ARCHITECTURE.md, "The implied conjunct") that is not a divergence —
|
|
458
467
|
§8.14 measures a value by its representative position, and that
|
|
459
468
|
position is missing in exactly the cases the box is, so `$within`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/db",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.72.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./types/index.d.ts",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"prepack": "npm run build:types"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@jarenjs/core": "^0.
|
|
88
|
-
"@jarenjs/json": "^0.
|
|
89
|
-
"@jarenjs/validate": "^0.
|
|
87
|
+
"@jarenjs/core": "^0.72.2",
|
|
88
|
+
"@jarenjs/json": "^0.72.2",
|
|
89
|
+
"@jarenjs/validate": "^0.72.2"
|
|
90
90
|
},
|
|
91
91
|
"bin": {
|
|
92
92
|
"jaren-db": "./src/cli.js"
|
package/src/algebra.js
CHANGED
|
@@ -57,7 +57,7 @@ export const PLAN_VERSION = 2;
|
|
|
57
57
|
* { p: 'udf', name: string, key: string } |
|
|
58
58
|
* { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
|
|
59
59
|
* n: string }, probe: { box: number[] } | { ext: string } } |
|
|
60
|
-
* { p: 'cellIn', column: string, cells: string[] } |
|
|
60
|
+
* { p: 'cellIn', column: string, cells: string[], keepEmpty?: boolean } |
|
|
61
61
|
* { p: 'cellPrefix', column: string, prefix: string } |
|
|
62
62
|
* { p: 'interval', columns: { start: string, end: string },
|
|
63
63
|
* probe: { from: number, to: number } } |
|
|
@@ -75,6 +75,9 @@ export const PLAN_VERSION = 2;
|
|
|
75
75
|
* — but each is TOTAL through its own `IS NOT NULL`, so a row with no
|
|
76
76
|
* box or no cell answers FALSE rather than SQL's NULL and negation
|
|
77
77
|
* still composes classically.
|
|
78
|
+
* A neighbourhood membership uses `keepEmpty: true` instead: NULL
|
|
79
|
+
* cells remain candidates so its residual preserves the query's error
|
|
80
|
+
* for an empty search item. Such a predicate is never exact.
|
|
78
81
|
*
|
|
79
82
|
* `colCmp` is the same idea one comparison wide: a bound over a
|
|
80
83
|
* DECLARED column, with no `json_type` beside it. The planner builds
|
package/src/cli.js
CHANGED
|
@@ -21,8 +21,10 @@ import {
|
|
|
21
21
|
migrateDocuments, streamDocuments, classifyAssertion,
|
|
22
22
|
} from './index.js';
|
|
23
23
|
import { nodeDriver } from './drivers/node.js';
|
|
24
|
+
import { prepareDocumentRun } from './documents.js';
|
|
25
|
+
import { normalizeAssertionBounds, createAssertionBoundGuard } from './document-steps.js';
|
|
24
26
|
import {
|
|
25
|
-
readDocuments, openAtomicTarget, openStreamTarget, openNullTarget,
|
|
27
|
+
readDocuments, readCollectionBundle, openAtomicTarget, openStreamTarget, openNullTarget,
|
|
26
28
|
formatOf, DOCUMENT_FORMATS,
|
|
27
29
|
} from './document-files.js';
|
|
28
30
|
|
|
@@ -37,7 +39,9 @@ Usage:
|
|
|
37
39
|
jaren-db check --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
|
|
38
40
|
jaren-db shape --model <model>
|
|
39
41
|
jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place --yes | --check)
|
|
40
|
-
[--format json|jsonl] [--out-format json|jsonl]
|
|
42
|
+
[--format json|jsonl|collections] [--out-format json|jsonl|collections]
|
|
43
|
+
[--in collection=file ...] [--collection <name>] [--batch-size <n>]
|
|
44
|
+
[--max-rows <n|none>] [--max-bytes <n|none>] [--max-distinct <n>]
|
|
41
45
|
|
|
42
46
|
A <model> or a migration is a .json file, or a MODULE (.js, .mjs, .cjs —
|
|
43
47
|
or .ts where Node strips types) whose default export, or its 'model' /
|
|
@@ -96,6 +100,7 @@ function parseArgs(argv) {
|
|
|
96
100
|
command: argv[2], from: null, to: null, model: null, store: null,
|
|
97
101
|
baseline: null, migrations: null, id: null, out: null,
|
|
98
102
|
snapshot: null, types: null,
|
|
103
|
+
inputs: [], maxRows: undefined, maxBytes: undefined, maxDistinct: undefined,
|
|
99
104
|
in: null, format: null, outFormat: null, collection: null,
|
|
100
105
|
batchSize: null, inPlace: false, check: false,
|
|
101
106
|
dryRun: false, yes: false, help: false,
|
|
@@ -112,7 +117,10 @@ function parseArgs(argv) {
|
|
|
112
117
|
case '--out': options.out = argv[++i]; break;
|
|
113
118
|
case '--snapshot': options.snapshot = argv[++i]; break;
|
|
114
119
|
case '--types': options.types = argv[++i]; break;
|
|
115
|
-
case '--in': options.in = argv[++i]; break;
|
|
120
|
+
case '--in': options.in = argv[++i]; options.inputs.push(options.in); break;
|
|
121
|
+
case '--max-rows': options.maxRows = argv[++i]; break;
|
|
122
|
+
case '--max-bytes': options.maxBytes = argv[++i]; break;
|
|
123
|
+
case '--max-distinct': options.maxDistinct = argv[++i]; break;
|
|
116
124
|
case '--out-format': options.outFormat = argv[++i]; break;
|
|
117
125
|
case '--format': options.format = argv[++i]; break;
|
|
118
126
|
case '--collection': options.collection = argv[++i]; break;
|
|
@@ -435,94 +443,107 @@ async function commandShape(options) {
|
|
|
435
443
|
*/
|
|
436
444
|
async function commandDocuments(options) {
|
|
437
445
|
if (options.migrations === null) misuse('documents needs --migrations <dir>');
|
|
438
|
-
if (options.
|
|
446
|
+
if (options.inputs.length === 0) misuse('documents needs --in <file> (or - for standard input)');
|
|
447
|
+
if (options.inputs.some((input) => typeof input !== 'string' || input.startsWith('--')))
|
|
448
|
+
misuse('--in requires a file or collection=file');
|
|
449
|
+
const multiple = options.inputs.length > 1 || /^[A-Za-z_][A-Za-z0-9_]*=/.test(options.in);
|
|
450
|
+
if (multiple && options.inPlace)
|
|
451
|
+
misuse('multiple sources require one --out collection bundle; several in-place renames are not atomic');
|
|
439
452
|
const sinks = [options.out !== null, options.inPlace, options.check].filter(Boolean).length;
|
|
440
|
-
if (sinks
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
misuse('documents takes exactly one of --out, --in-place and --check');
|
|
444
|
-
if (options.inPlace && options.in === '-')
|
|
445
|
-
misuse('--in-place needs a file to replace, not standard input');
|
|
446
|
-
if (options.inPlace && !options.yes)
|
|
447
|
-
misuse('--in-place rewrites the input file — pass --yes to confirm, or --out to write elsewhere');
|
|
453
|
+
if (sinks !== 1) misuse('documents takes exactly one of --out <file>, --in-place and --check');
|
|
454
|
+
if (options.inPlace && options.in === '-') misuse('--in-place needs a file to replace, not standard input');
|
|
455
|
+
if (options.inPlace && !options.yes) misuse('--in-place rewrites the input file — pass --yes to confirm, or --out to write elsewhere');
|
|
448
456
|
const batchSize = options.batchSize === null ? 500 : Number(options.batchSize);
|
|
449
|
-
if (!Number.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
457
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1) misuse('--batch-size must be a positive integer');
|
|
458
|
+
const declared = {};
|
|
459
|
+
for (const name of ['maxRows', 'maxBytes', 'maxDistinct']) {
|
|
460
|
+
if (options[name] === undefined) continue;
|
|
461
|
+
declared[name] = options[name] === 'none' ? null : Number(options[name]);
|
|
462
|
+
}
|
|
463
|
+
let assertionBounds;
|
|
464
|
+
try { assertionBounds = normalizeAssertionBounds(declared); }
|
|
465
|
+
catch (error) { misuse(error.message); }
|
|
466
|
+
const inFormat = options.format;
|
|
467
|
+
const bundleInput = inFormat === 'collections';
|
|
468
|
+
if (inFormat !== null && ![...DOCUMENT_FORMATS, 'collections'].includes(inFormat))
|
|
469
|
+
misuse('--format must be one of json, jsonl or collections');
|
|
470
|
+
if (bundleInput && multiple) misuse('--format collections takes one bundle file');
|
|
456
471
|
const target = options.inPlace ? options.in : options.out;
|
|
457
472
|
const toStdout = target === '-';
|
|
458
|
-
const outFormat = options.outFormat
|
|
459
|
-
?? (options.check || toStdout ? inFormat : formatOf(/** @type {string} */ (target)));
|
|
460
|
-
if (!DOCUMENT_FORMATS.includes(outFormat))
|
|
461
|
-
misuse(`--out-format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${outFormat}'`);
|
|
462
|
-
if (!fromStdin && !fs.existsSync(options.in)) fail(`no such file: '${options.in}'`);
|
|
463
|
-
|
|
464
473
|
const migrations = await loadMigrationsDir(options.migrations);
|
|
465
474
|
if (migrations.length === 0) fail(`no migration documents in '${options.migrations}'`);
|
|
466
|
-
|
|
467
|
-
// A document file holds ONE collection. The migrations say which:
|
|
468
|
-
// every document step must name it, or this chain cannot be applied to
|
|
469
|
-
// a file at all — running only the steps that match would leave the
|
|
470
|
-
// rest silently unapplied, which is the one outcome a migration runner
|
|
471
|
-
// may never produce.
|
|
472
475
|
const documentSteps = migrations.flatMap((migration) => migration.steps)
|
|
473
476
|
.filter((step) => step.kind === 'jslt' || step.kind === 'query');
|
|
474
477
|
const named = [...new Set(documentSteps.map((step) => step.collection))];
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
478
|
+
const files = {};
|
|
479
|
+
if (multiple) {
|
|
480
|
+
if (options.collection !== null) misuse('named --in sources already declare their collections');
|
|
481
|
+
for (const input of options.inputs) {
|
|
482
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(input);
|
|
483
|
+
if (match === null) misuse('each named --in must be collection=file');
|
|
484
|
+
if (Object.hasOwn(files, match[1])) misuse(`duplicate input collection '${match[1]}'`);
|
|
485
|
+
Object.defineProperty(files, match[1], { value: match[2], enumerable: true });
|
|
486
|
+
}
|
|
487
|
+
if (Object.values(files).filter((file) => file === '-').length > 1) misuse('standard input can supply only one collection');
|
|
488
|
+
}
|
|
489
|
+
else if (!bundleInput) {
|
|
490
|
+
if (named.length > 1) fail(`a document file holds one collection, but these migrations touch ${named.join(', ')}; supply --in collection=file for every collection, or --format collections`);
|
|
491
|
+
const name = named[0] ?? options.collection ?? 'documents';
|
|
492
|
+
if (options.collection !== null && options.collection !== name) misuse(`--collection names '${options.collection}', but these migrations touch '${name}'`);
|
|
493
|
+
Object.defineProperty(files, name, { value: options.in, enumerable: true });
|
|
494
|
+
}
|
|
495
|
+
const runOptions = { batchSize, assertionBounds };
|
|
496
|
+
// Physical and compilation refusals precede every document read, including
|
|
497
|
+
// the materializing route. A bundle's actual collection inventory is checked
|
|
498
|
+
// again after its bounded parse.
|
|
499
|
+
prepareDocumentRun(bundleInput ? named : Object.keys(files), migrations, runOptions);
|
|
500
|
+
let state;
|
|
501
|
+
if (bundleInput) state = await readCollectionBundle(options.in === '-' ? process.stdin : options.in, assertionBounds);
|
|
502
|
+
const names = bundleInput ? Object.keys(state) : Object.keys(files);
|
|
503
|
+
prepareDocumentRun(names, migrations, runOptions);
|
|
504
|
+
const outFormat = options.outFormat ?? (multiple || bundleInput ? 'collections'
|
|
505
|
+
: options.check || toStdout ? inFormat ?? (options.in === '-' ? 'jsonl' : formatOf(options.in)) : formatOf(target));
|
|
506
|
+
if (!['json', 'jsonl', 'collections'].includes(outFormat)) misuse('--out-format must be json, jsonl or collections');
|
|
507
|
+
if (names.length > 1 && outFormat !== 'collections') misuse('multiple collections require --out-format collections');
|
|
508
|
+
const materializes = bundleInput || documentSteps.some((step) => step.kind === 'query'
|
|
509
|
+
&& classifyAssertion(step.assert, { expect: step.expect, maxDistinct: assertionBounds.maxDistinct }).strategy === 'materialize');
|
|
510
|
+
const sources = Object.fromEntries(Object.entries(files).map(([name, file]) => [name,
|
|
511
|
+
readDocuments(file === '-' ? process.stdin : file, inFormat ?? (file === '-' ? 'jsonl' : formatOf(file)))]));
|
|
496
512
|
let sink;
|
|
497
513
|
if (options.check) sink = openNullTarget();
|
|
498
|
-
else if (toStdout) sink = openStreamTarget(process.stdout, outFormat);
|
|
499
|
-
else sink = await openAtomicTarget(
|
|
500
|
-
|
|
514
|
+
else if (toStdout) sink = openStreamTarget(process.stdout, outFormat, { collections: names });
|
|
515
|
+
else sink = await openAtomicTarget(target, outFormat, { collections: names });
|
|
501
516
|
try {
|
|
502
517
|
let report;
|
|
503
518
|
if (materializes) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
519
|
+
if (!bundleInput) {
|
|
520
|
+
state = {};
|
|
521
|
+
for (const name of names) {
|
|
522
|
+
const documents = [], guard = createAssertionBoundGuard(assertionBounds, name, 'the file runner');
|
|
523
|
+
for await (const document of sources[name]) { guard.admit(document); documents.push(document); }
|
|
524
|
+
Object.defineProperty(state, name, { value: documents, enumerable: true });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const out = await migrateDocuments(state, migrations, runOptions);
|
|
507
528
|
report = out.report;
|
|
508
|
-
for (const document of out.documents[
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
report = await streamDocuments({ [collection]: readDocuments(source(), inFormat) },
|
|
512
|
-
migrations, { batchSize, write: (name, document) => sink.write(document) });
|
|
529
|
+
for (const name of names) for (const document of out.documents[name]) await sink.write(document, name);
|
|
513
530
|
}
|
|
531
|
+
else report = await streamDocuments(sources, migrations,
|
|
532
|
+
{ ...runOptions, write: (name, document) => sink.write(document, name) });
|
|
514
533
|
const written = await sink.commit();
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
534
|
+
for (const name of names) {
|
|
535
|
+
const counts = report.counts[name] ?? { read: 0, transformed: 0, asserted: 0 };
|
|
536
|
+
console.log(`${options.check ? 'checked' : 'migrated'} '${name}': ${counts.read} read, `
|
|
537
|
+
+ `${counts.transformed} transformed, ${counts.asserted} asserted (${report.strategy[name]})`);
|
|
538
|
+
}
|
|
519
539
|
console.log(`applied: ${report.applied.join(', ')}`);
|
|
520
540
|
if (options.check) console.log('checked only — nothing was written');
|
|
521
541
|
else if (toStdout) console.log(`wrote ${written.documents} document(s) to standard output`);
|
|
522
542
|
else console.log(`wrote ${written.documents} document(s) to ${target} (${written.bytes} bytes)`);
|
|
523
543
|
}
|
|
524
544
|
catch (error) {
|
|
525
|
-
await sink.abort();
|
|
545
|
+
try { await sink.abort(); }
|
|
546
|
+
catch (cleanupError) { error.cleanupError = cleanupError; }
|
|
526
547
|
return fail(error.message);
|
|
527
548
|
}
|
|
528
549
|
}
|