@jarenjs/db 0.56.0 → 0.67.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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
@@ -118,19 +118,85 @@ const records = await store.changesSince(lastSeq); // JD2051 when no log
118
118
  ```
119
119
 
120
120
  `seq` is monotonic; with the log enabled the DATABASE allocates it —
121
- each record's `seq` is `MAX(seq) + 1` computed inside the insert
122
- statement and read back through `RETURNING` so two stores over one
123
- file never collide on the log's key and each sees the other's
124
- sequence continue; without the log it is per-process. `changesSince`
121
+ one statement, inside the write's own transaction, advances a durable
122
+ singleton row (`_jaren_changes_state`, the highest sequence this file
123
+ ever allocated) and reads the new value back through `RETURNING`, and
124
+ the record is then inserted under it so two stores over one file
125
+ never collide on the log's key, each sees the other's sequence continue,
126
+ and a write that rolls back takes its allocation back with its row. The
127
+ state row is engine metadata: it is created beside the log, seeded once
128
+ from an existing file's surviving `MAX(seq)` (0 for a file that never
129
+ held a row; a later open changes nothing), never lowered, never pruned,
130
+ and never a capture, live, model or migration subject. Without the log,
131
+ `seq` is per-process. `changesSince`
125
132
  answers records in the shape observers receive, `collections`
126
133
  included; a cursor that is not a number is a `TypeError`, as is a
127
134
  `retention` that is not a positive integer. Retention is
128
135
  a bounded count (`retention`, default 1000): older rows are pruned in
129
- the same transaction. The log is an ordered, replayable stream
130
- which is what makes a late-joining consumer possible. **Replication
131
- is not built here**, and this log alone does not make it safe: there
132
- is no conflict resolution, no site identity, no causal ordering
133
- across writers. That sentence is the whole claim.
136
+ the same transaction every write deletes the records more than
137
+ `retention` behind the one it just appended, and nothing else prunes
138
+ the log. The log is an ordered, replayable stream which is what
139
+ makes a late-joining consumer possible.
140
+
141
+ **`changesSince` is unbounded, and unsafe for a reconnecting
142
+ consumer.** It answers every surviving record in one array, with no
143
+ limit, no byte bound and no watermark: a consumer whose last `seq` fell
144
+ below the retention floor receives the suffix that happens to survive
145
+ and cannot distinguish "everything you missed" from "some of what you
146
+ missed, and the rest is gone" — it believes itself caught up with a
147
+ hole in its state. It stays as a published member, and it is not the
148
+ supported path for a consumer that reconnects.
149
+
150
+ **The bounded reader: `store.changes`.** Present exactly when the log
151
+ is enabled (`JD2051` otherwise, as `changesSince`).
152
+
153
+ - `changes.bounds()` answers the two watermarks: `earliestAvailable`,
154
+ the earliest surviving sequence (`MIN(seq)` over the log; `null` when
155
+ nothing survives), and `highWatermark`, the highest sequence the FILE
156
+ ever allocated, read from the durable state row — so a log that
157
+ retention emptied, reopened in a new process, still answers
158
+ `{ earliestAvailable: null, highWatermark: N }` and a cursor below `N`
159
+ meets `resetRequired` rather than a plausible empty history. Both are
160
+ the file's facts, never a process counter or a clock; cheap, and what a
161
+ consumer needs before it decides whether its cursor is usable.
162
+ - `changes.page({ after, limit, maxBytes, signal })` answers
163
+ `{ items, next, earliestAvailable, highWatermark, hasMore,
164
+ resetRequired }`. `after` is the last sequence seen and is required:
165
+ there is no legitimate "give me everything" for a change log. The
166
+ page never holds more than `limit` records (default 100, applied as
167
+ SQL `LIMIT`) nor more than `maxBytes` serialised patch bytes,
168
+ accumulated at record boundaries; a single record larger than
169
+ `maxBytes` is the refusal `JD2074` without advancing `next` — the
170
+ same rule and the same implementation an entity page uses
171
+ (MODEL-FORMAT §10.5). `hasMore` is decided by one peek past the
172
+ page; `next` is the sequence to continue from (`after` itself when
173
+ nothing was delivered); `signal` cancels at a record boundary
174
+ (`JD2072`).
175
+ - **`resetRequired: true`** when the record after `after` no longer
176
+ survives — `after + 1 < earliestAvailable` (or the log is empty
177
+ above `after`'s successor). Then `items` is **empty** and `next` is
178
+ **absent**: the refusal is total, because a partial suffix beside a
179
+ reset flag would invite a consumer to use both. The watermarks are
180
+ read after the rows, so a floor that rose during the read can only
181
+ make the verdict stricter, never let a pruned gap pass as a
182
+ continuation.
183
+
184
+ **The consumer's recovery procedure**, in words: keep the last `seq`
185
+ you applied; on reconnect, call `changes.page({ after: lastSeq })` and
186
+ apply pages while `hasMore`, storing `next` as you go; when a page
187
+ answers `resetRequired: true`, stop applying — your state has a hole —
188
+ re-seed it from a full snapshot of the collections you follow, and
189
+ resume paging from that page's `highWatermark`, because every record
190
+ at or below it is already reflected in the snapshot you just took.
191
+ Choosing how much history to keep is the host's decision
192
+ (`retention`); what the reader owes is that when rows go, it reports
193
+ the gap instead of hiding it.
194
+
195
+ **Replication is not built here**, and this log alone does not make
196
+ it safe: there is no conflict resolution, no site identity, no causal
197
+ ordering across writers. The bounded reader with its watermarks and
198
+ its explicit gap is the precondition a replication protocol would be
199
+ built on — not the protocol. That sentence is the whole claim.
134
200
 
135
201
  ## 6. Cross-connection behaviour and non-claims
136
202
 
@@ -168,7 +234,17 @@ live.close();
168
234
  `store.live(document, options)` registers an entity-root document (the
169
235
  multi-entity shape of MODEL-FORMAT §10) the same way. Live queries
170
236
  REQUIRE change capture — the patch stream is the invalidation source —
171
- and registering on a store opened without `capture` is `JD0050`.
237
+ and registering on a store opened without `capture` is `JD0050`. Both
238
+ registrations run under the store gate through their **initial query**
239
+ (MODEL-FORMAT §5.1): a registration made while another transaction is
240
+ open waits for it to settle and initializes from committed rows only, a
241
+ rolled-back row never reaches `result`, and a refused registration
242
+ leaves `stats().liveQueries` unchanged. A registration made from INSIDE
243
+ a transaction view initializes from that transaction's rows and shares
244
+ its fate — kept and maintained on commit, closed on rollback. Once
245
+ registered, the handle is maintained by committed writes alone and takes
246
+ no gate of its own. `changes.page()` takes the same `deadline` every
247
+ other page does (`JD2075` at a record boundary).
172
248
 
173
249
  A producer may hand a registration a CHAIN instead of a document: the
174
250
  `@jarenjs/linq/db` client's `live(chain, options)` passes the chain's
@@ -197,7 +273,10 @@ what the pushdown planner already means by it.
197
273
  | a spatial predicate the planner **refused** (no `derive` index on the member, an untyped member, an unbounded probe) | **re-run on invalidation**, the refusal named — it never translated, so nothing narrows the fetch | the previous result, for diffing |
198
274
  | a `$resample` or `$rolling` document over the collection, with an explicit `eventTime` and a fixed width (§13) | **event-time bucket / rolling state**: rows kept by bucket, or in instant order; only what a write can reach is folded again, through `@jarenjs/core/series` itself | the contributing rows, plus one fold per bucket |
199
275
  | the same document with no `eventTime`, a calendar width, a named zone, a `locf`/`linear` fill, a `first`/`last` aggregate, or a retention that does not cover the window | **re-run on invalidation**, the member that stopped it named (§13.2) | the previous result, for diffing |
200
- | joins, multi-entity roots, graph loads, every entity query | **re-run on invalidation declared, not attempted** in this version | the previous result, for diffing |
276
+ | indexed inner equi-joins and canonical allowing-empty left joins over mapped entity roots | **join dependency maintenance**; point-read changed keys and reevaluate their bounded outer owners | source rows, key indexes and projected tuples, bounded by `maxMaintained` and `maxBytes` |
277
+ | nested entity graph projections with indexed equality edges and unique binding names | **graph dependency maintenance**; a child change refreshes its bounded owners | source rows, reverse key indexes and graph outputs |
278
+ | explicit two-level collection groups with a singular parent key and bounded nested input | **nested-group maintenance**; recompute affected parents through the query engine | source leaves and parent outputs |
279
+ | unindexed/non-equi joins, self joins, explicit entity ordering/windows, object-root documents and load-spec graphs | **re-run on invalidation**, with the dependency or planner reason | previous result for diffing |
201
280
  | anything else: non-translatable predicates, `limit` without `orderBy`, `offset` > 0, windowed aggregates, `@jarenjs/linq`'s nested two-level `groupBy` emission, non-canonical group returns | **re-run on invalidation**, the reason named | the previous result, for diffing |
202
281
 
203
282
  The **physical mapping** of a `derive: 'bbox'` index (MODEL-FORMAT §2.1,
@@ -234,8 +313,8 @@ refuses at registration (`JD0051`), the same shape as capture's
234
313
  demanded session — an application that needs the property can refuse
235
314
  to start.
236
315
 
237
- The **canonical group form** the classifier recognises (and the only
238
- one the linq chain's nested emission re-runs, stated plainly):
316
+ The **canonical group form** the classifier recognises (the linq chain's group-of-groups emission still re-runs; explicit
317
+ nested groups have a separate bounded strategy below):
239
318
 
240
319
  ```json
