@jarenjs/linq 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 (79) hide show
  1. package/ARCHITECTURE.md +227 -0
  2. package/README.md +650 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1221 -0
  5. package/docs/DB-CLIENT.md +882 -0
  6. package/docs/FLOW-PEN.md +1033 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +778 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1092 -0
  12. package/docs/QUERY-PEN.md +1724 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +251 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +255 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +377 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +48 -11
  24. package/src/contract/define.js +282 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +338 -0
  28. package/src/db/handle.js +89 -0
  29. package/src/db/include.js +351 -0
  30. package/src/db/index.js +24 -0
  31. package/src/db/ledger.js +195 -0
  32. package/src/db/live.js +43 -0
  33. package/src/db/membership.js +37 -0
  34. package/src/db/open.js +130 -0
  35. package/src/document.js +143 -13
  36. package/src/effect.js +65 -0
  37. package/src/errors.js +78 -6
  38. package/src/expression.js +463 -36
  39. package/src/federate.js +531 -0
  40. package/src/flow/capture.js +33 -0
  41. package/src/flow/dag.js +316 -0
  42. package/src/flow/fsm.js +323 -0
  43. package/src/flow/index.js +22 -0
  44. package/src/forms/index.js +43 -0
  45. package/src/forms/rules.js +170 -0
  46. package/src/forms/submit.js +177 -0
  47. package/src/index.js +5 -2
  48. package/src/jslt/body.js +226 -0
  49. package/src/jslt/index.js +18 -0
  50. package/src/jslt/rules.js +202 -0
  51. package/src/json-boundary.js +90 -0
  52. package/src/migration/define.js +318 -0
  53. package/src/migration/index.js +15 -0
  54. package/src/migration/steps.js +244 -0
  55. package/src/model/collection.js +273 -0
  56. package/src/model/define.js +125 -0
  57. package/src/model/entity.js +307 -0
  58. package/src/model/index.js +47 -0
  59. package/src/model/relation.js +85 -0
  60. package/src/provider.js +137 -20
  61. package/src/schema/brand.js +31 -0
  62. package/src/schema/builders.js +526 -0
  63. package/src/schema/check.js +29 -0
  64. package/src/schema/emit.js +394 -0
  65. package/src/schema/factories.js +239 -0
  66. package/src/schema/index.js +37 -0
  67. package/src/schema-of.js +24 -0
  68. package/src/sequence.js +233 -103
  69. package/src/sources.js +10 -3
  70. package/types/app.d.ts +293 -0
  71. package/types/contract.d.ts +468 -0
  72. package/types/db.d.ts +359 -0
  73. package/types/flow.d.ts +285 -0
  74. package/types/forms.d.ts +253 -0
  75. package/types/index.d.ts +296 -26
  76. package/types/jslt.d.ts +193 -0
  77. package/types/migration.d.ts +201 -0
  78. package/types/model.d.ts +526 -0
  79. package/types/schema.d.ts +494 -0
