@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
@@ -92,7 +92,10 @@ them).
92
92
  already knows what it wrote. **Less complete, stated plainly**: it
93
93
  cannot see writes made through raw SQL, triggers, or another
94
94
  connection; a journal-mode delete of a row the store never read
95
- emits its `remove` without having seen the old document; and of the
95
+ emits its `remove` without having seen the old document; a keyed
96
+ `put` reads the stored document first, so it emits the `replace`
97
+ session mode emits and a `put` that changes nothing emits nothing;
98
+ and of the
96
99
  database's own `ON DELETE` side effects it reconstructs exactly ONE
97
100
  — join-table membership dying with its entity (read before the
98
101
  delete) — while cascades into CHILD rows (`onDelete: 'cascade'` /
@@ -114,14 +117,86 @@ reads forward:
114
117
  const records = await store.changesSince(lastSeq); // JD2051 when no log
115
118
  ```
116
119
 
117
- `seq` is monotonic; with the log enabled it continues across reopens
118
- (seeded from `MAX(seq)`), without it it is per-process. Retention is
120
+ `seq` is monotonic; with the log enabled the DATABASE allocates it —
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`
132
+ answers records in the shape observers receive, `collections`
133
+ included; a cursor that is not a number is a `TypeError`, as is a
134
+ `retention` that is not a positive integer. Retention is
119
135
  a bounded count (`retention`, default 1000): older rows are pruned in
120
- the same transaction. The log is an ordered, replayable stream
121
- which is what makes a late-joining consumer possible. **Replication
122
- is not built here**, and this log alone does not make it safe: there
123
- is no conflict resolution, no site identity, no causal ordering
124
- 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.
125
200
 
126
201
  ## 6. Cross-connection behaviour and non-claims
127
202
 
@@ -159,7 +234,25 @@ live.close();
159
234
  `store.live(document, options)` registers an entity-root document (the
160
235
  multi-entity shape of MODEL-FORMAT §10) the same way. Live queries
161
236
  REQUIRE change capture — the patch stream is the invalidation source —
162
- 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).
248
+
249
+ A producer may hand a registration a CHAIN instead of a document: the
250
+ `@jarenjs/linq/db` client's `live(chain, options)` passes the chain's
251
+ `toDocument()` and its `explain().bindings` as the externals to exactly
252
+ these two registrations (`store.live` for an entity-root chain,
253
+ `collection.live` for a collection's), so the strategy, the reason and
254
+ the maintenance are this table's — an entity chain re-runs, declared —
255
+ and this document stays the only place they are decided.
163
256
 
164
257
  **This table is normative.** Every row is implemented and tested;
165
258
  nothing outside it is attempted. Classification reads the compiled
@@ -260,7 +353,11 @@ state. `externals` are fixed at registration — a query whose inputs
260
353
  change is a new registration.
261
354
 
262
355
  Maintenance runs synchronously inside patch delivery, in commit
263
- order, on the store's own connection. Writes from ANOTHER connection
356
+ order, on the store's own connection. Delivery is never re-entered: a
357
+ write made from inside an observer or a subscriber commits at once,
358
+ but its record is queued and delivered after the current record has
359
+ reached every consumer, so sibling live views see commits in commit
360
+ order rather than in call-stack order. Writes from ANOTHER connection
264
361
  are invisible to capture (§6) and therefore to live queries; the
265
362
  coarse `dataVersion()` signal and the §11 topology are the honest
266
363
  answers, and re-registering re-reads.
@@ -332,8 +429,11 @@ Decided by a platform fact: OPFS synchronous access handles are
332
429
  all, so "one connection per tab" is not available and never will be.
333
430
  Therefore:
334
431
 
335
- - ONE owning context holds the sole connection — a `SharedWorker`
336
- where available, else a leader tab elected via `navigator.locks`
432
+ - ONE owning context holds the sole connection — the first tab's
433
+ dedicated worker to install the OPFS access-handle pool owns it (the
434
+ pool is exclusive by construction) and holds a `navigator.locks` lock
435
+ for its lifetime so a later tab can tell a busy owner from no owner
436
+ (a `BroadcastChannel` ping is the fallback where locks are absent) —
337
437
  and every other tab is a client;
338
438
  - queries, writes and the patch stream travel between clients and the
339
439
  owner over `BroadcastChannel` / `MessagePort`; a client's live query
@@ -346,8 +446,37 @@ Therefore:
346
446
  Node and Bun present the same API with no channel at all — the store
347
447
  is its own owner, and application code is identical everywhere. The
348
448
  in-browser proof of this topology (delivery across real tabs, the
349
- refusal, reload survival) belongs to the browser-driver order and its
350
- Playwright suite; this section is the decided contract it implements.
449
+ refusal, reload survival) is the website's Playwright suite over the
450
+ `#/data` studio; this section is the decided contract it implements.
451
+
452
+ The wasm session adapter performs an actual disposable create/attach/changeset/delete
453
+ probe. It declares sessions only after success; `sessionReason` explains journal
454
+ fallback. Changeset bytes are detached from wasm-owned memory before transfer, and
455
+ capture cleanup deletes every session even on rollback or connection close.
456
+
457
+ The studio probes isolated SharedArrayBuffer OPFS, header-free SAH-pool OPFS,
458
+ atomic IndexedDB snapshots, then visibly non-durable memory. IndexedDB snapshots
459
+ require exclusive ownership and acknowledge writes after atomic version replacement.
460
+ They expose no synchronous/live surface; the Store pane explicitly refreshes after
461
+ writes. Failed snapshot persistence invalidates the connection without publishing
462
+ partial state. [Execution hosts](HOSTS.md) specifies bounds and the observed matrix.
463
+
464
+ **The browser boot is a closed protocol.** Reaching an owner, a client
465
+ or a standalone memory store passes through five named stages —
466
+ `worker-start`, `sqlite-init`, `vfs-acquire`, `topology`,
467
+ `store-open` — and every attempt ends in exactly one of two states:
468
+ ready, or a stable failure record `{ code: 'DATA_BOOT', stage, message }`
469
+ naming the stage that failed. Each stage carries its own budget, so a
470
+ stage that never settles fails under its own name rather than under an
471
+ outer deadline that cannot say which resource to release; an OPFS pool
472
+ that is absent advances to the next persistence probe; an existing owner
473
+ produces the `client` answer above, while a pool install that hangs is a `vfs-acquire` failure
474
+ and never masquerades as absence. A failed attempt releases everything it
475
+ created — worker, port client, channel, listeners, timers — before the
476
+ page hears of it, so a retry (or a reload) starts clean, and the page
477
+ shows the stage and offers the retry. The stage runner is
478
+ `packages/website/src/lib/boot-stages.js`; the site's transport and its
479
+ owner worker are the two halves that run it.
351
480
 
