@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/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, toPromise } from './driver.js';
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, physicalSelection } from './physical.js';
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 for comparison: every
893
- * object carrying SQL text (tables, indexes), whitespace-collapsed,
894
- * engine-owned tables excluded, sorted. Shape equality after a migration —
895
- * this dump versus a fresh {@link createModelShape} — is the
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
- // the engine's own tables — history, the change log and its state
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: normalizeSchemaSql(String(row.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 chain(driver.open(':memory:', {}), (reference) =>
938
+ return withMigrationConnection({ driver, path: ':memory:' }, (reference) =>
981
939
  chain(chain(registerDeriveFunctions(reference),
982
940
  () => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
983
- const finish = (result) => chain(reference.close(), () => result);
984
- let outcome;
985
- try {
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
- const order = entityMapping.keys.map((k) => q(entityMapping.columns.find((c) => c.name === k).physical)).join(', ');
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
- * The entity mapping a migration step over `table` runs under, or
1102
- * `null` for a collection. The TARGET model maps the table: a chain's
1103
- * intermediate shapes are hashes only, so an entity transform belongs
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
- if (options.model === undefined) return null;
1112
- const entities = normalizeEntities(options.model);
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) return null;
1115
- return { entity, mapping: explainMapping(options.model).entities[table] };
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 whole migration chain on a shadow database: the baseline
1432
- * shape is created, every migration's steps run (over an empty data
1433
- * set the shadow proves STRUCTURE; the real-data facts are checked
1434
- * on the real store inside its transaction), and the end shape is
1435
- * verified against the target model. The real store is untouched
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 {{ batchSize: number }} options
1384
+ * @param {any} options
1443
1385
  * @returns {any} value-or-promise
1444
1386
  */
1445
- function replayOnShadow(driver, shadowPath, baseline, migrations, model, options) {
1446
- return chain(driver.open(shadowPath, {}), (shadow) => {
1447
- const finish = (result) => chain(shadow.close(), () => result);
1448
- // a UDF-expression index is invisible to a connection that has not
1449
- // registered the function (probed, never assumed): the shadow
1450
- // re-registers every declared function BEFORE any DDL runs
1451
- const registered = chain(registerDeriveFunctions(shadow), () =>
1452
- (options.registerFunctions !== undefined ? options.registerFunctions(shadow) : null));
1453
- const apply = (i) => {
1454
- if (i >= migrations.length) return null;
1455
- // a rebuild moves rows between tables while their keys point at
1456
- // the old one, so the switch comes off around it — on an engine
1457
- // that HAS a switch. One that always enforces cannot rebuild that
1458
- // way, and `alterTableFull` is why it never has to
1459
- const bracket = migrations[i].steps.some(
1460
- (candidate) => candidate.kind === 'rebuild')
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: any, path?: string }} target
1437
+ * @param {{ driver?: any, path?: string, connection?: any }} target
1544
1438
  * @param {any[]} migrations - the full ordered list
1545
- * @param {{ model?: any, registerFunctions?: (connection: any) => 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 {Promise<{ applied: string[], pending: string[],
1551
- * drift: string | null, upToDate: boolean }>}
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
- return Promise.reject(error);
1563
- }
1564
- return toPromise(chain(
1565
- target.driver.open(target.path ?? ':memory:', {}),
1566
- (connection) => {
1567
- const dialect = connection.dialect;
1568
- const statements = historyStatements(dialect);
1569
- const finish = (result) => chain(connection.close(), () => result);
1570
- const failClosed = (error) => chain(connection.close(), () => { throw error; });
1571
- let work;
1572
- try {
1573
- // §6's "writes NOTHING" holds for a status read too: the history
1574
- // table is probed, never created, and an absent one reads as an
1575
- // empty history the same promise the dry run makes
1576
- work = chain(registerDeriveFunctions(connection), () =>
1577
- chain(chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1578
- chain(probe.get([HISTORY_TABLE]), (present) => (present === undefined
1579
- ? []
1580
- : chain(connection.prepare(statements.select), (select) => select.all([]))))),
1581
- (rows) => chain(rows, () => {
1582
- for (let i = 0; i < rows.length; i++) {
1583
- const doc = migrations[i];
1584
- if (doc === undefined || doc.id !== rows[i].id
1585
- || migrationChecksum(doc) !== rows[i].checksum) {
1586
- throw refuse('JD0022',
1587
- `history position ${i} records '${rows[i].id}' but the migration list `
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: any, path?: string, busyTimeout?: number }} target
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 {Promise<any>}
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 === null || typeof target !== 'object'
1643
- || target.driver === null || typeof target.driver !== 'object'
1644
- || typeof target.driver.open !== 'function')
1645
- throw new TypeError('migrate needs { driver } (and usually { path })');
1646
- if (!Array.isArray(migrations))
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
- throw new TypeError('batchSize must be a positive safe integer');
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
- batchSize,
1670
- assertionBounds: normalizeAssertionBounds(options.assertionBounds),
1671
- onProgress: options.onProgress,
1672
- onAssertionPlan: options.onAssertionPlan,
1673
- registerFunctions: options.registerFunctions,
1674
- // the host's declared index-expression functions ride to every
1675
- // planner and every connection this run opens the shadow's
1676
- // baseline, the reference database and the real store all resolve a
1677
- // declared expression against the same declarations the open path
1678
- // was given, or they would plan DDL nobody can apply
1679
- expressions: options.expressions,
1680
- model: options.model,
1681
- runtime,
1682
- check,
1683
- };
1684
- try {
1685
- check();
1686
- }
1687
- catch (error) {
1688
- return Promise.reject(error);
1689
- }
1690
-
1691
- return toPromise(chain(
1692
- target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
1693
- (connection) => chain(
1694
- chain(registerDeriveFunctions(connection),
1695
- () => (options.registerFunctions !== undefined
1696
- ? options.registerFunctions(connection) : null)),
1697
- () => {
1698
- const dialect = connection.dialect;
1699
- const statements = historyStatements(dialect);
1700
- const finish = (result) => chain(connection.close(), () => result);
1701
- const failClosed = (error) => chain(connection.close(), () => { throw error; });
1702
-
1703
- let work;
1704
- try {
1705
- // §6's "writes NOTHING": an apply creates the empty history
1706
- // table before reading it, a DRY RUN probes for it instead and
1707
- // reads an absent one as an empty history — the promise a dry
1708
- // run makes is the reason it is safe to point at production
1709
- let historyExists = false;
1710
- const history = chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1711
- chain(probe.get([HISTORY_TABLE]), (row) => {
1712
- historyExists = row !== undefined;
1713
- return !historyExists ? [] : chain(connection.prepare(statements.select), (select) => select.all([]));
1714
- }));
1715
- work = chain(history, (appliedRows) => {
1716
- // the list must agree with the history: same ids, same
1717
- // order, same checksums an edited applied migration is
1718
- // always a bug worth failing on
1719
- for (let i = 0; i < appliedRows.length; i++) {
1720
- const row = appliedRows[i];
1721
- const doc = migrations[i];
1722
- if (doc === undefined || doc.id !== row.id) {
1723
- throw refuse('JD0022',
1724
- `history position ${i} records '${row.id}' but the migration list has `
1725
- + `'${doc?.id ?? '<nothing>'}' the list must contain every applied `
1726
- + 'migration, in order');
1727
- }
1728
- if (migrationChecksum(doc) !== row.checksum) {
1729
- throw refuse('JD0022',
1730
- `migration '${row.id}' differs from the document recorded in the `
1731
- + 'history an applied migration must never be edited');
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 last = i === pending.length - 1;
1836
- // the §10 procedure's pragma bracket, literally: the
1837
- // foreign_keys pragma is a no-op inside a transaction,
1838
- // and node:sqlite enables enforcement BY DEFAULT a
1839
- // parent-table rebuild could not even DROP without this
1840
- const bracket = migration.steps.some(
1841
- (candidate) => candidate.kind === 'rebuild');
1842
- return chain(
1843
- bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
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
- catch (error) {
1882
- return rollback(error);
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
- const settle = () => {
1885
- let result;
1886
- try { result = connection.exec(dialect.tx.commit); }
1887
- catch (error) { return rollback(error); }
1888
- return result instanceof Promise ? result.then(published, rollback) : published();
1889
- };
1890
- const published = () => chain(restore(), () => {
1891
- historyExists = true; applied.push(migration.id); return applyNext(i + 1);
1892
- });
1893
- return outcome instanceof Promise ? outcome.then(settle, rollback) : settle();
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(applyNext(0), () => ({
1897
- applied,
1898
- skipped: appliedRows.map((row) => row.id),
1899
- shape: expectedFrom,
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
- catch (error) {
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[] }[] }} options @returns {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 ?? [])] : [step.sql]);
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
- if (!['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE'].includes(words[0])
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
- throw refuse('JD0021', 'physical steps cannot change transaction or connection ownership');
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