@jarenjs/db 0.86.0 → 0.89.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 +34 -13
- package/README.md +18 -5
- package/docs/MIGRATION-FORMAT.md +259 -70
- package/docs/MODEL-FORMAT.md +43 -6
- package/docs/NATIVE-PLANS.md +5 -3
- package/docs/SQLITE-RELATIONAL.md +141 -6
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +169 -5
- package/schemas/jaren-migration.schema.json +164 -0
- package/src/ddl.js +8 -86
- package/src/dialects/sqlite-relational.js +2 -1
- package/src/dialects/sqlite-schema.js +82 -32
- package/src/dialects/sqlite.js +1 -0
- package/src/document-steps.js +23 -5
- package/src/drivers/bun.js +13 -3
- package/src/drivers/file-identity.js +14 -0
- package/src/drivers/node.js +2 -0
- package/src/foreign-key-scope.js +50 -0
- package/src/index.js +1 -1
- package/src/migrate.js +291 -497
- package/src/migration-target.js +104 -0
- package/src/mutation.js +13 -16
- package/src/physical-transform.js +207 -0
- package/src/relational-api.js +1 -1
- package/src/schema-sql.js +184 -0
- package/src/table-migration.js +50 -17
- package/types/index.d.ts +114 -14
- package/types/relational.d.ts +19 -1
package/src/migrate.js
CHANGED
|
@@ -30,7 +30,7 @@ import { compileJsonQuery } from '@jarenjs/json/query';
|
|
|
30
30
|
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
31
31
|
|
|
32
32
|
import { DbCompileError } from './errors.js';
|
|
33
|
-
import { chain
|
|
33
|
+
import { chain } from './driver.js';
|
|
34
34
|
import { normalizeModel } from './store.js';
|
|
35
35
|
import { planQuery } from './plan.js';
|
|
36
36
|
import { createQueryEngine, createQueryState } from './query.js';
|
|
@@ -42,8 +42,13 @@ import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
|
42
42
|
import { mergeEntityRow } from './graph.js';
|
|
43
43
|
import { entityCore } from './entity.js';
|
|
44
44
|
import { sqlTokens } from './dialects/check-read.js';
|
|
45
|
+
import { comparableDeclaredSql } from './schema-sql.js';
|
|
46
|
+
import { applyTableMigration } from './table-migration.js';
|
|
47
|
+
import { withForeignKeySettings } from './foreign-key-scope.js';
|
|
48
|
+
import { withMigrationConnection, physicalTargetOf, comparePhysicalTarget, verifyShadowOwnership } from './migration-target.js';
|
|
45
49
|
import { readSchema } from './introspect.js';
|
|
46
|
-
import { verifyPhysical
|
|
50
|
+
import { verifyPhysical } from './physical.js';
|
|
51
|
+
import { walkPhysicalRows, transformPhysicalRows } from './physical-transform.js';
|
|
47
52
|
import {
|
|
48
53
|
MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
|
|
49
54
|
normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
|
|
@@ -889,75 +894,28 @@ export function createModelShape(connection, model, expressions = undefined) {
|
|
|
889
894
|
}
|
|
890
895
|
|
|
891
896
|
/**
|
|
892
|
-
* The declared schema of a database, normalized
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
* acceptance criterion for every rebuild.
|
|
897
|
+
* The declared schema of a database, normalized through the SQL comparison
|
|
898
|
+
* owner. Physical column order is preserved by default; managed named-column
|
|
899
|
+
* parity may explicitly request `columnOrder: 'ignore'`. Engine-owned objects
|
|
900
|
+
* are excluded, and catalog object order is stable.
|
|
897
901
|
* @param {any} connection
|
|
902
|
+
* @param {import('./schema-sql.js').DeclaredSqlOptions} [options]
|
|
898
903
|
* @returns {any} value-or-promise of `{ type, name, owner, sql }[]`
|
|
899
904
|
*/
|
|
900
|
-
export function schemaShapeOf(connection) {
|
|
905
|
+
export function schemaShapeOf(connection, options = undefined) {
|
|
901
906
|
const dialect = connection.dialect;
|
|
902
907
|
return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
|
|
903
908
|
chain(statement.all([]), (rows) => rows
|
|
904
|
-
//
|
|
905
|
-
// row, the job queue and replication ledger — are never a model's drift
|
|
909
|
+
// History, change logs, jobs and replication metadata are engine-owned.
|
|
906
910
|
.filter((row) => !ENGINE_TABLES.has(String(row.name)) && !ENGINE_TABLES.has(String(row.owner)))
|
|
907
911
|
.map((row) => ({
|
|
908
912
|
type: String(row.type),
|
|
909
913
|
name: String(row.name),
|
|
910
914
|
owner: String(row.owner),
|
|
911
|
-
sql:
|
|
915
|
+
sql: comparableDeclaredSql(String(row.sql), options),
|
|
912
916
|
}))));
|
|
913
917
|
}
|
|
914
918
|
|
|
915
|
-
/**
|
|
916
|
-
* Whitespace-collapse a schema statement and, for a CREATE TABLE,
|
|
917
|
-
* SORT its top-level column/constraint list: `ALTER TABLE ADD COLUMN`
|
|
918
|
-
* appends at the end, so a migrated table's declared order can differ
|
|
919
|
-
* from a fresh build's without differing in meaning — every access in
|
|
920
|
-
* this store is by name.
|
|
921
|
-
* @param {string} sql
|
|
922
|
-
* @returns {string}
|
|
923
|
-
*/
|
|
924
|
-
function normalizeSchemaSql(sql) {
|
|
925
|
-
const collapsed = sql.replace(/\s+/g, ' ').trim();
|
|
926
|
-
const open = collapsed.indexOf('(');
|
|
927
|
-
if (!/^CREATE TABLE/i.test(collapsed) || open === -1) return collapsed;
|
|
928
|
-
const close = collapsed.lastIndexOf(')');
|
|
929
|
-
const head = collapsed.slice(0, open + 1);
|
|
930
|
-
const tail = collapsed.slice(close);
|
|
931
|
-
const body = collapsed.slice(open + 1, close);
|
|
932
|
-
/** @type {string[]} */
|
|
933
|
-
const parts = [];
|
|
934
|
-
let depth = 0;
|
|
935
|
-
let quote = null;
|
|
936
|
-
let current = '';
|
|
937
|
-
for (const character of body) {
|
|
938
|
-
if (quote !== null) {
|
|
939
|
-
current += character;
|
|
940
|
-
if (character === quote) quote = null;
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
if (character === "'" || character === '"') {
|
|
944
|
-
quote = character;
|
|
945
|
-
current += character;
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
if (character === '(') depth++;
|
|
949
|
-
if (character === ')') depth--;
|
|
950
|
-
if (character === ',' && depth === 0) {
|
|
951
|
-
parts.push(current.trim());
|
|
952
|
-
current = '';
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
current += character;
|
|
956
|
-
}
|
|
957
|
-
if (current.trim() !== '') parts.push(current.trim());
|
|
958
|
-
return head + parts.sort().join(', ') + tail;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
919
|
/**
|
|
962
920
|
* Compare a migrated database's schema against the shape a fresh
|
|
963
921
|
* `createModelShape(model)` produces, via a throwaway reference
|
|
@@ -977,15 +935,12 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
|
|
|
977
935
|
// per collection and per entity either way, and `declaredSqlText` is
|
|
978
936
|
// what tells a reader which half they got.
|
|
979
937
|
if (connection.dialect.capabilities.declaredSqlText !== true) return null;
|
|
980
|
-
return
|
|
938
|
+
return withMigrationConnection({ driver, path: ':memory:' }, (reference) =>
|
|
981
939
|
chain(chain(registerDeriveFunctions(reference),
|
|
982
940
|
() => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
outcome = chain(createModelShape(reference, model, expressions), () =>
|
|
987
|
-
chain(schemaShapeOf(reference), (wanted) =>
|
|
988
|
-
chain(schemaShapeOf(connection), (actual) => {
|
|
941
|
+
return chain(createModelShape(reference, model, expressions), () =>
|
|
942
|
+
chain(schemaShapeOf(reference, { columnOrder: 'ignore' }), (wanted) =>
|
|
943
|
+
chain(schemaShapeOf(connection, { columnOrder: 'ignore' }), (actual) => {
|
|
989
944
|
const wantedText = JSON.stringify(wanted);
|
|
990
945
|
const actualText = JSON.stringify(actual);
|
|
991
946
|
if (wantedText === actualText) return null;
|
|
@@ -1003,16 +958,6 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
|
|
|
1003
958
|
}
|
|
1004
959
|
return 'schemas differ in ordering only';
|
|
1005
960
|
})));
|
|
1006
|
-
}
|
|
1007
|
-
catch (error) {
|
|
1008
|
-
return chain(reference.close(), () => { throw error; });
|
|
1009
|
-
}
|
|
1010
|
-
if (outcome instanceof Promise) {
|
|
1011
|
-
return outcome.then(
|
|
1012
|
-
(value) => chain(reference.close(), () => value),
|
|
1013
|
-
(error) => chain(reference.close(), () => { throw error; }));
|
|
1014
|
-
}
|
|
1015
|
-
return finish(outcome);
|
|
1016
961
|
}));
|
|
1017
962
|
}
|
|
1018
963
|
|
|
@@ -1055,18 +1000,8 @@ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapp
|
|
|
1055
1000
|
// the rest-document alone could not see `id` or `name` at all
|
|
1056
1001
|
const dialect = connection.dialect;
|
|
1057
1002
|
const q = dialect.quoteIdentifier;
|
|
1058
|
-
if (entityMapping?.document === false)
|
|
1059
|
-
|
|
1060
|
-
const next = (offset) => {
|
|
1061
|
-
check?.();
|
|
1062
|
-
const sql = `SELECT ${physicalSelection(entityMapping, dialect)} FROM ${q(entityMapping.table)} ORDER BY ${order} ${dialect.limitClause(batchSize, offset)}`;
|
|
1063
|
-
return chain(connection.prepare(sql), (statement) => chain(statement.all([]), (rows) => {
|
|
1064
|
-
if (!rows.length) return null;
|
|
1065
|
-
return chain(handle(rows.map((row, i) => ({ ...row, rid: offset + i }))), () => next(offset + rows.length));
|
|
1066
|
-
}));
|
|
1067
|
-
};
|
|
1068
|
-
return next(0);
|
|
1069
|
-
}
|
|
1003
|
+
if (entityMapping?.document === false)
|
|
1004
|
+
return walkPhysicalRows(connection, entityMapping, batchSize, handle, check);
|
|
1070
1005
|
const rid = dialect.rowIdentity();
|
|
1071
1006
|
const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
|
|
1072
1007
|
const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
|
|
@@ -1098,21 +1033,24 @@ function entityColumnsOf(entityMapping) {
|
|
|
1098
1033
|
}
|
|
1099
1034
|
|
|
1100
1035
|
/**
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
* to the last migration of a chain (MIGRATION-FORMAT §9); without a
|
|
1105
|
-
* target model the step sees the rest-document, as it always did.
|
|
1106
|
-
* @param {any} options
|
|
1107
|
-
* @param {string} table
|
|
1036
|
+
* Resolve the explicitly declared current layout, or the final model when the
|
|
1037
|
+
* step carries none. Historical column names belong to their step's model.
|
|
1038
|
+
* @param {any} options @param {string} table @param {any} step @param {any} dialect
|
|
1108
1039
|
* @returns {{ entity: any, mapping: any } | null}
|
|
1109
1040
|
*/
|
|
1110
|
-
function entityStepMapping(options, table) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1041
|
+
function entityStepMapping(options, table, step, dialect) {
|
|
1042
|
+
const model = step?.model ?? options.model;
|
|
1043
|
+
if (model === undefined) return null;
|
|
1044
|
+
const entities = normalizeEntities(model);
|
|
1113
1045
|
const entity = entities.get(table);
|
|
1114
|
-
if (entity === undefined)
|
|
1115
|
-
|
|
1046
|
+
if (entity === undefined) {
|
|
1047
|
+
if (step?.model !== undefined && !normalizeModel(model, options.expressions).has(table))
|
|
1048
|
+
throw refuse('JD0021', `the step model declares no collection or entity '${table}'`);
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
const all = explainMapping(model), mapping = all.entities[table];
|
|
1052
|
+
return { entity, mapping: mapping.document === false
|
|
1053
|
+
? planEntity(table, mapping, all, dialect).physical : mapping };
|
|
1116
1054
|
}
|
|
1117
1055
|
|
|
1118
1056
|
/**
|
|
@@ -1131,8 +1069,6 @@ function runSteps(connection, migration, options) {
|
|
|
1131
1069
|
// migration in flight back whole, as any step failure does
|
|
1132
1070
|
if (options.check !== undefined) options.check();
|
|
1133
1071
|
const current = migration.steps[i];
|
|
1134
|
-
if (migration.physical && !['ddl', 'sql', 'rebuild'].includes(current.kind))
|
|
1135
|
-
throw refuse('JD0021', 'physical preservation plans use explicit SQL/rebuild steps and preservation assertions');
|
|
1136
1072
|
const fail = (reason, cause) => {
|
|
1137
1073
|
throw refuse('JD0023',
|
|
1138
1074
|
`migration '${migration.id}' step ${i} (${current.kind}) failed: ${reason}`,
|
|
@@ -1140,6 +1076,7 @@ function runSteps(connection, migration, options) {
|
|
|
1140
1076
|
};
|
|
1141
1077
|
// every step is its own savepoint inside the migration transaction
|
|
1142
1078
|
return chain(connection.transaction(() => {
|
|
1079
|
+
if (current.kind === 'table') return applyTableMigration(connection, current.plan);
|
|
1143
1080
|
if (current.kind === 'ddl' || current.kind === 'sql') {
|
|
1144
1081
|
// 'sql' is a DATA step spelled directly (§9.4): same execution
|
|
1145
1082
|
// as ddl, distinct on purpose — dry-run always shows it, and a
|
|
@@ -1215,13 +1152,19 @@ function runSteps(connection, migration, options) {
|
|
|
1215
1152
|
}, false, null, options.check), () => derivedRows));
|
|
1216
1153
|
}
|
|
1217
1154
|
if (current.kind === 'jslt') {
|
|
1218
|
-
const stepEntity = entityStepMapping(options, current.collection);
|
|
1155
|
+
const stepEntity = entityStepMapping(options, current.collection, current, dialect);
|
|
1219
1156
|
const operation = compileDocumentStep(current, i, {
|
|
1220
1157
|
migrationId: migration.id,
|
|
1221
1158
|
compileJslt: compileJsltStylesheet,
|
|
1222
1159
|
compileQuery: compileJsonQuery,
|
|
1223
1160
|
keys: stepEntity === null ? [] : stepEntity.mapping.keys,
|
|
1224
1161
|
});
|
|
1162
|
+
if (stepEntity?.mapping.document === false) {
|
|
1163
|
+
return transformPhysicalRows(connection, stepEntity, operation, {
|
|
1164
|
+
batchSize: options.batchSize, runtime: options.runtime, check: options.check,
|
|
1165
|
+
onProgress: options.onProgress, migration: migration.id, collection: current.collection,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1225
1168
|
if (stepEntity !== null) {
|
|
1226
1169
|
// an entity row is transformed WHOLE: the mapped columns fold in
|
|
1227
1170
|
// before the stylesheet and split out after it, through the
|
|
@@ -1275,7 +1218,7 @@ function runSteps(connection, migration, options) {
|
|
|
1275
1218
|
}, keyed, null, options.check), () => transformed));
|
|
1276
1219
|
}
|
|
1277
1220
|
// kind === 'query': the assertion step
|
|
1278
|
-
const assertionMapping = entityStepMapping(options, current.collection)?.mapping ?? null;
|
|
1221
|
+
const assertionMapping = entityStepMapping(options, current.collection, current, dialect)?.mapping ?? null;
|
|
1279
1222
|
const operation = compileDocumentStep(current, i, {
|
|
1280
1223
|
migrationId: migration.id,
|
|
1281
1224
|
compileJslt: compileJsltStylesheet,
|
|
@@ -1428,84 +1371,35 @@ function validateTargetState(connection, model, options) {
|
|
|
1428
1371
|
}
|
|
1429
1372
|
|
|
1430
1373
|
/**
|
|
1431
|
-
* Replay the
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
* until the shadow passes.
|
|
1374
|
+
* Replay through the same history and transaction owner on a disposable
|
|
1375
|
+
* connection. The default initializer creates an empty model shape; an explicit
|
|
1376
|
+
* fixture can instead supply populated historical tables and programs. The
|
|
1377
|
+
* primary remains untouched until replay and target acceptance pass.
|
|
1378
|
+
* @param {any} primary
|
|
1437
1379
|
* @param {any} driver
|
|
1438
1380
|
* @param {string} shadowPath
|
|
1439
1381
|
* @param {any} baseline
|
|
1440
1382
|
* @param {any[]} migrations
|
|
1441
1383
|
* @param {any} model - target model or undefined
|
|
1442
|
-
* @param {
|
|
1384
|
+
* @param {any} options
|
|
1443
1385
|
* @returns {any} value-or-promise
|
|
1444
1386
|
*/
|
|
1445
|
-
function replayOnShadow(driver, shadowPath, baseline, migrations, model, options) {
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
//
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
&& shadow.dialect.capabilities.foreignKeysAlwaysOn !== true;
|
|
1462
|
-
return chain(
|
|
1463
|
-
bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(false)) : null,
|
|
1464
|
-
() => chain(runSteps(shadow, migrations[i], options), () =>
|
|
1465
|
-
chain(bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(true)) : null,
|
|
1466
|
-
() => apply(i + 1))));
|
|
1467
|
-
};
|
|
1468
|
-
const run = () => chain(registered, () =>
|
|
1469
|
-
chain(createModelShape(shadow, baseline, options.expressions), () => chain(apply(0), () => {
|
|
1470
|
-
if (model === undefined) return null;
|
|
1471
|
-
const target = [...normalizeModel(model, options.expressions).values()];
|
|
1472
|
-
const verifyNext = (i) => {
|
|
1473
|
-
if (i >= target.length) return null;
|
|
1474
|
-
const plan = planCollection(target[i].name, target[i], shadow.dialect,
|
|
1475
|
-
mappingFor(shadow, options.expressions));
|
|
1476
|
-
return chain(
|
|
1477
|
-
verifyShape(shadow, plan, target[i].name, target[i].docPath),
|
|
1478
|
-
() => verifyNext(i + 1));
|
|
1479
|
-
};
|
|
1480
|
-
return chain(verifyNext(0), () => {
|
|
1481
|
-
if (normalizeEntities(model).size === 0) return null;
|
|
1482
|
-
// relational models: SHAPE EQUALITY against a fresh build is
|
|
1483
|
-
// the acceptance criterion — stronger than per-plan checks
|
|
1484
|
-
return chain(
|
|
1485
|
-
compareShapeToModel(driver, shadow, model, options.registerFunctions),
|
|
1486
|
-
(difference) => {
|
|
1487
|
-
if (difference !== null) {
|
|
1488
|
-
throw refuse('JD0023',
|
|
1489
|
-
`the shadow's migrated shape does not equal the target model's: ${difference}`);
|
|
1490
|
-
}
|
|
1491
|
-
return null;
|
|
1492
|
-
});
|
|
1493
|
-
});
|
|
1494
|
-
})));
|
|
1495
|
-
let outcome;
|
|
1496
|
-
try {
|
|
1497
|
-
outcome = run();
|
|
1498
|
-
}
|
|
1499
|
-
catch (error) {
|
|
1500
|
-
return chain(shadow.close(), () => { throw error; });
|
|
1501
|
-
}
|
|
1502
|
-
if (outcome instanceof Promise) {
|
|
1503
|
-
return outcome.then(
|
|
1504
|
-
(value) => chain(shadow.close(), () => value),
|
|
1505
|
-
(error) => chain(shadow.close(), () => { throw error; }));
|
|
1506
|
-
}
|
|
1507
|
-
return finish(outcome);
|
|
1508
|
-
});
|
|
1387
|
+
function replayOnShadow(primary, driver, shadowPath, baseline, migrations, model, options) {
|
|
1388
|
+
const independent = { ...driver, open: (...args) => chain(driver.open(...args), (shadow) => {
|
|
1389
|
+
// An injected opener returning the borrowed primary never transfers its
|
|
1390
|
+
// ownership: reject before the cleanup bracket could close that handle.
|
|
1391
|
+
if (shadow === primary) throw refuse('JD0021', 'shadow replay needs a different connection from the primary');
|
|
1392
|
+
return shadow;
|
|
1393
|
+
}) };
|
|
1394
|
+
return withMigrationConnection({ driver: independent, path: shadowPath }, (shadow) =>
|
|
1395
|
+
chain(verifyShadowOwnership(primary, shadow, driver), () => chain(registerDeriveFunctions(shadow), () => chain(options.registerFunctions?.(shadow), () =>
|
|
1396
|
+
chain(options.shadowFixture === undefined ? createModelShape(shadow, baseline, options.expressions)
|
|
1397
|
+
: options.shadowFixture(shadow), () => migrate({ connection: shadow }, migrations, {
|
|
1398
|
+
...options, baseline, model, shadow: false, shadowDriver: driver,
|
|
1399
|
+
shadowFixture: undefined,
|
|
1400
|
+
registerFunctions: options.registerFunctions === undefined ? undefined
|
|
1401
|
+
: (reference) => reference === shadow ? null : options.registerFunctions(reference),
|
|
1402
|
+
}))))));
|
|
1509
1403
|
}
|
|
1510
1404
|
|
|
1511
1405
|
/** History-table statement builders (dialect-spelled). */
|
|
@@ -1540,73 +1434,49 @@ function historyStatements(dialect) {
|
|
|
1540
1434
|
* applied migration was edited, and — once the chain is fully applied —
|
|
1541
1435
|
* whether the physical shape DRIFTED from the model (someone changed
|
|
1542
1436
|
* the database by hand, §12).
|
|
1543
|
-
* @param {{ driver
|
|
1437
|
+
* @param {{ driver?: any, path?: string, connection?: any }} target
|
|
1544
1438
|
* @param {any[]} migrations - the full ordered list
|
|
1545
|
-
* @param {{ model?: any,
|
|
1439
|
+
* @param {{ model?: any, physicalTarget?: any, shadowDriver?: any,
|
|
1440
|
+
* registerFunctions?: (connection: any) => any,
|
|
1546
1441
|
* signal?: AbortSignal, deadline?: number,
|
|
1547
1442
|
* runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
|
|
1548
1443
|
* - `signal`/`deadline` refuse a call already cancelled (`JD2080`) or
|
|
1549
1444
|
* past its deadline (`JD2075`) on the runtime record's clock
|
|
1550
|
-
* @returns {
|
|
1551
|
-
*
|
|
1445
|
+
* @returns {any} value-or-promise of applied/pending/drift/upToDate;
|
|
1446
|
+
* a borrowed synchronous connection stays synchronous with a physical target
|
|
1552
1447
|
*/
|
|
1553
1448
|
export function migrationStatus(target, migrations, options = {}) {
|
|
1554
|
-
// a call already cancelled, or past its deadline on the caller's
|
|
1555
|
-
// clock, opens nothing
|
|
1556
1449
|
try {
|
|
1557
|
-
refuseCancelled({ signal: options.signal, deadline: options.deadline },
|
|
1558
|
-
resolveRuntime(options.runtime).now,
|
|
1450
|
+
refuseCancelled({ signal: options.signal, deadline: options.deadline }, resolveRuntime(options.runtime).now,
|
|
1559
1451
|
{ abortCode: 'JD2080', aborted: 'it ran', passed: 'the status read ran', ran: 'no step ran' });
|
|
1560
1452
|
}
|
|
1561
|
-
catch (error) {
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
(connection) =>
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
+ `has '${doc?.id ?? '<nothing>'}' (or an edited document)`);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
const applied = rows.map((row) => String(row.id));
|
|
1592
|
-
const pending = migrations.slice(rows.length)
|
|
1593
|
-
.map((migration) => String(migration.id));
|
|
1594
|
-
if (pending.length > 0 || options.model === undefined) {
|
|
1595
|
-
return { applied, pending, drift: null, upToDate: pending.length === 0 };
|
|
1596
|
-
}
|
|
1597
|
-
return chain(
|
|
1598
|
-
compareShapeToModel(target.driver, connection, options.model,
|
|
1599
|
-
options.registerFunctions),
|
|
1600
|
-
(difference) => ({
|
|
1601
|
-
applied, pending, drift: difference, upToDate: difference === null,
|
|
1602
|
-
}));
|
|
1603
|
-
})));
|
|
1604
|
-
}
|
|
1605
|
-
catch (error) {
|
|
1606
|
-
return failClosed(error);
|
|
1607
|
-
}
|
|
1608
|
-
return work instanceof Promise ? work.then(finish, failClosed) : finish(work);
|
|
1609
|
-
}));
|
|
1453
|
+
catch (error) { if (target?.connection !== undefined) throw error; return Promise.reject(error); }
|
|
1454
|
+
if (!Array.isArray(migrations)) throw new TypeError('migrationStatus needs the full ordered migration list');
|
|
1455
|
+
return withMigrationConnection(target, (connection) => {
|
|
1456
|
+
if (connection.mustQueue) throw refuse('JD0021', 'status needs an exclusively available connection or its owning transaction scope');
|
|
1457
|
+
const dialect = connection.dialect, statements = historyStatements(dialect);
|
|
1458
|
+
return chain(registerDeriveFunctions(connection), () => chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1459
|
+
chain(probe.get([HISTORY_TABLE]), (present) => chain(present === undefined ? []
|
|
1460
|
+
: chain(connection.prepare(statements.select), (select) => select.all([])), (rows) => {
|
|
1461
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1462
|
+
const doc = migrations[i];
|
|
1463
|
+
if (doc === undefined || doc.id !== rows[i].id || migrationChecksum(doc) !== rows[i].checksum)
|
|
1464
|
+
throw refuse('JD0022', `history position ${i} records '${rows[i].id}' but the migration list has '${doc?.id ?? '<nothing>'}' (or an edited document)`);
|
|
1465
|
+
}
|
|
1466
|
+
const applied = rows.map((row) => String(row.id));
|
|
1467
|
+
const pending = migrations.slice(rows.length).map((migration) => String(migration.id));
|
|
1468
|
+
if (pending.length > 0) return { applied, pending, drift: null, upToDate: false };
|
|
1469
|
+
const physicalTarget = options.physicalTarget ?? migrations.at(-1)?.physical?.target;
|
|
1470
|
+
if (physicalTarget === undefined && options.model === undefined)
|
|
1471
|
+
return { applied, pending, drift: null, upToDate: true };
|
|
1472
|
+
const driver = options.shadowDriver ?? target.driver;
|
|
1473
|
+
if (physicalTarget === undefined && typeof driver?.open !== 'function')
|
|
1474
|
+
throw refuse('JD0021', 'borrowed model comparison requires shadowDriver or a complete physicalTarget');
|
|
1475
|
+
return chain(physicalTarget !== undefined ? comparePhysicalTarget(connection, physicalTarget)
|
|
1476
|
+
: compareShapeToModel(driver, connection, options.model, options.registerFunctions),
|
|
1477
|
+
(difference) => ({ applied, pending, drift: difference, upToDate: difference === null }));
|
|
1478
|
+
}))));
|
|
1479
|
+
});
|
|
1610
1480
|
}
|
|
1611
1481
|
|
|
1612
1482
|
/**
|
|
@@ -1621,11 +1491,14 @@ export function migrationStatus(target, migrations, options = {}) {
|
|
|
1621
1491
|
* The whole chain replays on a `:memory:` shadow before the real
|
|
1622
1492
|
* store is touched.
|
|
1623
1493
|
*
|
|
1624
|
-
* @param {{ driver
|
|
1494
|
+
* @param {{ driver?: any, path?: string, busyTimeout?: number, connection?: any }} target
|
|
1625
1495
|
* @param {any[]} migrations
|
|
1626
1496
|
* @param {{ baseline: any, model?: any, compileSchema?: Function,
|
|
1627
1497
|
* dryRun?: boolean, batchSize?: number, onProgress?: Function,
|
|
1628
1498
|
* shadow?: boolean, shadowPath?: string, shadowDriver?: any,
|
|
1499
|
+
* shadowFixture?: Function, physicalTarget?: any,
|
|
1500
|
+
* registerFunctions?: Function, expressions?: any,
|
|
1501
|
+
* assertionBounds?: any, onAssertionPlan?: Function,
|
|
1629
1502
|
* signal?: AbortSignal, deadline?: number,
|
|
1630
1503
|
* runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
|
|
1631
1504
|
* `signal` and `deadline` cancel between migrations, steps and
|
|
@@ -1636,286 +1509,179 @@ export function migrationStatus(target, migrations, options = {}) {
|
|
|
1636
1509
|
* migration is stamped with, and the clock and identifiers an entity
|
|
1637
1510
|
* step's `default: 'now'` / `default: 'uuid'` fill; the platform's own
|
|
1638
1511
|
* when absent
|
|
1639
|
-
* @returns {
|
|
1512
|
+
* @returns {any} Owned targets return a promise. Borrowed synchronous targets
|
|
1513
|
+
* with shadow:false and synchronous hooks settle in their caller's transaction.
|
|
1640
1514
|
*/
|
|
1641
1515
|
export function migrate(target, migrations, options) {
|
|
1642
|
-
if (target
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
throw new TypeError('migrate needs the full ordered migration list');
|
|
1648
|
-
if (options === null || typeof options !== 'object' || options.baseline === undefined)
|
|
1649
|
-
throw new TypeError(
|
|
1650
|
-
'migrate needs { baseline }: the model the store was first created with '
|
|
1651
|
-
+ '(the chain anchor and the shadow starting shape)');
|
|
1516
|
+
if (!target || typeof target !== 'object' || (target.connection === undefined && typeof target.driver?.open !== 'function'))
|
|
1517
|
+
throw new TypeError('migrate needs { driver, path? } or { connection }');
|
|
1518
|
+
if (!Array.isArray(migrations)) throw new TypeError('migrate needs the full ordered migration list');
|
|
1519
|
+
if (!options || typeof options !== 'object' || options.baseline === undefined)
|
|
1520
|
+
throw new TypeError('migrate needs { baseline }: the model the store was first created with');
|
|
1652
1521
|
const batchSize = options.batchSize ?? 500;
|
|
1653
|
-
if (!Number.isSafeInteger(batchSize) || batchSize < 1)
|
|
1654
|
-
|
|
1522
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new TypeError('batchSize must be a positive safe integer');
|
|
1523
|
+
if (options.shadowFixture !== undefined && typeof options.shadowFixture !== 'function') throw new TypeError('shadowFixture must initialize a disposable connection');
|
|
1655
1524
|
const runtime = resolveRuntime(options.runtime);
|
|
1656
|
-
/**
|
|
1657
|
-
* The cancellation boundary: between migrations, between steps and
|
|
1658
|
-
* between the batches of a data step, on the runtime record's clock.
|
|
1659
|
-
* Nothing interrupts a statement that has started; a refusal inside a
|
|
1660
|
-
* migration rolls that migration back whole and the completed ones
|
|
1661
|
-
* stand, so a rerun resumes from the recorded position.
|
|
1662
|
-
*/
|
|
1663
1525
|
const check = () => refuseCancelled({ signal: options.signal, deadline: options.deadline }, runtime.now, {
|
|
1664
1526
|
abortCode: 'JD2080', aborted: 'its next step', passed: 'its next step',
|
|
1665
|
-
ran: 'no further step ran; a migration in flight rolled back whole and a rerun resumes '
|
|
1666
|
-
+ 'from the recorded position',
|
|
1527
|
+
ran: 'no further step ran; a migration in flight rolled back whole and a rerun resumes from the recorded position',
|
|
1667
1528
|
});
|
|
1668
|
-
const runOptions = {
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
}
|
|
1734
|
-
const pending = migrations.slice(appliedRows.length);
|
|
1735
|
-
const currentShape = appliedRows.length > 0
|
|
1736
|
-
? appliedRows[appliedRows.length - 1].to_hash
|
|
1737
|
-
: shapeHash(options.baseline);
|
|
1738
|
-
|
|
1739
|
-
let expectedFrom = currentShape;
|
|
1740
|
-
for (const migration of pending) {
|
|
1741
|
-
checkMigrationDocument(migration);
|
|
1742
|
-
checkPreservationPlan(migration);
|
|
1743
|
-
if (migration.from !== expectedFrom) {
|
|
1744
|
-
throw refuse('JD0020',
|
|
1745
|
-
`migration '${migration.id}' expects shape '${migration.from}' but the `
|
|
1746
|
-
+ `database is at '${expectedFrom}' — refusing to run against the wrong shape`);
|
|
1747
|
-
}
|
|
1748
|
-
expectedFrom = migration.to;
|
|
1749
|
-
}
|
|
1750
|
-
if (options.model !== undefined && pending.length > 0
|
|
1751
|
-
&& expectedFrom !== shapeHash(options.model)) {
|
|
1752
|
-
throw refuse('JD0020',
|
|
1753
|
-
"the last migration's to-hash is not the target model's shape — the "
|
|
1754
|
-
+ 'migration chain and the code disagree about where this ends');
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
if (pending.length === 0) {
|
|
1758
|
-
return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
if (pending.some((m) => m.physical) && options.shadow !== false)
|
|
1762
|
-
throw refuse('JD0021', 'physical preservation plans require shadow:false; qualify against an explicit copy/fresh-target fixture');
|
|
1763
|
-
const shadowRun = options.shadow === false
|
|
1764
|
-
? null
|
|
1765
|
-
: replayOnShadow(options.shadowDriver ?? target.driver,
|
|
1766
|
-
options.shadowPath ?? ':memory:',
|
|
1767
|
-
options.baseline, migrations, options.model, runOptions);
|
|
1768
|
-
|
|
1769
|
-
return chain(shadowRun, () => {
|
|
1770
|
-
if (options.dryRun === true) {
|
|
1771
|
-
const rendered = [];
|
|
1772
|
-
const counts = {};
|
|
1773
|
-
const collect = (i) => {
|
|
1774
|
-
if (i >= pending.length) return null;
|
|
1775
|
-
const migration = pending[i];
|
|
1776
|
-
for (const migrationStep of migration.steps) {
|
|
1777
|
-
if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
|
|
1778
|
-
else if (migrationStep.kind === 'sql') {
|
|
1779
|
-
rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
|
|
1780
|
-
rendered.push(migrationStep.sql);
|
|
1781
|
-
}
|
|
1782
|
-
else if (migrationStep.kind === 'rebuild') {
|
|
1783
|
-
rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
|
|
1784
|
-
rendered.push(...migrationStep.create, migrationStep.copy,
|
|
1785
|
-
dialect.ddl.dropTable(migrationStep.table),
|
|
1786
|
-
dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
|
|
1787
|
-
migrationStep.table),
|
|
1788
|
-
...migrationStep.indexes);
|
|
1789
|
-
if (dialect.capabilities.foreignKeysAlwaysOn !== true)
|
|
1790
|
-
rendered.push(dialect.pragma.foreignKeyCheck());
|
|
1791
|
-
}
|
|
1792
|
-
else if (migrationStep.kind === 'jslt')
|
|
1793
|
-
rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
|
|
1794
|
-
else {
|
|
1795
|
-
const operation = compileDocumentStep(migrationStep, migration.steps.indexOf(migrationStep), {
|
|
1796
|
-
migrationId: migration.id, compileJslt: compileJsltStylesheet, compileQuery: compileJsonQuery,
|
|
1797
|
-
assertionBounds: runOptions.assertionBounds,
|
|
1798
|
-
});
|
|
1799
|
-
const strategy = assertionProvider(migrationStep.assert, migrationStep.collection, operation.shape) === null
|
|
1800
|
-
? operation.strategy : 'provider';
|
|
1801
|
-
rendered.push(`-- assert over '${migrationStep.collection}' (${strategy}; ${operation.reason})`);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
const jsltCollections = [...new Set(migration.steps
|
|
1805
|
-
.filter((s) => s.kind === 'jslt').map((s) => s.collection))];
|
|
1806
|
-
const count = (j) => {
|
|
1807
|
-
if (j >= jsltCollections.length) return null;
|
|
1808
|
-
const table = jsltCollections[j];
|
|
1809
|
-
const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
|
|
1810
|
-
+ `FROM ${dialect.quoteIdentifier(table)}`;
|
|
1811
|
-
return chain(connection.prepare(countSql), (statement) =>
|
|
1812
|
-
chain(statement.get([]), (row) => {
|
|
1813
|
-
counts[table] = row.n;
|
|
1814
|
-
return count(j + 1);
|
|
1815
|
-
}));
|
|
1816
|
-
};
|
|
1817
|
-
return chain(count(0), () => collect(i + 1));
|
|
1818
|
-
};
|
|
1819
|
-
return chain(collect(0), () => ({
|
|
1820
|
-
dryRun: true,
|
|
1821
|
-
pending: pending.map((migration) => migration.id),
|
|
1822
|
-
statements: rendered,
|
|
1823
|
-
counts,
|
|
1824
|
-
shadowValidated: options.shadow !== false,
|
|
1825
|
-
}));
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
// the real run: one exclusive transaction per migration
|
|
1829
|
-
const applied = [];
|
|
1830
|
-
const applyNext = (i) => {
|
|
1529
|
+
const runOptions = { batchSize, assertionBounds: normalizeAssertionBounds(options.assertionBounds),
|
|
1530
|
+
onProgress: options.onProgress, onAssertionPlan: options.onAssertionPlan,
|
|
1531
|
+
registerFunctions: options.registerFunctions, expressions: options.expressions,
|
|
1532
|
+
compileSchema: options.compileSchema, physicalTarget: options.physicalTarget,
|
|
1533
|
+
shadowFixture: options.shadowFixture, model: options.model,
|
|
1534
|
+
signal: options.signal, deadline: options.deadline, runtime, check };
|
|
1535
|
+
try { check(); }
|
|
1536
|
+
catch (error) { if (target.connection !== undefined) throw error; return Promise.reject(error); }
|
|
1537
|
+
if (options.physicalTarget !== undefined) physicalTargetOf(options.physicalTarget);
|
|
1538
|
+
const referenceDriver = options.shadowDriver ?? target.driver;
|
|
1539
|
+
return withMigrationConnection(target, (connection) => {
|
|
1540
|
+
if (connection.mustQueue) throw refuse('JD0021', 'migration needs an exclusively available connection or its owning transaction scope');
|
|
1541
|
+
return chain(registerDeriveFunctions(connection), () => chain(options.registerFunctions?.(connection), () => {
|
|
1542
|
+
const dialect = connection.dialect, statements = historyStatements(dialect);
|
|
1543
|
+
let historyExists = false;
|
|
1544
|
+
const readHistory = () => chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1545
|
+
chain(probe.get([HISTORY_TABLE]), (row) => {
|
|
1546
|
+
historyExists = row !== undefined;
|
|
1547
|
+
return historyExists ? chain(connection.prepare(statements.select), (select) => select.all([])) : [];
|
|
1548
|
+
}));
|
|
1549
|
+
return chain(readHistory(), (appliedRows) => {
|
|
1550
|
+
const verifyHistory = (count) => chain(readHistory(), (rows) => {
|
|
1551
|
+
if (rows.length !== count || rows.some((row, at) => row.id !== migrations[at]?.id
|
|
1552
|
+
|| row.checksum !== migrationChecksum(migrations[at])))
|
|
1553
|
+
throw refuse('JD0022', 'migration history changed while acquiring its writer; no step ran');
|
|
1554
|
+
});
|
|
1555
|
+
for (let i = 0; i < appliedRows.length; i++) {
|
|
1556
|
+
const row = appliedRows[i], doc = migrations[i];
|
|
1557
|
+
if (doc === undefined || doc.id !== row.id) throw refuse('JD0022',
|
|
1558
|
+
`history position ${i} records '${row.id}' but the migration list has '${doc?.id ?? '<nothing>'}' — the list must contain every applied migration, in order`);
|
|
1559
|
+
if (migrationChecksum(doc) !== row.checksum) throw refuse('JD0022',
|
|
1560
|
+
`migration '${row.id}' differs from the document recorded in the history — an applied migration must never be edited`);
|
|
1561
|
+
}
|
|
1562
|
+
const pending = migrations.slice(appliedRows.length);
|
|
1563
|
+
let expectedFrom = appliedRows.length ? appliedRows.at(-1).to_hash : shapeHash(options.baseline);
|
|
1564
|
+
for (const migration of pending) {
|
|
1565
|
+
checkMigrationDocument(migration); checkPreservationPlan(migration);
|
|
1566
|
+
if (migration.from !== expectedFrom) throw refuse('JD0020',
|
|
1567
|
+
`migration '${migration.id}' expects shape '${migration.from}' but the database is at '${expectedFrom}' — refusing to run against the wrong shape`);
|
|
1568
|
+
expectedFrom = migration.to;
|
|
1569
|
+
}
|
|
1570
|
+
if (options.model !== undefined && expectedFrom !== shapeHash(options.model)) throw refuse('JD0020',
|
|
1571
|
+
"the last migration's to-hash is not the target model's shape — the migration chain and the code disagree about where this ends");
|
|
1572
|
+
const finalTarget = options.physicalTarget ?? migrations.at(-1)?.physical?.target;
|
|
1573
|
+
const acceptTarget = (scope, targetShape) => targetShape === undefined ? null
|
|
1574
|
+
: chain(comparePhysicalTarget(scope, targetShape), (difference) => {
|
|
1575
|
+
if (difference !== null) throw refuse('JD0023', `the migrated physical target differs: ${difference}`);
|
|
1576
|
+
});
|
|
1577
|
+
if (pending.length === 0) {
|
|
1578
|
+
const result = { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1579
|
+
return finalTarget === undefined ? result : connection.transaction((scope) =>
|
|
1580
|
+
chain(verifyHistory(appliedRows.length), () => chain(acceptTarget(scope, finalTarget), () => result)), options.signal, 'immediate');
|
|
1581
|
+
}
|
|
1582
|
+
if (pending.some((m) => m.physical) && options.shadow !== false && options.shadowFixture === undefined)
|
|
1583
|
+
throw refuse('JD0021', 'physical preservation plans require shadow:false or an explicit shadowFixture initializer');
|
|
1584
|
+
if (options.shadow !== false && (typeof referenceDriver?.open !== 'function'
|
|
1585
|
+
|| options.shadowPath !== undefined && options.shadowPath !== ':memory:'
|
|
1586
|
+
&& target.path !== undefined && options.shadowPath === target.path))
|
|
1587
|
+
throw refuse('JD0021', 'shadow replay needs an independent driver and disposable path');
|
|
1588
|
+
const shadowRun = options.shadow === false ? null : replayOnShadow(connection, referenceDriver,
|
|
1589
|
+
options.shadowPath ?? ':memory:', options.baseline, migrations, options.model, runOptions);
|
|
1590
|
+
return chain(shadowRun, () => {
|
|
1591
|
+
if (options.dryRun === true) {
|
|
1592
|
+
const rendered = [], counts = {};
|
|
1593
|
+
const collect = (i) => {
|
|
1831
1594
|
if (i >= pending.length) return null;
|
|
1832
|
-
// the boundary between migrations
|
|
1833
|
-
check();
|
|
1834
1595
|
const migration = pending[i];
|
|
1835
|
-
const
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
? connection.exec(dialect.pragma.foreignKeys(false)) : null,
|
|
1845
|
-
() => chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
1846
|
-
const body = () => chain(migration.physical ? verifyPreservation(connection, migration.physical, false) : null, () =>
|
|
1847
|
-
chain(historyExists ? null : connection.exec(statements.create), () =>
|
|
1848
|
-
chain(runSteps(connection, migration, runOptions), () =>
|
|
1849
|
-
chain(migration.physical ? verifyPreservation(connection, migration.physical, true) : null, () =>
|
|
1850
|
-
chain(last && options.model !== undefined
|
|
1851
|
-
? chain(validateTargetState(connection, options.model,
|
|
1852
|
-
{ compileSchema: options.compileSchema, batchSize }),
|
|
1853
|
-
() => (normalizeEntities(options.model).size === 0 || migration.physical ? null
|
|
1854
|
-
: chain(compareShapeToModel(target.driver, connection,
|
|
1855
|
-
options.model, options.registerFunctions), (difference) => {
|
|
1856
|
-
if (difference !== null) {
|
|
1857
|
-
throw refuse('JD0023',
|
|
1858
|
-
`the migrated shape does not equal the target model's: ${difference}`);
|
|
1859
|
-
}
|
|
1860
|
-
return null;
|
|
1861
|
-
})))
|
|
1862
|
-
: null,
|
|
1863
|
-
() => chain(connection.prepare(statements.insert), (insert) =>
|
|
1864
|
-
insert.run([migration.id, runtime.now(), migration.from,
|
|
1865
|
-
migration.to, migrationChecksum(migration),
|
|
1866
|
-
migration.steps.length])))))));
|
|
1867
|
-
const restore = () => (bracket
|
|
1868
|
-
&& dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1869
|
-
? connection.exec(dialect.pragma.foreignKeys(true)) : null);
|
|
1870
|
-
const rollback = (error) =>
|
|
1871
|
-
chain(connection.exec(dialect.tx.rollback), () =>
|
|
1872
|
-
chain(restore(), () => { throw error; }));
|
|
1873
|
-
// only body() may route to this migration's rollback:
|
|
1874
|
-
// commit() chains the NEXT migration, whose failure
|
|
1875
|
-
// rolls ITSELF back — catching it here would roll
|
|
1876
|
-
// back a transaction that already committed
|
|
1877
|
-
let outcome;
|
|
1878
|
-
try {
|
|
1879
|
-
outcome = body();
|
|
1596
|
+
for (const migrationStep of migration.steps) {
|
|
1597
|
+
if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
|
|
1598
|
+
else if (migrationStep.kind === 'sql') rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`, migrationStep.sql);
|
|
1599
|
+
else if (migrationStep.kind === 'table') rendered.push(`-- guarded table '${migrationStep.plan.table}'`, ...migrationStep.plan.statements, ...migrationStep.plan.finish);
|
|
1600
|
+
else if (migrationStep.kind === 'rebuild') {
|
|
1601
|
+
rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`, ...migrationStep.create,
|
|
1602
|
+
migrationStep.copy, dialect.ddl.dropTable(migrationStep.table),
|
|
1603
|
+
dialect.ddl.renameTable(`${migrationStep.table}__rebuild`, migrationStep.table), ...migrationStep.indexes);
|
|
1604
|
+
if (dialect.capabilities.foreignKeysAlwaysOn !== true) rendered.push(dialect.pragma.foreignKeyCheck());
|
|
1880
1605
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1606
|
+
else if (migrationStep.kind === 'jslt') rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
|
|
1607
|
+
else {
|
|
1608
|
+
const operation = compileDocumentStep(migrationStep, migration.steps.indexOf(migrationStep), {
|
|
1609
|
+
migrationId: migration.id, compileJslt: compileJsltStylesheet, compileQuery: compileJsonQuery,
|
|
1610
|
+
assertionBounds: runOptions.assertionBounds,
|
|
1611
|
+
});
|
|
1612
|
+
const strategy = assertionProvider(migrationStep.assert, migrationStep.collection, operation.shape) === null ? operation.strategy : 'provider';
|
|
1613
|
+
rendered.push(`-- assert over '${migrationStep.collection}' (${strategy}; ${operation.reason})`);
|
|
1883
1614
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1615
|
+
}
|
|
1616
|
+
const transforms = migration.steps.filter((s) => s.kind === 'jslt');
|
|
1617
|
+
const count = (j) => {
|
|
1618
|
+
if (j >= transforms.length) return null;
|
|
1619
|
+
const current = transforms[j], mapping = entityStepMapping(runOptions, current.collection, current, dialect);
|
|
1620
|
+
const table = mapping?.mapping.table ?? current.collection;
|
|
1621
|
+
// Preview reads current relations; earlier planned SQL may create
|
|
1622
|
+
// this table/view or change its rows, but is not executed here.
|
|
1623
|
+
return chain(connection.prepare(dialect.introspect.tables()), (catalog) => chain(catalog.all([]), (relations) => {
|
|
1624
|
+
if (!relations.some((relation) => String(relation.name) === table)) {
|
|
1625
|
+
setObjectMember(counts, current.collection, null);
|
|
1626
|
+
return count(j + 1);
|
|
1627
|
+
}
|
|
1628
|
+
return chain(connection.prepare(`SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} FROM ${dialect.quoteIdentifier(table)}`), (statement) =>
|
|
1629
|
+
chain(statement.get([]), (row) => { setObjectMember(counts, current.collection, row.n); return count(j + 1); }));
|
|
1630
|
+
}));
|
|
1631
|
+
};
|
|
1632
|
+
return chain(count(0), () => collect(i + 1));
|
|
1895
1633
|
};
|
|
1896
|
-
return chain(
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1634
|
+
return chain(collect(0), () => ({ dryRun: true, pending: pending.map((m) => m.id),
|
|
1635
|
+
statements: rendered, counts, shadowValidated: options.shadow !== false }));
|
|
1636
|
+
}
|
|
1637
|
+
const applied = [];
|
|
1638
|
+
const applyNext = (i) => {
|
|
1639
|
+
if (i >= pending.length) return null;
|
|
1640
|
+
check();
|
|
1641
|
+
const migration = pending[i], last = i === pending.length - 1;
|
|
1642
|
+
const bracket = migration.steps.some((s) => s.kind === 'rebuild' || s.kind === 'table' && s.plan.rebuild)
|
|
1643
|
+
&& dialect.capabilities.foreignKeysAlwaysOn !== true;
|
|
1644
|
+
const body = (scope) => {
|
|
1645
|
+
check();
|
|
1646
|
+
// Admission may have waited behind a different process. Never rerun
|
|
1647
|
+
// a body using receipts read before that process committed.
|
|
1648
|
+
return chain(verifyHistory(appliedRows.length + applied.length), () => {
|
|
1649
|
+
let work = migration.physical ? verifyPreservation(scope, migration.physical, false) : null;
|
|
1650
|
+
work = chain(work, () => historyExists ? null : scope.exec(statements.create));
|
|
1651
|
+
work = chain(work, () => runSteps(scope, migration, runOptions));
|
|
1652
|
+
work = chain(work, () => migration.physical ? verifyPreservation(scope, migration.physical, true) : null);
|
|
1653
|
+
work = chain(work, () => acceptTarget(scope, last ? finalTarget : migration.physical?.target));
|
|
1654
|
+
work = chain(work, () => last && options.model !== undefined ? chain(validateTargetState(scope, options.model,
|
|
1655
|
+
{ compileSchema: options.compileSchema, batchSize }), () => {
|
|
1656
|
+
if (normalizeEntities(options.model).size === 0 || migration.physical || finalTarget !== undefined
|
|
1657
|
+
|| [...normalizeEntities(options.model).values()].some((e) => e.physical)) return null;
|
|
1658
|
+
if (typeof referenceDriver?.open !== 'function') throw refuse('JD0021', 'borrowed model comparison requires shadowDriver or a complete physicalTarget');
|
|
1659
|
+
return chain(compareShapeToModel(referenceDriver, scope, options.model, options.registerFunctions, options.expressions), (difference) => {
|
|
1660
|
+
if (difference !== null) throw refuse('JD0023', `the migrated shape does not equal the target model's: ${difference}`);
|
|
1661
|
+
});
|
|
1662
|
+
}) : null);
|
|
1663
|
+
return chain(work, () => chain(scope.prepare(statements.insert), (insert) =>
|
|
1664
|
+
insert.run([migration.id, runtime.now(), migration.from, migration.to, migrationChecksum(migration), migration.steps.length])));
|
|
1665
|
+
});
|
|
1666
|
+
};
|
|
1667
|
+
const transaction = () => connection.transaction(body, options.signal, 'immediate');
|
|
1668
|
+
return chain(bracket ? withForeignKeySettings(connection, transaction) : transaction(), () => {
|
|
1669
|
+
historyExists = true; applied.push(migration.id); return applyNext(i + 1);
|
|
1670
|
+
});
|
|
1671
|
+
};
|
|
1672
|
+
return chain(applyNext(0), () => ({ applied, skipped: appliedRows.map((row) => row.id), shape: expectedFrom }));
|
|
1902
1673
|
});
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
return failClosed(error);
|
|
1907
|
-
}
|
|
1908
|
-
return work instanceof Promise
|
|
1909
|
-
? work.then(finish, failClosed)
|
|
1910
|
-
: finish(work);
|
|
1911
|
-
})));
|
|
1674
|
+
});
|
|
1675
|
+
}));
|
|
1676
|
+
});
|
|
1912
1677
|
}
|
|
1913
1678
|
|
|
1914
1679
|
/** Plan an existing file's explicit preservation migration. Every source object
|
|
1915
1680
|
* needs a disposition; source/target assertions preserve application-owned facts.
|
|
1916
1681
|
* @param {any} connection @param {any} fromModel @param {any} toModel
|
|
1917
1682
|
* @param {{ id: string, steps: any[], dispositions: Record<string, 'preserve'|'replace'|'drop'>,
|
|
1918
|
-
* assertions?: { sql: string, params?: any[], expected: any[] }[]
|
|
1683
|
+
* assertions?: { sql: string, params?: any[], expected: any[] }[],
|
|
1684
|
+
* physicalTarget?: any }} options @returns {any} */
|
|
1919
1685
|
export function planPhysicalMigration(connection, fromModel, toModel, options) {
|
|
1920
1686
|
normalizeEntities(fromModel); normalizeEntities(toModel);
|
|
1921
1687
|
if (!options || typeof options.id !== 'string' || !options.id || !Array.isArray(options.steps))
|
|
@@ -1931,7 +1697,8 @@ export function planPhysicalMigration(connection, fromModel, toModel, options) {
|
|
|
1931
1697
|
throw refuse('JD0021', 'preservation assertions require a SELECT and expected rows');
|
|
1932
1698
|
}
|
|
1933
1699
|
const migration = { $migration: MIGRATION_VERSION, id: options.id, from: shapeHash(fromModel), to: shapeHash(toModel),
|
|
1934
|
-
steps: options.steps, physical: { source, dispositions, assertions
|
|
1700
|
+
steps: options.steps, physical: { source, dispositions, assertions,
|
|
1701
|
+
...(options.physicalTarget === undefined ? {} : { target: structuredClone(options.physicalTarget) }) } };
|
|
1935
1702
|
checkMigrationDocument(migration);
|
|
1936
1703
|
checkPreservationPlan(migration);
|
|
1937
1704
|
return migration;
|
|
@@ -1940,10 +1707,12 @@ export function planPhysicalMigration(connection, fromModel, toModel, options) {
|
|
|
1940
1707
|
|
|
1941
1708
|
/** Validate saved plans again at execution, including SQL ownership boundaries. */
|
|
1942
1709
|
function checkPreservationPlan(migration) {
|
|
1710
|
+
checkMigrationStatements(migration);
|
|
1943
1711
|
const physical = migration.physical;
|
|
1944
1712
|
if (physical === undefined) return;
|
|
1945
1713
|
const fail = () => { throw refuse('JD0021', 'invalid physical source, dispositions, assertions or steps'); };
|
|
1946
1714
|
if (!physical || !Array.isArray(physical.source) || !physical.dispositions || !Array.isArray(physical.assertions)) fail();
|
|
1715
|
+
if (physical.target !== undefined) physicalTargetOf(physical.target);
|
|
1947
1716
|
const keys = physical.source.map((object) => {
|
|
1948
1717
|
if (!object || typeof object.name !== 'string' || !['table', 'view', 'index', 'trigger'].includes(object.type)) fail();
|
|
1949
1718
|
return `${object.type}:${object.name}`;
|
|
@@ -1953,15 +1722,40 @@ function checkPreservationPlan(migration) {
|
|
|
1953
1722
|
for (const assertion of physical.assertions)
|
|
1954
1723
|
if (!assertion || typeof assertion.sql !== 'string' || !/^SELECT\b/i.test(assertion.sql.trim())
|
|
1955
1724
|
|| !Array.isArray(assertion.expected) || (assertion.params !== undefined && !Array.isArray(assertion.params))) fail();
|
|
1956
|
-
if (migration.steps.some((step) => !['ddl', 'sql', 'rebuild'].includes(step.kind))) fail();
|
|
1725
|
+
if (migration.steps.some((step) => !['ddl', 'sql', 'rebuild', 'table', 'jslt', 'query'].includes(step.kind))) fail();
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** A saved step is one statement or one trigger program. Transaction aliases
|
|
1729
|
+
* and trailing statements cannot escape its savepoint or publish partial work. */
|
|
1730
|
+
function checkMigrationStatements(migration) {
|
|
1957
1731
|
const fragments = migration.steps.flatMap((step) => step.kind === 'rebuild'
|
|
1958
|
-
? [...(step.create ?? []), step.copy, ...(step.indexes ?? [])] :
|
|
1732
|
+
? [...(step.create ?? []), step.copy, ...(step.indexes ?? [])] : step.kind === 'table'
|
|
1733
|
+
? [...step.plan.statements, ...step.plan.finish] : ['ddl', 'sql'].includes(step.kind) ? [step.sql] : []);
|
|
1959
1734
|
for (const sql of fragments) {
|
|
1960
1735
|
const tokens = typeof sql === 'string' ? sqlTokens(sql) : [];
|
|
1961
1736
|
const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
|
|
1962
|
-
|
|
1737
|
+
const fail = () => { throw refuse('JD0021', 'migration steps cannot change transaction or connection ownership; use one statement per step'); };
|
|
1738
|
+
if (!['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE', 'WITH', 'REPLACE'].includes(words[0])
|
|
1963
1739
|
|| words.some((w) => /^(?:COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|VACUUM)$/.test(w)))
|
|
1964
|
-
|
|
1740
|
+
fail();
|
|
1741
|
+
const trigger = words[0] === 'CREATE' && (words[1] === 'TRIGGER'
|
|
1742
|
+
|| ['TEMP', 'TEMPORARY'].includes(words[1]) && words[2] === 'TRIGGER');
|
|
1743
|
+
if (!trigger) {
|
|
1744
|
+
if (words.includes('BEGIN') || tokens.some((token, i) => token.kind === 'symbol'
|
|
1745
|
+
&& token.value === ';' && i !== tokens.length - 1)) fail();
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const begin = tokens.findIndex((token) => token.kind === 'word' && token.value.toUpperCase() === 'BEGIN');
|
|
1749
|
+
if (begin < 0) fail();
|
|
1750
|
+
let depth = 1, end = -1;
|
|
1751
|
+
for (let i = begin + 1; i < tokens.length; i++) {
|
|
1752
|
+
const token = tokens[i];
|
|
1753
|
+
if (token.kind !== 'word') continue;
|
|
1754
|
+
const word = token.value.toUpperCase();
|
|
1755
|
+
if (word === 'CASE' || word === 'BEGIN') depth++;
|
|
1756
|
+
else if (word === 'END' && --depth === 0) { end = i; break; }
|
|
1757
|
+
}
|
|
1758
|
+
if (end < 0 || tokens.slice(end + 1).some((token, i) => i !== 0 || token.kind !== 'symbol' || token.value !== ';')) fail();
|
|
1965
1759
|
}
|
|
1966
1760
|
}
|
|
1967
1761
|
|