352
481
  ## 12. Lifecycle, bounds, and non-goals
353
482
 
@@ -375,9 +504,11 @@ ERRORING rather than degrading (the D14 rule — the bound is printed):
375
504
 
376
505
  Non-claims, in one place: no incremental joins (re-run is the declared
377
506
  strategy), no cross-connection invalidation (§6's `data_version` is
378
- the signal), no maintenance over asynchronous connections in this
379
- version (every current driver is synchronous; the browser driver's
380
- order owns that story), no replication, and no ordering guarantee for
507
+ the signal), no maintenance over asynchronous connections
508
+ `capabilities.live` is `false` there and a registration is `JD0051`
509
+ naming the reason, because maintenance point-reads rows synchronously
510
+ inside delivery (the wasm driver's oo1 API is synchronous, which is
511
+ why the browser has live queries at all) — no replication, and no ordering guarantee for
381
512
  unordered queries beyond §9's determinism.
382
513
 
383
514
  ## 13. Event time
@@ -404,7 +535,8 @@ live.stats().watermark; // what it is now
404
535
  `eventTime` is a **closed** member set: `path`, `watermark`,
405
536
  `allowedLateness` (default 0) and `retention`. Anything else — a
406
537
  misspelling, a non-finite epoch, a negative lateness, a `path` that is
407
- not a singular row selector — is `JD0053` at registration, not a member
538
+ not a singular row selector — is `JD0053` at registration (its
539
+ `docPath` names the collection, `/collections/<name>`), not a member
408
540
  quietly ignored. `advance()` refuses a value that is not finite or that
409
541
  goes backwards (a `TypeError`), and it is absent on every view
