@jarenjs/db 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
@@ -0,0 +1,348 @@
1
+ # The Jaren live format — change capture and live queries
2
+
3
+ This document is normative. The key words MUST, MUST NOT, SHOULD and
4
+ MAY are to be interpreted as described in RFC 2119.
5
+
6
+ Change capture and live queries are ONE story — patches out — and
7
+ share this document. This order writes §§1–6 (capture); live queries
8
+ continue at §7. Error codes join the package's single runtime table
9
+ (MODEL-FORMAT §7), never a per-document list.
10
+
11
+ ## 1. Scope
12
+
13
+ Every committed write to a store opened with `capture` produces an
14
+ observable, ordered stream of **RFC 6902 patches** describing what
15
+ changed — derived from SQLite's own session changesets where the
16
+ binding has them, from a write-path journal where it does not. One
17
+ diff format then runs end to end: store → patch → live query → patch
18
+ → O(k) render. The patches MUST be consumable by
19
+ `applyJSONPatch` from `@jarenjs/json/patch`, unmodified.
20
+
21
+ ```js
22
+ const store = await openStore(model, {
23
+ driver: nodeDriver(),
24
+ capture: { mode: 'auto', log: { retention: 1000 } },
25
+ });
26
+ const stop = store.observe(({ seq, at, source, collections, patch }) => {
27
+ // one record per committed transaction, in commit order
28
+ });
29
+ ```
30
+
31
+ Capture is **opt-in per store** — it costs a transaction wrapper and
32
+ (in session mode) a session per commit; the measured overhead is
33
+ published with the benchmarks, not waved away.
34
+
35
+ ## 2. The pointer contract
36
+
37
+ Every op's `path` is `/<table>/<key>/<path…>`, each token escaped per
38
+ RFC 6901 (`~` → `~0`, `/` → `~1`):
39
+
40
+ - `<table>` is the collection, entity or join-table name.
41
+ - `<key>`: a SINGLE key renders as its scalar text (integers in
42
+ decimal); a COMPOSITE key renders as the JSON text of its parts
43
+ array (`["ada","math"]`), then escaped as one token. The encoding is
44
+ deterministic: parts appear in declared key order.
45
+ - `<path…>` is the document-relative remainder, RFC 6901 as always.
46
+
47
+ The contract is stable — consumers depend on it. Join-table rows are
48
+ tiny documents under the join table's name (`/Label_User/["admin","u1"]`
49
+ → `{ "Label_key": "admin", "User_key": "u1" }`, columns in the sorted
50
+ pair order): membership changes are part of the stream, not a blind
51
+ spot.
52
+
53
+ **Op order within a record is UNSPECIFIED.** A commit carries one net
54
+ op per row (SQLite sessions coalesce insert+update, drop
55
+ insert+delete, omit no-op updates — and the journal mirrors that
56
+ discipline), every op targets a distinct pointer, and the record
57
+ applies correctly in any order. The two capture modes MAY order the
58
+ same ops differently; they MUST agree as sets.
59
+
60
+ ## 3. The op mapping
61
+
62
+ | Row change | Patch |
63
+ |---|---|
64
+ | INSERT | `add` at `/<table>/<key>` with the full document (mapped columns folded back: booleans as `true`/`false`, SQL `NULL` absent, derived epoch columns skipped — the document string is authoritative) |
65
+ | DELETE | `remove` at `/<table>/<key>` |
66
+ | UPDATE, document column changed | old JSONB diffed against new (`createJSONPatch`) → minimal nested ops under `/<table>/<key>` |
67
+ | UPDATE, mapped scalar/foreign-key column changed | property-level op at `/<table>/<key>/<property>`: `replace` (both present), `add` (was `NULL`), `remove` (now `NULL` — absent per MODEL-FORMAT §9.3) |
68
+ | join-table INSERT / DELETE | `add` / `remove` of the membership row document |
69
+
70
+ Changesets (not patchsets) are used because they carry OLD values —
71
+ that is what makes the minimal nested diff and the add/remove/replace
72
+ discrimination possible; the price is a larger in-memory blob per
73
+ transaction, stated here. Integer values beyond ±2⁵³ convert lossily
74
+ to JS numbers (a raw-SQL concern only; JS documents cannot produce
75
+ them).
76
+
77
+ ## 4. Capture modes and their limitations
78
+
79
+ `capture.mode` is `'auto'` (default), `'session'` or `'journal'`;
80
+ `store.capabilities.capture` reports what actually runs
81
+ (`'session'`, `'journal'` or `'none'`).
82
+
83
+ - **`session`** — SQLite's session extension records row changes at
84
+ the storage layer: every write through this connection is seen,
85
+ including future raw-SQL surfaces. Requires the binding to expose
86
+ `createSession` (node:sqlite does; **bun:sqlite does not**; a wasm
87
+ build may not). Sessions are per COMMIT, never long-lived (an
88
+ unbounded session is a memory leak); a rolled-back transaction —
89
+ and a rolled-back savepoint inside a committed one — contributes
90
+ nothing (probed and pinned by test).
91
+ - **`journal`** — the write path itself emits records: the store
92
+ already knows what it wrote. **Less complete, stated plainly**: it
93
+ cannot see writes made through raw SQL, triggers, or another
94
+ connection; a journal-mode delete of a row the store never read
95
+ emits its `remove` without having seen the old document; and of the
96
+ database's own `ON DELETE` side effects it reconstructs exactly ONE
97
+ — join-table membership dying with its entity (read before the
98
+ delete) — while cascades into CHILD rows (`onDelete: 'cascade'` /
99
+ `'setNull'` on one-to-many relations) stay invisible. For writes
100
+ made through the store API within those bounds the two modes MUST
101
+ produce the same op sets — proven by a differential test.
102
+ - Requesting `mode: 'session'` on a driver without sessions is a
103
+ `TypeError` at open — an application that needs completeness can
104
+ refuse to start rather than silently miss changes.
105
+
106
+ ## 5. The persisted log and retention
107
+
108
+ With `capture.log`, each record is appended to `_jaren_changes`
109
+ **inside the same transaction** as the writes it describes — an
110
+ observer crash cannot lose a committed record, and a late joiner
111
+ reads forward:
112
+
113
+ ```js
114
+ const records = await store.changesSince(lastSeq); // JD2051 when no log
115
+ ```
116
+
117
+ `seq` is monotonic; with the log enabled it continues across reopens
118
+ (seeded from `MAX(seq)`), without it it is per-process. Retention is
119
+ a bounded count (`retention`, default 1000): older rows are pruned in
120
+ the same transaction. The log is an ordered, replayable stream —
121
+ which is what makes a late-joining consumer possible. **Replication
122
+ is not built here**, and this log alone does not make it safe: there
123
+ is no conflict resolution, no site identity, no causal ordering
124
+ across writers. That sentence is the whole claim.
125
+
126
+ ## 6. Cross-connection behaviour and non-claims
127
+
128
+ Another process (or another connection) writing to the same file
129
+ produces NO local patches — sessions and journals are per-connection
130
+ facts. The honest mitigation is a coarse signal, not a pretend
131
+ fine-grained one: `store.dataVersion()` reads `PRAGMA data_version`,
132
+ which changes when ANOTHER connection commits; poll it and treat a
133
+ change as "re-read what you care about". Cross-tab delivery is §7's
134
+ story (the live-query layer).
135
+
136
+ Non-claims, in one place: no replication, no conflict resolution, no
137
+ capture of writes made by other connections, no capture on stores
138
+ opened without `capture`, and no statement-level ordering within a
139
+ commit (§2).
140
+
141
+ ## 7. Live queries: the maintenance table
142
+
143
+ A live query is a registered query document whose result is
144
+ **maintained** as committed writes arrive, emitting RFC 6902 patches
145
+ against its own result document (§9). Registration:
146
+
147
+ ```js
148
+ const live = await store.collection('users').live(document, {
149
+ externals: {}, // fixed at registration (§8)
150
+ mode: 'auto', // 'auto' | 'incremental' | 'rerun'
151
+ });
152
+ live.result; // the maintained result document
153
+ live.mode; // { strategy, mode: 'incremental'|'rerun', reason }
154
+ const stop = live.subscribe(({ patch, seq, error }) => { /* … */ });
155
+ live.close();
156
+ ```
157
+
158
+ `store.live(document, options)` registers an entity-root document (the
159
+ multi-entity shape of MODEL-FORMAT §10) the same way. Live queries
160
+ REQUIRE change capture — the patch stream is the invalidation source —
161
+ and registering on a store opened without `capture` is `JD0050`.
162
+
163
+ **This table is normative.** Every row is implemented and tested;
164
+ nothing outside it is attempted. Classification reads the compiled
165
+ PLAN (never the raw document), so "extractable" below means exactly
166
+ what the pushdown planner already means by it.
167
+
168
+ | Construct (as planned) | Strategy | Maintained state |
169
+ |---|---|---|
170
+ | `where` whose predicates translate (the plan's filter), no order, no aggregate | **incremental rows**: per-row re-evaluation; insert / remove / replace in the result | the result rows |
171
+ | the same with a per-row `select` projection (row-mode plan) | **incremental rows**: the affected row alone is recomputed; a source row may project to several items | result rows, grouped by source key |
172
+ | `orderBy` over extractable paths, optional `limit`, offset 0 | **maintained window**: a sorted structure; ties broken by the collection key, appended as the final sort term; an insert sorting beyond a full window is a no-op | the window rows and their sort keys |
173
+ | whole-query `count` / `sum` / `avg` / `min` / `max` (the plan's aggregate), optional `where` | **running accumulator** plus a per-row contribution map — a delete can only be answered from retained contributions (§3: a `remove` carries no old value). `min`/`max` removal of the last extremum holder FALLS BACK to a recompute over the retained contributions; the accumulator alone cannot answer, and this fallback is the documented cost | one contribution per matching row |
174
+ | single-level `groupBy` with aggregate returns, in the canonical form below | **per-group deltas**: the accumulator machinery, one instance per group; groups appear in first-appearance order, exactly the engine's order | per-group, per-row contributions |
175
+ | joins, multi-entity roots, graph loads, every entity query | **re-run on invalidation — declared, not attempted** in this version | the previous result, for diffing |
176
+ | anything else: non-translatable predicates, `limit` without `orderBy`, `offset` > 0, windowed aggregates, `@jarenjs/linq`'s nested two-level `groupBy` emission, non-canonical group returns | **re-run on invalidation**, the reason named | the previous result, for diffing |
177
+
178
+ Re-run is a first-class, documented outcome, not a failure. What is
179
+ forbidden is *silently* re-running while the reader believes the query
180
+ is incremental: `live.mode` reports `'incremental'` or `'rerun'`, the
181
+ strategy, and — for re-run — the reason. `mode: 'incremental'` in the
182
+ options DEMANDS incrementality: a query that classifies as re-run then
183
+ refuses at registration (`JD0051`), the same shape as capture's
184
+ demanded session — an application that needs the property can refuse
185
+ to start.
186
+
187
+ The **canonical group form** the classifier recognises (and the only
188
+ one — the linq chain's nested emission re-runs, stated plainly):
189
+
190
+ ```json
191
+ { "$for": { "it": "$[*]" },
192
+ "$where": { "…optional, translatable…": [] },
193
+ "$groupby": { "g": "$it.dept" },
194
+ "$return": { "key": { "$default": ["$g", null] },
195
+ "n": { "$count": "$it" }, "total": { "$sum": "$it.pay" } } }
196
+ ```
197
+
198
+ After `$groupby`, `$it` is the group's item sequence and `$g` its key;
199
+ return members are the group key or an aggregate over `$it` (a path
200
+ below it selects the aggregated member). Anything else in the return
201
+ is not canonical and re-runs.
202
+
203
+ ## 8. Invalidation
204
+
205
+ Each live query derives its dependencies from the plan at
206
+ registration:
207
+
208
+ - the collection (or, for entity documents, every entity the plan
209
+ binds), matched first against a record's `collections` — a
210
+ non-matching record costs ONE array scan;
211
+ - for accumulator and group strategies, the top-level members the
212
+ plan actually reads (filter refs, the aggregated path, the group
213
+ key): an update record touching only other members is skipped;
214
+ - row and window strategies depend on their WHOLE collection — the
215
+ result carries the row documents, so any member change changes an
216
+ emitted row.
217
+
218
+ Matching is by table plus pointer prefix — cheap, sound, and
219
+ **over-approximate in exactly one direction**: an unnecessary
220
+ re-evaluation is a performance bug; a missed one would be a
221
+ correctness bug, so the approximation always leans toward
222
+ re-evaluating. Row inserts arrive with their full document in the
223
+ patch; row UPDATES are minimal (§3), so maintenance issues a
224
+ point-read of the touched row (one indexed lookup per touched key per
225
+ record) to re-evaluate; deletes are answered entirely from maintained
226
+ state. `externals` are fixed at registration — a query whose inputs
227
+ change is a new registration.
228
+
229
+ Maintenance runs synchronously inside patch delivery, in commit
230
+ order, on the store's own connection. Writes from ANOTHER connection
231
+ are invisible to capture (§6) and therefore to live queries; the
232
+ coarse `dataVersion()` signal and the §11 topology are the honest
233
+ answers, and re-registering re-reads.
234
+
235
+ ## 9. The emitted patch contract
236
+
237
+ The result document is `{ "rows": [...] }` — always. Row and window
238
+ strategies fill `rows` with result items; group strategies with one
239
+ row per group; a whole-query aggregate is a ZERO-OR-ONE row result
240
+ (`rows: [42]`; an empty `min()` is `rows: []`, the engine's
241
+ undefined-as-absent mapping made visible). Consumers hold the result
242
+ document and apply patches to it; ops are `add`, `remove` and
243
+ `replace` only.
244
+
245
+ - **One record, one emission.** A committed transaction touching any
246
+ number of rows produces at most ONE `{ patch, seq }` event per live
247
+ query — the record's changes coalesce into one patch array, and a
248
+ record that ends up changing nothing emits nothing.
249
+ - **Structural sharing is the contract, not an optimisation.** After
250
+ an emission, every unaffected row in `live.result` is
251
+ REFERENCE-IDENTICAL to before; the `rows` array and the result
252
+ object are fresh per emission (a held previous result is never
253
+ mutated). Applying the emitted patch with `applyJSONPatch`'s
254
+ copy-on-write preserves the same sharing on the consumer's side —
255
+ which is what keeps the O(k) renderer's fast paths alive end to
256
+ end.
257
+ - **Order.** An ordered (window) query's row order is the engine's,
258
+ with ties broken by the collection key — the key is appended to the
259
+ declared terms at registration, so the order is total and stable by
260
+ construction. An UNORDERED query's initial order is the engine's;
261
+ maintenance then appends newly matching rows and splices removed
262
+ ones, which is deterministic given the write history but is NOT
263
+ re-derived rowid order — a consumer that needs a specific order
264
+ declares an `orderBy`. Group rows keep first-appearance order.
265
+ - `seq` is the capture record's `seq`; re-run emissions carry it too.
266
+
267
+ ## 10. The app binding
268
+
269
+ A live query reaches an app as a SUBSCRIPTION (APP-FORMAT §5.3) whose
270
+ handler dispatches a patch-carrying action — generated documents, no
271
+ import in either direction (the `fsmToApp` precedent):
272
+
273
+ ```js
274
+ import { liveAppBinding, createLiveSubscription } from '@jarenjs/db/app';
275
+
276
+ const { subscription, actions } = liveAppBinding({
277
+ run: 'db/live', action: 'db/liveChanged', statePath: '/live/users' });
278
+ // subscription → { run: 'db/live', with: { …, statePath, action } }
279
+ // actions → { 'db/liveChanged': { patch: '$payload' } }
280
+
281
+ createApp({ …doc, subs: [subscription], actions: { …doc.actions, ...actions } },
282
+ { subs: { 'db/live': createLiveSubscription(store) } });
283
+ ```
284
+
285
+ The handler registers the live query, dispatches ONE initializing
286
+ patch (`replace` of the whole `statePath` slot with the initial
287
+ result), then forwards each emission with every op's path prefixed by
288
+ `statePath` — the prefixing happens in the handler, so the action
289
+ document stays the two-line literal above and the app loop needs
290
+ nothing new: `@jarenjs/app` already applies patches copy-on-write and
291
+ derives changed paths, which is the payoff of one diff format end to
292
+ end. Closing is the subscription's cleanup; a handler props change
293
+ restarts it through the app's own key rule.
294
+
295
+ ## 11. Cross-tab: the owner topology
296
+
297
+ Decided by a platform fact: OPFS synchronous access handles are
298
+ **exclusive** — a second tab cannot open the same database files at
299
+ all, so "one connection per tab" is not available and never will be.
300
+ Therefore:
301
+
302
+ - ONE owning context holds the sole connection — a `SharedWorker`
303
+ where available, else a leader tab elected via `navigator.locks` —
304
+ and every other tab is a client;
305
+ - queries, writes and the patch stream travel between clients and the
306
+ owner over `BroadcastChannel` / `MessagePort`; a client's live query
307
+ is a remote registration whose emissions arrive as messages;
308
+ - a second context attempting to OPEN the database is refused with
309
+ `JD2061` — a coded refusal, never a mysterious storage failure;
310
+ - conflict handling stays out of scope: there is one writer by
311
+ construction.
312
+
313
+ Node and Bun present the same API with no channel at all — the store
314
+ is its own owner, and application code is identical everywhere. The
315
+ in-browser proof of this topology (delivery across real tabs, the
316
+ refusal, reload survival) belongs to the browser-driver order and its
317
+ Playwright suite; this section is the decided contract it implements.
318
+
319
+ ## 12. Lifecycle, bounds, and non-goals
320
+
321
+ A live query holds resources: dependency registrations, its
322
+ maintained state, possibly a sorted window. `close()` releases all of
323
+ them and is MANDATORY; closing the store closes every live query
324
+ first; after close, `subscribe` and re-registration refuse, `result`
325
+ stays readable (the last value), and a leak test asserts the live set
326
+ after a forced GC.
327
+
328
+ Bounds, both configurable at `openStore({ live: { … } })`, both
329
+ ERRORING rather than degrading (the D14 rule — the bound is printed):
330
+
331
+ - `maxQueries` (default 64): registrations beyond it are `JD0052`;
332
+ - `maxMaintained` (default 10 000): the per-query ceiling on
333
+ maintained ENTRIES — result rows, window rows, accumulator and
334
+ per-group contributions all count, because the state is the cost.
335
+ Crossing it mid-maintenance is `JD2060`: the live query delivers the
336
+ error to its subscribers and CLOSES — degraded silence is the one
337
+ outcome this format forbids. An unbounded live query over a growing
338
+ table is the classic memory leak of this category; the accumulator
339
+ strategies trade exactly one contribution entry per matching row for
340
+ delete-correctness, and a count over a table larger than the bound
341
+ is a conscious `maxMaintained` raise, not a silent one.
342
+
343
+ Non-claims, in one place: no incremental joins (re-run is the declared
344
+ strategy), no cross-connection invalidation (§6's `data_version` is
345
+ the signal), no maintenance over asynchronous connections in this
346
+ version (every current driver is synchronous; the browser driver's
347
+ order owns that story), no replication, and no ordering guarantee for
348
+ unordered queries beyond §9's determinism.
@@ -0,0 +1,302 @@
1
+ # The Jaren migration format (`jaren-migration`)
2
+
3
+ This document is normative. The key words MUST, MUST NOT, SHOULD and
4
+ MAY are to be interpreted as described in RFC 2119.
5
+
6
+ Canonical schema: [`schemas/jaren-migration.schema.json`](../schemas/jaren-migration.schema.json)
7
+ (draft 2020-12), with the mechanically derived draft-07 twin beside it.
8
+
9
+ ## 1. Scope
10
+
11
+ A store evolves without hand-written SQL: two model documents diff into
12
+ a **migration document** whose ordered steps are rendered DDL, JSLT
13
+ data transforms and query assertions. The migration replays on a
14
+ shadow database before the real store is touched; a history table
15
+ records what ran, in what order, with a checksum; rolling forward is
16
+ deterministic and inspectable, and the generated SQL is always shown
17
+ before it is executed.
18
+
19
+ This is the phase-A payoff for storing documents rather than rows: a
20
+ shape change is a **transformation of values**, not a table rebuild.
21
+
22
+ ## 2. The migration document
23
+
24
+ ```json
25
+ {
26
+ "$migration": "0.1",
27
+ "id": "0002-split-name",
28
+ "from": "1m60qna…", "to": "8kf22bd…",
29
+ "steps": [
30
+ { "kind": "ddl", "sql": "DROP INDEX \"users_by_first\"" },
31
+ { "kind": "jslt", "collection": "users", "stylesheet": [ { "match": "$", "body": { } } ] },
32
+ { "kind": "query", "collection": "users",
33
+ "assert": { "$for": { "it": "$[*]" }, "$where": { "$empty": "$it.name" }, "$return": "$it.id" } }
34
+ ]
35
+ }
36
+ ```
37
+
38
+ - `from`/`to` are `hashContent(canonicalizeJson(model))` — the
39
+ identity of a **shape**, not a version number a human must remember
40
+ to bump. A migration whose `from` does not match the database's
41
+ recorded shape MUST refuse to run (`JD0020`).
42
+ - `kind: "ddl"` executes one rendered statement. The planner produces
43
+ these through the dialect; they are ordinary text in the document so
44
+ a reviewer reads exactly what will run.
45
+ - `kind: "jslt"` rewrites every document of a collection through a
46
+ compiled JSLT stylesheet, in batches, inside the migration's
47
+ transaction. The empty stylesheet (`[]`) is the identity transform.
48
+ A step carrying `"draft": true` is a planner placeholder and MUST
49
+ refuse to run (`JD0021`) until the author fills it in.
50
+ - `kind: "query"` is an assertion: the query runs over the
51
+ collection's documents and MUST answer an empty sequence (`expect:
52
+ "empty"`, the default) or an EBV-true value (`expect: "ebv"`) for
53
+ the migration to proceed. This is how a migration states its own
54
+ precondition — "no user has a null email before the NOT NULL
55
+ index" — and it is checked on the shadow first.
56
+ - Steps are ordered, and the order is the contract.
57
+
58
+ ## 3. Planning and the widening/narrowing rule
59
+
60
+ `planMigration(fromModel, toModel, { dialect, id })` produces
61
+ `{ migration, report }` by diffing the two models' PHYSICAL plans:
62
+
63
+ - An added collection becomes its full CREATE DDL; a removed
64
+ collection becomes a `DROP TABLE` step whose note says
65
+ **DESTRUCTIVE** plainly, and `report.destructive` is `true` — the
66
+ planner never guesses at data loss.
67
+ - A rename cannot be inferred from a diff. It is DECLARED with
68
+ `"x-rename": "oldName"` on the target collection; the planner emits
69
+ the rename first and rebuilds the indexes (a renamed SQLite table
70
+ keeps its old index names — probed). Without the hint, a rename is
71
+ a drop plus a create and the report says so.
72
+ - Added, removed and changed indexes become index DDL — reusing the
73
+ store's own DDL generator, never a second implementation. A changed
74
+ generated column (type or path) is a drop plus an add, with its
75
+ dependent indexes dropped first and recreated after.
76
+ - A changed schema gets a `jslt` step with the identity stylesheet and
77
+ `"draft": true`. The planner **cannot** infer a data transform and
78
+ MUST NOT pretend to — a silent identity transform is how data gets
79
+ quietly lost. The author fills in the stylesheet, or deletes the
80
+ step when the change is a pure widening.
81
+ - **The widening/narrowing rule runs against real data, not schema
82
+ comparison**: at the end of the migration run (inside its
83
+ transaction) every stored document is validated against the target
84
+ schema through the injected `compileSchema` hook. A document that no
85
+ longer validates is `JD0021` and the whole migration rolls back — a
86
+ narrowing without an adequate transform cannot land. A widening
87
+ needs no transform, and passes this check by fact.
88
+ - Changing a collection's key declaration is not planned (a rebuild);
89
+ the planner refuses with a `TypeError` naming the non-goal.
90
+
91
+ ## 4. The shadow database
92
+
93
+ Before the real store is touched, the WHOLE chain — the baseline
94
+ shape, every applied migration, every pending migration — replays on a
95
+ shadow database (`:memory:` by default; free with SQLite, no server).
96
+ The shadow proves **structure**: every DDL statement runs, every
97
+ stylesheet and assertion compiles and executes, and the end shape is
98
+ verified against the target model. A failure there leaves the real
99
+ store untouched.
100
+
101
+ The shadow runs over an empty data set; the real-data facts (the
102
+ widening check, key consistency, the assertions over real rows) run on
103
+ the real store inside its transaction. The model format declares no
104
+ UDF-expression indexes, so there is no function set to re-register on
105
+ the shadow — stated here because a dialect that allowed such indexes
106
+ would make the shadow fail on a schema the real store accepts.
107
+
108
+ ## 5. History and checksums
109
+
110
+ ```
111
+ _jaren_migrations(id TEXT PRIMARY KEY, applied_at INTEGER,
112
+ from_hash TEXT, to_hash TEXT, checksum TEXT, steps INTEGER)
113
+ ```
114
+
115
+ `checksum` is `hashContent(canonicalizeJson(migration))` —
116
+ signature-grade (D12), never the memo-grade `contentKey`. On every
117
+ run, the supplied migration list MUST contain every applied migration,
118
+ in order, with matching checksums; a migration whose recorded checksum
119
+ differs from the document on disk is `JD0022` — someone edited an
120
+ applied migration, which is always a bug and always worth failing on.
121
+ The database's current shape is the last applied `to_hash`, or the
122
+ hash of the `baseline` model when no migration has run.
123
+
124
+ ## 6. Running and batching
125
+
126
+ `migrate({ driver, path }, migrations, options)`:
127
+
128
+ - `options.baseline` (REQUIRED) — the model the store was FIRST
129
+ created with: the chain's anchor and the shadow's starting shape.
130
+ - `options.model` (RECOMMENDED) — the target model. When present, the
131
+ last pending migration's `to` MUST equal its shape hash (`JD0020`
132
+ otherwise), the physical end shape is verified, and the real-data
133
+ validation of §3 runs.
134
+ - `dryRun: true` prints every statement and the affected document
135
+ counts, validates the chain on the shadow, and writes NOTHING. The
136
+ API default is to run; a CLI SHOULD default to the dry run.
137
+ - Each pending migration runs in ONE exclusive transaction
138
+ (`BEGIN IMMEDIATE` on SQLite — concurrent writers wait or time out
139
+ under the busy timeout) with a savepoint per step; any failure rolls
140
+ back the whole migration including its earlier steps. Where a driver
141
+ cannot open exclusively, the transaction still isolates; the busy
142
+ policy of MODEL-FORMAT §4 governs contention.
143
+ - JSLT steps walk the collection in bounded batches
144
+ (`options.batchSize`, default 500) ordered by row identity, report
145
+ progress through `options.onProgress`, and never hold the whole
146
+ collection in memory. Assertion steps read the whole collection into
147
+ one array — a documented cost; keep assertions early, before the
148
+ data grows.
149
+ - A transform MUST NOT change a caller-keyed document's key member —
150
+ the key column would go stale; the run refuses (`JD0023`).
151
+
152
+ ## 7. Non-goals
153
+
154
+ - **Down migrations are not shipped in 0.1.** A JSLT transform is not
155
+ generally invertible, and a reverse step that silently loses data is
156
+ worse than a restore from backup. The recommended path: branch the
157
+ shape (a new collection or a new store), migrate forward, drop the
158
+ old collection once verified. `down` is not planned for this format
159
+ version; if it ever arrives it will be an explicit author-written
160
+ document, never an inferred inverse.
161
+ - **Inferred renames.** A diff cannot distinguish a rename from a drop
162
+ plus a create; guessing risks silent data loss. Renames are declared
163
+ with `x-rename`, or they are what they look like.
164
+ - **Key-declaration changes** (see §3) — a rebuild, not a migration
165
+ step.
166
+
167
+ ## 8. Error codes
168
+
169
+ | code | raised when |
170
+ |---|---|
171
+ | `JD0020` | the migration's from-shape does not match the database |
172
+ | `JD0021` | the migration is missing a required data transform |
173
+ | `JD0022` | an applied migration disagrees with the history record |
174
+ | `JD0023` | a migration step failed |
175
+
176
+ These live in the same runtime `DB_CODES` table as the storage codes
177
+ (MODEL-FORMAT §7); the union of both documents is proven in sync with
178
+ the runtime table by a test.
179
+
180
+ ## 9. Relational changes (entities)
181
+
182
+ `planModelMigration(fromModel, toModel, { dialect })` extends the §3
183
+ planner to models with `entities`. The strategy table is the design;
184
+ every row has a shadow-verified test that migrates seeded data:
185
+
186
+ | Change | Strategy |
187
+ |---|---|
188
+ | add mapped column (property added, or moved out of the document) | `ALTER TABLE ADD COLUMN` — always nullable (absent reads back absent, MODEL-FORMAT §9.3) — plus a `sql` data step when the property's values already live in the document |
189
+ | drop mapped column (property removed, or moved into the document) | fold the column back into the document first (`sql` step) when the property survives; drop its index, then `DROP COLUMN` where SQLite's conditions hold, else rebuild |
190
+ | change type / enum CHECK / key / epoch flavor | **rebuild** (§10) |
191
+ | add or drop an index (`unique`/`index`/version) | plain DDL |
192
+ | add or drop a relation (foreign-key column, join table) | foreign keys **rebuild** the holder; join tables create/drop directly |
193
+ | entity added / dropped | create / `DROP TABLE` (destructive, named) |
194
+ | entity renamed | declared with `x-rename` on the target entity — never inferred; join tables renamed mechanically with their endpoints |
195
+ | scalar ⇄ JSONB move (`column: "json"` toggled, shape change) | rebuild + a data step |
196
+
197
+ Two rules keep the diff honest:
198
+
199
+ - **Document changes are compared with `x-entity` stripped.** A pure
200
+ mapping change (an index added, a column toggle) is NOT a document
201
+ schema change and demands no transform; a real document change
202
+ yields the §3 draft-`jslt` step over the entity's table.
203
+ - **Epoch columns populate in SQL** via
204
+ `(julianday(value) − 2440587.5) × 86 400 000`, rounded to the
205
+ millisecond — fractional seconds beyond that are the write
206
+ contract's business (MODEL-FORMAT §10.3), not the migration's.
207
+
208
+ ### 9.4 The `sql` step
209
+
210
+ ```json
211
+ { "kind": "sql", "sql": "UPDATE …", "note": "why" }
212
+ ```
213
+
214
+ A data step spelled directly — for row-shuffling that is more honest
215
+ as SQL than as a stylesheet. Runs like `ddl` (inside the migration's
216
+ transaction, its own savepoint), but a dry run ALWAYS prints it with
217
+ its note, and reviewers read intent from the kind.
218
+
219
+ ## 10. The rebuild procedure
220
+
221
+ SQLite's `ALTER TABLE` cannot drop a constraint, change a type or
222
+ reorder columns; the documented procedure for "making other kinds of
223
+ table schema changes" (sqlite.org/lang_altertable.html §7) is followed
224
+ literally, as one implementation used by every rebuilding strategy:
225
+
226
+ ```json
227
+ { "kind": "rebuild", "table": "User",
228
+ "create": ["CREATE TABLE \"User__rebuild\" (…)"],
229
+ "copy": "INSERT INTO \"User__rebuild\" (…) SELECT … FROM \"User\"",
230
+ "indexes": ["CREATE INDEX …"], "note": "…" }
231
+ ```
232
+
233
+ The step is SELF-CONTAINED rendered SQL — reviewable in the migration
234
+ document, mechanical to run: create the new shape under the temporary
235
+ name, copy (the planner renders the column mapping: surviving columns
236
+ verbatim, new ones from the document, dropped ones already folded),
237
+ `DROP` the old table, `RENAME` the new one into place, recreate every
238
+ index from the target model, then **`PRAGMA foreign_key_check` inside
239
+ the transaction** — a broken reference fails the migration rather
240
+ than shipping.
241
+
242
+ Two deviations from the cited twelve steps, recorded: (1) the
243
+ procedure brackets itself with `PRAGMA foreign_keys=OFF/ON`, which is
244
+ a no-op inside a transaction — the migration connection never enables
245
+ the pragma (SQLite's default is off; `openStore` enables AND verifies
246
+ it per connection), so enforcement during the rebuild is off exactly
247
+ as the procedure wants, and `foreign_key_check` provides the
248
+ guarantee; (2) triggers and views are not re-created because this
249
+ store creates none — a hand-added trigger is outside the model and
250
+ outside the diff, which drift (§12) will name.
251
+
252
+ **Shape equality is the acceptance criterion.** After a rebuild —
253
+ after ANY relational migration — the database's declared schema
254
+ (`schemaShapeOf`) must equal what a fresh `createModelShape(toModel)`
255
+ produces, indexes, foreign keys and constraints included. The shadow
256
+ asserts it before the real database is touched, and the real run
257
+ asserts it again after the last migration.
258
+
259
+ **UDF-expression indexes.** An index over a registered deterministic
260
+ function is invisible to any connection that has not registered the
261
+ function (probed): `migrate(…, { registerFunctions })` re-registers
262
+ every declared function on the real, shadow AND reference connections
263
+ before any DDL runs — without it, a rebuild would fail (or silently
264
+ drop the index) on a schema the store accepts.
265
+
266
+ ## 11. The CLI
267
+
268
+ `jaren-db` drives the workflow (mirroring `jaren-emit`):
269
+
270
+ ```
271
+ jaren-db plan --from <model> --to <model> [--store <db>] [--id x] [--out file]
272
+ jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>]
273
+ jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
274
+ jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>]
275
+ jaren-db shape --model <model>
276
+ ```
277
+
278
+ - `plan` diffs two model FILES (a database stores shape hashes, not
279
+ models — the from-model is the previous model file); with `--store`
280
+ it first verifies the from-model's hash matches the database's
281
+ recorded shape.
282
+ - `check` is the CI command: exit 1 when migrations are pending OR the
283
+ database drifted; 0 in sync.
284
+ - `apply` prints every statement before running; destructive steps
285
+ (drop table/column, rebuild) require `--yes` or an interactive
286
+ confirmation that NAMES what is lost. Default is dry-run + ask.
287
+ - `status` lists applied/pending and reports drift (§12).
288
+ - `shape` prints the physical mapping a model produces.
289
+
290
+ ## 12. Drift
291
+
292
+ Drift is the database not matching what its history says it should
293
+ be: someone changed it by hand. `status`/`check` detect it by
294
+ verifying the current model's physical shape against the actual
295
+ database (`schemaShapeOf` against `createModelShape`, when the chain
296
+ is fully applied) — a hand-added index, a dropped column or a foreign
297
+ key edited outside a migration is named early, which is the
298
+ difference between a puzzled afternoon and a five-minute fix.
299
+
300
+ Down migrations REMAIN a non-goal (§7's reasoning is unchanged): a
301
+ down migration is a data-loss generator wearing a seatbelt; recovery
302
+ is a backup restored plus the forward chain.