@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
package/src/migrate.js ADDED
@@ -0,0 +1,1411 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Document migrations (D12): two model documents diff into a
4
+ * migration document whose steps are rendered DDL, JSLT data
5
+ * transforms and query assertions; the migration replays on a shadow
6
+ * database first; a history table records what ran with a
7
+ * signature-grade checksum. This is the phase-A payoff for storing
8
+ * documents rather than rows: a shape change is a transformation of
9
+ * VALUES, not a table rebuild.
10
+ *
11
+ * Identity is a hash, not a version number: `from`/`to` are
12
+ * `hashContent(canonicalizeJson(model))` — the identity of a SHAPE,
13
+ * which nobody has to remember to bump. The checksum discipline is
14
+ * D12's: `canonicalizeJson` + `hashContent` (signature-grade — throws
15
+ * on the unserializable), never the memo-grade `contentKey`.
16
+ *
17
+ * Like the query emitter, this module is part of the emitter layer:
18
+ * the structural SQL it composes (the history table's statements, the
19
+ * batched row walk) is built from dialect primitives, and every
20
+ * planner-produced statement is rendered by the dialect into the
21
+ * migration DOCUMENT — shown before it is ever executed.
22
+ */
23
+
24
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
25
+ import { hashContent } from '@jarenjs/core/string';
26
+ import { compileJsonQuery } from '@jarenjs/json/query';
27
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
28
+
29
+ import { DbCompileError } from './errors.js';
30
+ import { chain, toPromise } from './driver.js';
31
+ import { normalizeModel } from './store.js';
32
+ import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
33
+ import { normalizeEntities, explainMapping } from './model.js';
34
+
35
+ /** The migration format version. */
36
+ export const MIGRATION_VERSION = '0.1';
37
+
38
+ /** The history table name (outside the model's identifier namespace
39
+ * conventions on purpose — a collection cannot collide with it). */
40
+ export const HISTORY_TABLE = '_jaren_migrations';
41
+
42
+ /**
43
+ * The signature-grade identity of a model SHAPE.
44
+ * @param {any} model - A jaren-model document
45
+ * @returns {string}
46
+ */
47
+ export function shapeHash(model) {
48
+ return hashContent(canonicalizeJson(model));
49
+ }
50
+
51
+ /**
52
+ * The signature-grade checksum of a migration document.
53
+ * @param {any} migration
54
+ * @returns {string}
55
+ */
56
+ export function migrationChecksum(migration) {
57
+ return hashContent(canonicalizeJson(migration));
58
+ }
59
+
60
+ /**
61
+ * @param {string} code
62
+ * @param {string} reason
63
+ * @param {Error} [cause]
64
+ * @returns {DbCompileError}
65
+ */
66
+ function refuse(code, reason, cause) {
67
+ return new DbCompileError(code, reason, undefined, cause);
68
+ }
69
+
70
+ /**
71
+ * Plan a migration between two model documents. The planner diffs the
72
+ * PHYSICAL plans (columns, indexes) and renders DDL through the
73
+ * dialect; a changed schema gets a DRAFT identity transform that
74
+ * refuses to run until the author fills it in — the planner cannot
75
+ * infer a data transform and does not pretend to. Renames are declared
76
+ * (`x-rename` on the target collection), never guessed.
77
+ * @param {any} fromModel
78
+ * @param {any} toModel
79
+ * @param {{ id?: string, dialect?: any }} [options]
80
+ * @returns {{ migration: any, report: {
81
+ * renamed: { from: string, to: string }[],
82
+ * added: string[], removed: string[],
83
+ * schemaChanged: string[], drafts: string[],
84
+ * destructive: boolean } }}
85
+ */
86
+ export function planMigration(fromModel, toModel, options = undefined) {
87
+ const dialect = options?.dialect ?? null;
88
+ if (dialect === null || typeof dialect !== 'object')
89
+ throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
90
+ const fromCollections = normalizeModel(fromModel);
91
+ const toCollections = normalizeModel(toModel);
92
+
93
+ const steps = [];
94
+ const report = {
95
+ renamed: [], added: [], removed: [], schemaChanged: [], drafts: [],
96
+ destructive: false,
97
+ };
98
+
99
+ // declared renames first: the physical table moves, its old-prefixed
100
+ // indexes stay behind (probed) and are rebuilt by the index diff
101
+ const renamedFrom = new Map();
102
+ for (const name of toCollections.keys()) {
103
+ const hint = toModel.collections[name]?.['x-rename'];
104
+ if (hint === undefined) continue;
105
+ if (!fromCollections.has(hint)) {
106
+ throw new TypeError(
107
+ `x-rename on '${name}' names '${hint}', which the from-model does not declare`);
108
+ }
109
+ if (fromCollections.has(name)) {
110
+ throw new TypeError(
111
+ `x-rename on '${name}' collides: the from-model already declares '${name}'`);
112
+ }
113
+ if (toCollections.has(hint)) {
114
+ throw new TypeError(
115
+ `x-rename on '${name}' collides: '${hint}' is also declared in the target model — `
116
+ + 'a rename consumes its source');
117
+ }
118
+ renamedFrom.set(name, hint);
119
+ report.renamed.push({ from: hint, to: name });
120
+ steps.push({
121
+ kind: 'ddl',
122
+ sql: dialect.ddl.renameTable(hint, name),
123
+ note: `rename collection '${hint}' to '${name}'`,
124
+ });
125
+ }
126
+ const consumedOldNames = new Set(renamedFrom.values());
127
+
128
+ for (const [name, toCollection] of toCollections) {
129
+ const oldName = renamedFrom.get(name) ?? name;
130
+ const fromCollection = renamedFrom.has(name)
131
+ ? fromCollections.get(renamedFrom.get(name))
132
+ : fromCollections.get(name);
133
+
134
+ if (fromCollection === undefined) {
135
+ report.added.push(name);
136
+ for (const sql of planCollection(name, toCollection, dialect).createSql)
137
+ steps.push({ kind: 'ddl', sql, note: `create collection '${name}'` });
138
+ continue;
139
+ }
140
+
141
+ // the from-side physical facts live under the RENAMED table: same
142
+ // columns, but index names still carry the old collection prefix
143
+ const fromPlan = planCollection(oldName, fromCollection, dialect);
144
+ const toPlan = planCollection(name, toCollection, dialect);
145
+ if (fromPlan.keyType !== toPlan.keyType
146
+ || fromCollection.identity !== toCollection.identity
147
+ || canonicalizeJson(fromCollection.key) !== canonicalizeJson(toCollection.key)) {
148
+ throw new TypeError(
149
+ `collection '${name}': changing the key declaration requires a table rebuild, `
150
+ + 'which this planner does not produce (a named non-goal — branch the shape instead)');
151
+ }
152
+
153
+ const fromColumns = new Map(fromPlan.generated.map((g) => [g.name, g]));
154
+ const toColumns = new Map(toPlan.generated.map((g) => [g.name, g]));
155
+ const fromIndexes = new Map(fromPlan.expected.indexes.map((i) => [i.name, i]));
156
+ const toIndexes = new Map(toPlan.expected.indexes.map((i) => [i.name, i]));
157
+
158
+ const columnChanged = (a, b) => a.type !== b.type || a.pathText !== b.pathText;
159
+ const indexChanged = (a, b) => a.unique !== b.unique
160
+ || a.columns.join(',') !== b.columns.join(',');
161
+
162
+ // columns first decide their fate; an index rebuilds when it
163
+ // changes OR when any column it covers is dropped or changed (the
164
+ // database refuses to drop a column under a live index) — then
165
+ // everything runs in dependency order: drop indexes, drop columns,
166
+ // add columns, create indexes
167
+ const disturbedColumns = new Set();
168
+ for (const [columnName, fromColumn] of fromColumns) {
169
+ const target = toColumns.get(columnName);
170
+ if (target === undefined || columnChanged(fromColumn, target))
171
+ disturbedColumns.add(columnName);
172
+ }
173
+ const indexNeedsRebuild = (indexName, fromIndex) => {
174
+ const target = toIndexes.get(indexName);
175
+ return target === undefined || indexChanged(fromIndex, target)
176
+ || fromIndex.columns.some((column) => disturbedColumns.has(column));
177
+ };
178
+ for (const [indexName, fromIndex] of fromIndexes) {
179
+ if (indexNeedsRebuild(indexName, fromIndex)) {
180
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropIndex(indexName),
181
+ note: `drop index '${indexName}' on '${name}'` });
182
+ }
183
+ }
184
+ for (const columnName of disturbedColumns) {
185
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropColumn(name, columnName),
186
+ note: `drop generated column '${columnName}' on '${name}'` });
187
+ }
188
+ for (const [columnName, toColumn] of toColumns) {
189
+ const source = fromColumns.get(columnName);
190
+ if (source === undefined || columnChanged(source, toColumn)) {
191
+ steps.push({
192
+ kind: 'ddl',
193
+ sql: dialect.ddl.addGeneratedColumn(
194
+ { table: name, docColumn: toPlan.docColumn, column: toColumn }),
195
+ note: `add generated column '${columnName}' on '${name}'`,
196
+ });
197
+ }
198
+ }
199
+ for (const [indexName, toIndex] of toIndexes) {
200
+ const source = fromIndexes.get(indexName);
201
+ const rebuilt = source !== undefined && indexNeedsRebuild(indexName, source);
202
+ if (source === undefined || rebuilt) {
203
+ steps.push({
204
+ kind: 'ddl',
205
+ sql: dialect.ddl.createIndex(
206
+ { name: indexName, table: name, columns: toIndex.columns, unique: toIndex.unique }),
207
+ note: `create index '${indexName}' on '${name}'`,
208
+ });
209
+ }
210
+ }
211
+
212
+ if (canonicalizeJson(fromCollection.schema) !== canonicalizeJson(toCollection.schema)) {
213
+ report.schemaChanged.push(name);
214
+ report.drafts.push(name);
215
+ steps.push({
216
+ kind: 'jslt',
217
+ collection: name,
218
+ stylesheet: [],
219
+ draft: true,
220
+ note: `the schema of '${name}' changed; the planner cannot infer the data `
221
+ + 'transform. Fill in the stylesheet (or delete this step if every stored '
222
+ + 'document already validates against the new schema) and remove "draft".',
223
+ });
224
+ }
225
+ }
226
+
227
+ for (const [name] of fromCollections) {
228
+ if (toCollections.has(name) || consumedOldNames.has(name)) continue;
229
+ report.removed.push(name);
230
+ report.destructive = true;
231
+ steps.push({
232
+ kind: 'ddl',
233
+ sql: dialect.ddl.dropTable(name),
234
+ note: `DESTRUCTIVE: drop collection '${name}' and every document in it. `
235
+ + 'A rename is declared with x-rename on the target collection; without '
236
+ + 'one, this is a drop plus a create.',
237
+ });
238
+ }
239
+
240
+ planEntityChanges(fromModel, toModel, dialect, steps, report);
241
+
242
+ const migration = {
243
+ $migration: MIGRATION_VERSION,
244
+ id: options?.id ?? `to-${shapeHash(toModel).slice(0, 8)}`,
245
+ from: shapeHash(fromModel),
246
+ to: shapeHash(toModel),
247
+ steps,
248
+ };
249
+ return { migration, report };
250
+ }
251
+
252
+ /** `planMigration` handles the whole model — collections AND entities
253
+ * — since the relational order; this name says so. */
254
+ export const planModelMigration = planMigration;
255
+
256
+ /** Deep-copy a schema with the mapping vocabulary stripped: a pure
257
+ * mapping change (an index, a column toggle) is not a DOCUMENT change
258
+ * and demands no transform. */
259
+ function stripEntityVocabulary(node) {
260
+ if (Array.isArray(node)) return node.map(stripEntityVocabulary);
261
+ if (node === null || typeof node !== 'object') return node;
262
+ /** @type {any} */
263
+ const out = {};
264
+ for (const key of Object.keys(node)) {
265
+ if (key === 'x-entity' || key === 'x-rename') continue;
266
+ out[key] = stripEntityVocabulary(node[key]);
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /**
272
+ * The relational half of the diff (§9): entity add/drop/rename, the
273
+ * additive and droppable column strategies with their data steps, the
274
+ * rebuild for everything structural, join tables, and the stripped
275
+ * document-change rule.
276
+ * @param {any} fromModel
277
+ * @param {any} toModel
278
+ * @param {any} dialect
279
+ * @param {any[]} steps
280
+ * @param {any} report
281
+ */
282
+ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
283
+ const fromEntities = normalizeEntities(fromModel);
284
+ const toEntities = normalizeEntities(toModel);
285
+ if (fromEntities.size === 0 && toEntities.size === 0) return;
286
+ const fromMapping = fromEntities.size > 0
287
+ ? explainMapping(fromModel) : { entities: {}, joinTables: {} };
288
+ const toMapping = toEntities.size > 0
289
+ ? explainMapping(toModel) : { entities: {}, joinTables: {} };
290
+ const q = dialect.quoteIdentifier;
291
+ const pathText = (name) => dialect.jsonPathText([{ name }]);
292
+ const docExtract = (docSql, name) => dialect.jsonExtract(docSql, pathText(name));
293
+ const storageType = (storage) => dialect.typeFor(storage, 'generated');
294
+
295
+ // ————— declared entity renames (join tables move with them) —————
296
+ const renamedFrom = new Map();
297
+ for (const name of toEntities.keys()) {
298
+ const hint = toModel.entities[name]?.['x-rename'];
299
+ if (hint === undefined) continue;
300
+ if (!fromEntities.has(hint)) {
301
+ throw new TypeError(
302
+ `x-rename on entity '${name}' names '${hint}', which the from-model does not declare`);
303
+ }
304
+ if (fromEntities.has(name) || toEntities.has(hint)) {
305
+ throw new TypeError(
306
+ `x-rename on entity '${name}' collides — a rename consumes its source`);
307
+ }
308
+ renamedFrom.set(name, hint);
309
+ report.renamed.push({ from: hint, to: name });
310
+ steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(hint, name),
311
+ note: `rename entity '${hint}' to '${name}'` });
312
+ for (const joinName of Object.keys(fromMapping.joinTables)) {
313
+ const pair = joinName.split('_');
314
+ if (!pair.includes(hint)) continue;
315
+ const renamedPair = pair.map((part) => (part === hint ? name : part)).sort();
316
+ const newJoin = renamedPair.join('_');
317
+ if (newJoin !== joinName) {
318
+ steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(joinName, newJoin),
319
+ note: `rename join table '${joinName}' with its endpoint` });
320
+ steps.push({ kind: 'ddl',
321
+ sql: dialect.ddl.renameColumn(newJoin, `${hint}_key`, `${name}_key`),
322
+ note: `rename the endpoint column '${hint}_key' with its entity` });
323
+ }
324
+ }
325
+ }
326
+ const consumedOldEntities = new Set(renamedFrom.values());
327
+ const renamedJoinName = (joinName) => {
328
+ const pair = joinName.split('_');
329
+ return pair
330
+ .map((part) => {
331
+ for (const [to, from] of renamedFrom) if (from === part) return to;
332
+ return part;
333
+ })
334
+ .sort().join('_');
335
+ };
336
+
337
+ // ————— per-entity strategies —————
338
+ for (const [name, toEntity] of toEntities) {
339
+ const fromName = renamedFrom.get(name) ?? name;
340
+ const fromEntity = fromEntities.get(fromName);
341
+ const tm = toMapping.entities[name];
342
+
343
+ if (fromEntity === undefined) {
344
+ report.added.push(name);
345
+ for (const sql of planEntity(name, tm, toMapping, dialect).createSql)
346
+ steps.push({ kind: 'ddl', sql, note: `create entity '${name}'` });
347
+ continue;
348
+ }
349
+ const fm = fromMapping.entities[fromName];
350
+
351
+ const fkKey = (fk) => `${fk.column}|${fk.references}|${fk.referencesKey}|${fk.onDelete}`;
352
+ const fromFks = new Set(fm.foreignKeys.map(fkKey));
353
+ const toFks = new Set(tm.foreignKeys.map(fkKey));
354
+ const fksEqual = fromFks.size === toFks.size
355
+ && [...fromFks].every((key) => toFks.has(key));
356
+ const columnFacts = (column) =>
357
+ `${column.storage}|${column.source}|${JSON.stringify(column.check ?? null)}`;
358
+ const fromColumns = new Map(fm.columns.map((column) => [column.name, column]));
359
+ const toColumns = new Map(tm.columns.map((column) => [column.name, column]));
360
+ const changedColumns = [...toColumns.keys()].filter((columnName) =>
361
+ fromColumns.has(columnName)
362
+ && columnFacts(fromColumns.get(columnName)) !== columnFacts(toColumns.get(columnName)));
363
+ const addedColumns = [...toColumns.keys()]
364
+ .filter((columnName) => !fromColumns.has(columnName));
365
+ const droppedColumns = [...fromColumns.keys()]
366
+ .filter((columnName) => !toColumns.has(columnName));
367
+ const keysEqual = JSON.stringify(fm.keys) === JSON.stringify(tm.keys);
368
+
369
+ const needsRebuild = !keysEqual || !fksEqual || changedColumns.length > 0
370
+ || addedColumns.some((columnName) => toColumns.get(columnName).check !== undefined);
371
+
372
+ if (needsRebuild) {
373
+ steps.push(...renderRebuild(name, fromName, fm, tm,
374
+ fromMapping, toMapping, dialect, report));
375
+ }
376
+ else {
377
+ // index diff first (a column cannot drop under a live index);
378
+ // from-side index NAMES survive a rename with the OLD prefix
379
+ const fromPlanIndexes = planEntity(fromName, fm, fromMapping, dialect)
380
+ .expected.indexes;
381
+ const toPlanIndexes = planEntity(name, tm, toMapping, dialect)
382
+ .expected.indexes;
383
+ const disturbed = new Set(droppedColumns);
384
+ const toIndexByName = new Map(toPlanIndexes.map((index) => [index.name, index]));
385
+ const fromIndexByName = new Map(fromPlanIndexes.map((index) => [index.name, index]));
386
+ const indexChanged = (a, b) => a.unique !== b.unique
387
+ || a.columns.join(',') !== b.columns.join(',');
388
+ const indexDies = (index) => !toIndexByName.has(index.name)
389
+ || indexChanged(index, toIndexByName.get(index.name))
390
+ || index.columns.some((column) => disturbed.has(column));
391
+ for (const index of fromPlanIndexes) {
392
+ if (indexDies(index)) {
393
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropIndex(index.name),
394
+ note: `drop index '${index.name}' on '${name}'` });
395
+ }
396
+ }
397
+ // dropped columns: fold survivors back into the document first
398
+ for (const columnName of droppedColumns) {
399
+ const column = fromColumns.get(columnName);
400
+ const property = toEntity.properties.get(columnName);
401
+ const survives = property !== undefined && column.source !== 'epoch(document)';
402
+ if (survives) {
403
+ const fold = column.storage === 'boolean'
404
+ ? dialect.jsonEncode(`CASE WHEN ${q(columnName)} = 1 THEN 'true' ELSE 'false' END`)
405
+ : q(columnName);
406
+ steps.push({ kind: 'sql',
407
+ sql: `UPDATE ${q(name)} SET ${q('doc')} = `
408
+ + `${dialect.jsonSet(q('doc'), pathText(columnName), fold)} `
409
+ + `WHERE ${q(columnName)} IS NOT NULL`,
410
+ note: `fold '${columnName}' back into the document before dropping its column` });
411
+ }
412
+ else if (property === undefined) {
413
+ report.destructive = true;
414
+ }
415
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropColumn(name, columnName),
416
+ note: property === undefined
417
+ ? `DESTRUCTIVE: drop column '${columnName}' on '${name}' — the property is gone`
418
+ : `drop column '${columnName}' on '${name}' (the value lives in the document now)` });
419
+ }
420
+ // added columns (plain, check-free by the rebuild rule)
421
+ for (const columnName of addedColumns) {
422
+ const column = toColumns.get(columnName);
423
+ steps.push({ kind: 'ddl',
424
+ sql: dialect.ddl.addColumn({ table: name,
425
+ column: { name: columnName, type: storageType(column.storage) } }),
426
+ note: `add column '${columnName}' on '${name}'` });
427
+ const wasDocStored = fromEntity.properties.has(columnName);
428
+ if (wasDocStored && column.source === 'epoch(document)') {
429
+ steps.push({ kind: 'sql',
430
+ sql: `UPDATE ${q(name)} SET ${q(columnName)} = `
431
+ + `${dialect.epochFromRfc3339(docExtract(q('doc'), columnName))} `
432
+ + `WHERE ${docExtract(q('doc'), columnName)} IS NOT NULL`,
433
+ note: `derive the epoch column from the document's '${columnName}' strings` });
434
+ }
435
+ else if (wasDocStored) {
436
+ steps.push({ kind: 'sql',
437
+ sql: `UPDATE ${q(name)} SET ${q(columnName)} = ${docExtract(q('doc'), columnName)}, `
438
+ + `${q('doc')} = ${dialect.jsonRemove(q('doc'), pathText(columnName))} `
439
+ + `WHERE ${docExtract(q('doc'), columnName)} IS NOT NULL`,
440
+ note: `move '${columnName}' out of the document into its column` });
441
+ }
442
+ }
443
+ for (const index of toPlanIndexes) {
444
+ const source = fromIndexByName.get(index.name);
445
+ if (source === undefined || indexDies(source)) {
446
+ steps.push({ kind: 'ddl',
447
+ sql: dialect.ddl.createIndex({ name: index.name, table: name,
448
+ columns: index.columns, unique: index.unique }),
449
+ note: `create index '${index.name}' on '${name}'` });
450
+ }
451
+ }
452
+ }
453
+
454
+ // the stripped document-change rule (§9)
455
+ if (canonicalizeJson(stripEntityVocabulary(fromEntity.schema))
456
+ !== canonicalizeJson(stripEntityVocabulary(toEntity.schema))) {
457
+ report.schemaChanged.push(name);
458
+ report.drafts.push(name);
459
+ steps.push({
460
+ kind: 'jslt', collection: name, stylesheet: [], draft: true,
461
+ note: `the document schema of entity '${name}' changed; fill in the transform `
462
+ + '(or delete this step if every stored document already validates) and remove "draft"',
463
+ });
464
+ }
465
+ }
466
+
467
+ // ————— dropped entities —————
468
+ for (const [name] of fromEntities) {
469
+ if (toEntities.has(name) || consumedOldEntities.has(name)) continue;
470
+ report.removed.push(name);
471
+ report.destructive = true;
472
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(name),
473
+ note: `DESTRUCTIVE: drop entity '${name}' and every row in it` });
474
+ }
475
+
476
+ // ————— join tables —————
477
+ const fromJoins = new Set(Object.keys(fromMapping.joinTables).map(renamedJoinName));
478
+ for (const joinName of Object.keys(toMapping.joinTables)) {
479
+ if (fromJoins.has(joinName)) continue;
480
+ for (const sql of planJoinTable(joinName, toMapping.joinTables[joinName],
481
+ toMapping, dialect).createSql) {
482
+ steps.push({ kind: 'ddl', sql, note: `create join table '${joinName}'` });
483
+ }
484
+ }
485
+ const toJoins = new Set(Object.keys(toMapping.joinTables));
486
+ for (const joinName of Object.keys(fromMapping.joinTables)) {
487
+ const finalName = renamedJoinName(joinName);
488
+ if (toJoins.has(finalName)) continue;
489
+ report.destructive = true;
490
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(finalName),
491
+ note: `DESTRUCTIVE: drop join table '${finalName}' and its memberships` });
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Render one rebuild step (§10): self-contained SQL — the temporary
497
+ * table, the column-mapped copy, the final-name indexes.
498
+ */
499
+ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect, report) {
500
+ const q = dialect.quoteIdentifier;
501
+ const pathText = (memberName) => dialect.jsonPathText([{ name: memberName }]);
502
+ const temporary = `${name}__rebuild`;
503
+ const create = [planEntity(temporary, tm, toMapping, dialect).createSql[0]];
504
+ const indexes = planEntity(name, tm, toMapping, dialect).createSql.slice(1);
505
+
506
+ const fromColumns = new Map(fm.columns.map((column) => [column.name, column]));
507
+ const fromFkOnly = fm.foreignKeys
508
+ .filter((fk) => !fromColumns.has(fk.column)).map((fk) => fk.column);
509
+ const fromHas = (columnName) =>
510
+ fromColumns.has(columnName) || fromFkOnly.includes(columnName);
511
+ const storageType = (storage) => dialect.typeFor(storage, 'generated');
512
+
513
+ // the to-table's column order: scalars (non-fk-claimed), then
514
+ // foreign keys, then the document — exactly planEntity's assembly
515
+ const fkNames = new Set(tm.foreignKeys.map((fk) => fk.column));
516
+ const ordered = [
517
+ ...tm.columns.filter((column) => !fkNames.has(column.name))
518
+ .map((column) => ({ name: column.name, column })),
519
+ ...tm.foreignKeys.map((fk) => ({ name: fk.column, column: null })),
520
+ ];
521
+
522
+ /** @type {string[]} */
523
+ const targets = [];
524
+ /** @type {string[]} */
525
+ const sources = [];
526
+ let docExpr = q('doc');
527
+ const lost = [];
528
+ for (const { name: columnName, column } of ordered) {
529
+ targets.push(q(columnName));
530
+ if (fromHas(columnName)) {
531
+ const fromColumn = fromColumns.get(columnName);
532
+ const sameStorage = column === null || fromColumn === undefined
533
+ || fromColumn.storage === column.storage;
534
+ if (column !== null && column.source === 'epoch(document)'
535
+ && fromColumn !== undefined && fromColumn.source !== 'epoch(document)') {
536
+ // plain text column becomes a derived instant: derive from the
537
+ // old column and keep the string in the document
538
+ sources.push(dialect.epochFromRfc3339(q(columnName)));
539
+ docExpr = dialect.jsonSet(docExpr, pathText(columnName), q(columnName));
540
+ }
541
+ else if (sameStorage) {
542
+ sources.push(q(columnName));
543
+ }
544
+ else {
545
+ sources.push(`CAST(${q(columnName)} AS ${storageType(column.storage)})`);
546
+ }
547
+ continue;
548
+ }
549
+ // a new column: from the document when the property existed there
550
+ sources.push(column !== null && column.source === 'epoch(document)'
551
+ ? dialect.epochFromRfc3339(dialect.jsonExtract(q('doc'), pathText(columnName)))
552
+ : dialect.jsonExtract(q('doc'), pathText(columnName)));
553
+ }
554
+ // columns that vanish: fold survivors into the document, name losses
555
+ for (const [columnName, fromColumn] of fromColumns) {
556
+ if (ordered.some((entry) => entry.name === columnName)) continue;
557
+ const survives = fromColumn.source !== 'epoch(document)'
558
+ && toMapping.entities[name] !== undefined
559
+ && tm.document.includes(columnName);
560
+ if (survives) {
561
+ const fold = fromColumn.storage === 'boolean'
562
+ ? dialect.jsonEncode(`CASE WHEN ${q(columnName)} = 1 THEN 'true' ELSE 'false' END`)
563
+ : q(columnName);
564
+ docExpr = dialect.jsonSet(docExpr, pathText(columnName), fold);
565
+ }
566
+ else if (fromColumn.source !== 'epoch(document)') {
567
+ lost.push(columnName);
568
+ }
569
+ }
570
+ // properties that moved INTO columns leave the document
571
+ for (const { name: columnName, column } of ordered) {
572
+ if (!fromHas(columnName) && (column === null || column.source !== 'epoch(document)'))
573
+ docExpr = dialect.jsonRemove(docExpr, pathText(columnName));
574
+ }
575
+ targets.push(q('doc'));
576
+ sources.push(docExpr);
577
+
578
+ if (lost.length > 0) report.destructive = true;
579
+ const copy = `INSERT INTO ${q(temporary)} (${targets.join(', ')}) `
580
+ + `SELECT ${sources.join(', ')} FROM ${q(name)}`;
581
+ return [{
582
+ kind: 'rebuild', table: name, create, copy, indexes,
583
+ note: `rebuild '${name}' (${fromName === name ? '' : `renamed from '${fromName}'; `}`
584
+ + `structural change)${lost.length > 0
585
+ ? ` — DESTRUCTIVE: column(s) ${lost.join(', ')} are dropped with their data` : ''}`,
586
+ }];
587
+ }
588
+
589
+ /**
590
+ * Create a model's WHOLE physical shape on a connection: collections,
591
+ * entity tables and join tables, exactly as `openStore` would. Used
592
+ * by the shadow baseline, the fresh reference database that shape
593
+ * equality compares against, and the tests.
594
+ * @param {any} connection
595
+ * @param {any} model
596
+ * @returns {any} value-or-promise
597
+ */
598
+ export function createModelShape(connection, model) {
599
+ const dialect = connection.dialect;
600
+ /** @type {string[]} */
601
+ const statements = [];
602
+ for (const collection of normalizeModel(model).values())
603
+ statements.push(...planCollection(collection.name, collection, dialect).createSql);
604
+ const entities = normalizeEntities(model);
605
+ if (entities.size > 0) {
606
+ const mapping = explainMapping(model);
607
+ for (const name of Object.keys(mapping.entities)) {
608
+ statements.push(
609
+ ...planEntity(name, mapping.entities[name], mapping, dialect).createSql);
610
+ }
611
+ for (const name of Object.keys(mapping.joinTables)) {
612
+ statements.push(
613
+ ...planJoinTable(name, mapping.joinTables[name], mapping, dialect).createSql);
614
+ }
615
+ }
616
+ const run = (i) => (i >= statements.length
617
+ ? null
618
+ : chain(connection.exec(statements[i]), () => run(i + 1)));
619
+ return run(0);
620
+ }
621
+
622
+ /**
623
+ * The declared schema of a database, normalized for comparison: every
624
+ * object carrying SQL text (tables, indexes), whitespace-collapsed,
625
+ * history table excluded, sorted. Shape equality after a migration —
626
+ * this dump versus a fresh {@link createModelShape} — is the
627
+ * acceptance criterion for every rebuild.
628
+ * @param {any} connection
629
+ * @returns {any} value-or-promise of `{ type, name, owner, sql }[]`
630
+ */
631
+ export function schemaShapeOf(connection) {
632
+ const dialect = connection.dialect;
633
+ return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
634
+ chain(statement.all([]), (rows) => rows
635
+ .filter((row) => row.name !== HISTORY_TABLE && row.owner !== HISTORY_TABLE)
636
+ .map((row) => ({
637
+ type: String(row.type),
638
+ name: String(row.name),
639
+ owner: String(row.owner),
640
+ sql: normalizeSchemaSql(String(row.sql)),
641
+ }))));
642
+ }
643
+
644
+ /**
645
+ * Whitespace-collapse a schema statement and, for a CREATE TABLE,
646
+ * SORT its top-level column/constraint list: `ALTER TABLE ADD COLUMN`
647
+ * appends at the end, so a migrated table's declared order can differ
648
+ * from a fresh build's without differing in meaning — every access in
649
+ * this store is by name.
650
+ * @param {string} sql
651
+ * @returns {string}
652
+ */
653
+ function normalizeSchemaSql(sql) {
654
+ const collapsed = sql.replace(/\s+/g, ' ').trim();
655
+ const open = collapsed.indexOf('(');
656
+ if (!/^CREATE TABLE/i.test(collapsed) || open === -1) return collapsed;
657
+ const close = collapsed.lastIndexOf(')');
658
+ const head = collapsed.slice(0, open + 1);
659
+ const tail = collapsed.slice(close);
660
+ const body = collapsed.slice(open + 1, close);
661
+ /** @type {string[]} */
662
+ const parts = [];
663
+ let depth = 0;
664
+ let quote = null;
665
+ let current = '';
666
+ for (const character of body) {
667
+ if (quote !== null) {
668
+ current += character;
669
+ if (character === quote) quote = null;
670
+ continue;
671
+ }
672
+ if (character === "'" || character === '"') {
673
+ quote = character;
674
+ current += character;
675
+ continue;
676
+ }
677
+ if (character === '(') depth++;
678
+ if (character === ')') depth--;
679
+ if (character === ',' && depth === 0) {
680
+ parts.push(current.trim());
681
+ current = '';
682
+ continue;
683
+ }
684
+ current += character;
685
+ }
686
+ if (current.trim() !== '') parts.push(current.trim());
687
+ return head + parts.sort().join(', ') + tail;
688
+ }
689
+
690
+ /**
691
+ * Compare a migrated database's schema against the shape a fresh
692
+ * `createModelShape(model)` produces, via a throwaway reference
693
+ * database. Returns `null` when equal, or a one-line difference.
694
+ * @param {any} driver
695
+ * @param {any} connection - the migrated database
696
+ * @param {any} model - the target model
697
+ * @param {((connection: any) => any) | undefined} registerFunctions
698
+ * @returns {any} value-or-promise of `string | null`
699
+ */
700
+ export function compareShapeToModel(driver, connection, model, registerFunctions) {
701
+ return chain(driver.open(':memory:', {}), (reference) =>
702
+ chain(registerFunctions !== undefined ? registerFunctions(reference) : null, () => {
703
+ const finish = (result) => chain(reference.close(), () => result);
704
+ let outcome;
705
+ try {
706
+ outcome = chain(createModelShape(reference, model), () =>
707
+ chain(schemaShapeOf(reference), (wanted) =>
708
+ chain(schemaShapeOf(connection), (actual) => {
709
+ const wantedText = JSON.stringify(wanted);
710
+ const actualText = JSON.stringify(actual);
711
+ if (wantedText === actualText) return null;
712
+ const byKey = (rows) => new Map(rows.map(
713
+ (row) => [`${row.type}:${row.name}`, row.sql]));
714
+ const wantedMap = byKey(wanted);
715
+ const actualMap = byKey(actual);
716
+ for (const [key, sql] of wantedMap) {
717
+ if (!actualMap.has(key)) return `missing ${key}`;
718
+ if (actualMap.get(key) !== sql)
719
+ return `${key} differs: have [${actualMap.get(key)}], want [${sql}]`;
720
+ }
721
+ for (const key of actualMap.keys()) {
722
+ if (!wantedMap.has(key)) return `unexpected ${key}`;
723
+ }
724
+ return 'schemas differ in ordering only';
725
+ })));
726
+ }
727
+ catch (error) {
728
+ return chain(reference.close(), () => { throw error; });
729
+ }
730
+ if (outcome instanceof Promise) {
731
+ return outcome.then(
732
+ (value) => chain(reference.close(), () => value),
733
+ (error) => chain(reference.close(), () => { throw error; }));
734
+ }
735
+ return finish(outcome);
736
+ }));
737
+ }
738
+
739
+ const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild']);
740
+
741
+ /**
742
+ * Structural validation of one migration document, including the
743
+ * draft refusal (`JD0021`).
744
+ * @param {any} migration
745
+ */
746
+ function checkMigrationDocument(migration) {
747
+ if (migration === null || typeof migration !== 'object'
748
+ || migration.$migration !== MIGRATION_VERSION
749
+ || typeof migration.id !== 'string' || migration.id === ''
750
+ || typeof migration.from !== 'string' || typeof migration.to !== 'string'
751
+ || !Array.isArray(migration.steps)) {
752
+ throw refuse('JD0023',
753
+ `migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
754
+ }
755
+ for (let i = 0; i < migration.steps.length; i++) {
756
+ const step = migration.steps[i];
757
+ if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
758
+ throw refuse('JD0023',
759
+ `migration '${migration.id}' step ${i} has no recognised kind`);
760
+ }
761
+ if (step.kind === 'rebuild'
762
+ && (typeof step.table !== 'string' || !Array.isArray(step.create)
763
+ || typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
764
+ throw refuse('JD0023',
765
+ `migration '${migration.id}' step ${i} is a rebuild without its rendered `
766
+ + 'table/create/copy/indexes');
767
+ }
768
+ if (step.kind === 'sql' && typeof step.sql !== 'string') {
769
+ throw refuse('JD0023',
770
+ `migration '${migration.id}' step ${i} is a sql step without sql text`);
771
+ }
772
+ if (step.kind === 'jslt' && step.draft === true) {
773
+ throw refuse('JD0021',
774
+ `migration '${migration.id}' step ${i} is a DRAFT transform for collection `
775
+ + `'${step.collection}' — the planner cannot infer a data transform; fill in `
776
+ + 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
777
+ }
778
+ }
779
+ }
780
+
781
+ /**
782
+ * The batched row walk shared by transforms and post-validation:
783
+ * `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
784
+ * — bounded memory over a collection of any size.
785
+ * @param {any} connection
786
+ * @param {string} table
787
+ * @param {number} batchSize
788
+ * @param {(rows: { rid: any, doc: string, key: any }[]) => any} handle
789
+ * value-or-promise per batch
790
+ * @returns {any}
791
+ */
792
+ function walkRows(connection, table, batchSize, handle, keyed = true) {
793
+ // entity tables carry no 'key' column — the transform walk goes by
794
+ // row identity alone; only the collection walks select the key
795
+ const dialect = connection.dialect;
796
+ const q = dialect.quoteIdentifier;
797
+ const rid = dialect.rowIdentity();
798
+ const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
799
+ const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
800
+ + `${keySelect} FROM ${q(table)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
801
+ + `ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
802
+ return chain(connection.prepare(sql), (statement) => {
803
+ const nextBatch = (after) =>
804
+ chain(statement.all([after]), (rows) => {
805
+ if (rows.length === 0) return null;
806
+ return chain(handle(rows), () =>
807
+ nextBatch(rows[rows.length - 1].rid));
808
+ });
809
+ return nextBatch(-1);
810
+ });
811
+ }
812
+
813
+ /** All documents of a collection (the assertion steps' working set —
814
+ * a documented whole-collection read). */
815
+ function allDocs(connection, table) {
816
+ const dialect = connection.dialect;
817
+ const q = dialect.quoteIdentifier;
818
+ const sql = `SELECT ${dialect.jsonText(q('doc'))} AS ${q('doc')} FROM ${q(table)} `
819
+ + `ORDER BY ${dialect.rowIdentity()}`;
820
+ return chain(connection.prepare(sql), (statement) =>
821
+ chain(statement.all([]), (rows) => rows.map((row) => JSON.parse(row.doc))));
822
+ }
823
+
824
+ /**
825
+ * Run one migration's steps against a connection.
826
+ * @param {any} connection
827
+ * @param {any} migration
828
+ * @param {{ batchSize: number, onProgress?: Function }} options
829
+ * @returns {any} value-or-promise
830
+ */
831
+ function runSteps(connection, migration, options) {
832
+ const dialect = connection.dialect;
833
+ const q = dialect.quoteIdentifier;
834
+ const step = (i) => {
835
+ if (i >= migration.steps.length) return null;
836
+ const current = migration.steps[i];
837
+ const fail = (reason, cause) => {
838
+ throw refuse('JD0023',
839
+ `migration '${migration.id}' step ${i} (${current.kind}) failed: ${reason}`,
840
+ cause);
841
+ };
842
+ // every step is its own savepoint inside the migration transaction
843
+ return chain(connection.transaction(() => {
844
+ if (current.kind === 'ddl' || current.kind === 'sql') {
845
+ // 'sql' is a DATA step spelled directly (§9.4): same execution
846
+ // as ddl, distinct on purpose — dry-run always shows it, and a
847
+ // reviewer reads intent from the kind
848
+ try {
849
+ return connection.exec(current.sql);
850
+ }
851
+ catch (cause) {
852
+ return fail(/** @type {Error} */ (cause).message, /** @type {Error} */ (cause));
853
+ }
854
+ }
855
+ if (current.kind === 'rebuild') {
856
+ // the documented ALTER TABLE procedure (§10): create the new
857
+ // shape under the temporary name, copy, drop, rename, recreate
858
+ // indexes, then PRAGMA foreign_key_check INSIDE the
859
+ // transaction — a broken reference fails the migration
860
+ const temporary = `${current.table}__rebuild`;
861
+ const statements = [
862
+ ...current.create,
863
+ current.copy,
864
+ dialect.ddl.dropTable(current.table),
865
+ dialect.ddl.renameTable(temporary, current.table),
866
+ ...current.indexes,
867
+ ];
868
+ const runNext = (j) => {
869
+ if (j >= statements.length) {
870
+ return chain(connection.prepare(dialect.pragma.foreignKeyCheck()),
871
+ (checkStatement) => chain(checkStatement.all([]), (violations) => {
872
+ if (violations.length > 0) {
873
+ fail(`foreign_key_check found ${violations.length} broken reference(s) `
874
+ + `after rebuilding '${current.table}' `
875
+ + `(first: ${JSON.stringify(violations[0])})`);
876
+ }
877
+ return null;
878
+ }));
879
+ }
880
+ try {
881
+ return chain(connection.exec(statements[j]), () => runNext(j + 1));
882
+ }
883
+ catch (cause) {
884
+ return fail(/** @type {Error} */ (cause).message, /** @type {Error} */ (cause));
885
+ }
886
+ };
887
+ return runNext(0);
888
+ }
889
+ if (current.kind === 'jslt') {
890
+ let transform;
891
+ try {
892
+ transform = compileJsltStylesheet(current.stylesheet);
893
+ }
894
+ catch (cause) {
895
+ return fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
896
+ /** @type {Error} */ (cause));
897
+ }
898
+ const updateSql = `UPDATE ${q(current.collection)} SET ${q('doc')} = `
899
+ + `${dialect.jsonEncode(dialect.parameterRef(1, 'doc'))} `
900
+ + `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(2, 'rid')}`;
901
+ let transformed = 0;
902
+ const keyed = false;
903
+ return chain(connection.prepare(updateSql), (update) =>
904
+ chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
905
+ for (const row of rows) {
906
+ const next = transform(JSON.parse(row.doc));
907
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
908
+ fail(`the transform produced a non-document for row ${row.rid}`);
909
+ update.run([JSON.stringify(next), row.rid]);
910
+ transformed++;
911
+ }
912
+ options.onProgress?.({
913
+ migration: migration.id,
914
+ collection: current.collection,
915
+ transformed,
916
+ });
917
+ }, keyed), () => transformed));
918
+ }
919
+ // kind === 'query': the assertion step
920
+ let compiled;
921
+ try {
922
+ compiled = compileJsonQuery(current.assert);
923
+ }
924
+ catch (cause) {
925
+ return fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
926
+ /** @type {Error} */ (cause));
927
+ }
928
+ return chain(allDocs(connection, current.collection), (docs) => {
929
+ if (current.expect === 'ebv') {
930
+ if (!compiled.ebv(docs)) fail('the EBV assertion answered false');
931
+ return null;
932
+ }
933
+ const result = compiled(docs);
934
+ if (result !== undefined) {
935
+ const count = Array.isArray(result) ? result.length : 1;
936
+ fail(`the assertion expected an empty sequence, got ${count} item(s)`);
937
+ }
938
+ return null;
939
+ });
940
+ }), () => step(i + 1));
941
+ };
942
+ return step(0);
943
+ }
944
+
945
+ /**
946
+ * Validate the final state against the target model on this
947
+ * connection: physical shape, schema conformance of every stored
948
+ * document (when a `compileSchema` hook is provided — the real-data
949
+ * widening/narrowing FACT), and key-column consistency for
950
+ * caller-keyed collections.
951
+ * @param {any} connection
952
+ * @param {any} model
953
+ * @param {{ compileSchema?: Function, batchSize: number }} options
954
+ * @returns {any} value-or-promise
955
+ */
956
+ function validateTargetState(connection, model, options) {
957
+ const collections = [...normalizeModel(model).values()];
958
+ const entities = [...normalizeEntities(model).values()];
959
+ const dialect = connection.dialect;
960
+ const q = dialect.quoteIdentifier;
961
+ // entity tables carry no 'key' column; the batched walk goes by row
962
+ // identity and validates every stored document against the target
963
+ const verifyEntity = (i) => {
964
+ if (i >= entities.length) return null;
965
+ const entity = entities[i];
966
+ const validate = options.compileSchema !== undefined
967
+ ? options.compileSchema(entity.schema)
968
+ : null;
969
+ if (validate === null) return verifyEntity(i + 1);
970
+ const rid = dialect.rowIdentity();
971
+ const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')} `
972
+ + `FROM ${q(entity.name)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
973
+ + `ORDER BY ${rid} ${dialect.limitClause(options.batchSize, undefined)}`;
974
+ return chain(connection.prepare(sql), (statement) => {
975
+ const nextBatch = (after) =>
976
+ chain(statement.all([after]), (rows) => {
977
+ if (rows.length === 0) return null;
978
+ for (const row of rows) {
979
+ const outcome = validate(JSON.parse(row.doc));
980
+ const valid = outcome === true || outcome?.valid === true;
981
+ if (!valid) {
982
+ throw refuse('JD0021',
983
+ `entity '${entity.name}': a stored document (row ${row.rid}) does not `
984
+ + 'validate against the target schema — a narrowing needs a data transform');
985
+ }
986
+ }
987
+ return nextBatch(rows[rows.length - 1].rid);
988
+ });
989
+ return nextBatch(-1);
990
+ });
991
+ };
992
+ const verifyNext = (i) => {
993
+ if (i >= collections.length) return null;
994
+ const collection = collections[i];
995
+ const plan = planCollection(collection.name, collection, dialect);
996
+ const validate = options.compileSchema !== undefined
997
+ ? options.compileSchema(collection.schema)
998
+ : null;
999
+ return chain(verifyShape(connection, plan, collection.name, collection.docPath), () =>
1000
+ chain(walkRows(connection, collection.name, options.batchSize, (rows) => {
1001
+ for (const row of rows) {
1002
+ const doc = JSON.parse(row.doc);
1003
+ if (validate !== null) {
1004
+ const outcome = validate(doc);
1005
+ const valid = outcome === true || outcome?.valid === true;
1006
+ if (!valid) {
1007
+ throw refuse('JD0021',
1008
+ `collection '${collection.name}': the stored document under key `
1009
+ + `'${String(row.k)}' does not validate against the target schema — `
1010
+ + 'a narrowing needs a data transform');
1011
+ }
1012
+ }
1013
+ if (collection.keySegments !== null) {
1014
+ let node = doc;
1015
+ for (const segment of collection.keySegments) node = node?.[segment.name];
1016
+ if (node !== row.k) {
1017
+ throw refuse('JD0023',
1018
+ `collection '${collection.name}': a transform changed the key member `
1019
+ + `of '${String(row.k)}' — key changes are not supported in ${MIGRATION_VERSION}`);
1020
+ }
1021
+ }
1022
+ }
1023
+ }), () => verifyNext(i + 1)));
1024
+ };
1025
+ return chain(verifyNext(0), () => verifyEntity(0));
1026
+ }
1027
+
1028
+ /**
1029
+ * Replay the whole migration chain on a shadow database: the baseline
1030
+ * shape is created, every migration's steps run (over an empty data
1031
+ * set — the shadow proves STRUCTURE; the real-data facts are checked
1032
+ * on the real store inside its transaction), and the end shape is
1033
+ * verified against the target model. The real store is untouched
1034
+ * until the shadow passes.
1035
+ * @param {any} driver
1036
+ * @param {string} shadowPath
1037
+ * @param {any} baseline
1038
+ * @param {any[]} migrations
1039
+ * @param {any} model - target model or undefined
1040
+ * @param {{ batchSize: number }} options
1041
+ * @returns {any} value-or-promise
1042
+ */
1043
+ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options) {
1044
+ return chain(driver.open(shadowPath, {}), (shadow) => {
1045
+ const finish = (result) => chain(shadow.close(), () => result);
1046
+ // a UDF-expression index is invisible to a connection that has not
1047
+ // registered the function (probed, never assumed): the shadow
1048
+ // re-registers every declared function BEFORE any DDL runs
1049
+ const registered = options.registerFunctions !== undefined
1050
+ ? options.registerFunctions(shadow)
1051
+ : null;
1052
+ const apply = (i) => {
1053
+ if (i >= migrations.length) return null;
1054
+ const bracket = migrations[i].steps.some(
1055
+ (candidate) => candidate.kind === 'rebuild');
1056
+ return chain(
1057
+ bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(false)) : null,
1058
+ () => chain(runSteps(shadow, migrations[i], options), () =>
1059
+ chain(bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(true)) : null,
1060
+ () => apply(i + 1))));
1061
+ };
1062
+ const run = () => chain(registered, () =>
1063
+ chain(createModelShape(shadow, baseline), () => chain(apply(0), () => {
1064
+ if (model === undefined) return null;
1065
+ const target = [...normalizeModel(model).values()];
1066
+ const verifyNext = (i) => {
1067
+ if (i >= target.length) return null;
1068
+ const plan = planCollection(target[i].name, target[i], shadow.dialect);
1069
+ return chain(
1070
+ verifyShape(shadow, plan, target[i].name, target[i].docPath),
1071
+ () => verifyNext(i + 1));
1072
+ };
1073
+ return chain(verifyNext(0), () => {
1074
+ if (normalizeEntities(model).size === 0) return null;
1075
+ // relational models: SHAPE EQUALITY against a fresh build is
1076
+ // the acceptance criterion — stronger than per-plan checks
1077
+ return chain(
1078
+ compareShapeToModel(driver, shadow, model, options.registerFunctions),
1079
+ (difference) => {
1080
+ if (difference !== null) {
1081
+ throw refuse('JD0023',
1082
+ `the shadow's migrated shape does not equal the target model's: ${difference}`);
1083
+ }
1084
+ return null;
1085
+ });
1086
+ });
1087
+ })));
1088
+ let outcome;
1089
+ try {
1090
+ outcome = run();
1091
+ }
1092
+ catch (error) {
1093
+ return chain(shadow.close(), () => { throw error; });
1094
+ }
1095
+ if (outcome instanceof Promise) {
1096
+ return outcome.then(
1097
+ (value) => chain(shadow.close(), () => value),
1098
+ (error) => chain(shadow.close(), () => { throw error; }));
1099
+ }
1100
+ return finish(outcome);
1101
+ });
1102
+ }
1103
+
1104
+ /** History-table statement builders (dialect-spelled). */
1105
+ function historyStatements(dialect) {
1106
+ const q = dialect.quoteIdentifier;
1107
+ const text = dialect.typeFor('string', 'key');
1108
+ const integer = dialect.typeFor('integer', 'key');
1109
+ return {
1110
+ create: dialect.ddl.createPlainTable({
1111
+ table: HISTORY_TABLE,
1112
+ columns: [
1113
+ { name: 'id', type: text, primaryKey: true },
1114
+ { name: 'applied_at', type: integer },
1115
+ { name: 'from_hash', type: text },
1116
+ { name: 'to_hash', type: text },
1117
+ { name: 'checksum', type: text },
1118
+ { name: 'steps', type: integer },
1119
+ ],
1120
+ }),
1121
+ select: `SELECT ${['id', 'from_hash', 'to_hash', 'checksum'].map(q).join(', ')} `
1122
+ + `FROM ${q(HISTORY_TABLE)} ORDER BY ${dialect.rowIdentity()}`,
1123
+ insert: `INSERT INTO ${q(HISTORY_TABLE)} `
1124
+ + `(${['id', 'applied_at', 'from_hash', 'to_hash', 'checksum', 'steps'].map(q).join(', ')}) `
1125
+ + `VALUES (${[1, 2, 3, 4, 5, 6].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
1126
+ };
1127
+ }
1128
+
1129
+ /**
1130
+ * Report a database's migration state without touching it: what is
1131
+ * applied, what is pending, whether an applied migration was edited,
1132
+ * and — once the chain is fully applied — whether the physical shape
1133
+ * DRIFTED from the model (someone changed the database by hand, §12).
1134
+ * @param {{ driver: any, path?: string }} target
1135
+ * @param {any[]} migrations - the full ordered list
1136
+ * @param {{ baseline: any, model?: any,
1137
+ * registerFunctions?: (connection: any) => any }} options
1138
+ * @returns {Promise<{ applied: string[], pending: string[],
1139
+ * drift: string | null, upToDate: boolean }>}
1140
+ */
1141
+ export function migrationStatus(target, migrations, options) {
1142
+ return toPromise(chain(
1143
+ target.driver.open(target.path ?? ':memory:', {}),
1144
+ (connection) => {
1145
+ const dialect = connection.dialect;
1146
+ const statements = historyStatements(dialect);
1147
+ const finish = (result) => chain(connection.close(), () => result);
1148
+ const failClosed = (error) => chain(connection.close(), () => { throw error; });
1149
+ let work;
1150
+ try {
1151
+ work = chain(connection.exec(statements.create), () =>
1152
+ chain(connection.prepare(statements.select), (select) =>
1153
+ chain(select.all([]), (rows) => {
1154
+ for (let i = 0; i < rows.length; i++) {
1155
+ const doc = migrations[i];
1156
+ if (doc === undefined || doc.id !== rows[i].id
1157
+ || migrationChecksum(doc) !== rows[i].checksum) {
1158
+ throw refuse('JD0022',
1159
+ `history position ${i} records '${rows[i].id}' but the migration list `
1160
+ + `has '${doc?.id ?? '<nothing>'}' (or an edited document)`);
1161
+ }
1162
+ }
1163
+ const applied = rows.map((row) => String(row.id));
1164
+ const pending = migrations.slice(rows.length)
1165
+ .map((migration) => String(migration.id));
1166
+ if (pending.length > 0 || options.model === undefined) {
1167
+ return { applied, pending, drift: null, upToDate: pending.length === 0 };
1168
+ }
1169
+ return chain(
1170
+ compareShapeToModel(target.driver, connection, options.model,
1171
+ options.registerFunctions),
1172
+ (difference) => ({
1173
+ applied, pending, drift: difference, upToDate: difference === null,
1174
+ }));
1175
+ })));
1176
+ }
1177
+ catch (error) {
1178
+ return failClosed(error);
1179
+ }
1180
+ return work instanceof Promise ? work.then(finish, failClosed) : finish(work);
1181
+ }));
1182
+ }
1183
+
1184
+ /**
1185
+ * Apply pending migrations to a database.
1186
+ *
1187
+ * The contract: `migrations` is the FULL ordered list (applied and
1188
+ * pending — the migrations directory); `baseline` is the model the
1189
+ * store was first created with (the chain's anchor and the shadow's
1190
+ * starting shape); `model` is the target model the code now carries.
1191
+ * Each pending migration runs in ONE exclusive transaction with a
1192
+ * savepoint per step; a failing step rolls the whole migration back.
1193
+ * The whole chain replays on a `:memory:` shadow before the real
1194
+ * store is touched.
1195
+ *
1196
+ * @param {{ driver: any, path?: string, busyTimeout?: number }} target
1197
+ * @param {any[]} migrations
1198
+ * @param {{ baseline: any, model?: any, compileSchema?: Function,
1199
+ * dryRun?: boolean, batchSize?: number, onProgress?: Function,
1200
+ * shadow?: boolean, shadowPath?: string }} options
1201
+ * @returns {Promise<any>}
1202
+ */
1203
+ export function migrate(target, migrations, options) {
1204
+ if (target === null || typeof target !== 'object'
1205
+ || target.driver === null || typeof target.driver !== 'object'
1206
+ || typeof target.driver.open !== 'function')
1207
+ throw new TypeError('migrate needs { driver } (and usually { path })');
1208
+ if (!Array.isArray(migrations))
1209
+ throw new TypeError('migrate needs the full ordered migration list');
1210
+ if (options === null || typeof options !== 'object' || options.baseline === undefined)
1211
+ throw new TypeError(
1212
+ 'migrate needs { baseline }: the model the store was first created with '
1213
+ + '(the chain anchor and the shadow starting shape)');
1214
+ const batchSize = options.batchSize ?? 500;
1215
+ const runOptions = {
1216
+ batchSize,
1217
+ onProgress: options.onProgress,
1218
+ registerFunctions: options.registerFunctions,
1219
+ };
1220
+
1221
+ return toPromise(chain(
1222
+ target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
1223
+ (connection) => chain(
1224
+ options.registerFunctions !== undefined
1225
+ ? options.registerFunctions(connection) : null,
1226
+ () => {
1227
+ const dialect = connection.dialect;
1228
+ const statements = historyStatements(dialect);
1229
+ const finish = (result) => chain(connection.close(), () => result);
1230
+ const failClosed = (error) => chain(connection.close(), () => { throw error; });
1231
+
1232
+ let work;
1233
+ try {
1234
+ work = chain(connection.exec(statements.create), () =>
1235
+ chain(connection.prepare(statements.select), (select) =>
1236
+ chain(select.all([]), (appliedRows) => {
1237
+ // the list must agree with the history: same ids, same
1238
+ // order, same checksums — an edited applied migration is
1239
+ // always a bug worth failing on
1240
+ for (let i = 0; i < appliedRows.length; i++) {
1241
+ const row = appliedRows[i];
1242
+ const doc = migrations[i];
1243
+ if (doc === undefined || doc.id !== row.id) {
1244
+ throw refuse('JD0022',
1245
+ `history position ${i} records '${row.id}' but the migration list has `
1246
+ + `'${doc?.id ?? '<nothing>'}' — the list must contain every applied `
1247
+ + 'migration, in order');
1248
+ }
1249
+ if (migrationChecksum(doc) !== row.checksum) {
1250
+ throw refuse('JD0022',
1251
+ `migration '${row.id}' differs from the document recorded in the `
1252
+ + 'history — an applied migration must never be edited');
1253
+ }
1254
+ }
1255
+ const pending = migrations.slice(appliedRows.length);
1256
+ const currentShape = appliedRows.length > 0
1257
+ ? appliedRows[appliedRows.length - 1].to_hash
1258
+ : shapeHash(options.baseline);
1259
+
1260
+ let expectedFrom = currentShape;
1261
+ for (const migration of pending) {
1262
+ checkMigrationDocument(migration);
1263
+ if (migration.from !== expectedFrom) {
1264
+ throw refuse('JD0020',
1265
+ `migration '${migration.id}' expects shape '${migration.from}' but the `
1266
+ + `database is at '${expectedFrom}' — refusing to run against the wrong shape`);
1267
+ }
1268
+ expectedFrom = migration.to;
1269
+ }
1270
+ if (options.model !== undefined && pending.length > 0
1271
+ && expectedFrom !== shapeHash(options.model)) {
1272
+ throw refuse('JD0020',
1273
+ "the last migration's to-hash is not the target model's shape — the "
1274
+ + 'migration chain and the code disagree about where this ends');
1275
+ }
1276
+
1277
+ if (pending.length === 0) {
1278
+ return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
1279
+ }
1280
+
1281
+ const shadowRun = options.shadow === false
1282
+ ? null
1283
+ : replayOnShadow(target.driver, options.shadowPath ?? ':memory:',
1284
+ options.baseline, migrations, options.model, runOptions);
1285
+
1286
+ return chain(shadowRun, () => {
1287
+ if (options.dryRun === true) {
1288
+ const rendered = [];
1289
+ const counts = {};
1290
+ const collect = (i) => {
1291
+ if (i >= pending.length) return null;
1292
+ const migration = pending[i];
1293
+ for (const migrationStep of migration.steps) {
1294
+ if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
1295
+ else if (migrationStep.kind === 'sql') {
1296
+ rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
1297
+ rendered.push(migrationStep.sql);
1298
+ }
1299
+ else if (migrationStep.kind === 'rebuild') {
1300
+ rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
1301
+ rendered.push(...migrationStep.create, migrationStep.copy,
1302
+ dialect.ddl.dropTable(migrationStep.table),
1303
+ dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
1304
+ migrationStep.table),
1305
+ ...migrationStep.indexes,
1306
+ dialect.pragma.foreignKeyCheck());
1307
+ }
1308
+ else if (migrationStep.kind === 'jslt')
1309
+ rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
1310
+ else rendered.push(`-- assert over '${migrationStep.collection}'`);
1311
+ }
1312
+ const jsltCollections = [...new Set(migration.steps
1313
+ .filter((s) => s.kind === 'jslt').map((s) => s.collection))];
1314
+ const count = (j) => {
1315
+ if (j >= jsltCollections.length) return null;
1316
+ const table = jsltCollections[j];
1317
+ const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
1318
+ + `FROM ${dialect.quoteIdentifier(table)}`;
1319
+ return chain(connection.prepare(countSql), (statement) =>
1320
+ chain(statement.get([]), (row) => {
1321
+ counts[table] = row.n;
1322
+ return count(j + 1);
1323
+ }));
1324
+ };
1325
+ return chain(count(0), () => collect(i + 1));
1326
+ };
1327
+ return chain(collect(0), () => ({
1328
+ dryRun: true,
1329
+ pending: pending.map((migration) => migration.id),
1330
+ statements: rendered,
1331
+ counts,
1332
+ shadowValidated: options.shadow !== false,
1333
+ }));
1334
+ }
1335
+
1336
+ // the real run: one exclusive transaction per migration
1337
+ const applied = [];
1338
+ const applyNext = (i) => {
1339
+ if (i >= pending.length) return null;
1340
+ const migration = pending[i];
1341
+ const last = i === pending.length - 1;
1342
+ // the §10 procedure's pragma bracket, literally: the
1343
+ // foreign_keys pragma is a no-op inside a transaction,
1344
+ // and node:sqlite enables enforcement BY DEFAULT — a
1345
+ // parent-table rebuild could not even DROP without this
1346
+ const bracket = migration.steps.some(
1347
+ (candidate) => candidate.kind === 'rebuild');
1348
+ return chain(
1349
+ bracket ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1350
+ () => chain(connection.exec(dialect.tx.beginImmediate), () => {
1351
+ const body = () => chain(runSteps(connection, migration, runOptions), () =>
1352
+ chain(last && options.model !== undefined
1353
+ ? chain(validateTargetState(connection, options.model,
1354
+ { compileSchema: options.compileSchema, batchSize }),
1355
+ () => (normalizeEntities(options.model).size === 0 ? null
1356
+ : chain(compareShapeToModel(target.driver, connection,
1357
+ options.model, options.registerFunctions), (difference) => {
1358
+ if (difference !== null) {
1359
+ throw refuse('JD0023',
1360
+ `the migrated shape does not equal the target model's: ${difference}`);
1361
+ }
1362
+ return null;
1363
+ })))
1364
+ : null,
1365
+ () => chain(connection.prepare(statements.insert), (insert) =>
1366
+ insert.run([migration.id, Date.now(), migration.from,
1367
+ migration.to, migrationChecksum(migration),
1368
+ migration.steps.length]))));
1369
+ const restore = () => (bracket
1370
+ ? connection.exec(dialect.pragma.foreignKeys(true)) : null);
1371
+ const commit = () => chain(connection.exec(dialect.tx.commit), () =>
1372
+ chain(restore(), () => {
1373
+ applied.push(migration.id);
1374
+ return applyNext(i + 1);
1375
+ }));
1376
+ const rollback = (error) =>
1377
+ chain(connection.exec(dialect.tx.rollback), () =>
1378
+ chain(restore(), () => { throw error; }));
1379
+ // only body() may route to this migration's rollback:
1380
+ // commit() chains the NEXT migration, whose failure
1381
+ // rolls ITSELF back — catching it here would roll
1382
+ // back a transaction that already committed
1383
+ let outcome;
1384
+ try {
1385
+ outcome = body();
1386
+ }
1387
+ catch (error) {
1388
+ return rollback(error);
1389
+ }
1390
+ return outcome instanceof Promise
1391
+ ? outcome.then(commit, rollback)
1392
+ : commit();
1393
+ }));
1394
+ };
1395
+ return chain(applyNext(0), () => ({
1396
+ applied,
1397
+ skipped: appliedRows.map((row) => row.id),
1398
+ shape: expectedFrom,
1399
+ }));
1400
+ });
1401
+ })));
1402
+
1403
+ }
1404
+ catch (error) {
1405
+ return failClosed(error);
1406
+ }
1407
+ return work instanceof Promise
1408
+ ? work.then(finish, failClosed)
1409
+ : finish(work);
1410
+ })));
1411
+ }