410
542
  registered without an `eventTime`. An entity document has no collection
@@ -415,7 +547,12 @@ to place rows in and re-runs, so an `eventTime` on `store.live` is
415
547
 
416
548
  Two documents, and only these two shapes: `$resample` and `$rolling`
417
549
  whose series operand is the collection (`"$[*]"`, or a FLWOR over it
418
- whose `$where` narrows and whose `$return` is the bare binding).
550
+ whose `$where` narrows and whose `$return` is the bare binding). A
551
+ spec that spells `at` and `value` explicitly — the spelling the query
552
+ language accepts — is maintained: the view folds with the kernel
553
+ reading the declared instant member and the `value` member the spec
554
+ names, and a `value` selector the view cannot follow re-runs with the
555
+ reason named, never a maintained view that dies on its first fold.
419
556
 
420
557
  - **A bucket view keeps its rows by bucket.** A write touches one bucket
421
558
  — two, when it moves a reading across a boundary — and exactly those
@@ -45,18 +45,29 @@ shape change is a **transformation of values**, not a table rebuild.
45
45
  - `kind: "jslt"` rewrites every document of a collection through a
46
46
  compiled JSLT stylesheet, in batches, inside the migration's
47
47
  transaction. The empty stylesheet (`[]`) is the identity transform.
48
- A step carrying `"draft": true` is a planner placeholder and MUST
49
- refuse to run (`JD0021`) until the author fills it in.
48
+ Over an ENTITY table the stylesheet sees the whole row — the mapped
49
+ columns merged into the document under the TARGET model's mapping
50
+ and what it returns is split back into columns and document by that
51
+ mapping; the key member is kept from the row (a stylesheet that
52
+ omits it loses nothing) and a stylesheet that changes it is
53
+ `JD0023`. A step carrying `"draft": true` is a planner placeholder
54
+ and MUST refuse to run (`JD0021`) until the author fills it in.
50
55
  - `kind: "query"` is an assertion: the query runs over the
51
56
  collection's documents and MUST answer an empty sequence (`expect:
