@jarenjs/linq 0.49.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- 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-->23,599<!--/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-->40,857<!--/fact--> bytes for it.
|