@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.
- package/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- 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
|
+
}
|