52
57
  "empty"`, the default) or an EBV-true value (`expect: "ebv"`) for
53
58
  the migration to proceed. This is how a migration states its own
54
59
  precondition — "no user has a null email before the NOT NULL
55
- index" — and it is checked on the shadow first.
60
+ index" — and it is checked on the shadow first. Over an entity table
61
+ the assertion reads the same merged rows a `jslt` step sees.
56
62
  - `kind: "derive"` recomputes named STORED derived index columns from
57
63
  the documents already in a collection — the backfill described in
58
64
  §2.1. It is idempotent: a derived value is a pure function of the
59
65
  document, so a replay writes what the first run wrote.
66
+ - `kind: "sql"` executes one rendered DATA statement — a fold of a
67
+ column into the document, a backfill, an `INSERT … SELECT` — §9.4; a
68
+ dry run always prints it with its note.
69
+ - `kind: "rebuild"` is the entity restructure of §10, self-contained:
70
+ the `CREATE` of the new shape, the copy and the index DDL.
60
71
  - Steps are ordered, and the order is the contract.
61
72
 
62
73
  ### 2.1 Derived spatial columns and the backfill
@@ -97,7 +108,11 @@ and the column entry carries the width the value is packed to:
97
108
  ## 3. Planning and the widening/narrowing rule
98
109
 
99
110
  `planMigration(fromModel, toModel, { dialect, id, derived })` produces
100
- `{ migration, report }` by diffing the two models' PHYSICAL plans:
111
+ `{ migration, report }` by diffing the two models' PHYSICAL plans. The
112
+ from-model is the previous model — the previous model FILE, or, under
113
+ the CLI's snapshot discipline (§11), the committed `model.snapshot.json`
114
+ the last `plan` advanced: a database stores shape hashes, never models,
115
+ so the previous shape lives beside the code, where a diff can read it.
101
116
 
102
117
  - An added collection becomes its full CREATE DDL; a removed
103
118
  collection becomes a `DROP TABLE` step whose note says
@@ -107,7 +122,16 @@ and the column entry carries the width the value is packed to:
107
122
  `"x-rename": "oldName"` on the target collection; the planner emits
108
123
  the rename first and rebuilds the indexes (a renamed SQLite table
109
124
  keeps its old index names — probed). Without the hint, a rename is
110
- a drop plus a create and the report says so.
125
+ a drop plus a create and the report says so. The hint is not part of
126
+ the shape: `x-rename` is stripped before the shape hash is computed,
127
+ so a model that keeps carrying a satisfied hint hashes the same as
128
+ one without it and plans nothing — a rename is idempotent across
129
+ `plan` runs. An entity rename carries its join tables with it, by
130
+ the endpoints the mapping records rather than by splitting the
131
+ table's name (an entity name may itself contain `_`); where the
132
+ rename flips the sorted endpoint order the join table is rebuilt
133
+ (create, `INSERT … SELECT`, drop) under its new name and no
134
+ membership is lost.
111
135
  - Added, removed and changed indexes become index DDL — reusing the
112
136
  store's own DDL generator, never a second implementation. A changed
113
137
  generated column (type or path) is a drop plus an add, with its
@@ -123,7 +147,10 @@ and the column entry carries the width the value is packed to:
123
147
  schema through the injected `compileSchema` hook. A document that no
124
148
  longer validates is `JD0021` and the whole migration rolls back — a
125
149
  narrowing without an adequate transform cannot land. A widening
126
- needs no transform, and passes this check by fact.
150
+ needs no transform, and passes this check by fact. For an entity
151
+ the validated document is the whole row — columns merged back under
152
+ the target mapping — so a pure widening of a column-mapped member
153
+ passes and a narrowing of one is caught.
127
154
  - Changing a collection's key declaration is not planned (a rebuild);
128
155
  the planner refuses with a `TypeError` naming the non-goal.
129
156
 
@@ -139,10 +166,15 @@ store untouched.
139
166
 
140
167
  The shadow runs over an empty data set; the real-data facts (the
141
168
  widening check, key consistency, the assertions over real rows) run on
142
- the real store inside its transaction. The model format declares no
143
- UDF-expression indexes, so there is no function set to re-register on
144
- the shadow stated here because a dialect that allowed such indexes
145
- would make the shadow fail on a schema the real store accepts.
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
175
+ the shadow, the real and the reference connections before any DDL
176
+ (§10), so a hand-created index over a registered deterministic
177
+ function neither fails the shadow nor is silently dropped by it.
146
178
 
147
179
  ## 5. History and checksums
148
180
 
@@ -171,23 +203,120 @@ hash of the `baseline` model when no migration has run.
171
203
  otherwise), the physical end shape is verified, and the real-data
172
204
  validation of §3 runs.
173
205
  - `dryRun: true` prints every statement and the affected document
174
- counts, validates the chain on the shadow, and writes NOTHING. The
175
- API default is to run; a CLI SHOULD default to the dry run.
206
+ counts, validates the chain on the shadow, and writes NOTHING — not
207
+ even the history table: it PROBES for one and reads an absent one as
208
+ an empty history, so a dry run may be pointed at a production
209
+ database and leave its file byte-identical. The API default is to
210
+ run; a CLI SHOULD default to the dry run.
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.
176
216
  - Each pending migration runs in ONE exclusive transaction
177
217
  (`BEGIN IMMEDIATE` on SQLite — concurrent writers wait or time out
178
218
  under the busy timeout) with a savepoint per step; any failure rolls
179
219
  back the whole migration including its earlier steps. Where a driver
180
220
  cannot open exclusively, the transaction still isolates; the busy
181
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.
182
262
  - JSLT steps walk the collection in bounded batches
183
263
  (`options.batchSize`, default 500) ordered by row identity, report
184
- progress through `options.onProgress`, and never hold the whole
185
- collection in memory. Assertion steps read the whole collection into
186
- one array a documented cost; keep assertions early, before the
187
- 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.
188
276
  - A transform MUST NOT change a caller-keyed document's key member —
189
277
  the key column would go stale; the run refuses (`JD0023`).
190
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
+
191
320
  ## 7. Non-goals
192
321
 
193
322
  - **Down migrations are not shipped in 0.1.** A JSLT transform is not
@@ -211,6 +340,7 @@ hash of the `baseline` model when no migration has run.
211
340
  | `JD0021` | the migration is missing a required data transform |
212
341
  | `JD0022` | an applied migration disagrees with the history record |
213
342
  | `JD0023` | a migration step failed |
343
+ | `JD0024` | a document source or target could not be read or written |
214
344
 
215
345
  These live in the same runtime `DB_CODES` table as the storage codes
216
346
  (MODEL-FORMAT §7); the union of both documents is proven in sync with
@@ -225,13 +355,13 @@ every row has a shadow-verified test that migrates seeded data:
225
355
  | Change | Strategy |
226
356
  |---|---|
227
357
  | add mapped column (property added, or moved out of the document) | `ALTER TABLE ADD COLUMN` — always nullable (absent reads back absent, MODEL-FORMAT §9.3) — plus a `sql` data step when the property's values already live in the document |
228
- | drop mapped column (property removed, or moved into the document) | fold the column back into the document first (`sql` step) when the property survives; drop its index, then `DROP COLUMN` where SQLite's conditions hold, else rebuild |
358
+ | drop mapped column (property removed, or moved into the document) | fold the column back into the document first (`sql` step) when the property survives — a `NULL` column folds to ABSENT, never to JSON `null`, so §9.3's rule survives the fold, in the rebuild copy too; drop its index, then `DROP COLUMN` where SQLite's conditions hold, else rebuild |
229
359
  | change type / enum CHECK / key / epoch flavor | **rebuild** (§10) |
230
360
  | add or drop an index (`unique`/`index`/version) | plain DDL |
231
- | add or drop a relation (foreign-key column, join table) | foreign keys **rebuild** the holder; join tables create/drop directly |
232
- | entity added / dropped | create / `DROP TABLE` (destructive, named) |
361
+ | add or drop a relation (foreign-key column, join table) | foreign keys **rebuild** the holder — an inferred foreign-key column the target model no longer declares is folded into the document when the target declares the property, else named in `report.lost` and the plan is destructive; join tables create/drop directly |
362
+ | entity added / dropped | create / `DROP TABLE` (destructive, named), children before parents so no foreign key dangles mid-migration |
233
363
  | entity renamed | declared with `x-rename` on the target entity — never inferred; join tables renamed mechanically with their endpoints |
234
- | scalar ⇄ JSONB move (`column: "json"` toggled, shape change) | rebuild + a data step |
364
+ | scalar ⇄ JSONB move (`column: "json"` toggled) | the first two rows: `ADD COLUMN` plus a `sql` lift out of the document, or a `sql` fold plus `DROP COLUMN` — a rebuild only where SQLite cannot drop the column in place |
235
365
 
236
366
  Two rules keep the diff honest:
237
367
 
@@ -279,12 +409,13 @@ the transaction** — a broken reference fails the migration rather
279
409
  than shipping.
280
410
 
281
411
  Two deviations from the cited twelve steps, recorded: (1) the
282
- procedure brackets itself with `PRAGMA foreign_keys=OFF/ON`, which is
283
- a no-op inside a transaction the migration connection never enables
284
- the pragma (SQLite's default is off; `openStore` enables AND verifies
285
- it per connection), so enforcement during the rebuild is off exactly
286
- as the procedure wants, and `foreign_key_check` provides the
287
- guarantee; (2) triggers and views are not re-created because this
412
+ procedure's `PRAGMA foreign_keys=OFF/ON` bracket is honoured
413
+ literally, OUTSIDE the transaction (inside one the pragma is a no-op):
414
+ `node:sqlite` enables enforcement by default, and with it on a parent
415
+ table could not even be dropped, so a migration holding a rebuild
416
+ step turns enforcement off before `BEGIN IMMEDIATE` and back on after
417
+ it settles, and `foreign_key_check` inside the transaction provides
418
+ the guarantee the bracket suspended; (2) triggers and views are not re-created because this
288
419
  store creates none — a hand-added trigger is outside the model and
289
420
  outside the diff, which drift (§12) will name.
290
421
 
@@ -307,24 +438,100 @@ drop the index) on a schema the store accepts.
307
438
  `jaren-db` drives the workflow (mirroring `jaren-emit`):
308
439
 
309
440
  ```