@@ -0,0 +1,781 @@
1
+ # The Jaren migration pen
2
+
3
+ > `./migration` — `$migration` 0.1 documents: the two shape hashes and
4
+ > the ordered steps the runner takes. **Read it when** you are moving a
5
+ > store from one model to the next
6
+
7
+ Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
8
+ interpreted as described in RFC 2119. This document is a **guide** — read
9
+ it in order and you can write the format — whose one normative section is
10
+ [§2 The mapping table](#2-the-mapping-table); the rules every pen keeps, the shared refusal table, the
11
+ index of the other pens and every pen's mapping table collected in one
12
+ place are the normative reference,
13
+ [LINQ-FORMAT.md](LINQ-FORMAT.md).
14
+
15
+ ## 1. What it writes
16
+
17
+ Your model changed and the database has rows in it. Somebody has to write
18
+ the step that carries those rows across, and the part of that step no
19
+ planner can infer — the data transform — is the part you would most like
20
+ the compiler to check. That is this pen: the planner renders the DDL, you
21
+ write the transform against the OLD row shape and the NEW one, and the
22
+ document that comes out is what `migrate()` runs.
23
+
24
+ ```js
25
+ import { defineMigration, fromPlanned } from '@jarenjs/linq/migration';
26
+ ```
27
+
28
+ writes `$migration` 0.1 documents — the `jaren-migration` grammar
29
+ `packages/db/schemas/jaren-migration.schema.json` publishes (with a
30
+ draft-07 twin beside it), whose one specification is
31
+ [MIGRATION-FORMAT.md](../../db/docs/MIGRATION-FORMAT.md) §2 — and
32
+ `@jarenjs/db`'s `migrate()` takes what this pen emits unchanged. The
33
+ document is two shape hashes and an ordered list of steps; the order is
34
+ the contract.
35
+
36
+ **The running example.** §3 is one project's `migrations/` directory read
37
+ in order — the migration that adds a member, the one that rewrites it,
38
+ the one that backfills a derived column, the same first migration as the
39
+ planner actually leaves it, and one over a column-mapped date. §5 reads
40
+ the types the transforms are checked against off the same models.
41
+
42
+ Four things are worth naming before the tables:
43
+
44
+ - **Identity is the shape hash, and the pen computes what the store
45
+ computes.** `from` and `to` are
46
+ `hashContent(canonicalizeJson(model))` with the `x-rename` planning
47
+ hints stripped (`packages/linq/src/migration/define.js:45-75`) — the
48
+ store's own rule, from the same two functions. A test holds the pen's
49
+ hash equal to `@jarenjs/db`'s `shapeHash` over every model-pen corpus
50
+ model, rename hint included, and asserts that a model differing only by
51
+ a hint hashes the same (`test/linq/migration-pen.test.js`, "identity is
52
+ the shape hash"). A database records shapes, never version numbers a
53
+ human has to remember to bump; a migration whose `from` does not match
54
+ the recorded shape refuses to run (`JD0020`).
55
+ - **The planner still plans; the pen types the human part.**
56
+ `jaren-db plan --model ./model.js` diffs the committed
57
+ `model.snapshot.json` against the model, renders the DDL through the
58
+ dialect, and leaves the data transform it cannot infer as a step marked
59
+ `"draft": true` (MIGRATION-FORMAT §3). It cannot infer a transform and
60
+ MUST NOT pretend to. `fromPlanned(planned, { from, to })` takes that
61
+ document up so a `transform` typed old row → new row REPLACES the
62
+ draft, in place. The pen never sets and never clears a `draft` flag: an
63
+ untouched draft still refuses to run (`JD0021`, the runner's rule), and
64
+ a test asserts exactly that.
65
+ - **The engine is somewhere else.** Nothing under
66
+ `packages/linq/src/migration/` imports `@jarenjs/db`,
67
+ `@jarenjs/validate`, `@jarenjs/emit` or the query engine — a test
68
+ asserts it file by file. A transform's body is captured through the
69
+ JSLT pen's `body()` ([JSLT-PEN.md](JSLT-PEN.md)); an assertion's
70
+ predicate through the chain's recording proxy; the hash is
71
+ `@jarenjs/core`'s over `@jarenjs/json`'s canonical form. Everything
72
+ else about a migration — the shadow replay, the widening check against
73
+ real data, history and checksums, batching — is the runner's, and §6
74
+ says so plainly because a reader who believes otherwise will lose data.
75
+ - **Immutability and identity are the binder's rules, and this pen keeps
76
+ them.** Every step method answers a NEW `Migration`; `.document` is
77
+ assembled once, memoized and deep-frozen, and `toJSON()` returns it —
78
+ stated in full, for every pen, in
79
+ [LINQ-FORMAT.md](LINQ-FORMAT.md) §1.2.
80
+
81
+ The workflow the pen sits in, end to end:
82
+
83
+ ```
84
+ a model module → jaren-db plan --model ./model.js --out ./migrations
85
+ (diffs the snapshot, renders the DDL, drafts the transform)
86
+ → a migration module: fromPlanned(planned, { from, to })
87
+ with a typed .transform(…) replacing the draft
88
+ → jaren-db check (in CI: an unplanned model change, a
89
+ pending migration or drift exits 1)
90
+ → jaren-db apply
91
+ ```
92
+
93
+ `jaren-db` loads model and migration MODULES beside JSON and refuses one
94
+ that is not pure (MIGRATION-FORMAT §11). The two models a migration names
95
+ are the model pen's ([MODEL-PEN.md](MODEL-PEN.md)) — the previous one
96
+ kept beside the current one, which is also what keeps the transform
97
+ typed (§5).
98
+
99
+ ## 2. The mapping table
100
+
101
+ Every name `@jarenjs/linq/migration` exports that a caller writes, and
102
+ every method reachable on the builder it hands back. The one class it
103
+ also exports, `Migration`, is §5's: a caller meets it as a type and as an
104
+ `instanceof` narrow, never by calling it — its constructor is private.
105
+
106
+ Status: **native** (emits the named step), **refused** (a coded error
107
+ naming the reason).
108
+
109
+ | Method | Emits | Type reading | Status |
110
+ |---|---|---|---|
111
+ | `defineMigration({ id, from, to, note? })` | `{ $migration: '0.1', id, from, to, note?, steps }` — `from`/`to` the two models' shape hashes, exactly `shapeHash` (pinned) | `Migration<From, To>`, the two model documents' phantoms | native; not a `$model` document, an empty `id`, another member `JL0101` |
112
+ | `.ddl(sql, note?)` | `{ kind: 'ddl', sql, note? }` — one rendered statement (§2) | — | native; an empty statement `JL0101` |
113
+ | `.sql(sql, note?)` | `{ kind: 'sql', sql, note? }` — one data statement spelled directly (§9.4); a dry run always prints it with its note | — | native; an empty statement `JL0101` |
114
+ | `.transform(name, (row, x) => …)` | `{ kind: 'jslt', collection: name, stylesheet: [{ match: '$', body }] }` — one root rule, the body captured through the JSLT pen's `body()` over the WHOLE row, `x.root`/`x.path` the externals the engine binds (JSLT-FORMAT §8.2) | `row` is `Expr<Old>` (`DocOf<From, name>`); the result must spell `New` — a dropped, mistyped or foreign member does not compile; the honest top (`get()`) is admitted where a precise value is | native; a table the target model does not declare `JL0106`; an undeclared external `JL0104` |
115
+ | `.transform(name, stylesheet(…))`, `.transform(name, rules)` | the rules ARRAY — a `jslt` step carries the array, so the envelope's `unmatched`/`modes` have no place in it | a typed stylesheet's or first rule's `Out` must be `New`; a hand-written rule is the honest top | native; a disposition or a mode table `JL0102`; not JSON `JL0101` |
116
+ | `.assert(name, (row) => …, { expect? })` | `{ kind: 'query', collection: name, assert: { $for: { it: '$[*]' }, $where: <predicate>, $return: '$it' }, expect? }` — the format's own `$for` over the rows; the predicate names the VIOLATION (`expect: 'empty'`, the default, absent from the document) or the witness (`expect: 'ebv'`) | `row` is the members the two shapes share — a precondition sees old rows, a postcondition new ones, and what both agree on is what neither lies about; annotate (`(row: Expr<User>) => …`) when one shape is meant | native; another `expect` `JL0101`; an external `JL0104`; an undeclared table `JL0106` |
117
+ | `.assert(name, query, { expect? })` | the query document verbatim | — | native |
118
+ | `.derive(name, columns)` | `{ kind: 'derive', collection: name, columns }` — a backfill of stored derived columns (§2.1), the columns verbatim | `readonly DeriveColumn[]` | native; no columns `JL0101`; an undeclared table `JL0106` |
119
+ | `.step(raw)` | any planner-emitted step, verbatim — the escape that keeps `rebuild` (§10) authorable without the pen re-implementing it; a `draft` flag rides untouched | `MigrationStep` | native; an unrecognised kind or a missing member (the runner's `JD0023` rules, seen early) `JL0101` |
120
+ | `fromPlanned(document, { from?, to? })` | the planner's document, taken up: `.transform(name, …)` replaces its draft for `name` in place; every other method appends | the models type the transforms and are checked against the document's hashes | native; a model that is not the planned one `JL0102`; two drafts for one name, or no draft and no target model `JL0106` |
121
+ | `.document`, `.toJSON()` | the deep-frozen `$migration` document — assembled once and memoized, so `a.document === a.document` | `MigrationDocument` | native |
122
+
123
+ **This table is complete, and deliberately short.** Ten callable names
124
+ against eleven rows: the step vocabulary of `$migration` 0.1 is six kinds
125
+ (`ddl`, `jslt`, `query`, `derive`, `sql`, `rebuild`), five of them have a
126
+ method here and the sixth is `step()`. What makes this pen worth reading
127
+ is not the size of its surface but what each step is checked against —
128
+ §4 is the richest refusal section in the family relative to the pen's
129
+ size, and it is the reason.
130
+
131
+ Three rules the table implies, spelled out:
132
+
133
+ - **Identity stays the shape hash.** A database stores hashes, not
134
+ models; the pen computes what the store computes, from the same two
135
+ functions, with the same hint stripped — and the pin over every corpus
136
+ model is what keeps the two equal. A `x-rename` hint is a PLANNING
137
+ instruction, not shape, so a model that keeps carrying a satisfied hint
138
+ hashes the same as one without it and plans nothing (MIGRATION-FORMAT
139
+ §3 — a rename is idempotent across `plan` runs).
140
+ - **A step's table is one the target model declares.** The runner would
141
+ fail the statement on a table that does not exist; the pen says so
142
+ first (`JL0106`) — for `transform`, `assert` and `derive` alike,
143
+ whenever it knows the target. Entities and collections both count as
144
+ declared. Over a planned document alone it knows only the drafts, so
145
+ every other name is the runner's to judge and a transform for another
146
+ table is spelled with `step()`.
147
+ - **Steps are appended in the order they are called**, and `transform`
148
+ over a planned document is the one exception: it replaces the draft for
149
+ that name IN PLACE, so the planner's ordering — DDL before the data
150
+ step it depends on — survives.
151
+
152
+ ## 3. Worked examples
153
+
154
+ Every `js` fence below exports exactly one migration, and the `json`
155
+ fence that follows it is the document the pen emits — executed by
156
+ `test/linq/pen-docs.test.js`, which imports each fence from the workspace
157
+ and reads its `toJSON()`. Every document here also validates against both
158
+ published artifacts, the 2020-12 one and its draft-07 twin.
159
+
160
+ One project's `migrations/` directory, in the order the files are
161
+ numbered: `0002` gives every user a handle, `0003` shouts their names,
162
+ `0004` backfills a derived column, then `0002` again as the planner
163
+ actually leaves it, and `0005` splits a date out of a timestamp. Read in
164
+ order they are one database's history.
165
+
166
+ One caveat about every fence here, because it is the thing a reader will
167
+ misread: each declares only the entities the step touches, so the fences
168
+ stay readable. A real migration's `from` and `to` are the hashes of the
169
+ WHOLE model at those two points — that is what the database recorded and
170
+ what `migrate()` compares against.
171
+
172
+ **0002, by hand.** A migration between two model-pen models: the DDL the
173
+ planner would render, a typed transform, a precondition:
174
+
175
+ ```js
176
+ import * as m from '@jarenjs/linq/model';
177
+ import { defineMigration } from '@jarenjs/linq/migration';
178
+
179
+ const v1 = m.defineModel({ entities: {
180
+ User: m.object({ id: m.string().key(), name: m.string(), age: m.integer().optional() }),
181
+ } });
182
+ const v2 = m.defineModel({ entities: {
183
+ User: m.object({ id: m.string().key(), name: m.string(), age: m.integer().optional(), handle: m.string() }),
184
+ } });
185
+
186
+ export const handles = defineMigration({ id: '0002-handles', from: v1, to: v2, note: 'every user gets a handle' })
187
+ .ddl('ALTER TABLE "User" ADD COLUMN "handle" TEXT', "add column 'handle' on 'User'")
188
+ .transform('User', (u) => ({ id: u.id, name: u.name, age: u.age, handle: u.name.lower() }))
189
+ .assert('User', (u) => u.name.isEmpty());
190
+ ```
191
+
192
+ ```json
193
+ {
194
+ "$migration": "0.1",
195
+ "id": "0002-handles",
196
+ "from": "x7457y",
197
+ "to": "hn656j",
198
+ "note": "every user gets a handle",
199
+ "steps": [
200
+ { "kind": "ddl", "sql": "ALTER TABLE \"User\" ADD COLUMN \"handle\" TEXT",
201
+ "note": "add column 'handle' on 'User'" },
202
+ { "kind": "jslt", "collection": "User",
203
+ "stylesheet": [ { "match": "$",
204
+ "body": { "id": "$.id", "name": "$.name", "age": "$.age",
205
+ "handle": { "$lower": "$.name" } } } ] },
206
+ { "kind": "query", "collection": "User",
207
+ "assert": { "$for": { "it": "$[*]" }, "$where": { "$empty": "$it.name" },
208
+ "$return": "$it" } }
209
+ ]
210
+ }
211
+ ```
212
+
213
+ `x7457y` and `hn656j` are not literals a human types: they are what
214
+ `shapeHash(v1)` and `shapeHash(v2)` answer, and they change the day
215
+ either model's shape does — which is the point. The transform's callback
216
+ runs ONCE, at build, against the chain's recording proxy: `u.id` records
217
+ `$.id`, `u.name.lower()` records `{ $lower: '$.name' }`, and what is left
218
+ behind is the `body` above. The assertion names the VIOLATION — no user
219
+ may have an empty name — because `expect` defaults to `'empty'` and is
220
+ absent from the document when it does.
221
+
222
+ **0003.** The same `User`, transformed with a stylesheet written through
223
+ the JSLT pen rather than a lambda, and an `ebv`
224
+ assertion — the other direction, where the matching rows are the witness:
225
+
226
+ ```js
227
+ import * as m from '@jarenjs/linq/model';
228
+ import { defineMigration } from '@jarenjs/linq/migration';
229
+ import { stylesheet, rule } from '@jarenjs/linq/jslt';
230
+
231
+ const v2 = m.defineModel({ entities: {
232
+ User: m.object({ id: m.string().key(), name: m.string(), handle: m.string() }),
233
+ } });
234
+
235
+ export const shouty = defineMigration({ id: '0003-shout', from: v2, to: v2 })
236
+ .transform('User', stylesheet([
237
+ rule('$', (u) => ({ id: u.id, name: u.name.upper(), handle: u.handle })),
238
+ ]))
239
+ .assert('User', (u) => u.handle.exists(), { expect: 'ebv' });
240
+ ```
241
+
242
+ ```json
243
+ {
244
+ "$migration": "0.1",
245
+ "id": "0003-shout",
246
+ "from": "eedea8",
247
+ "to": "eedea8",
248
+ "steps": [
249
+ { "kind": "jslt", "collection": "User",
250
+ "stylesheet": [ { "match": "$",
251
+ "body": { "id": "$.id", "name": { "$upper": "$.name" },
252
+ "handle": "$.handle" } } ] },
253
+ { "kind": "query", "collection": "User",
254
+ "assert": { "$for": { "it": "$[*]" }, "$where": { "$exists": "$it.handle" },
255
+ "$return": "$it" },
256
+ "expect": "ebv" }
257
+ ]
258
+ }
259
+ ```
260
+
261
+ `from` and `to` are the same hash, and that is legal: a migration that
262
+ only rewrites data moves no shape. What reaches the step is the
263
+ stylesheet's RULES array — the `$jslt` envelope does not; a `jslt` step
264
+ carries an array and nothing else (MIGRATION-FORMAT §2), which is why an
265
+ envelope carrying `unmatched` or `modes` is refused rather than silently
266
+ truncated (§4.2).
267
+
268
+ **0004** touches the other half of the same database — the collection
269
+ that holds coordinates. A backfill: a `sql` data step, a `derive`
270
+ recompute, and a `rebuild`
271
+ handed through verbatim:
272
+
273
+ ```js
274
+ import * as m from '@jarenjs/linq/model';
275
+ import { defineMigration } from '@jarenjs/linq/migration';
276
+
277
+ const model = m.defineModel({ collections: {
278
+ places: m.collection(m.object({ id: m.string(), cell: m.array(m.number()) }), {
279
+ key: '/id',
280
+ indexes: [m.index((p) => p.cell, { name: 'gx_cell_gh7', derive: 'geohash', precision: 7 })],
281
+ }),
282
+ } });
283
+
284
+ export const backfill = defineMigration({ id: '0004-cells', from: model, to: model })
285
+ .sql('UPDATE "places" SET "doc" = json_remove("doc", \'$.legacy\')', 'drop the legacy member')
286
+ .derive('places', [{ name: 'gx_cell_gh7', derive: 'geohash', precision: 7, segments: [{ name: 'cell' }] }])
287
+ .step({
288
+ kind: 'rebuild',
289
+ table: 'places',
290
+ create: ['CREATE TABLE "places__rebuild" ("id" TEXT PRIMARY KEY, "doc" BLOB NOT NULL) STRICT'],
291
+ copy: 'INSERT INTO "places__rebuild" ("id", "doc") SELECT "id", "doc" FROM "places"',
292
+ indexes: [],
293
+ });
294
+ ```
295
+
296
+ ```json
297
+ {
298
+ "$migration": "0.1",
299
+ "id": "0004-cells",
300
+ "from": "vxjj1b",
301
+ "to": "vxjj1b",
302
+ "steps": [
303
+ { "kind": "sql", "sql": "UPDATE \"places\" SET \"doc\" = json_remove(\"doc\", '$.legacy')",
304
+ "note": "drop the legacy member" },
305
+ { "kind": "derive", "collection": "places",
306
+ "columns": [ { "name": "gx_cell_gh7", "derive": "geohash", "precision": 7,
307
+ "segments": [ { "name": "cell" } ] } ] },
308
+ { "kind": "rebuild", "table": "places",
309
+ "create": [ "CREATE TABLE \"places__rebuild\" (\"id\" TEXT PRIMARY KEY, \"doc\" BLOB NOT NULL) STRICT" ],
310
+ "copy": "INSERT INTO \"places__rebuild\" (\"id\", \"doc\") SELECT \"id\", \"doc\" FROM \"places\"",
311
+ "indexes": [] }
312
+ ]
313
+ }
314
+ ```
315
+
316
+ `derive` is idempotent by construction — a derived value is a pure
317
+ function of the document, so a replay writes what the first run wrote —
318
+ and it is what a STORED derived column needs, because such a column
319
+ arrives `NULL` and a query pushed to a `NULL` column silently returns
320
+ fewer rows (MIGRATION-FORMAT §2.1). The `rebuild` rides through `step()`
321
+ because §10's procedure is the planner's to render: the pen would have to
322
+ re-implement it to type it, and LINQ-FORMAT §1.1 rule 1 forbids that. It
323
+ is still checked — a `rebuild` without `create`, `copy` or `indexes` is
324
+ `JL0101` (§4.1).
325
+
326
+ **0002 again, as the planner actually leaves it.** This is the route you
327
+ take in practice, and the one the first fence skipped: the document
328
+ `jaren-db plan --model ./model.js` wrote, with
329
+ its draft replaced by a typed transform:
330
+
331
+ ```js
332
+ import * as m from '@jarenjs/linq/model';
333
+ import { fromPlanned } from '@jarenjs/linq/migration';
334
+
335
+ const v1 = m.defineModel({ entities: {
336
+ User: m.object({ id: m.string().key(), name: m.string(), age: m.integer().optional() }),
337
+ } });
338
+ const v2 = m.defineModel({ entities: {
339
+ User: m.object({ id: m.string().key(), name: m.string(), age: m.integer().optional(), handle: m.string() }),
340
+ } });
341
+
342
+ // what `jaren-db plan --model ./model.js` wrote: the DDL it rendered, and
343
+ // the transform it could not infer, left as a draft that refuses to run
344
+ const planned = {
345
+ $migration: '0.1',
346
+ id: '0002-handles',
347
+ from: 'x7457y',
348
+ to: 'hn656j',
349
+ steps: [
350
+ { kind: 'ddl', sql: 'ALTER TABLE "User" ADD COLUMN "handle" TEXT', note: "add column 'handle' on 'User'" },
351
+ { kind: 'jslt', collection: 'User', stylesheet: [], draft: true,
352
+ note: 'the document schema of entity \'User\' changed; fill in the transform (or delete this step if every stored document already validates) and remove "draft"' },
353
+ ],
354
+ };
355
+
356
+ export const typed = fromPlanned(planned, { from: v1, to: v2 })
357
+ .transform('User', (u) => ({ id: u.id, name: u.name, age: u.age, handle: u.name.lower() }));
358
+ ```
359
+
360
+ ```json
361
+ {
362
+ "$migration": "0.1",
363
+ "id": "0002-handles",
364
+ "from": "x7457y",
365
+ "to": "hn656j",
366
+ "steps": [
367
+ { "kind": "ddl", "sql": "ALTER TABLE \"User\" ADD COLUMN \"handle\" TEXT",
368
+ "note": "add column 'handle' on 'User'" },
369
+ { "kind": "jslt", "collection": "User",
370
+ "stylesheet": [ { "match": "$",
371
+ "body": { "id": "$.id", "name": "$.name", "age": "$.age",
372
+ "handle": { "$lower": "$.name" } } } ] }
373
+ ]
374
+ }
375
+ ```
376
+
377
+ The draft is GONE — replaced, at its own index, so the DDL still runs
378
+ first — and its `note` went with it, because a note explaining what the
379
+ author still has to do is false once they have done it. The step count
380
+ does not change, and the id and both hashes are the planner's. The two
381
+ routes to a document are the same document:
382
+ `test/linq/migration-pen.test.js` asserts that replacing a planner's
383
+ draft and replaying that planner's non-draft steps through `step()`
384
+ before the same `transform` produce `deepStrictEqual` documents. Had the
385
+ author never written the transform, the draft would have ridden through
386
+ untouched and `migrate()` would have refused the whole migration
387
+ (`JD0021`) — which the same file asserts against a seeded store.
388
+
389
+ **0005.** A transform over a COLUMN-MAPPED member — the pair that decides whether
390
+ a reader trusts this pen with real data:
391
+
392
+ ```js
393
+ import * as m from '@jarenjs/linq/model';
394
+ import { defineMigration } from '@jarenjs/linq/migration';
395
+
396
+ const before = m.defineModel({ entities: {
397
+ Event: m.object({ id: m.string().key(), stamp: m.datetime().column('integer') }),
398
+ } });
399
+ const after = m.defineModel({ entities: {
400
+ Event: m.object({ id: m.string().key(), stamp: m.datetime().column('integer'), day: m.date().column('integer') }),
401
+ } });
402
+
403
+ export const days = defineMigration({ id: '0005-days', from: before, to: after })
404
+ .ddl('ALTER TABLE "Event" ADD COLUMN "day" INTEGER')
405
+ .transform('Event', (e) => ({ id: e.id, stamp: e.stamp, day: e.stamp.substring(0, 10) }));
406
+ ```
407
+
408
+ ```json
409
+ {
410
+ "$migration": "0.1",
411
+ "id": "0005-days",
412
+ "from": "1r3h9t3",
413
+ "to": "vwfxn7",
414
+ "steps": [
415
+ { "kind": "ddl", "sql": "ALTER TABLE \"Event\" ADD COLUMN \"day\" INTEGER" },
416
+ { "kind": "jslt", "collection": "Event",
417
+ "stylesheet": [ { "match": "$",
418
+ "body": { "id": "$.id", "stamp": "$.stamp",
419
+ "day": { "$substring": [ "$.stamp", 0, 10 ] } } } ] }
420
+ ]
421
+ }
422
+ ```
423
+
424
+ **A transform over a column-mapped member migrates the column.** The
425
+ stylesheet is written against the DOCUMENT — `$.stamp` is the RFC 3339
426
+ string, and `day` is spelled as one — and the runner splits what it
427
+ returns back into columns and document under the TARGET model's mapping
428
+ (MIGRATION-FORMAT §2). `day` is declared `column('integer')`, so the row
429
+ that goes in as `{ stamp: '2026-08-29T10:00:00Z', day: '2026-08-29' }`
430
+ lands in SQLite as `stamp = 1787997600000, day = 1787961600000`, with
431
+ both strings still in the document. Nothing in the transform mentions a
432
+ column, and nothing should: the mapping is the model's. The runtime twin
433
+ of this claim — a seeded store, migrated, then read with raw SQL — is in
434
+ `test/linq/migration-pen.test.js`.
435
+
436
+ ## 4. Refusals
437
+
438
+ The migration pen raises these four `LinqBuildError` codes and no
439
+ others — `test/linq/pen-docs.test.js` holds this list equal, in both
440
+ directions, to the codes `packages/linq/src/migration/` throws. The full
441
+ condition each code states across every pen is the binder's,
442
+ [LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3. **`JL0106` is this pen's alone**:
443
+ no other pen in the family raises it.
444
+
445
+ | Code | What this pen raises it for |
446
+ |---|---|
447
+ | `JL0101` | a value this pen cannot spell, an option it does not know, or a step whose kind or required members the runner's own structural check would reject |
448
+ | `JL0102` | a construct a `jslt` step cannot carry, or a model that is not the one the planner planned |
449
+ | `JL0104` | an external a captured transform body or assertion predicate named |
450
+ | `JL0106` | a step naming an entity or collection the target model does not declare; or a `transform` over a planned document that finds no draft to replace, or two |
451
+
452
+ Every message below is the one the pen raised when the spelling beside it
453
+ was run, with the code prefix (`JL0101: `) removed. Where a row lists
454
+ several spellings, the message shown is the first one's — the shared
455
+ predicates interpolate the method name, so the others differ only in the
456
+ word the message opens with. `docPath`, where the refusal carries one, is
457
+ the JSON pointer of the node being assembled and is appended to the
458
+ message text as well (`… at /expect`).
459
+
460
+ Throughout, `m()` is `defineMigration({ id: 'r', from: v1, to: v2 })`
461
+ over §3's first two models, whose target declares `User` and nothing
462
+ else.
463
+
464
+ ### 4.1 `JL0101` — the value, the option and the step
465
+
466
+ | The spelling that trips it | The message | The spelling that works |
467
+ |---|---|---|
468
+ | `defineMigration(null)` | `defineMigration() takes { id, from, to, note? }` | the four members |
469
+ | `defineMigration({ id: '', from: v1, to: v2 })` | `defineMigration() id is a non-empty string, got a string` — `docPath` `/id` | `'0002-handles'` |
470
+ | `defineMigration({ id: 'x', from: {}, to: v2 })` | `defineMigration() from is a $model 0.1 document (defineModel(…), or its JSON), got an object without $model: '0.1'` | `defineModel(…)`, or a snapshot |
471
+ | `defineMigration({ …, extra: 1 })` | `defineMigration() does not take 'extra'` | `id`, `from`, `to`, `note` |
472
+ | `defineMigration({ …, note: 1 })` | `defineMigration() note is a string, got 1` — `docPath` `/note` | a string |
473
+ | `m().ddl(42)`, `m().sql('')` | `ddl() takes one rendered SQL statement as a non-empty string, got 42` | one rendered statement |
474
+ | `m().ddl('SELECT 1', 1)` | `ddl() note is a string, got 1` | a string, or no note |
475
+ | `m().transform('User', 42)` | `transform() takes a callback (row, x) => …, a stylesheet(…) document or a rules array, got 42` | one of the three spellings |
476
+ | `m().transform('User', [() => 1])` | `transform() rules received a Array instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | `rule('$', (u) => …)`, or a JSON rules array |
477
+ | `m().transform('User', { $jslt: '0.1', rules: 1 })` | `transform() stylesheet rules are an array, got 1` — `docPath` `/rules` | an array of rules |
478
+ | `m().transform('no-such', fn)` | `transform() names an entity or collection by identifier ('users'), got a string` | an identifier |
479
+ | `m().assert('User', p, { expect: 'maybe' })` | `assert() expect is 'empty' or 'ebv' (MIGRATION-FORMAT §2), got a string` — `docPath` `/expect` | `'empty'` or `'ebv'` |
480
+ | `m().assert('User', p, { other: 1 })` | `assert() does not take 'other'` | `{ expect }` |
481
+ | `m().assert('User', undefined)` | `assert() takes a predicate (row) => … or a query document over the rows` | a predicate |
482
+ | `m().derive('User', [])` | `derive() takes a non-empty array of derived-column records ({ name, derive, segments }), got a Array instance` | the columns the planner emitted |
483
+ | `m().step({ kind: 'nope' })`, `m().step(42)` | `step() takes a migration step with a recognised kind (ddl, jslt, query, derive, sql, rebuild), got kind a string` | one of the six kinds |
484
+ | `m().step({ kind: 'rebuild', table: 'User' })` | `step() 'rebuild' needs 'create' (MIGRATION-FORMAT §2)` — `docPath` `/create` | the rendered parts |
485
+ | `m().step({ kind: 'sql' })` | `step() 'sql' needs 'sql' (MIGRATION-FORMAT §2)` — `docPath` `/sql` | the statement |
486
+ | `m().step({ kind: 'jslt', collection: 'User' })` | `step() 'jslt' needs 'stylesheet' (MIGRATION-FORMAT §2)` — `docPath` `/stylesheet` | a rules array |
487
+ | `m().step({ kind: 'query', collection: 'User' })` | `step() 'query' needs 'assert' (MIGRATION-FORMAT §2)` — `docPath` `/assert` | a query document |
488
+ | `m().step({ kind: 'derive', collection: 'User', columns: [] })` | `step() 'derive' needs 'columns' (MIGRATION-FORMAT §2)` — `docPath` `/columns` | a non-empty array |
489
+ | `fromPlanned({ id: 'x' })`, `fromPlanned(42)` | `fromPlanned() takes a $migration 0.1 document — { $migration, id, from, to, steps } — as jaren-db plan writes it` | the planner's document |
490
+ | `fromPlanned({ …, extra: 1 })` | `fromPlanned() document carries 'extra', which the migration format does not declare` — `docPath` `/extra` | the six head members |
491
+ | `fromPlanned(planned, { nope: 1 })` | `fromPlanned() does not take 'nope'` | `{ from, to }` |
492
+ | `fromPlanned(planned, 42)` | `fromPlanned() options are { from?, to? }, got 42` | an options object |
493
+
494
+ `step()`'s structural rows are MIRRORED from the runner's own `JD0023`
495
+ rules, not invented here: the pen sees the same missing member the runner
496
+ would, and says so at build rather than half-way through a transaction.
497
+ That is LINQ-FORMAT §1.1 rule 1's "the engine's own rule, seen earlier"
498
+ in its clearest form.
499
+
500
+ ### 4.2 `JL0102` — the construct, and the model that is not the planned one
501
+
502
+ | The spelling that trips it | The message | The spelling that works |
503
+ |---|---|---|
504
+ | `m().transform('User', stylesheet([], { unmatched: 'error' }))` | `transform() takes a stylesheet's rules — a jslt step carries the rules array (MIGRATION-FORMAT §2), so 'unmatched' has no place in it; write the rules without it` — `docPath` `/unmatched` | `stylesheet([rule('$', fn)])`, or the rules array |
505
+ | `m().transform('User', stylesheet([], { modes: { toc: … } }))` | the same message, with `'modes'` | one root rule per step |
506
+ | `fromPlanned(planned, { from: v2 })` | `fromPlanned() from model has shape 'hn656j', but the planned migration's from is 'x7457y' — the model given is not the one the planner planned from` — `docPath` `/from` | the model the planner planned from |
507
+ | `fromPlanned(planned, { to: v1 })` | `fromPlanned() to model has shape 'x7457y', but the planned migration's to is 'hn656j' — the model given is not the one the planner planned to` — `docPath` `/to` | the model the planner planned to |
508
+
509
+ The stylesheet rows are a real limit of the STEP, not a limit of the JSLT
510
+ pen: a stylesheet is an envelope with a disposition and a mode table, and
511
+ a `jslt` step is an array of rules. Refusing it is what keeps the
512
+ truncation from being silent. The `fromPlanned` rows are the other kind —
513
+ a check the pen can make because both hashes are in front of it, and one
514
+ whose failure means the author edited a model after planning against it.
515
+
516
+ ### 4.3 `JL0104` — the externals a capture may not name
517
+
518
+ | The spelling that trips it | The message | The spelling that works |
519
+ |---|---|---|
520
+ | `m().transform('User', (u, x) => ({ …, handle: x.rate }))` | `a body() rule cannot bind 'rate' — its query evaluates with exactly 2 externals, 'root' and 'path'; anything else has nothing to bind to — a stylesheet parameter is declared first: body(fn, { externals: ['rate'] })` | `x.root` and `x.path` |
521
+ | `m().assert('User', (u, x) => x.limit.gt(1))` | `an assert() predicate cannot bind 'limit' — a query assertion runs over the table's rows with no externals (MIGRATION-FORMAT §2)` | compare against a literal, or against another member |
522
+
523
+ The two are deliberately different, and the difference is the engine's:
524
+ a `jslt` step's body is evaluated by the stylesheet engine, which binds
525
+ `root` and `path` (JSLT-FORMAT §8.2); a `query` step's assertion is
526
+ evaluated over the table's rows with nothing bound at all. A migration
527
+ document is a value that has to mean the same thing in CI, on a laptop
528
+ and on a server — a parameter it could read from the environment is
529
+ exactly what it must not have.
530
+
531
+ ### 4.4 `JL0106` — the table, and the draft
532
+
533
+ This pen's own code. Two conditions, and no other pen raises either.
534
+
535
+ **A step naming a table the target model does not declare.** Checked for
536
+ `transform`, `assert` and `derive`, whenever the target model is known.
537
+
538
+ | The spelling that trips it | The message | The spelling that works |
539
+ |---|---|---|
540
+ | `m().transform('Post', fn)` | `transform() names 'Post', which the target model does not declare — it declares 'User'` | a declared name — the message lists them |
541
+ | `m().assert('Post', p)` | `assert() names 'Post', which the target model does not declare — it declares 'User'` | as above |
542
+ | `m().derive('Post', columns)` | `derive() names 'Post', which the target model does not declare — it declares 'User'` | as above |
543
+ | `fromPlanned(planned, { to: v2 }).transform('Nope', fn)` | `transform() names 'Nope', which the target model does not declare — it declares 'User'` | as above |
544
+ | a target model declaring neither entities nor collections | `transform() names 'User', which the target model does not declare — it declares nothing` | give the target model its tables |
545
+
546
+ A collection counts as declared exactly as an entity does — the pen reads
547
+ both members of the target model (`src/migration/define.js:98-104`) — so
548
+ a migration over a phase-A collection store needs no special spelling.
549
+
550
+ **A `transform` over a planned document with no draft to replace, or
551
+ two.** Only `fromPlanned` can reach these: `defineMigration` starts with
552
+ no steps, so there is never a draft.
553
+
554
+ | The spelling that trips it | The message | The spelling that works |
555
+ |---|---|---|
556
+ | `fromPlanned(planned).transform('Nope', fn)` — no models given | `transform() over 'Nope': the planned migration drafts no transform for it and no target model was given — pass { to } to fromPlanned(), or spell the step with step()` | `fromPlanned(planned, { from: v1, to: v2 })`, or `.step({ kind: 'jslt', … })` |
557
+ | `fromPlanned(twoDrafts).transform('User', fn)` | `transform() cannot tell which draft to replace: the planned migration carries 2 draft transforms for 'User'` | edit the planned document down to one draft per table, or replace them with `step()` calls |
558
+
559
+ The first is not a mistake so much as a missing fact: without a target
560
+ model the pen knows only which tables the planner drafted, so a name it
561
+ has never seen could be a typo or could be perfectly good. It refuses and
562
+ names both ways out rather than guessing. The second cannot arise from
563
+ `jaren-db plan`, which drafts at most one transform per table; it arises
564
+ when a planned document is edited or two are concatenated, and a pen that
565
+ picked one would be picking which of the author's two transforms to
566
+ throw away.
567
+
568
+ Note what is NOT checked: with no target model, `ddl`, `sql`, `assert`,
569
+ `derive` and `step` take any identifier, and the runner judges. That is
570
+ the honest position — the pen refuses what it can see, and it cannot see
571
+ a model it was not given.
572
+
573
+ ## 5. The types
574
+
575
+ §3's first migration, as a project actually keeps it: the previous model
576
+ in a module beside the current one, and the transform checked against
577
+ both.
578
+
579
+ ```ts
580
+ import { defineMigration, fromPlanned } from '@jarenjs/linq/migration';
581
+ import type { DocOf, MigrationDocument } from '@jarenjs/linq/migration';
582
+ import type { Expr } from '@jarenjs/linq';
583
+ import { model as v1 } from './models/v1.js'; // the previous model module, kept beside the current one
584
+ import { model as v2 } from './model.js';
585
+
586
+ const handles = defineMigration({ id: '0002-handles', from: v1, to: v2 })
587
+ .transform('User', (u) => ({ id: u.id, name: u.name, handle: u.name.lower() }));
588
+ // ^ Expr<DocOf<typeof v1, 'User'>> — the OLD row
589
+ // ^ must spell the NEW row: a dropped `handle` does not compile
590
+ const document: MigrationDocument = handles.document;
591
+ ```
592
+
593
+ ### 5.1 Two model documents, four readings
594
+
595
+ `Migration<From, To>` carries the two model documents as type parameters,
596
+ and every method reads one of four things off them
597
+ (`packages/linq/types/migration.d.ts`):
598
+
599
+ | Type | What it reads | Where it is used |
600
+ |---|---|---|
601
+ | `DeclaredNames<M>` | `keyof E \| keyof C` of the target's phantoms — `string` for a document the type cannot read | the `name` parameter of `transform`, `assert`, `derive` |
602
+ | `DocOf<M, N>` | `InferMeta<M>[N]['doc']` — the model pen's document shape for one table; the honest `unknown` for a collection, a JSON snapshot, or a name the model does not type | the transform's row in, and the row it must spell |
603
+ | `Spell<T>` | how a value of `T` may be written: itself, an expression yielding it, the honest top (`UnknownExpr`), or — for an object — a literal spelling each member the same way | the transform callback's result |
604
+ | `SheetFor<S, New>` / `RulesFor<R, New>` | a typed stylesheet's or first rule's `Out` against the new row; `unknown` (accepted) when the rule is hand-written | the two non-callback `transform` overloads |
605
+
606
+ The row an `assert` predicate sees is `MemberExpr<DocOf<From, N> |
607
+ DocOf<To, N>>` — the members the two shapes SHARE. A precondition runs
608
+ over old rows and a postcondition over new ones, and the pen cannot know
609
+ which this one is, so what both agree on is what neither lies about;
610
+ annotate (`(u: Expr<NewUser>) => …`) when one shape is meant.
611
+
612
+ ### 5.2 The honest limits, and the two routes past them
613
+
614
+ **From a JSON snapshot the old row is `unknown`.** A JSON literal is
615
+ never inferred (LINQ-FORMAT §1.1, rule 2), so `from: snapshot` gives the
616
+ callback the honest top and `u.get('name')` is how it is read. Two routes
617
+ keep the type: keep the previous model MODULE beside the current one, as
618
+ the example above does; or ask the CLI for emit's declaration of the
619
+ snapshot — `jaren-db snapshot --model ./model.js --types ./model.d.ts` —
620
+ and annotate the row from it, `(u: Expr<User>) => …`.
621
+
622
+ **A body's extra member is caught on a direct annotation, not in the
623
+ callback.** `Spell<New>` rejects a member the new row does not have, but
624
+ a contextually typed callback RESULT is not excess-checked by TypeScript;
625
+ `test/consumer/linq-migration.ts` pins both halves, the negative on a
626
+ `const extra: Spell<NewUser> = …` annotation. At run time the target
627
+ model's closed schema refuses the member, and the widening check turns
628
+ that into `JD0021` with the whole migration rolled back.
629
+
630
+ **The honest top is admitted wherever a precise value is.** `u.get(
631
+ 'legacyHandle')` type-checks as the new row's `handle`, because the
632
+ runtime validator is the judge there and a type that refused it would
633
+ make a legitimate migration unwritable.
634
+
635
+ ### 5.3 The exported class
636
+
637
+ `Migration<From, To>` is the one export a caller does not call: its
638
+ constructor is private, `defineMigration` and `fromPlanned` are the two
639
+ ways to get one, and every step method answers a new one. A caller meets
640
+ it as a type (`const typed: Migration<typeof v1, typeof v2> = handles`)
641
+ and as an `instanceof` narrow — `test/linq/migration-pen.test.js` uses
642
+ both. The document types beside it are ordinary interfaces:
643
+ `MigrationDocument`, `MigrationStep` and its six arms (`DdlStep`,
644
+ `SqlStep`, `JsltStep`, `QueryStep`, `DeriveStep`, `RebuildStep`), and
645
+ `DeriveColumn`. `JsltStep` declares `draft?: boolean` with the comment
646
+ that says the whole rule: the runner refuses it, and the pen never sets
647
+ or clears it.
648
+
649
+ ### 5.4 What the pins hold
650
+
651
+ | File | What it proves |
652
+ |---|---|
653
+ | `test/consumer/linq-migration.ts` | `DocOf<>` equal to the two hand-written row shapes; the old row in and the new row out; eleven negatives — a member only the new shape has, a dropped required member, a member of the wrong type, a member the new row does not have (on a direct `Spell<>` annotation), an undeclared table, the identity where the shapes differ, a stylesheet whose output lacks a member, a typed rule whose output is not the new row, a `fromPlanned` transform dropping a member, an assertion reading a member only one shape has, and an `expect` outside the two — plus the snapshot's honest top and the `fromPlanned` overloads |
654
+ | `test/linq/migration-pen.test.js` | the runtime twin: every corpus migration emits its hand-written document byte-equal and deep-frozen, validates against both artifacts, builds twice to one document and one checksum, and hashes its shapes exactly as `shapeHash` does; `fromPlanned` replacing exactly the draft; an untouched draft still refusing at run (`JD0021`); and the v1 → v2 migration applying to a seeded store, shadow replay included, with the column-mapped member written to its column |
655
+
656
+ ## 6. What it cannot spell
657
+
658
+ This pen's limits are unusual for the family: almost nothing is refused
659
+ as unspellable, because a migration document is mostly rendered SQL and
660
+ the pen's job is to carry it. What it cannot do is REASON about that SQL,
661
+ and the honest statement of that is the section. §6.4 closes it with the
662
+ cases where the answer is not to reach for this pen at all.
663
+
664
+ ### 6.1 The physical step kinds ride verbatim
665
+
666
+ `ddl` and `sql` are strings —
667
+ the pen checks that a statement is a non-empty string and nothing more.
668
+ It does not parse SQL, does not know the dialect, and cannot tell an
669
+ `ALTER TABLE` from a `DROP TABLE`. `rebuild` goes further: it has no
670
+ method at all, only `step()`, because MIGRATION-FORMAT §10's procedure
671
+ (create the new shape, copy, drop, rename, rebuild the indexes) is
672
+ rendered by the planner against a dialect, and a pen that typed it would
673
+ be re-implementing the planner. The alternative for all three is the same
674
+ one the format intends: let `jaren-db plan` render them, and take the
675
+ document up with `fromPlanned`.
676
+
677
+ ### 6.2 `defineMigration` does not validate a migration against a database
678
+
679
+ This is the sentence a reader most needs, because believing otherwise
680
+ loses data. What the pen checks is in §4 and nothing else. Everything
681
+ below is `@jarenjs/db`'s `migrate()`, and none of it has happened when
682
+ `.document` returns:
683
+
684
+ - **The from-shape against the database.** A migration whose `from` does
685
+ not match the shape the database recorded refuses to run (`JD0020`).
686
+ The pen computes the hash; only the runner compares it to a database.
687
+ - **The shadow replay.** Before the real store is touched, the WHOLE
688
+ chain — baseline, applied and pending — replays on a shadow database:
689
+ every DDL statement runs, every stylesheet and assertion compiles and
690
+ executes, and the end shape is verified against the target model
691
+ (MIGRATION-FORMAT §4). A step the pen accepted and SQLite rejects fails
692
+ there, with the real store untouched.
693
+ - **The widening/narrowing check against real data.** At the end of the
694
+ run, inside its transaction, every stored document is validated against
695
+ the target schema. A document that no longer validates is `JD0021` and
696
+ the whole migration rolls back. A transform that forgets a new required
697
+ member compiles, emits, passes the shadow — and fails here, which is
698
+ the only place it CAN fail, because the answer depends on the rows.
699
+ `test/linq/migration-pen.test.js` pins exactly that.
700
+ - **Assertions.** A `query` step's verdict is a fact about the data. The
701
+ pen writes the query; the runner runs it, on the shadow and then for
702
+ real.
703
+ - **History, checksums and drift.** `JD0022` for an applied migration
704
+ that disagrees with the history record, and `jaren-db check`'s drift
705
+ detection, are the CLI's and the runner's (MIGRATION-FORMAT §5, §12).
706
+
707
+ ### 6.3 Two the format itself does not carry
708
+
709
+ Down migrations are not
710
+ shipped in 0.1 — a JSLT transform is not generally invertible, and a
711
+ reverse step that silently loses data is worse than a restore from backup
712
+ (MIGRATION-FORMAT §7); the recommended path is to branch the shape,
713
+ migrate forward and drop the old table once verified. A rename is
714
+ DECLARED, never inferred, with `.renamedFrom()` on the model pen's
715
+ builder ([MODEL-PEN.md](MODEL-PEN.md) §2.1) — a diff cannot tell a rename
716
+ from a drop plus a create, and guessing risks silent data loss.
717
+
718
+ ### 6.4 When not to reach for this pen
719
+
720
+ - **The planner's document already runs.** If `jaren-db plan` produced no
721
+ draft — a pure DDL change, an index added, a column widened — the
722
+ document it wrote is finished. Commit it. This pen exists for the step
723
+ the planner left blank, and reaching for it to retype a document that
724
+ was already correct adds a build step and a chance to diverge.
725
+ - **The migration is one SQL statement and no data moves.** `.ddl()`
726
+ around a string you would otherwise commit as JSON buys the shape hash
727
+ and nothing else, and the shape hash is what `plan` computes anyway.
728
+ - **The transform cannot be spelled as a query.** A body is captured
729
+ through the JSLT pen and lowers to `$jslt`, so it can only do what the
730
+ query language has operators for (§4.3, and
731
+ [JSLT-PEN.md](JSLT-PEN.md) §6). A transform that needs to call out —
732
+ a hash, a network lookup, a library — is a `sql` step against a table
733
+ you populate beforehand, or a program run outside the migration
734
+ entirely.
735
+ - **You want to undo something.** There are no down migrations in 0.1
736
+ and §6.3 says why. Branch the shape and migrate forward; a restore from
737
+ backup is a better answer than a reverse step that loses a column
738
+ quietly.
739
+ - **The document is generated per environment.** A `$migration` is
740
+ identified by two shape hashes and applied once, recorded in history.
741
+ Anything that would make the document differ between two databases of
742
+ the same shape — an environment name in a statement, a conditional step
743
+ — is not a migration, it is deployment configuration, and it belongs
744
+ outside the document.
745
+
746
+ ## 7. Cost
747
+
748
+ `@jarenjs/linq/migration` builds to **<!--fact:bundle.migration-->24,259<!--/fact--> bytes** as a minified,
749
+ tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
750
+ measures and `npm run test:tree-shaking` reports, published rounded
751
+ (<!--fact:bundle.migration.kb-->24<!--/fact--> kB) beside the other nine subpath prices in
752
+ [docs/CONSUMING.md](../../../docs/CONSUMING.md).
753
+
754
+ The probe is a gate, not a report: building a two-step migration as a
755
+ consumer would, it asserts four things and fails the build on any of
756
+ them:
757
+
758
+ - **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
759
+ `concurrency.js`, `provider.js`, `sources.js` or `schema-of.js`
760
+ contributes a byte;
761
+ - **no schema pen and no model pen** — not one byte of
762
+ `packages/linq/src/schema/` or `packages/linq/src/model/`;
763
+ - **no engine and no store** — not one byte of `@jarenjs/validate`,
764
+ `@jarenjs/emit`, `@jarenjs/db`, `@jarenjs/formats` or `@jarenjs/refs`,
765
+ and nothing of `@jarenjs/json` beyond `canonical.js` and `pointer.js`,
766
+ which are what a shape identity is made of;
767
+ - **a ceiling** of 24,000 bytes; and the other direction, that the chain
768
+ and the schema pen carry no byte of `packages/linq/src/migration/`.
769
+
770
+ **A migration module imports the model pen's phantoms, not its
771
+ runtime.** That second assertion is the interesting one, because a
772
+ migration is written against two models and typed by them: the
773
+ declarations import `ModelDocument` and `InferMeta` from the model pen's
774
+ `.d.ts` (`packages/linq/types/migration.d.ts:28`), and a type import
775
+ costs nothing at run time. What reaches the bundle is `defineMigration`,
776
+ the step spellings, the JSLT pen's `body()` with the shared recording
777
+ proxy behind it, and the canonicalizer — and the two model documents
778
+ themselves arrive as data, deep-frozen JSON that the consumer's own model
779
+ module built. So a consumer who ships migrations to a browser does not
780
+ ship the model pen with them; a consumer who OPENS a store does, and pays
781
+ `./model`'s <!--fact:bundle.model-->41,582<!--/fact--> bytes for it.