@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.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/docs/LIVE-FORMAT.md
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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 (
|
|
238
|
-
|
|
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 —
|
|
357
|
-
|
|
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)
|
|
371
|
-
|
|
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
|
|
398
|
-
|
|
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)
|
|
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.
|
package/docs/MIGRATION-FORMAT.md
CHANGED
|
@@ -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.
|
|
170
|
-
|
|
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`)
|
|
208
|
-
history table
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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 —
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|