@jarenjs/db 0.49.2 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/migrate.js
CHANGED
|
@@ -23,15 +23,28 @@
|
|
|
23
23
|
|
|
24
24
|
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
25
25
|
import { hashContent } from '@jarenjs/core/string';
|
|
26
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
27
|
+
import { refuseCancelled } from './cancellation.js';
|
|
28
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
26
29
|
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
27
30
|
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
28
31
|
|
|
29
32
|
import { DbCompileError } from './errors.js';
|
|
30
33
|
import { chain, toPromise } from './driver.js';
|
|
31
34
|
import { normalizeModel } from './store.js';
|
|
35
|
+
import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
|
|
36
|
+
import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
|
|
32
37
|
import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
|
|
33
38
|
import { normalizeEntities, explainMapping } from './model.js';
|
|
34
39
|
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
40
|
+
import { mergeEntityRow } from './graph.js';
|
|
41
|
+
import { entityCore } from './entity.js';
|
|
42
|
+
import {
|
|
43
|
+
MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
|
|
44
|
+
normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
|
|
45
|
+
} from './document-steps.js';
|
|
46
|
+
|
|
47
|
+
export { MIGRATION_VERSION, isPerDocumentAssertion, ASSERTION_BOUNDS_DEFAULT };
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
50
|
* The physical mapping a connection's driver imposes on derived index
|
|
@@ -41,22 +54,30 @@ import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
|
41
54
|
* @param {any} connection
|
|
42
55
|
* @returns {{ derived: 'virtual' | 'stored', rtree: boolean }}
|
|
43
56
|
*/
|
|
44
|
-
function mappingFor(connection) {
|
|
57
|
+
function mappingFor(connection, expressions = undefined) {
|
|
58
|
+
const registered = connection.capabilities?.deterministicIndexableFunctions === true;
|
|
45
59
|
return {
|
|
46
|
-
derived:
|
|
47
|
-
? 'virtual' : 'stored',
|
|
60
|
+
derived: registered ? 'virtual' : 'stored',
|
|
48
61
|
// the same reasoning for the R*Tree mapping: a build without the
|
|
49
62
|
// module plans (and verifies) the B-tree shape
|
|
50
63
|
rtree: connection.capabilities?.rtree === true,
|
|
64
|
+
// and the same for a declared index expression: this connection
|
|
65
|
+
// either computes it or calls the engine's own immutable function
|
|
66
|
+
expressions,
|
|
67
|
+
registered,
|
|
51
68
|
};
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
/** The migration format version. */
|
|
55
|
-
export const MIGRATION_VERSION = '0.1';
|
|
56
|
-
|
|
57
71
|
/** The history table name (outside the model's identifier namespace
|
|
58
72
|
* conventions on purpose — a collection cannot collide with it). */
|
|
59
73
|
export const HISTORY_TABLE = '_jaren_migrations';
|
|
74
|
+
/** The tables the engine owns beside a model's: never a shape-drift finding. */
|
|
75
|
+
/** The tables this package owns. A model never declared one, so one
|
|
76
|
+
* found in a database is the engine's own bookkeeping rather than
|
|
77
|
+
* anybody's drift — the drift check skips them and the introspector
|
|
78
|
+
* does not derive them. */
|
|
79
|
+
export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
|
|
80
|
+
JOBS_TABLE, JOB_CHECKPOINTS_TABLE]);
|
|
60
81
|
|
|
61
82
|
/**
|
|
62
83
|
* The signature-grade identity of a model SHAPE.
|
|
@@ -64,7 +85,41 @@ export const HISTORY_TABLE = '_jaren_migrations';
|
|
|
64
85
|
* @returns {string}
|
|
65
86
|
*/
|
|
66
87
|
export function shapeHash(model) {
|
|
67
|
-
return hashContent(canonicalizeJson(model));
|
|
88
|
+
return hashContent(canonicalizeJson(withoutRenameHints(model)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The model without its `x-rename` hints. A hint is a PLANNING
|
|
93
|
+
* instruction, not shape: two models that differ only by the hint
|
|
94
|
+
* describe the same database, and hashing the hint made an empty
|
|
95
|
+
* migration necessary just to move the recorded shape once the hint
|
|
96
|
+
* was removed.
|
|
97
|
+
* @param {any} model
|
|
98
|
+
* @returns {any}
|
|
99
|
+
*/
|
|
100
|
+
function withoutRenameHints(model) {
|
|
101
|
+
if (model === null || typeof model !== 'object') return model;
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const key of Object.keys(model)) setObjectMember(out, key, model[key]);
|
|
104
|
+
for (const member of ['collections', 'entities']) {
|
|
105
|
+
const declared = model[member];
|
|
106
|
+
if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) continue;
|
|
107
|
+
const stripped = {};
|
|
108
|
+
for (const name of Object.keys(declared)) {
|
|
109
|
+
const spec = declared[name];
|
|
110
|
+
if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)
|
|
111
|
+
&& Object.hasOwn(spec, 'x-rename')) {
|
|
112
|
+
const copy = { ...spec };
|
|
113
|
+
delete copy['x-rename'];
|
|
114
|
+
setObjectMember(stripped, name, copy);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
setObjectMember(stripped, name, spec);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
out[member] = stripped;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
68
123
|
}
|
|
69
124
|
|
|
70
125
|
/**
|
|
@@ -125,7 +180,8 @@ function deriveStep(collection, plan, columnNames, note) {
|
|
|
125
180
|
* @param {any} fromModel
|
|
126
181
|
* @param {any} toModel
|
|
127
182
|
* @param {{ id?: string, dialect?: any,
|
|
128
|
-
* derived?: 'virtual' | 'stored', rtree?: boolean
|
|
183
|
+
* derived?: 'virtual' | 'stored', rtree?: boolean,
|
|
184
|
+
* expressions?: Record<string, any> }} [options]
|
|
129
185
|
* @returns {{ migration: any, report: {
|
|
130
186
|
* renamed: { from: string, to: string }[],
|
|
131
187
|
* added: string[], removed: string[],
|
|
@@ -136,9 +192,13 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
136
192
|
const dialect = options?.dialect ?? null;
|
|
137
193
|
if (dialect === null || typeof dialect !== 'object')
|
|
138
194
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
139
|
-
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false
|
|
140
|
-
|
|
141
|
-
|
|
195
|
+
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
|
|
196
|
+
// a model that declares an index EXPRESSION resolves its functions
|
|
197
|
+
// here too: a plan is DDL, and DDL over a function this planner was
|
|
198
|
+
// not told about is DDL nobody can apply
|
|
199
|
+
expressions: options?.expressions, registered: options?.derived !== 'stored' };
|
|
200
|
+
const fromCollections = normalizeModel(fromModel, options?.expressions);
|
|
201
|
+
const toCollections = normalizeModel(toModel, options?.expressions);
|
|
142
202
|
|
|
143
203
|
const steps = [];
|
|
144
204
|
const report = {
|
|
@@ -153,6 +213,10 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
153
213
|
const hint = toModel.collections[name]?.['x-rename'];
|
|
154
214
|
if (hint === undefined) continue;
|
|
155
215
|
if (!fromCollections.has(hint)) {
|
|
216
|
+
// the hint's work is done once the from-model already declares the
|
|
217
|
+
// target and no longer the source: planning a model against itself
|
|
218
|
+
// must yield nothing, not refuse the hint it still carries
|
|
219
|
+
if (fromCollections.has(name)) continue;
|
|
156
220
|
throw new TypeError(
|
|
157
221
|
`x-rename on '${name}' names '${hint}', which the from-model does not declare`);
|
|
158
222
|
}
|
|
@@ -423,6 +487,7 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
|
|
|
423
487
|
const hint = toModel.entities[name]?.['x-rename'];
|
|
424
488
|
if (hint === undefined) continue;
|
|
425
489
|
if (!fromEntities.has(hint)) {
|
|
490
|
+
if (fromEntities.has(name)) continue; // a satisfied hint (see the collections)
|
|
426
491
|
throw new TypeError(
|
|
427
492
|
`x-rename on entity '${name}' names '${hint}', which the from-model does not declare`);
|
|
428
493
|
}
|
|
@@ -434,30 +499,62 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
|
|
|
434
499
|
report.renamed.push({ from: hint, to: name });
|
|
435
500
|
steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(hint, name),
|
|
436
501
|
note: `rename entity '${hint}' to '${name}'` });
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
502
|
+
}
|
|
503
|
+
const consumedOldEntities = new Set(renamedFrom.values());
|
|
504
|
+
// a join table's endpoints come from the MAPPING, never from splitting
|
|
505
|
+
// its name: an entity named with an underscore, or a `through` name,
|
|
506
|
+
// does not split into its endpoints — and a split that guessed wrong
|
|
507
|
+
// renamed the table to a name nothing declares, created the declared
|
|
508
|
+
// one empty, and dropped the memberships as "destructive"
|
|
509
|
+
const implicitJoinName = (join) => [join.left.entity, join.right.entity].sort().join('_');
|
|
510
|
+
const renamedEntity = (entityName) => {
|
|
511
|
+
for (const [to, from] of renamedFrom) if (from === entityName) return to;
|
|
512
|
+
return entityName;
|
|
513
|
+
};
|
|
514
|
+
const renamedJoinName = (joinName) => {
|
|
515
|
+
const join = fromMapping.joinTables[joinName];
|
|
516
|
+
if (joinName !== implicitJoinName(join)) return joinName; // a `through` name stays
|
|
517
|
+
return [renamedEntity(join.left.entity), renamedEntity(join.right.entity)].sort().join('_');
|
|
518
|
+
};
|
|
519
|
+
for (const [joinName, join] of Object.entries(fromMapping.joinTables)) {
|
|
520
|
+
if (![join.left, join.right].some((side) => renamedEntity(side.entity) !== side.entity)) continue;
|
|
521
|
+
const newJoin = renamedJoinName(joinName);
|
|
522
|
+
const target = toMapping.joinTables[newJoin];
|
|
523
|
+
if (target === undefined) continue; // the relation is gone: the drop below names it
|
|
524
|
+
// the fresh build orders the endpoint columns by the SORTED entity
|
|
525
|
+
// names, and a rename can flip that order — then the primary key's
|
|
526
|
+
// column order would differ from a fresh build's and the shape
|
|
527
|
+
// check would refuse the migrated database, so the table is rebuilt
|
|
528
|
+
// in the target order with its rows copied; when the order holds,
|
|
529
|
+
// renaming the table and the column is enough
|
|
530
|
+
const renamedColumns = [join.left, join.right]
|
|
531
|
+
.map((side) => ({ from: side.column, to: `${renamedEntity(side.entity)}_key` }));
|
|
532
|
+
const targetOrder = [target.left.column, target.right.column];
|
|
533
|
+
const sameOrder = renamedColumns.every((column, i) => column.to === targetOrder[i]);
|
|
534
|
+
if (sameOrder) {
|
|
442
535
|
if (newJoin !== joinName) {
|
|
443
536
|
steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(joinName, newJoin),
|
|
444
537
|
note: `rename join table '${joinName}' with its endpoint` });
|
|
445
|
-
steps.push({ kind: 'ddl',
|
|
446
|
-
sql: dialect.ddl.renameColumn(newJoin, `${hint}_key`, `${name}_key`),
|
|
447
|
-
note: `rename the endpoint column '${hint}_key' with its entity` });
|
|
448
538
|
}
|
|
539
|
+
for (const column of renamedColumns) {
|
|
540
|
+
if (column.from === column.to) continue;
|
|
541
|
+
steps.push({ kind: 'ddl', sql: dialect.ddl.renameColumn(newJoin, column.from, column.to),
|
|
542
|
+
note: `rename the endpoint column '${column.from}' with its entity` });
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
449
545
|
}
|
|
546
|
+
const q = dialect.quoteIdentifier;
|
|
547
|
+
const sourceOf = (toColumn) => renamedColumns.find((column) => column.to === toColumn).from;
|
|
548
|
+
for (const sql of planJoinTable(newJoin, target, toMapping, dialect).createSql) {
|
|
549
|
+
steps.push({ kind: 'ddl', sql, note: `rebuild join table '${joinName}' as '${newJoin}' in its endpoint order` });
|
|
550
|
+
}
|
|
551
|
+
steps.push({ kind: 'sql',
|
|
552
|
+
sql: `INSERT INTO ${q(newJoin)} (${targetOrder.map(q).join(', ')}) `
|
|
553
|
+
+ `SELECT ${targetOrder.map((column) => q(sourceOf(column))).join(', ')} FROM ${q(joinName)}`,
|
|
554
|
+
note: `copy the memberships of '${joinName}' into '${newJoin}'` });
|
|
555
|
+
steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(joinName),
|
|
556
|
+
note: `drop '${joinName}' — its memberships now live in '${newJoin}'` });
|
|
450
557
|
}
|
|
451
|
-
const consumedOldEntities = new Set(renamedFrom.values());
|
|
452
|
-
const renamedJoinName = (joinName) => {
|
|
453
|
-
const pair = joinName.split('_');
|
|
454
|
-
return pair
|
|
455
|
-
.map((part) => {
|
|
456
|
-
for (const [to, from] of renamedFrom) if (from === part) return to;
|
|
457
|
-
return part;
|
|
458
|
-
})
|
|
459
|
-
.sort().join('_');
|
|
460
|
-
};
|
|
461
558
|
|
|
462
559
|
// ————— per-entity strategies —————
|
|
463
560
|
for (const [name, toEntity] of toEntities) {
|
|
@@ -589,9 +686,26 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
|
|
|
589
686
|
}
|
|
590
687
|
}
|
|
591
688
|
|
|
592
|
-
// ————— dropped entities —————
|
|
593
|
-
|
|
594
|
-
|
|
689
|
+
// ————— dropped entities, children before parents —————
|
|
690
|
+
// foreign keys are enforced while a migration runs (node:sqlite has
|
|
691
|
+
// them on by default), so a parent with RESTRICT children cannot go
|
|
692
|
+
// first: the dropped set is ordered so that every entity referencing
|
|
693
|
+
// another dropped entity is dropped before it
|
|
694
|
+
const dropped = [...fromEntities.keys()]
|
|
695
|
+
.filter((name) => !toEntities.has(name) && !consumedOldEntities.has(name));
|
|
696
|
+
const droppedSet = new Set(dropped);
|
|
697
|
+
const references = (name) => new Set(fromMapping.entities[name].foreignKeys
|
|
698
|
+
.map((fk) => fk.references).filter((target) => droppedSet.has(target) && target !== name));
|
|
699
|
+
const dropOrder = [];
|
|
700
|
+
const placed = new Set();
|
|
701
|
+
while (dropOrder.length < dropped.length) {
|
|
702
|
+
// ready: every dropped entity that no other UNPLACED dropped entity references
|
|
703
|
+
const ready = dropped.filter((name) => !placed.has(name)
|
|
704
|
+
&& !dropped.some((other) => !placed.has(other) && other !== name && references(other).has(name)));
|
|
705
|
+
if (ready.length === 0) { dropOrder.push(...dropped.filter((name) => !placed.has(name))); break; }
|
|
706
|
+
for (const name of ready) { placed.add(name); dropOrder.push(name); }
|
|
707
|
+
}
|
|
708
|
+
for (const name of dropOrder) {
|
|
595
709
|
report.removed.push(name);
|
|
596
710
|
report.destructive = true;
|
|
597
711
|
steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(name),
|
|
@@ -650,6 +764,12 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
|
|
|
650
764
|
const sources = [];
|
|
651
765
|
let docExpr = q('doc');
|
|
652
766
|
const lost = [];
|
|
767
|
+
// a SQL NULL is an ABSENT member (§9.3): folding it in as JSON null
|
|
768
|
+
// turned every row without the value into a narrowing the target
|
|
769
|
+
// schema refused
|
|
770
|
+
const foldColumn = (expression, columnName, fold) =>
|
|
771
|
+
`CASE WHEN ${q(columnName)} IS NULL THEN ${expression} `
|
|
772
|
+
+ `ELSE ${dialect.jsonSet(expression, pathText(columnName), fold)} END`;
|
|
653
773
|
for (const { name: columnName, column } of ordered) {
|
|
654
774
|
targets.push(q(columnName));
|
|
655
775
|
if (fromHas(columnName)) {
|
|
@@ -659,9 +779,10 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
|
|
|
659
779
|
if (column !== null && column.source === 'epoch(document)'
|
|
660
780
|
&& fromColumn !== undefined && fromColumn.source !== 'epoch(document)') {
|
|
661
781
|
// plain text column becomes a derived instant: derive from the
|
|
662
|
-
// old column and keep the string in the document
|
|
782
|
+
// old column and keep the string in the document — an absent
|
|
783
|
+
// string stays absent (§9.3), never a JSON null
|
|
663
784
|
sources.push(dialect.epochFromRfc3339(q(columnName)));
|
|
664
|
-
docExpr =
|
|
785
|
+
docExpr = foldColumn(docExpr, columnName, q(columnName));
|
|
665
786
|
}
|
|
666
787
|
else if (sameStorage) {
|
|
667
788
|
sources.push(q(columnName));
|
|
@@ -686,12 +807,25 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
|
|
|
686
807
|
const fold = fromColumn.storage === 'boolean'
|
|
687
808
|
? dialect.jsonEncode(`CASE WHEN ${q(columnName)} = 1 THEN 'true' ELSE 'false' END`)
|
|
688
809
|
: q(columnName);
|
|
689
|
-
docExpr =
|
|
810
|
+
docExpr = foldColumn(docExpr, columnName, fold);
|
|
690
811
|
}
|
|
691
812
|
else if (fromColumn.source !== 'epoch(document)') {
|
|
692
813
|
lost.push(columnName);
|
|
693
814
|
}
|
|
694
815
|
}
|
|
816
|
+
// an INFERRED foreign-key column (no property of its own) that the
|
|
817
|
+
// target no longer carries: its values are lost unless the target
|
|
818
|
+
// declares the property, in which case they fold into the document —
|
|
819
|
+
// walking the mapped columns alone dropped it without a word
|
|
820
|
+
for (const columnName of fromFkOnly) {
|
|
821
|
+
if (ordered.some((entry) => entry.name === columnName)) continue;
|
|
822
|
+
if (toMapping.entities[name] !== undefined && tm.document.includes(columnName)) {
|
|
823
|
+
docExpr = foldColumn(docExpr, columnName, q(columnName));
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
lost.push(columnName);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
695
829
|
// properties that moved INTO columns leave the document
|
|
696
830
|
for (const { name: columnName, column } of ordered) {
|
|
697
831
|
if (!fromHas(columnName) && (column === null || column.source !== 'epoch(document)'))
|
|
@@ -718,15 +852,19 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
|
|
|
718
852
|
* equality compares against, and the tests.
|
|
719
853
|
* @param {any} connection
|
|
720
854
|
* @param {any} model
|
|
855
|
+
* @param {Record<string, any>} [expressions] - the host's declared
|
|
856
|
+
* index-expression functions, resolved into the DDL the same way the
|
|
857
|
+
* open path resolves them
|
|
721
858
|
* @returns {any} value-or-promise
|
|
722
859
|
*/
|
|
723
|
-
export function createModelShape(connection, model) {
|
|
860
|
+
export function createModelShape(connection, model, expressions = undefined) {
|
|
724
861
|
const dialect = connection.dialect;
|
|
725
862
|
/** @type {string[]} */
|
|
726
863
|
const statements = [];
|
|
727
|
-
for (const collection of normalizeModel(model).values()) {
|
|
864
|
+
for (const collection of normalizeModel(model, expressions).values()) {
|
|
728
865
|
statements.push(
|
|
729
|
-
...planCollection(collection.name, collection, dialect,
|
|
866
|
+
...planCollection(collection.name, collection, dialect,
|
|
867
|
+
mappingFor(connection, expressions)).createSql);
|
|
730
868
|
}
|
|
731
869
|
const entities = normalizeEntities(model);
|
|
732
870
|
if (entities.size > 0) {
|
|
@@ -759,7 +897,9 @@ export function schemaShapeOf(connection) {
|
|
|
759
897
|
const dialect = connection.dialect;
|
|
760
898
|
return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
|
|
761
899
|
chain(statement.all([]), (rows) => rows
|
|
762
|
-
|
|
900
|
+
// the engine's own tables — history, the change log and its state
|
|
901
|
+
// row, the job queue — are never a model's drift
|
|
902
|
+
.filter((row) => !ENGINE_TABLES.has(String(row.name)) && !ENGINE_TABLES.has(String(row.owner)))
|
|
763
903
|
.map((row) => ({
|
|
764
904
|
type: String(row.type),
|
|
765
905
|
name: String(row.name),
|
|
@@ -824,14 +964,22 @@ function normalizeSchemaSql(sql) {
|
|
|
824
964
|
* @param {((connection: any) => any) | undefined} registerFunctions
|
|
825
965
|
* @returns {any} value-or-promise of `string | null`
|
|
826
966
|
*/
|
|
827
|
-
export function compareShapeToModel(driver, connection, model, registerFunctions) {
|
|
967
|
+
export function compareShapeToModel(driver, connection, model, registerFunctions, expressions) {
|
|
968
|
+
// The comparison IS a text comparison: it builds the model's shape in
|
|
969
|
+
// a reference database and compares the two engines' stored CREATE
|
|
970
|
+
// statements. An engine that keeps none has nothing to compare, and
|
|
971
|
+
// says so here rather than reading an undefined statement — the
|
|
972
|
+
// structural drift check (columns, indexes, foreign-key tuples) runs
|
|
973
|
+
// per collection and per entity either way, and `declaredSqlText` is
|
|
974
|
+
// what tells a reader which half they got.
|
|
975
|
+
if (connection.dialect.capabilities.declaredSqlText !== true) return null;
|
|
828
976
|
return chain(driver.open(':memory:', {}), (reference) =>
|
|
829
977
|
chain(chain(registerDeriveFunctions(reference),
|
|
830
978
|
() => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
|
|
831
979
|
const finish = (result) => chain(reference.close(), () => result);
|
|
832
980
|
let outcome;
|
|
833
981
|
try {
|
|
834
|
-
outcome = chain(createModelShape(reference, model), () =>
|
|
982
|
+
outcome = chain(createModelShape(reference, model, expressions), () =>
|
|
835
983
|
chain(schemaShapeOf(reference), (wanted) =>
|
|
836
984
|
chain(schemaShapeOf(connection), (actual) => {
|
|
837
985
|
const wantedText = JSON.stringify(wanted);
|
|
@@ -864,54 +1012,6 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
|
|
|
864
1012
|
}));
|
|
865
1013
|
}
|
|
866
1014
|
|
|
867
|
-
const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* Structural validation of one migration document, including the
|
|
871
|
-
* draft refusal (`JD0021`).
|
|
872
|
-
* @param {any} migration
|
|
873
|
-
*/
|
|
874
|
-
function checkMigrationDocument(migration) {
|
|
875
|
-
if (migration === null || typeof migration !== 'object'
|
|
876
|
-
|| migration.$migration !== MIGRATION_VERSION
|
|
877
|
-
|| typeof migration.id !== 'string' || migration.id === ''
|
|
878
|
-
|| typeof migration.from !== 'string' || typeof migration.to !== 'string'
|
|
879
|
-
|| !Array.isArray(migration.steps)) {
|
|
880
|
-
throw refuse('JD0023',
|
|
881
|
-
`migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
|
|
882
|
-
}
|
|
883
|
-
for (let i = 0; i < migration.steps.length; i++) {
|
|
884
|
-
const step = migration.steps[i];
|
|
885
|
-
if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
|
|
886
|
-
throw refuse('JD0023',
|
|
887
|
-
`migration '${migration.id}' step ${i} has no recognised kind`);
|
|
888
|
-
}
|
|
889
|
-
if (step.kind === 'rebuild'
|
|
890
|
-
&& (typeof step.table !== 'string' || !Array.isArray(step.create)
|
|
891
|
-
|| typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
|
|
892
|
-
throw refuse('JD0023',
|
|
893
|
-
`migration '${migration.id}' step ${i} is a rebuild without its rendered `
|
|
894
|
-
+ 'table/create/copy/indexes');
|
|
895
|
-
}
|
|
896
|
-
if (step.kind === 'sql' && typeof step.sql !== 'string') {
|
|
897
|
-
throw refuse('JD0023',
|
|
898
|
-
`migration '${migration.id}' step ${i} is a sql step without sql text`);
|
|
899
|
-
}
|
|
900
|
-
if (step.kind === 'derive'
|
|
901
|
-
&& (typeof step.collection !== 'string' || !Array.isArray(step.columns)
|
|
902
|
-
|| step.columns.length === 0)) {
|
|
903
|
-
throw refuse('JD0023',
|
|
904
|
-
`migration '${migration.id}' step ${i} is a derive backfill without its columns`);
|
|
905
|
-
}
|
|
906
|
-
if (step.kind === 'jslt' && step.draft === true) {
|
|
907
|
-
throw refuse('JD0021',
|
|
908
|
-
`migration '${migration.id}' step ${i} is a DRAFT transform for collection `
|
|
909
|
-
+ `'${step.collection}' — the planner cannot infer a data transform; fill in `
|
|
910
|
-
+ 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
1015
|
/**
|
|
916
1016
|
* The batched row walk shared by transforms and post-validation:
|
|
917
1017
|
* `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
|
|
@@ -921,38 +1021,67 @@ function checkMigrationDocument(migration) {
|
|
|
921
1021
|
* @param {number} batchSize
|
|
922
1022
|
* @param {(rows: { rid: any, doc: string, key: any }[]) => any} handle
|
|
923
1023
|
* value-or-promise per batch
|
|
1024
|
+
* @param {boolean} [keyed]
|
|
1025
|
+
* @param {any} [entityMapping]
|
|
1026
|
+
* @param {() => void} [check] - run before every batch: the
|
|
1027
|
+
* cancellation boundary a data step has between its batches
|
|
924
1028
|
* @returns {any}
|
|
925
1029
|
*/
|
|
926
|
-
function walkRows(connection, table, batchSize, handle, keyed = true
|
|
1030
|
+
function walkRows(connection, table, batchSize, handle, keyed = true, entityMapping = null,
|
|
1031
|
+
check = undefined) {
|
|
927
1032
|
// entity tables carry no 'key' column — the transform walk goes by
|
|
928
|
-
// row identity alone; only the collection walks select the key
|
|
1033
|
+
// row identity alone; only the collection walks select the key. An
|
|
1034
|
+
// entity's mapped columns ride beside the document so the row can be
|
|
1035
|
+
// read WHOLE (`mergeEntityRow`): a transform or an assertion that saw
|
|
1036
|
+
// the rest-document alone could not see `id` or `name` at all
|
|
929
1037
|
const dialect = connection.dialect;
|
|
930
1038
|
const q = dialect.quoteIdentifier;
|
|
931
1039
|
const rid = dialect.rowIdentity();
|
|
932
1040
|
const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1041
|
+
const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
|
|
1042
|
+
.map((column) => `, ${q(column)}`).join('');
|
|
1043
|
+
const select = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
|
|
1044
|
+
+ `${keySelect}${columnSelect} FROM ${q(table)}`;
|
|
1045
|
+
const ordered = ` ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
|
|
1046
|
+
// An INTEGER PRIMARY KEY can be negative. The first batch has no
|
|
1047
|
+
// lower bound; subsequent batches seek from a row actually read.
|
|
1048
|
+
return chain(connection.prepare(select + ordered), (first) => chain(connection.prepare(
|
|
1049
|
+
`${select} WHERE ${rid} > ${dialect.parameterRef(1, 'after')}${ordered}`), (statement) => {
|
|
1050
|
+
const nextBatch = (after) => {
|
|
1051
|
+
if (check !== undefined) check();
|
|
1052
|
+
return chain(after === undefined ? first.all([]) : statement.all([after]), (rows) => {
|
|
939
1053
|
if (rows.length === 0) return null;
|
|
940
1054
|
return chain(handle(rows), () =>
|
|
941
1055
|
nextBatch(rows[rows.length - 1].rid));
|
|
942
1056
|
});
|
|
943
|
-
|
|
944
|
-
|
|
1057
|
+
};
|
|
1058
|
+
return nextBatch(undefined);
|
|
1059
|
+
}));
|
|
945
1060
|
}
|
|
946
1061
|
|
|
947
|
-
/**
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1062
|
+
/** The physical columns an entity row carries beside its document. */
|
|
1063
|
+
function entityColumnsOf(entityMapping) {
|
|
1064
|
+
const names = new Set(entityMapping.columns.map((column) => column.name));
|
|
1065
|
+
for (const fk of entityMapping.foreignKeys) names.add(fk.column);
|
|
1066
|
+
return [...names];
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The entity mapping a migration step over `table` runs under, or
|
|
1071
|
+
* `null` for a collection. The TARGET model maps the table: a chain's
|
|
1072
|
+
* intermediate shapes are hashes only, so an entity transform belongs
|
|
1073
|
+
* to the last migration of a chain (MIGRATION-FORMAT §9); without a
|
|
1074
|
+
* target model the step sees the rest-document, as it always did.
|
|
1075
|
+
* @param {any} options
|
|
1076
|
+
* @param {string} table
|
|
1077
|
+
* @returns {{ entity: any, mapping: any } | null}
|
|
1078
|
+
*/
|
|
1079
|
+
function entityStepMapping(options, table) {
|
|
1080
|
+
if (options.model === undefined) return null;
|
|
1081
|
+
const entities = normalizeEntities(options.model);
|
|
1082
|
+
const entity = entities.get(table);
|
|
1083
|
+
if (entity === undefined) return null;
|
|
1084
|
+
return { entity, mapping: explainMapping(options.model).entities[table] };
|
|
956
1085
|
}
|
|
957
1086
|
|
|
958
1087
|
/**
|
|
@@ -967,6 +1096,9 @@ function runSteps(connection, migration, options) {
|
|
|
967
1096
|
const q = dialect.quoteIdentifier;
|
|
968
1097
|
const step = (i) => {
|
|
969
1098
|
if (i >= migration.steps.length) return null;
|
|
1099
|
+
// the cancellation boundary between steps: a refusal here rolls the
|
|
1100
|
+
// migration in flight back whole, as any step failure does
|
|
1101
|
+
if (options.check !== undefined) options.check();
|
|
970
1102
|
const current = migration.steps[i];
|
|
971
1103
|
const fail = (reason, cause) => {
|
|
972
1104
|
throw refuse('JD0023',
|
|
@@ -1001,6 +1133,7 @@ function runSteps(connection, migration, options) {
|
|
|
1001
1133
|
];
|
|
1002
1134
|
const runNext = (j) => {
|
|
1003
1135
|
if (j >= statements.length) {
|
|
1136
|
+
if (dialect.capabilities.foreignKeysAlwaysOn === true) return null;
|
|
1004
1137
|
return chain(connection.prepare(dialect.pragma.foreignKeyCheck()),
|
|
1005
1138
|
(checkStatement) => chain(checkStatement.all([]), (violations) => {
|
|
1006
1139
|
if (violations.length > 0) {
|
|
@@ -1046,16 +1179,48 @@ function runSteps(connection, migration, options) {
|
|
|
1046
1179
|
collection: current.collection,
|
|
1047
1180
|
derived: derivedRows,
|
|
1048
1181
|
});
|
|
1049
|
-
}, false), () => derivedRows));
|
|
1182
|
+
}, false, null, options.check), () => derivedRows));
|
|
1050
1183
|
}
|
|
1051
1184
|
if (current.kind === 'jslt') {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1185
|
+
const stepEntity = entityStepMapping(options, current.collection);
|
|
1186
|
+
const operation = compileDocumentStep(current, i, {
|
|
1187
|
+
migrationId: migration.id,
|
|
1188
|
+
compileJslt: compileJsltStylesheet,
|
|
1189
|
+
compileQuery: compileJsonQuery,
|
|
1190
|
+
keys: stepEntity === null ? [] : stepEntity.mapping.keys,
|
|
1191
|
+
});
|
|
1192
|
+
if (stepEntity !== null) {
|
|
1193
|
+
// an entity row is transformed WHOLE: the mapped columns fold in
|
|
1194
|
+
// before the stylesheet and split out after it, through the
|
|
1195
|
+
// entity's own split — a column-mapped member the stylesheet
|
|
1196
|
+
// wrote used to land in the document and be shadowed on read
|
|
1197
|
+
const core = entityCore(connection, stepEntity.entity, stepEntity.mapping, null,
|
|
1198
|
+
options.runtime);
|
|
1199
|
+
const columns = entityColumnsOf(stepEntity.mapping);
|
|
1200
|
+
const assignments = [
|
|
1201
|
+
...columns.map((column, i) => `${q(column)} = ${dialect.parameterRef(i + 1, 'v')}`),
|
|
1202
|
+
`${q('doc')} = ${dialect.jsonEncode(dialect.parameterRef(columns.length + 1, 'doc'))}`,
|
|
1203
|
+
];
|
|
1204
|
+
const updateSql = `UPDATE ${q(current.collection)} SET ${assignments.join(', ')} `
|
|
1205
|
+
+ `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(columns.length + 2, 'rid')}`;
|
|
1206
|
+
let transformed = 0;
|
|
1207
|
+
return chain(connection.prepare(updateSql), (update) =>
|
|
1208
|
+
chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
1209
|
+
for (const row of rows) {
|
|
1210
|
+
const whole = mergeEntityRow(stepEntity.mapping, row, 'doc');
|
|
1211
|
+
const next = operation.apply(whole, row.rid);
|
|
1212
|
+
const { values, rest } = core.plan.split(next);
|
|
1213
|
+
const byName = new Map(values.map((value) => [value.name, value.value]));
|
|
1214
|
+
update.run([...columns.map((column) => byName.get(column) ?? null),
|
|
1215
|
+
JSON.stringify(rest), row.rid]);
|
|
1216
|
+
transformed++;
|
|
1217
|
+
}
|
|
1218
|
+
options.onProgress?.({
|
|
1219
|
+
migration: migration.id,
|
|
1220
|
+
collection: current.collection,
|
|
1221
|
+
transformed,
|
|
1222
|
+
});
|
|
1223
|
+
}, false, stepEntity.mapping, options.check), () => transformed));
|
|
1059
1224
|
}
|
|
1060
1225
|
const updateSql = `UPDATE ${q(current.collection)} SET ${q('doc')} = `
|
|
1061
1226
|
+ `${dialect.jsonEncode(dialect.parameterRef(1, 'doc'))} `
|
|
@@ -1065,9 +1230,7 @@ function runSteps(connection, migration, options) {
|
|
|
1065
1230
|
return chain(connection.prepare(updateSql), (update) =>
|
|
1066
1231
|
chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
1067
1232
|
for (const row of rows) {
|
|
1068
|
-
const next =
|
|
1069
|
-
if (next === null || typeof next !== 'object' || Array.isArray(next))
|
|
1070
|
-
fail(`the transform produced a non-document for row ${row.rid}`);
|
|
1233
|
+
const next = operation.apply(JSON.parse(row.doc), row.rid);
|
|
1071
1234
|
update.run([JSON.stringify(next), row.rid]);
|
|
1072
1235
|
transformed++;
|
|
1073
1236
|
}
|
|
@@ -1076,29 +1239,66 @@ function runSteps(connection, migration, options) {
|
|
|
1076
1239
|
collection: current.collection,
|
|
1077
1240
|
transformed,
|
|
1078
1241
|
});
|
|
1079
|
-
}, keyed), () => transformed));
|
|
1242
|
+
}, keyed, null, options.check), () => transformed));
|
|
1080
1243
|
}
|
|
1081
1244
|
// kind === 'query': the assertion step
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1245
|
+
const assertionMapping = entityStepMapping(options, current.collection)?.mapping ?? null;
|
|
1246
|
+
const operation = compileDocumentStep(current, i, {
|
|
1247
|
+
migrationId: migration.id,
|
|
1248
|
+
compileJslt: compileJsltStylesheet,
|
|
1249
|
+
compileQuery: compileJsonQuery,
|
|
1250
|
+
});
|
|
1251
|
+
const assertOver = operation.assert;
|
|
1252
|
+
const readDoc = (row) => (assertionMapping === null
|
|
1253
|
+
? JSON.parse(row.doc)
|
|
1254
|
+
: mergeEntityRow(assertionMapping, row, 'doc'));
|
|
1255
|
+
|
|
1256
|
+
if (operation.fold !== null) {
|
|
1257
|
+
// an associative aggregate: each batch is answered by the engine
|
|
1258
|
+
// and the partial answers combine, so the collection is never
|
|
1259
|
+
// held. The whole-collection read this replaces is the one
|
|
1260
|
+
// statement a cross-document assertion used to cost.
|
|
1261
|
+
let accumulated = operation.fold.start();
|
|
1262
|
+
let folded = 0;
|
|
1263
|
+
return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
1264
|
+
accumulated = operation.fold.combine(accumulated, rows.map(readDoc));
|
|
1265
|
+
folded += rows.length;
|
|
1266
|
+
options.onProgress?.({
|
|
1267
|
+
migration: migration.id,
|
|
1268
|
+
collection: current.collection,
|
|
1269
|
+
asserted: folded,
|
|
1270
|
+
});
|
|
1271
|
+
}, false, assertionMapping, options.check), () => operation.fold.finish(accumulated));
|
|
1085
1272
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1273
|
+
|
|
1274
|
+
if (!operation.perDocument) {
|
|
1275
|
+
// materializing: the answer needs every document at once. That is
|
|
1276
|
+
// a cost, so it is bounded and the bound is crossed BEFORE the
|
|
1277
|
+
// excess is held — the walk stops at the row that would break it
|
|
1278
|
+
const guard = createAssertionBoundGuard(options.assertionBounds, current.collection,
|
|
1279
|
+
`the assertion of migration '${migration.id}' step ${i}`);
|
|
1280
|
+
const gathered = [];
|
|
1281
|
+
return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
1282
|
+
for (const row of rows) {
|
|
1283
|
+
const doc = readDoc(row);
|
|
1284
|
+
guard.admit(doc, typeof row.doc === 'string' ? row.doc : undefined);
|
|
1285
|
+
gathered.push(doc);
|
|
1286
|
+
}
|
|
1287
|
+
}, false, assertionMapping, options.check), () => assertOver(gathered));
|
|
1089
1288
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1289
|
+
// per-document: walk in keyset batches like every other step,
|
|
1290
|
+
// failing fast at the first batch that violates
|
|
1291
|
+
let asserted = 0;
|
|
1292
|
+
return walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
1293
|
+
const docs = rows.map(readDoc);
|
|
1294
|
+
assertOver(docs);
|
|
1295
|
+
asserted += docs.length;
|
|
1296
|
+
options.onProgress?.({
|
|
1297
|
+
migration: migration.id,
|
|
1298
|
+
collection: current.collection,
|
|
1299
|
+
asserted,
|
|
1300
|
+
});
|
|
1301
|
+
}, false, assertionMapping, options.check);
|
|
1102
1302
|
}), () => step(i + 1));
|
|
1103
1303
|
};
|
|
1104
1304
|
return step(0);
|
|
@@ -1116,10 +1316,9 @@ function runSteps(connection, migration, options) {
|
|
|
1116
1316
|
* @returns {any} value-or-promise
|
|
1117
1317
|
*/
|
|
1118
1318
|
function validateTargetState(connection, model, options) {
|
|
1119
|
-
const collections = [...normalizeModel(model).values()];
|
|
1319
|
+
const collections = [...normalizeModel(model, options.expressions).values()];
|
|
1120
1320
|
const entities = [...normalizeEntities(model).values()];
|
|
1121
1321
|
const dialect = connection.dialect;
|
|
1122
|
-
const q = dialect.quoteIdentifier;
|
|
1123
1322
|
// entity tables carry no 'key' column; the batched walk goes by row
|
|
1124
1323
|
// identity and validates every stored document against the target
|
|
1125
1324
|
const verifyEntity = (i) => {
|
|
@@ -1129,32 +1328,27 @@ function validateTargetState(connection, model, options) {
|
|
|
1129
1328
|
? options.compileSchema(entity.schema)
|
|
1130
1329
|
: null;
|
|
1131
1330
|
if (validate === null) return verifyEntity(i + 1);
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
return chain(connection.
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
return nextBatch(rows[rows.length - 1].rid);
|
|
1150
|
-
});
|
|
1151
|
-
return nextBatch(-1);
|
|
1152
|
-
});
|
|
1331
|
+
// the WHOLE document — mapped columns folded in — is what the target
|
|
1332
|
+
// schema judges; the rest-document alone failed every entity whose
|
|
1333
|
+
// required members are columns, so a pure widening could not land
|
|
1334
|
+
const entityMapping = explainMapping(model).entities[entity.name];
|
|
1335
|
+
return chain(walkRows(connection, entity.name, options.batchSize, (rows) => {
|
|
1336
|
+
for (const row of rows) {
|
|
1337
|
+
const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
|
|
1338
|
+
const valid = outcome === true || outcome?.valid === true;
|
|
1339
|
+
if (!valid) {
|
|
1340
|
+
throw refuse('JD0021',
|
|
1341
|
+
`entity '${entity.name}': a stored document (row ${row.rid}) does not `
|
|
1342
|
+
+ 'validate against the target schema — a narrowing needs a data transform');
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}, false, entityMapping), () => verifyEntity(i + 1));
|
|
1153
1346
|
};
|
|
1154
1347
|
const verifyNext = (i) => {
|
|
1155
1348
|
if (i >= collections.length) return null;
|
|
1156
1349
|
const collection = collections[i];
|
|
1157
|
-
const plan = planCollection(collection.name, collection, dialect,
|
|
1350
|
+
const plan = planCollection(collection.name, collection, dialect,
|
|
1351
|
+
mappingFor(connection, options.expressions));
|
|
1158
1352
|
const validate = options.compileSchema !== undefined
|
|
1159
1353
|
? options.compileSchema(collection.schema)
|
|
1160
1354
|
: null;
|
|
@@ -1212,8 +1406,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
|
|
|
1212
1406
|
(options.registerFunctions !== undefined ? options.registerFunctions(shadow) : null));
|
|
1213
1407
|
const apply = (i) => {
|
|
1214
1408
|
if (i >= migrations.length) return null;
|
|
1409
|
+
// a rebuild moves rows between tables while their keys point at
|
|
1410
|
+
// the old one, so the switch comes off around it — on an engine
|
|
1411
|
+
// that HAS a switch. One that always enforces cannot rebuild that
|
|
1412
|
+
// way, and `alterTableFull` is why it never has to
|
|
1215
1413
|
const bracket = migrations[i].steps.some(
|
|
1216
|
-
(candidate) => candidate.kind === 'rebuild')
|
|
1414
|
+
(candidate) => candidate.kind === 'rebuild')
|
|
1415
|
+
&& shadow.dialect.capabilities.foreignKeysAlwaysOn !== true;
|
|
1217
1416
|
return chain(
|
|
1218
1417
|
bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(false)) : null,
|
|
1219
1418
|
() => chain(runSteps(shadow, migrations[i], options), () =>
|
|
@@ -1221,13 +1420,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
|
|
|
1221
1420
|
() => apply(i + 1))));
|
|
1222
1421
|
};
|
|
1223
1422
|
const run = () => chain(registered, () =>
|
|
1224
|
-
chain(createModelShape(shadow, baseline), () => chain(apply(0), () => {
|
|
1423
|
+
chain(createModelShape(shadow, baseline, options.expressions), () => chain(apply(0), () => {
|
|
1225
1424
|
if (model === undefined) return null;
|
|
1226
|
-
const target = [...normalizeModel(model).values()];
|
|
1425
|
+
const target = [...normalizeModel(model, options.expressions).values()];
|
|
1227
1426
|
const verifyNext = (i) => {
|
|
1228
1427
|
if (i >= target.length) return null;
|
|
1229
1428
|
const plan = planCollection(target[i].name, target[i], shadow.dialect,
|
|
1230
|
-
mappingFor(shadow));
|
|
1429
|
+
mappingFor(shadow, options.expressions));
|
|
1231
1430
|
return chain(
|
|
1232
1431
|
verifyShape(shadow, plan, target[i].name, target[i].docPath),
|
|
1233
1432
|
() => verifyNext(i + 1));
|
|
@@ -1289,18 +1488,33 @@ function historyStatements(dialect) {
|
|
|
1289
1488
|
}
|
|
1290
1489
|
|
|
1291
1490
|
/**
|
|
1292
|
-
* Report a database's migration state without touching it
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1491
|
+
* Report a database's migration state without touching it — the
|
|
1492
|
+
* history table is probed, never created, so a fresh file stays byte
|
|
1493
|
+
* for byte what it was: what is applied, what is pending, whether an
|
|
1494
|
+
* applied migration was edited, and — once the chain is fully applied —
|
|
1495
|
+
* whether the physical shape DRIFTED from the model (someone changed
|
|
1496
|
+
* the database by hand, §12).
|
|
1296
1497
|
* @param {{ driver: any, path?: string }} target
|
|
1297
1498
|
* @param {any[]} migrations - the full ordered list
|
|
1298
|
-
* @param {{
|
|
1299
|
-
*
|
|
1499
|
+
* @param {{ model?: any, registerFunctions?: (connection: any) => any,
|
|
1500
|
+
* signal?: AbortSignal, deadline?: number,
|
|
1501
|
+
* runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
|
|
1502
|
+
* - `signal`/`deadline` refuse a call already cancelled (`JD2080`) or
|
|
1503
|
+
* past its deadline (`JD2075`) on the runtime record's clock
|
|
1300
1504
|
* @returns {Promise<{ applied: string[], pending: string[],
|
|
1301
1505
|
* drift: string | null, upToDate: boolean }>}
|
|
1302
1506
|
*/
|
|
1303
|
-
export function migrationStatus(target, migrations, options) {
|
|
1507
|
+
export function migrationStatus(target, migrations, options = {}) {
|
|
1508
|
+
// a call already cancelled, or past its deadline on the caller's
|
|
1509
|
+
// clock, opens nothing
|
|
1510
|
+
try {
|
|
1511
|
+
refuseCancelled({ signal: options.signal, deadline: options.deadline },
|
|
1512
|
+
resolveRuntime(options.runtime).now,
|
|
1513
|
+
{ abortCode: 'JD2080', aborted: 'it ran', passed: 'the status read ran', ran: 'no step ran' });
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
return Promise.reject(error);
|
|
1517
|
+
}
|
|
1304
1518
|
return toPromise(chain(
|
|
1305
1519
|
target.driver.open(target.path ?? ':memory:', {}),
|
|
1306
1520
|
(connection) => {
|
|
@@ -1310,10 +1524,15 @@ export function migrationStatus(target, migrations, options) {
|
|
|
1310
1524
|
const failClosed = (error) => chain(connection.close(), () => { throw error; });
|
|
1311
1525
|
let work;
|
|
1312
1526
|
try {
|
|
1527
|
+
// §6's "writes NOTHING" holds for a status read too: the history
|
|
1528
|
+
// table is probed, never created, and an absent one reads as an
|
|
1529
|
+
// empty history — the same promise the dry run makes
|
|
1313
1530
|
work = chain(registerDeriveFunctions(connection), () =>
|
|
1314
|
-
chain(connection.
|
|
1315
|
-
|
|
1316
|
-
|
|
1531
|
+
chain(chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1532
|
+
chain(probe.get([HISTORY_TABLE]), (present) => (present === undefined
|
|
1533
|
+
? []
|
|
1534
|
+
: chain(connection.prepare(statements.select), (select) => select.all([]))))),
|
|
1535
|
+
(rows) => chain(rows, () => {
|
|
1317
1536
|
for (let i = 0; i < rows.length; i++) {
|
|
1318
1537
|
const doc = migrations[i];
|
|
1319
1538
|
if (doc === undefined || doc.id !== rows[i].id
|
|
@@ -1335,7 +1554,7 @@ export function migrationStatus(target, migrations, options) {
|
|
|
1335
1554
|
(difference) => ({
|
|
1336
1555
|
applied, pending, drift: difference, upToDate: difference === null,
|
|
1337
1556
|
}));
|
|
1338
|
-
})))
|
|
1557
|
+
})));
|
|
1339
1558
|
}
|
|
1340
1559
|
catch (error) {
|
|
1341
1560
|
return failClosed(error);
|
|
@@ -1360,7 +1579,17 @@ export function migrationStatus(target, migrations, options) {
|
|
|
1360
1579
|
* @param {any[]} migrations
|
|
1361
1580
|
* @param {{ baseline: any, model?: any, compileSchema?: Function,
|
|
1362
1581
|
* dryRun?: boolean, batchSize?: number, onProgress?: Function,
|
|
1363
|
-
* shadow?: boolean, shadowPath?: string
|
|
1582
|
+
* shadow?: boolean, shadowPath?: string, shadowDriver?: any,
|
|
1583
|
+
* signal?: AbortSignal, deadline?: number,
|
|
1584
|
+
* runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
|
|
1585
|
+
* `signal` and `deadline` cancel between migrations, steps and
|
|
1586
|
+
* batches (`JD2080` / `JD2075`, the deadline read against `runtime`'s
|
|
1587
|
+
* clock); a cancelled migration rolls back whole and the completed
|
|
1588
|
+
* ones stand.
|
|
1589
|
+
* `runtime` is the host's runtime record: the clock every applied
|
|
1590
|
+
* migration is stamped with, and the clock and identifiers an entity
|
|
1591
|
+
* step's `default: 'now'` / `default: 'uuid'` fill; the platform's own
|
|
1592
|
+
* when absent
|
|
1364
1593
|
* @returns {Promise<any>}
|
|
1365
1594
|
*/
|
|
1366
1595
|
export function migrate(target, migrations, options) {
|
|
@@ -1375,11 +1604,40 @@ export function migrate(target, migrations, options) {
|
|
|
1375
1604
|
'migrate needs { baseline }: the model the store was first created with '
|
|
1376
1605
|
+ '(the chain anchor and the shadow starting shape)');
|
|
1377
1606
|
const batchSize = options.batchSize ?? 500;
|
|
1607
|
+
const runtime = resolveRuntime(options.runtime);
|
|
1608
|
+
/**
|
|
1609
|
+
* The cancellation boundary: between migrations, between steps and
|
|
1610
|
+
* between the batches of a data step, on the runtime record's clock.
|
|
1611
|
+
* Nothing interrupts a statement that has started; a refusal inside a
|
|
1612
|
+
* migration rolls that migration back whole and the completed ones
|
|
1613
|
+
* stand, so a rerun resumes from the recorded position.
|
|
1614
|
+
*/
|
|
1615
|
+
const check = () => refuseCancelled({ signal: options.signal, deadline: options.deadline }, runtime.now, {
|
|
1616
|
+
abortCode: 'JD2080', aborted: 'its next step', passed: 'its next step',
|
|
1617
|
+
ran: 'no further step ran; a migration in flight rolled back whole and a rerun resumes '
|
|
1618
|
+
+ 'from the recorded position',
|
|
1619
|
+
});
|
|
1378
1620
|
const runOptions = {
|
|
1379
1621
|
batchSize,
|
|
1622
|
+
assertionBounds: normalizeAssertionBounds(options.assertionBounds),
|
|
1380
1623
|
onProgress: options.onProgress,
|
|
1381
1624
|
registerFunctions: options.registerFunctions,
|
|
1625
|
+
// the host's declared index-expression functions ride to every
|
|
1626
|
+
// planner and every connection this run opens — the shadow's
|
|
1627
|
+
// baseline, the reference database and the real store all resolve a
|
|
1628
|
+
// declared expression against the same declarations the open path
|
|
1629
|
+
// was given, or they would plan DDL nobody can apply
|
|
1630
|
+
expressions: options.expressions,
|
|
1631
|
+
model: options.model,
|
|
1632
|
+
runtime,
|
|
1633
|
+
check,
|
|
1382
1634
|
};
|
|
1635
|
+
try {
|
|
1636
|
+
check();
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
return Promise.reject(error);
|
|
1640
|
+
}
|
|
1383
1641
|
|
|
1384
1642
|
return toPromise(chain(
|
|
1385
1643
|
target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
|
|
@@ -1395,174 +1653,189 @@ export function migrate(target, migrations, options) {
|
|
|
1395
1653
|
|
|
1396
1654
|
let work;
|
|
1397
1655
|
try {
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1656
|
+
// §6's "writes NOTHING": an apply creates the empty history
|
|
1657
|
+
// table before reading it, a DRY RUN probes for it instead and
|
|
1658
|
+
// reads an absent one as an empty history — the promise a dry
|
|
1659
|
+
// run makes is the reason it is safe to point at production
|
|
1660
|
+
const history = options.dryRun === true
|
|
1661
|
+
? chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1662
|
+
chain(probe.get([HISTORY_TABLE]), (row) => (row === undefined
|
|
1663
|
+
? []
|
|
1664
|
+
: chain(connection.prepare(statements.select), (select) => select.all([])))))
|
|
1665
|
+
: chain(connection.exec(statements.create), () =>
|
|
1666
|
+
chain(connection.prepare(statements.select), (select) => select.all([])));
|
|
1667
|
+
work = chain(history, (appliedRows) => {
|
|
1668
|
+
// the list must agree with the history: same ids, same
|
|
1669
|
+
// order, same checksums — an edited applied migration is
|
|
1670
|
+
// always a bug worth failing on
|
|
1671
|
+
for (let i = 0; i < appliedRows.length; i++) {
|
|
1672
|
+
const row = appliedRows[i];
|
|
1673
|
+
const doc = migrations[i];
|
|
1674
|
+
if (doc === undefined || doc.id !== row.id) {
|
|
1675
|
+
throw refuse('JD0022',
|
|
1676
|
+
`history position ${i} records '${row.id}' but the migration list has `
|
|
1677
|
+
+ `'${doc?.id ?? '<nothing>'}' — the list must contain every applied `
|
|
1678
|
+
+ 'migration, in order');
|
|
1418
1679
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
let expectedFrom = currentShape;
|
|
1425
|
-
for (const migration of pending) {
|
|
1426
|
-
checkMigrationDocument(migration);
|
|
1427
|
-
if (migration.from !== expectedFrom) {
|
|
1428
|
-
throw refuse('JD0020',
|
|
1429
|
-
`migration '${migration.id}' expects shape '${migration.from}' but the `
|
|
1430
|
-
+ `database is at '${expectedFrom}' — refusing to run against the wrong shape`);
|
|
1431
|
-
}
|
|
1432
|
-
expectedFrom = migration.to;
|
|
1680
|
+
if (migrationChecksum(doc) !== row.checksum) {
|
|
1681
|
+
throw refuse('JD0022',
|
|
1682
|
+
`migration '${row.id}' differs from the document recorded in the `
|
|
1683
|
+
+ 'history — an applied migration must never be edited');
|
|
1433
1684
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1685
|
+
}
|
|
1686
|
+
const pending = migrations.slice(appliedRows.length);
|
|
1687
|
+
const currentShape = appliedRows.length > 0
|
|
1688
|
+
? appliedRows[appliedRows.length - 1].to_hash
|
|
1689
|
+
: shapeHash(options.baseline);
|
|
1690
|
+
|
|
1691
|
+
let expectedFrom = currentShape;
|
|
1692
|
+
for (const migration of pending) {
|
|
1693
|
+
checkMigrationDocument(migration);
|
|
1694
|
+
if (migration.from !== expectedFrom) {
|
|
1436
1695
|
throw refuse('JD0020',
|
|
1437
|
-
|
|
1438
|
-
+ '
|
|
1696
|
+
`migration '${migration.id}' expects shape '${migration.from}' but the `
|
|
1697
|
+
+ `database is at '${expectedFrom}' — refusing to run against the wrong shape`);
|
|
1439
1698
|
}
|
|
1699
|
+
expectedFrom = migration.to;
|
|
1700
|
+
}
|
|
1701
|
+
if (options.model !== undefined && pending.length > 0
|
|
1702
|
+
&& expectedFrom !== shapeHash(options.model)) {
|
|
1703
|
+
throw refuse('JD0020',
|
|
1704
|
+
"the last migration's to-hash is not the target model's shape — the "
|
|
1705
|
+
+ 'migration chain and the code disagree about where this ends');
|
|
1706
|
+
}
|
|
1440
1707
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1708
|
+
if (pending.length === 0) {
|
|
1709
|
+
return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1710
|
+
}
|
|
1444
1711
|
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
return chain(shadowRun, () => {
|
|
1451
|
-
if (options.dryRun === true) {
|
|
1452
|
-
const rendered = [];
|
|
1453
|
-
const counts = {};
|
|
1454
|
-
const collect = (i) => {
|
|
1455
|
-
if (i >= pending.length) return null;
|
|
1456
|
-
const migration = pending[i];
|
|
1457
|
-
for (const migrationStep of migration.steps) {
|
|
1458
|
-
if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
|
|
1459
|
-
else if (migrationStep.kind === 'sql') {
|
|
1460
|
-
rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
|
|
1461
|
-
rendered.push(migrationStep.sql);
|
|
1462
|
-
}
|
|
1463
|
-
else if (migrationStep.kind === 'rebuild') {
|
|
1464
|
-
rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
|
|
1465
|
-
rendered.push(...migrationStep.create, migrationStep.copy,
|
|
1466
|
-
dialect.ddl.dropTable(migrationStep.table),
|
|
1467
|
-
dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
|
|
1468
|
-
migrationStep.table),
|
|
1469
|
-
...migrationStep.indexes,
|
|
1470
|
-
dialect.pragma.foreignKeyCheck());
|
|
1471
|
-
}
|
|
1472
|
-
else if (migrationStep.kind === 'jslt')
|
|
1473
|
-
rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
|
|
1474
|
-
else rendered.push(`-- assert over '${migrationStep.collection}'`);
|
|
1475
|
-
}
|
|
1476
|
-
const jsltCollections = [...new Set(migration.steps
|
|
1477
|
-
.filter((s) => s.kind === 'jslt').map((s) => s.collection))];
|
|
1478
|
-
const count = (j) => {
|
|
1479
|
-
if (j >= jsltCollections.length) return null;
|
|
1480
|
-
const table = jsltCollections[j];
|
|
1481
|
-
const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
|
|
1482
|
-
+ `FROM ${dialect.quoteIdentifier(table)}`;
|
|
1483
|
-
return chain(connection.prepare(countSql), (statement) =>
|
|
1484
|
-
chain(statement.get([]), (row) => {
|
|
1485
|
-
counts[table] = row.n;
|
|
1486
|
-
return count(j + 1);
|
|
1487
|
-
}));
|
|
1488
|
-
};
|
|
1489
|
-
return chain(count(0), () => collect(i + 1));
|
|
1490
|
-
};
|
|
1491
|
-
return chain(collect(0), () => ({
|
|
1492
|
-
dryRun: true,
|
|
1493
|
-
pending: pending.map((migration) => migration.id),
|
|
1494
|
-
statements: rendered,
|
|
1495
|
-
counts,
|
|
1496
|
-
shadowValidated: options.shadow !== false,
|
|
1497
|
-
}));
|
|
1498
|
-
}
|
|
1712
|
+
const shadowRun = options.shadow === false
|
|
1713
|
+
? null
|
|
1714
|
+
: replayOnShadow(options.shadowDriver ?? target.driver,
|
|
1715
|
+
options.shadowPath ?? ':memory:',
|
|
1716
|
+
options.baseline, migrations, options.model, runOptions);
|
|
1499
1717
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1718
|
+
return chain(shadowRun, () => {
|
|
1719
|
+
if (options.dryRun === true) {
|
|
1720
|
+
const rendered = [];
|
|
1721
|
+
const counts = {};
|
|
1722
|
+
const collect = (i) => {
|
|
1503
1723
|
if (i >= pending.length) return null;
|
|
1504
1724
|
const migration = pending[i];
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
const bracket = migration.steps.some(
|
|
1511
|
-
(candidate) => candidate.kind === 'rebuild');
|
|
1512
|
-
return chain(
|
|
1513
|
-
bracket ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
|
|
1514
|
-
() => chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
1515
|
-
const body = () => chain(runSteps(connection, migration, runOptions), () =>
|
|
1516
|
-
chain(last && options.model !== undefined
|
|
1517
|
-
? chain(validateTargetState(connection, options.model,
|
|
1518
|
-
{ compileSchema: options.compileSchema, batchSize }),
|
|
1519
|
-
() => (normalizeEntities(options.model).size === 0 ? null
|
|
1520
|
-
: chain(compareShapeToModel(target.driver, connection,
|
|
1521
|
-
options.model, options.registerFunctions), (difference) => {
|
|
1522
|
-
if (difference !== null) {
|
|
1523
|
-
throw refuse('JD0023',
|
|
1524
|
-
`the migrated shape does not equal the target model's: ${difference}`);
|
|
1525
|
-
}
|
|
1526
|
-
return null;
|
|
1527
|
-
})))
|
|
1528
|
-
: null,
|
|
1529
|
-
() => chain(connection.prepare(statements.insert), (insert) =>
|
|
1530
|
-
insert.run([migration.id, Date.now(), migration.from,
|
|
1531
|
-
migration.to, migrationChecksum(migration),
|
|
1532
|
-
migration.steps.length]))));
|
|
1533
|
-
const restore = () => (bracket
|
|
1534
|
-
? connection.exec(dialect.pragma.foreignKeys(true)) : null);
|
|
1535
|
-
const commit = () => chain(connection.exec(dialect.tx.commit), () =>
|
|
1536
|
-
chain(restore(), () => {
|
|
1537
|
-
applied.push(migration.id);
|
|
1538
|
-
return applyNext(i + 1);
|
|
1539
|
-
}));
|
|
1540
|
-
const rollback = (error) =>
|
|
1541
|
-
chain(connection.exec(dialect.tx.rollback), () =>
|
|
1542
|
-
chain(restore(), () => { throw error; }));
|
|
1543
|
-
// only body() may route to this migration's rollback:
|
|
1544
|
-
// commit() chains the NEXT migration, whose failure
|
|
1545
|
-
// rolls ITSELF back — catching it here would roll
|
|
1546
|
-
// back a transaction that already committed
|
|
1547
|
-
let outcome;
|
|
1548
|
-
try {
|
|
1549
|
-
outcome = body();
|
|
1725
|
+
for (const migrationStep of migration.steps) {
|
|
1726
|
+
if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
|
|
1727
|
+
else if (migrationStep.kind === 'sql') {
|
|
1728
|
+
rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
|
|
1729
|
+
rendered.push(migrationStep.sql);
|
|
1550
1730
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1731
|
+
else if (migrationStep.kind === 'rebuild') {
|
|
1732
|
+
rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
|
|
1733
|
+
rendered.push(...migrationStep.create, migrationStep.copy,
|
|
1734
|
+
dialect.ddl.dropTable(migrationStep.table),
|
|
1735
|
+
dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
|
|
1736
|
+
migrationStep.table),
|
|
1737
|
+
...migrationStep.indexes);
|
|
1738
|
+
if (dialect.capabilities.foreignKeysAlwaysOn !== true)
|
|
1739
|
+
rendered.push(dialect.pragma.foreignKeyCheck());
|
|
1553
1740
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
}
|
|
1741
|
+
else if (migrationStep.kind === 'jslt')
|
|
1742
|
+
rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
|
|
1743
|
+
else rendered.push(`-- assert over '${migrationStep.collection}'`);
|
|
1744
|
+
}
|
|
1745
|
+
const jsltCollections = [...new Set(migration.steps
|
|
1746
|
+
.filter((s) => s.kind === 'jslt').map((s) => s.collection))];
|
|
1747
|
+
const count = (j) => {
|
|
1748
|
+
if (j >= jsltCollections.length) return null;
|
|
1749
|
+
const table = jsltCollections[j];
|
|
1750
|
+
const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
|
|
1751
|
+
+ `FROM ${dialect.quoteIdentifier(table)}`;
|
|
1752
|
+
return chain(connection.prepare(countSql), (statement) =>
|
|
1753
|
+
chain(statement.get([]), (row) => {
|
|
1754
|
+
counts[table] = row.n;
|
|
1755
|
+
return count(j + 1);
|
|
1756
|
+
}));
|
|
1757
|
+
};
|
|
1758
|
+
return chain(count(0), () => collect(i + 1));
|
|
1558
1759
|
};
|
|
1559
|
-
return chain(
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1760
|
+
return chain(collect(0), () => ({
|
|
1761
|
+
dryRun: true,
|
|
1762
|
+
pending: pending.map((migration) => migration.id),
|
|
1763
|
+
statements: rendered,
|
|
1764
|
+
counts,
|
|
1765
|
+
shadowValidated: options.shadow !== false,
|
|
1563
1766
|
}));
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// the real run: one exclusive transaction per migration
|
|
1770
|
+
const applied = [];
|
|
1771
|
+
const applyNext = (i) => {
|
|
1772
|
+
if (i >= pending.length) return null;
|
|
1773
|
+
// the boundary between migrations
|
|
1774
|
+
check();
|
|
1775
|
+
const migration = pending[i];
|
|
1776
|
+
const last = i === pending.length - 1;
|
|
1777
|
+
// the §10 procedure's pragma bracket, literally: the
|
|
1778
|
+
// foreign_keys pragma is a no-op inside a transaction,
|
|
1779
|
+
// and node:sqlite enables enforcement BY DEFAULT — a
|
|
1780
|
+
// parent-table rebuild could not even DROP without this
|
|
1781
|
+
const bracket = migration.steps.some(
|
|
1782
|
+
(candidate) => candidate.kind === 'rebuild');
|
|
1783
|
+
return chain(
|
|
1784
|
+
bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1785
|
+
? connection.exec(dialect.pragma.foreignKeys(false)) : null,
|
|
1786
|
+
() => chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
1787
|
+
const body = () => chain(runSteps(connection, migration, runOptions), () =>
|
|
1788
|
+
chain(last && options.model !== undefined
|
|
1789
|
+
? chain(validateTargetState(connection, options.model,
|
|
1790
|
+
{ compileSchema: options.compileSchema, batchSize }),
|
|
1791
|
+
() => (normalizeEntities(options.model).size === 0 ? null
|
|
1792
|
+
: chain(compareShapeToModel(target.driver, connection,
|
|
1793
|
+
options.model, options.registerFunctions), (difference) => {
|
|
1794
|
+
if (difference !== null) {
|
|
1795
|
+
throw refuse('JD0023',
|
|
1796
|
+
`the migrated shape does not equal the target model's: ${difference}`);
|
|
1797
|
+
}
|
|
1798
|
+
return null;
|
|
1799
|
+
})))
|
|
1800
|
+
: null,
|
|
1801
|
+
() => chain(connection.prepare(statements.insert), (insert) =>
|
|
1802
|
+
insert.run([migration.id, runtime.now(), migration.from,
|
|
1803
|
+
migration.to, migrationChecksum(migration),
|
|
1804
|
+
migration.steps.length]))));
|
|
1805
|
+
const restore = () => (bracket
|
|
1806
|
+
&& dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1807
|
+
? connection.exec(dialect.pragma.foreignKeys(true)) : null);
|
|
1808
|
+
const commit = () => chain(connection.exec(dialect.tx.commit), () =>
|
|
1809
|
+
chain(restore(), () => {
|
|
1810
|
+
applied.push(migration.id);
|
|
1811
|
+
return applyNext(i + 1);
|
|
1812
|
+
}));
|
|
1813
|
+
const rollback = (error) =>
|
|
1814
|
+
chain(connection.exec(dialect.tx.rollback), () =>
|
|
1815
|
+
chain(restore(), () => { throw error; }));
|
|
1816
|
+
// only body() may route to this migration's rollback:
|
|
1817
|
+
// commit() chains the NEXT migration, whose failure
|
|
1818
|
+
// rolls ITSELF back — catching it here would roll
|
|
1819
|
+
// back a transaction that already committed
|
|
1820
|
+
let outcome;
|
|
1821
|
+
try {
|
|
1822
|
+
outcome = body();
|
|
1823
|
+
}
|
|
1824
|
+
catch (error) {
|
|
1825
|
+
return rollback(error);
|
|
1826
|
+
}
|
|
1827
|
+
return outcome instanceof Promise
|
|
1828
|
+
? outcome.then(commit, rollback)
|
|
1829
|
+
: commit();
|
|
1830
|
+
}));
|
|
1831
|
+
};
|
|
1832
|
+
return chain(applyNext(0), () => ({
|
|
1833
|
+
applied,
|
|
1834
|
+
skipped: appliedRows.map((row) => row.id),
|
|
1835
|
+
shape: expectedFrom,
|
|
1836
|
+
}));
|
|
1837
|
+
});
|
|
1838
|
+
});
|
|
1566
1839
|
|
|
1567
1840
|
}
|
|
1568
1841
|
catch (error) {
|