310
- jaren-db plan --from <model> --to <model> [--store <db>] [--id x] [--out file]
311
- jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>]
312
- jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
313
- jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>]
314
- jaren-db shape --model <model>
441
+ jaren-db plan --from <model> --to <model> [--store <db>] [--id x] [--out file]
442
+ jaren-db plan --model <model> [--snapshot <file>] [--store <db>] [--id x] --out file
443
+ jaren-db snapshot --model <model> [--snapshot <file>] [--types <file>]
444
+ jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
445
+ jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
446
+ jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
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>]
315
451
  ```
316
452
 
453
+ - **A model or a migration is a `.json` file or a MODULE.** `--model`,
454
+ `--from`, `--to` and `--baseline` accept a `.json` file or a module
455
+ (`.js`, `.mjs`, `.cjs` — and `.ts` where the host strips types: Node
456
+ ≥ 24 does by default, and `--no-strip-types` is refused by name)
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`
459
+ export — the model pen's document, or any object whose `toJSON()`
460
+ emits one; `--migrations <dir>` reads `.json` files and modules
461
+ (`default` or `migration` — the migration pen's builder), sorted by
462
+ file name. A module whose export is not a document, or whose emission
463
+ is not JSON, fails with the module named. **Modules are pure:** the
464
+ CLI loads every module TWICE (two `import()`s under distinct
465
+ cache-busting queries) and refuses one whose two emissions differ —
466
+ no clock, no env, no randomness — because a migration that hashes
467
+ differently per load can never match its own history.
317
468
  - `plan` diffs two model FILES (a database stores shape hashes, not
318
- models — the from-model is the previous model file); with `--store`
319
- it first verifies the from-model's hash matches the database's
320
- recorded shape.
321
- - `check` is the CI command: exit 1 when migrations are pending OR the
322
- database drifted; 0 in sync.
323
- - `apply` prints every statement before running; destructive steps
324
- (drop table/column, rebuild) require `--yes` or an interactive
325
- confirmation that NAMES what is lost. Default is dry-run + ask.
326
- - `status` lists applied/pending and reports drift (§12).
469
+ models — the from-model is the previous model file), or, with
470
+ `--model`, the committed SNAPSHOT against the model: `--snapshot`
471
+ names it and defaults to `model.snapshot.json` beside the model; a
472
+ model whose shape equals the snapshot's plans nothing and exits 0;
473
+ otherwise the migration is written (`--out`) and the snapshot is
474
+ advanced to the model — without `--out` the plan is printed and the
475
+ snapshot stays, and the CLI says so. With `--store` it first compares
476
+ the from-model's physical shape with the database itself never
477
+ with the history, which would refuse every database that has applied
478
+ a migration.
479
+ - `snapshot` writes the model's snapshot (`--snapshot`, the same
480
+ default) — from the model the store was created with, before the
481
+ first `plan --model`; with `--types <file>` it also writes emit's
482
+ TypeScript declaration for the model (`entityEmitModel` rendered by
483
+ `@jarenjs/emit`, loaded lazily — `@jarenjs/db` does not depend on emit,
484
+ and a host without it is told exactly what `--types` needs), so a
485
+ transform over a JSON snapshot can be typed by annotation. Two runs on
486
+ one input write nothing the second time.
487
+ - `check` is the CI command: exit 1 on an UNPLANNED MODEL CHANGE (a
488
+ snapshot in use whose shape is not the model's — the model moved and
489
+ nobody planned; named as such, never as the database's drift), when
490
+ migrations are pending, OR when the database drifted; 0 in sync.
491
+ `--model` is required — without it drift cannot be measured, and
492
+ `check` refuses rather than print `in sync`. `status` reports the same
493
+ verdict on its `model:` line.
494
+ - `apply` prints every statement, then asks; destructive steps (drop
495
+ table/column, rebuild) print what is lost and ask for that
496
+ separately. `--yes` answers both, `--dry-run` stops after the
497
+ printout. Without an interactive terminal there is nobody to ask, so
498
+ `apply` without `--yes` exits 1 after the printout with nothing
499
+ applied — a CI job passes `--yes` deliberately, never by default.
500
+ `apply --dry-run` is the CLI's printout, not §6's `dryRun: true`: it
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`.
504
+ - `status` lists applied/pending and reports drift (§12); on a
505
+ database without a history table it creates nothing (§6).
327
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.
328
535
 
329
536
  ## 12. Drift
330
537