241
320
  { "$for": { "it": "$[*]" },
@@ -353,8 +432,11 @@ Decided by a platform fact: OPFS synchronous access handles are
353
432
  all, so "one connection per tab" is not available and never will be.
354
433
  Therefore:
355
434
 
356
- - ONE owning context holds the sole connection — a `SharedWorker`
357
- where available, else a leader tab elected via `navigator.locks`
435
+ - ONE owning context holds the sole connection — the first tab's
436
+ dedicated worker to install the OPFS access-handle pool owns it (the
437
+ pool is exclusive by construction) and holds a `navigator.locks` lock
438
+ for its lifetime so a later tab can tell a busy owner from no owner
439
+ (a `BroadcastChannel` ping is the fallback where locks are absent) —
358
440
  and every other tab is a client;
359
441
  - queries, writes and the patch stream travel between clients and the
360
442
  owner over `BroadcastChannel` / `MessagePort`; a client's live query
@@ -367,8 +449,37 @@ Therefore:
367
449
  Node and Bun present the same API with no channel at all — the store
368
450
  is its own owner, and application code is identical everywhere. The
369
451
  in-browser proof of this topology (delivery across real tabs, the
370
- refusal, reload survival) belongs to the browser-driver order and its
371
- Playwright suite; this section is the decided contract it implements.
452
+ refusal, reload survival) is the website's Playwright suite over the
453
+ `#/data` studio; this section is the decided contract it implements.
454
+
455
+ The wasm session adapter performs an actual disposable create/attach/changeset/delete
456
+ probe. It declares sessions only after success; `sessionReason` explains journal
457
+ fallback. Changeset bytes are detached from wasm-owned memory before transfer, and
458
+ capture cleanup deletes every session even on rollback or connection close.
459
+
460
+ The studio probes isolated SharedArrayBuffer OPFS, header-free SAH-pool OPFS,
461
+ atomic IndexedDB snapshots, then visibly non-durable memory. IndexedDB snapshots
462
+ require exclusive ownership and acknowledge writes after atomic version replacement.
463
+ They expose no synchronous/live surface; the Store pane explicitly refreshes after
464
+ writes. Failed snapshot persistence invalidates the connection without publishing
465
+ partial state. [Execution hosts](HOSTS.md) specifies bounds and the observed matrix.
466
+
467
+ **The browser boot is a closed protocol.** Reaching an owner, a client
468
+ or a standalone memory store passes through five named stages —
469
+ `worker-start`, `sqlite-init`, `vfs-acquire`, `topology`,
470
+ `store-open` — and every attempt ends in exactly one of two states:
471
+ ready, or a stable failure record `{ code: 'DATA_BOOT', stage, message }`
472
+ naming the stage that failed. Each stage carries its own budget, so a
473
+ stage that never settles fails under its own name rather than under an
474
+ outer deadline that cannot say which resource to release; an OPFS pool
475
+ that is absent advances to the next persistence probe; an existing owner
476
+ produces the `client` answer above, while a pool install that hangs is a `vfs-acquire` failure
477
+ and never masquerades as absence. A failed attempt releases everything it
478
+ created — worker, port client, channel, listeners, timers — before the
479
+ page hears of it, so a retry (or a reload) starts clean, and the page
480
+ shows the stage and offers the retry. The stage runner is
481
+ `packages/website/src/lib/boot-stages.js`; the site's transport and its
482
+ owner worker are the two halves that run it.
372
483
 
