@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 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, which is the same ceiling SQLite's INTEGER
262
- has; `json`/`jsonb` arrive parsed, because the client's type parsers are
263
- the host's configuration, and the row decoder reads text; and a
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…)` | **exact** | the membership test compares whole strings and the column is exactly one of them. Nine cells, never one: two points ten metres apart can differ in the FIRST character of their cell (D7), so a single prefix is bucketing and only the neighbourhood is proximity |
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; `$within` and a bounded
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 cell test are exact and
435
- need no refinement; `$within` and a bounded `$distance` push the box
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 | 119 µs | 1.09 ms | 9.2× |
1076
- | rolling (5 min window, mean), 1000 rows | 407 µs | 3.86 ms | 9.5× |
1077
- | bucket (60 s ladder, mean), 10000 rows | 136 µs | 10.9 ms | 80.2× |
1078
- | rolling (5 min window, mean), 10000 rows | 13.7 ms | 62.7 ms | 4.6× |
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-->80.2× for the bucket and 4.6× for the rolling at 10,000 readings<!--/fact-->. A bucket
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
@@ -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`. If it recorded no node value yet, there is
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
@@ -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
- | per-document | a FLWOR over `$[*]` whose `$where`/`$return` read only the binding | one keyset batch at a time; fails fast at the first batch that violates |
239
- | fold | exactly one of `$count`, `$sum`, `$min`, `$max` over the root | one batch at a time; each batch is answered by the ENGINE and the partial answers combine |
240
- | materialize | everything else (`$let`, `$distinct`, a nested `$for`, two aggregates) | every document at once, under `assertionBounds` |
241
-
242
- A fold is sound because the operator is associative: the answer over a
243
- collection is the combination of the answers over any partition of it.
244
- Nothing reimplements an operator each batch is evaluated by the same
245
- compiled query the whole-collection path would use, and only the
246
- COMBINE step is written here, so null handling, empty-sequence answers
247
- and type coercions are the engine's. A suite runs every fold shape both
248
- ways, over ten corpora and six partitions, and requires the value and
249
- the verdict to be indistinguishable; a shape that cannot pass it is not
250
- in the set.
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 shapes that are answered in batches instead. `null` on either
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 fast at the first batch that
269
- violates, because its answer over each batch is its answer over the
270
- whole. A cross-document assertion (one that reads the root: `$count:
271
- '$[*]'`, a `$let`, a `$distinct`, a nested `$for`) reads the whole
272
- collection into one array a stated cost; keep such assertions
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
- - **A file holds ONE collection.** The migrations name it; a chain
514
- whose document steps touch more than one cannot be applied to a
515
- file, and is refused rather than partly run. `--collection` asserts
516
- which collection the file holds and refuses a mismatch.
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
@@ -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(jsonb_extract("doc", '<path>'))` | `node`, `wasm` |
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 a predicate pushed to
456
- that column.** For the spatial predicates the planner promotes
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.67.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.67.0",
88
- "@jarenjs/json": "^0.67.0",
89
- "@jarenjs/validate": "^0.67.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] [--collection <name>] [--batch-size <n>]
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.in === null) misuse('documents needs --in <file> (or - for standard input)');
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 === 0)
441
- misuse('documents needs one of --out <file>, --in-place or --check');
442
- if (sinks > 1)
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.isInteger(batchSize) || batchSize < 1)
450
- misuse(`--batch-size must be a positive integer, not '${options.batchSize}'`);
451
-
452
- const fromStdin = options.in === '-';
453
- const inFormat = options.format ?? (fromStdin ? 'jsonl' : formatOf(options.in));
454
- if (!DOCUMENT_FORMATS.includes(inFormat))
455
- misuse(`--format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${inFormat}'`);
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
- if (named.length > 1) {
476
- fail(`a document file holds one collection, and these migrations touch ${named.length} `
477
- + `(${named.join(', ')}) run them against a store, or split the chain so each `
478
- + 'migration touches the collection its file holds');
479
- }
480
- if (options.collection !== null && named.length === 1 && options.collection !== named[0]) {
481
- misuse(`--collection names '${options.collection}', but these migrations touch `
482
- + `'${named[0]}' is this the right file for them?`);
483
- }
484
- // with no document step at all there is no collection to infer; the
485
- // run still proceeds, because a physical step must be REFUSED by name
486
- // rather than reported as a missing collection
487
- const collection = named[0] ?? options.collection ?? 'documents';
488
-
489
- // only a MATERIALIZING assertion needs the collection at once; a
490
- // per-document predicate and an associative aggregate are both
491
- // answered one batch at a time, so they stream
492
- const materializes = documentSteps.some((step) => step.collection === collection
493
- && step.kind === 'query' && classifyAssertion(step.assert).strategy === 'materialize');
494
-
495
- const source = () => (fromStdin ? process.stdin : options.in);
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(/** @type {string} */ (target), outFormat);
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
- const documents = [];
505
- for await (const document of readDocuments(source(), inFormat)) documents.push(document);
506
- const out = await migrateDocuments({ [collection]: documents }, migrations, { batchSize });
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[collection]) await sink.write(document);
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 counts = report.counts[collection] ?? { read: 0, transformed: 0, asserted: 0 };
516
- console.log(`${options.check ? 'checked' : 'migrated'} '${collection}': `
517
- + `${counts.read} read, ${counts.transformed} transformed, ${counts.asserted} asserted `
518
- + `(${report.strategy[collection]})`);
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
  }