373
484
  ## 12. Lifecycle, bounds, and non-goals
374
485
 
@@ -394,13 +505,14 @@ ERRORING rather than degrading (the D14 rule — the bound is printed):
394
505
  delete-correctness, and a count over a table larger than the bound
395
506
  is a conscious `maxMaintained` raise, not a silent one.
396
507
 
397
- Non-claims, in one place: no incremental joins (re-run is the declared
398
- strategy), no cross-connection invalidation (§6's `data_version` is
508
+ Non-claims, in one place: no maintenance of unindexed or non-equality joins,
509
+ no cross-connection invalidation (§6's `data_version` is
399
510
  the signal), no maintenance over asynchronous connections —
400
511
  `capabilities.live` is `false` there and a registration is `JD0051`
401
512
  naming the reason, because maintenance point-reads rows synchronously
402
513
  inside delivery (the wasm driver's oo1 API is synchronous, which is
403
- why the browser has live queries at all) no replication, and no ordering guarantee for
514
+ why the browser has live queries at all). Replication is specified separately in
515
+ [REPLICATION-FORMAT](REPLICATION-FORMAT.md). There is no ordering guarantee for
404
516
  unordered queries beyond §9's determinism.
405
517
 
406
518
  ## 13. Event time
@@ -508,3 +620,40 @@ holds a shuffled stream of inserts, in-place updates, instant moves and
508
620
  deletes against `resampleSeries` / `rollingSeries` over the whole
509
621
  collection after each one, which is the only oracle that cannot drift
510
622
  with the implementation.
623
+
624
+
625
+ ## Bounded joins, graph projections and nested groups
626
+
627
+ `join`, `graph` and `nested-group` strategies charge their source rows and
628
+ result entries to `live.maxMaintained`, and serialized input/output payloads to
629
+ `live.maxBytes` (default 4 MiB). These credits bound cached payloads rather than
630
+ claiming to measure JavaScript heap overhead. Initialization uses a limited
631
+ source read; updates read changed keys, then visit cached indexed dependencies.
632
+ Bounds are checked while caches grow. Overflow is `JD2060`, emits one error,
633
+ closes the subscription and releases dependency caches. It never relabels an
634
+ unbounded query as incremental.
635
+
636
+ Entity equality columns need a primary-key prefix, declared index or mapped
637
+ foreign-key index. Every binding needs a key and a distinct root. The projected
638
+ identity is the tuple of source identities, so duplicate projected values remain
639
+ distinct. Default entity result order follows physical row insertion order;
640
+ point reads preserve that order even when a key is deleted and reinserted. A
641
+ left join uses a canonical `$allowing-empty` binding over an equality-filtered
642
+ inner subquery. Its absent child can be defaulted to null. Graph projections
643
+ embed equality-filtered child queries in a single outer row's return object.
644
+ Global-root reads outside those bindings re-run because changing one row can
645
+ change every projected graph.
646
+
647
+ `dependencyReads`, `refreshedRoots` and `refreshedGroups` expose the work done.
648
+ `dependencyReads` counts logical changed-row reads; a row-position lookup is an
649
+ additional statement. No full query reruns occur under these strategy labels.
650
+ Materializing and diffing the final bounded output still costs work proportional
651
+ to its size. See the equal-correctness [measurements](REPLICATION-FORMAT.md#measurements)
652
+ for startup and high-fan-out losses beside selective wins.
653
+
654
+ Two-level grouping currently accepts an explicit parent `$groupby` over a
655
+ singular member, with one nested group over that parent's bound row sequence.
656
+ Count, sum, average, minimum and maximum recompute from only the affected
657
+ parent's bounded leaves. An offset, an unsupported operator, a global input to
658
+ the nested group, or a group-of-groups LINQ emission remains a named rerun.
659
+ Replicated writes enter the same committed capture stream as local writes.
@@ -166,8 +166,12 @@ store untouched.
166
166
 
167
167
  The shadow runs over an empty data set; the real-data facts (the
168
168
  widening check, key consistency, the assertions over real rows) run on
169
- the real store inside its transaction. The shadow registers the same
170
- functions as the real run: `migrate(…, { registerFunctions })` runs on
169
+ the real store inside its transaction. Batched transforms and validation
170
+ include every row identity, including negative integer primary keys;
171
+ target-schema validation visits every declared entity, even after an
172
+ empty entity table.
173
+ The shadow registers the same functions as the real run:
174
+ `migrate(…, { registerFunctions })` runs on
171
175
  the shadow, the real and the reference connections before any DDL
172
176
  (§10), so a hand-created index over a registered deterministic
173
177
  function neither fails the shadow nor is silently dropped by it.
@@ -204,26 +208,115 @@ hash of the `baseline` model when no migration has run.
204
208
  an empty history, so a dry run may be pointed at a production
205
209
  database and leave its file byte-identical. The API default is to
206
210
  run; a CLI SHOULD default to the dry run.
207
- - `migrationStatus` (and the CLI's `status`/`check`) create the empty
208
- history table on a database that has none the one write a reading
209
- command makes, so a fresh file answers `applied: (none)` rather than
210
- a missing-table error. This is the one place the two differ: a dry
211
- run reports the same state and writes nothing at all.
211
+ - `migrationStatus` (and the CLI's `status`/`check`) write nothing
212
+ either: the history table is probed, never created, and an absent one
213
+ reads as an empty history, so a fresh file answers `applied: (none)`
214
+ and stays byte for byte what it was. The history table is created by
215
+ the real run alone, before its first migration is recorded.
212
216
  - Each pending migration runs in ONE exclusive transaction
213
217
  (`BEGIN IMMEDIATE` on SQLite — concurrent writers wait or time out
214
218
  under the busy timeout) with a savepoint per step; any failure rolls
215
219
  back the whole migration including its earlier steps. Where a driver
216
220
  cannot open exclusively, the transaction still isolates; the busy
217
221
  policy of MODEL-FORMAT §4 governs contention.
222
+ - A run is cancellable: `migrate(target, migrations, { signal,
223
+ deadline })` checks both BETWEEN migrations, between steps and
224
+ between the batches of a data step — never inside a statement, which
225
+ runs to its end — with the deadline read against `options.runtime`'s
226
+ clock. An abort is `JD2080` and a passed deadline `JD2075`; the
227
+ migration in flight rolls back whole (its savepoints, its history
228
+ row), the migrations already committed stand, and a rerun resumes
229
+ from the recorded position. The shadow replay is cancellable at the
230
+ same boundaries. `migrationStatus` refuses a call already cancelled
231
+ or past its deadline before it opens anything.
232
+ - **An assertion is classified before it runs, and the classification
233
+ decides what it costs.** One classifier answers for every host — a
234
+ Store, an array, a file — so they cannot disagree about the price:
235
+
236
+ | strategy | which assertions | what it costs |
237
+ |---|---|---|
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.
251
+
252
+ **A materializing assertion is bounded.** `options.assertionBounds`
253
+ defaults to `{ maxRows: 100000, maxBytes: 67108864 }` and is crossed
254
+ BEFORE the excess is held — the walk stops at the row that would break
255
+ it, refusing `JD2007` (rows) or `JD2076` (bytes) and naming the two
256
+ assertion shapes that are answered in batches instead. `null` on either
257
+ member removes that bound, which a caller must ask for: an unbounded
258
+ read nobody declared is exactly what this classification removes. This
259
+ is a deliberate behavior change — a migration that used to read a very
260
+ large collection whole now refuses until its bound is raised or its
261
+ assertion is rewritten.
218
262
  - JSLT steps walk the collection in bounded batches
219
263
  (`options.batchSize`, default 500) ordered by row identity, report
220
- progress through `options.onProgress`, and never hold the whole
221
- collection in memory. Assertion steps read the whole collection into
222
- one array a documented cost; keep assertions early, before the
223
- data grows.
264
+ progress through `options.onProgress` (`{ migration, collection,
265
+ transformed | derived | asserted }`, one event per batch), and never
266
+ hold the whole collection in memory. A PER-DOCUMENT assertion a
267
+ 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.
224
276
  - A transform MUST NOT change a caller-keyed document's key member —
225
277
  the key column would go stale; the run refuses (`JD0023`).
226
278
 
279
+ ### 6.1 Running without a database
280
+
281
+ A migration's `jslt` and `query` steps act on DOCUMENTS, so they do not
282
+ need tables. Two surfaces run them against documents a caller already
283
+ holds, sharing one implementation of what a step means with the Store —
284
+ the same transform rule, the same key rule, the same classification of
285
+ an assertion, the same refusals in the same words.
286
+
287
+ - `migrateDocuments({ collection: [...] }, migrations, options)` answers
288
+ `{ documents, report }`. The source is REWINDABLE, so every step runs
289
+ over the whole collection before the next begins, exactly as a Store
290
+ runs it. This is what makes its answer — and its refusal, on the same
291
+ step — identical to the Store's for the same documents.
292
+ - `streamDocuments({ collection: iterable }, migrations, { write })`
293
+ walks a source that can be read only once, writing each document out
294
+ as it finishes. The input is consumed exactly once and nothing beyond
295
+ one batch is held, so a collection larger than memory still migrates.
296
+
297
+ Both refuse, BEFORE asking for the first document, any step this host
298
+ cannot honour (`JD0023`):
299
+
300
+ | Step kind | Without a database |
301
+ |---|---|
302
+ | `jslt`, `query` | runs |
303
+ | `ddl`, `sql`, `rebuild`, `derive` | refused by name — no tables to change |
304
+
305
+ A step naming a collection the caller did not supply is refused the same
306
+ way. Nothing is half-applied: a runner without a transaction cannot take
307
+ a partial write back, so the whole refusal happens before the first read.
308
+
309
+ Two limits are the single pass's, and are stated rather than hidden:
310
+
311
+ - A CROSS-DOCUMENT assertion needs every document at once, which one
312
+ pass does not hold. `streamDocuments` refuses it by name; run that
313
+ collection through `migrateDocuments`, whose source it can re-read.
314
+ - When two different steps would each refuse, `migrateDocuments` and the
315
+ Store name the EARLIER step, because each step finishes before the
316
+ next begins. `streamDocuments` carries a batch through every step, so
317
+ it can name the later one. Both refuse, with the same code and the
318
+ same words; only which step is named can differ.
319
+
227
320
  ## 7. Non-goals
228
321
 
229
322
  - **Down migrations are not shipped in 0.1.** A JSLT transform is not
@@ -247,6 +340,7 @@ hash of the `baseline` model when no migration has run.
247
340
  | `JD0021` | the migration is missing a required data transform |
248
341
  | `JD0022` | an applied migration disagrees with the history record |
249
342
  | `JD0023` | a migration step failed |
343
+ | `JD0024` | a document source or target could not be read or written |
250
344
 
251
345
  These live in the same runtime `DB_CODES` table as the storage codes
252
346
  (MODEL-FORMAT §7); the union of both documents is proven in sync with
@@ -351,13 +445,17 @@ jaren-db status --model <model> --store <db> --baseline <model> [--migrations
351
445
  jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
352
446
  jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
353
447
  jaren-db shape --model <model>
448
+ jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place --yes | --check)
449
+ [--format json|jsonl] [--out-format json|jsonl] [--collection <name>]
450
+ [--batch-size <n>]
354
451
  ```
355
452
 
356
453
  - **A model or a migration is a `.json` file or a MODULE.** `--model`,
357
454
  `--from`, `--to` and `--baseline` accept a `.json` file or a module
358
455
  (`.js`, `.mjs`, `.cjs` — and `.ts` where the host strips types: Node
359
456
  ≥ 24 does by default, and `--no-strip-types` is refused by name)
360
- loaded with `import()` and read as its `default` export or its `model`
457
+ loaded through `@jarenjs/json/node` the suite's one document loader,
458
+ shared with `jaren-contract` — and read as its `default` export or its `model`
361
459
  export — the model pen's document, or any object whose `toJSON()`
362
460
  emits one; `--migrations <dir>` reads `.json` files and modules
363
461
  (`default` or `migration` — the migration pen's builder), sorted by
@@ -400,13 +498,40 @@ jaren-db shape --model <model>
400
498
  `apply` without `--yes` exits 1 after the printout with nothing
401
499
  applied — a CI job passes `--yes` deliberately, never by default.
402
500
  `apply --dry-run` is the CLI's printout, not §6's `dryRun: true`: it
403
- reads the history the way `status` does — creating the empty table on
404
- a database that has none and does NOT replay the chain on the
405
- shadow, so a draft step still prints instead of refusing. The
406
- shadow's verdict comes with the real `apply`.
501
+ reads the history the way `status` does — probed, never created and
502
+ does NOT replay the chain on the shadow, so a draft step still prints
503
+ instead of refusing. The shadow's verdict comes with the real `apply`.
407
504
  - `status` lists applied/pending and reports drift (§12); on a
408
- database without a history table it creates the empty one (§6).
505
+ database without a history table it creates nothing (§6).
409
506
  - `shape` prints the physical mapping a model produces.
507
+ - `documents` runs a migration's DOCUMENT steps over a file instead of a
508
+ database — §6.1's runners, given a path or stdio. `--in`/`--out` take
509
+ a file or `-`; the encoding follows the extension (`.jsonl`/`.ndjson`
510
+ line-delimited, everything else one JSON array) unless `--format` /
511
+ `--out-format` says otherwise, and stdio defaults to JSONL. Input and
512
+ 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.
517
+ - **`--out` writes elsewhere; `--in-place` replaces the input and
518
+ needs `--yes`.** Either way the documents land in a sibling
519
+ temporary that is renamed over the target only once every document
520
+ has survived every step. A failure — a step, a malformed source, a
521
+ cancelled run — removes the temporary and leaves the target byte for
522
+ byte as it was.
523
+ - **`--check` transforms and validates everything and writes nothing**,
524
+ which is the CI shape: it answers whether this chain still applies
525
+ to this data.
526
+ - **Three exit codes, three meanings:** `0` the chain applies and every
527
+ assertion holds; `1` the run failed (a step refused, the source was
528
+ malformed, the file was missing, a step needs a database); `2` the
529
+ command line itself was wrong (a missing or contradictory flag, an
530
+ unknown format). A script can tell "you asked for the wrong thing"
531
+ from "what you asked for does not hold".
532
+ - The report names the strategy §6.1 chose — `streamed`, or
533
+ `materialized` when a cross-document assertion needs the collection
534
+ at once.
410
535
 
411
536
  ## 12. Drift
412
537