@quereus/isolation 4.5.0 → 4.6.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.
@@ -1,8 +1,8 @@
1
- import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation, inferType, validateAndParse, validateCollationForType, formatKeyValue } from '@quereus/quereus';
1
+ import { MemoryTableModule, PhysicalType, QuereusError, StatusCode } from '@quereus/quereus';
2
2
  import { IsolatedTable } from './isolated-table.js';
3
3
  import { applyOverlayToUnderlying } from './flush.js';
4
- import { makeFullScanFilterInfo } from './filter-info.js';
5
4
  import { iterateEffectiveRows, makePkKeySerializer } from './overlay-rows.js';
5
+ import { applyPkRekeyMarkerDrops, assertColumnNameNotTombstone, buildAlterPoisonMessage, deriveAlterMigrationPlan, migrateOverlayForward, planPkRekeyMarkerDrops, reinsertPkRekeyMarkers, validateOverlayMigration, } from './alter-migration.js';
6
6
  let overlayIdCounter = 0;
7
7
  /**
8
8
  * Generates a unique overlay ID for each overlay table instance.
@@ -617,6 +617,21 @@ export class IsolationModule {
617
617
  async notifyLensDeployment(db, logicalSchemaName, snapshot) {
618
618
  await this.underlying.notifyLensDeployment?.(db, logicalSchemaName, snapshot);
619
619
  }
620
+ /**
621
+ * Forwards module registration to the underlying module.
622
+ *
623
+ * `Database.registerModule` calls `onRegister` on the REGISTERED module, which is this
624
+ * wrapper when a store is isolated. The underlying module uses it to subscribe to the
625
+ * database's schema-change notifier before any statement runs; without this forward an
626
+ * isolation-wrapped store would fall back to subscribing lazily at its first table
627
+ * hook and would silently drop a view created ahead of that.
628
+ *
629
+ * Which `Database` the module serves is isolation-transparent, so a straight delegate
630
+ * is correct; a throw must propagate (it fails the registration by contract).
631
+ */
632
+ onRegister(db, moduleName) {
633
+ this.underlying.onRegister?.(db, moduleName);
634
+ }
620
635
  /**
621
636
  * Forwards the view / materialized-view catalog-persistability veto to the underlying
622
637
  * module.
@@ -960,7 +975,7 @@ export class IsolationModule {
960
975
  const lower = indexName.toLowerCase();
961
976
  return schema.indexes?.find(idx => idx.name.toLowerCase() === lower);
962
977
  }
963
- /** Case-insensitive "does `schema` declare an index named `indexName`". */
978
+ /** Case-insensitive "does `schema` declare an index named `indexName`". Part of {@link AlterMigrationHost}. */
964
979
  schemaHasIndex(schema, indexName) {
965
980
  return this.findSchemaIndex(schema, indexName) !== undefined;
966
981
  }
@@ -1073,6 +1088,12 @@ export class IsolationModule {
1073
1088
  throw e;
1074
1089
  throw this.issuerOverlayDriftError(schemaName, tableName, ddlDescription, e);
1075
1090
  }
1091
+ // NOTE: preparation the failed `apply()` already performed is NOT undone here — the
1092
+ // PK-re-key arm drops collapsed deletion markers before forwarding the re-key
1093
+ // (`dropCollapsedPkRekeyMarkers`), and a refusal after that leaves them dropped.
1094
+ // Inert while poison is terminal: the only exit is a rollback that discards the
1095
+ // overlay whole. If poison ever becomes recoverable, this overlay must be restored
1096
+ // the way the issuer's is (`reinsertPkRekeyMarkers`) before it can be un-poisoned.
1076
1097
  overlayState.poison = { message: this.buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
1077
1098
  }
1078
1099
  }
@@ -1080,31 +1101,20 @@ export class IsolationModule {
1080
1101
  * Delegates ALTER TABLE to the underlying module and carries any per-connection
1081
1102
  * overlays to the post-alter schema without discarding staged rows.
1082
1103
  *
1083
- * Every change type forwards to each overlay IN PLACE — through the overlay's own
1104
+ * The migration machinery itself — deriving the per-change-type constants, dry-running
1105
+ * one overlay against them, and reshaping it forward — lives in `alter-migration.ts`;
1106
+ * this method owns the surrounding lifecycle. Every change type but one forwards to each
1107
+ * overlay IN PLACE via {@link migrateOverlayForward} — through the overlay's own
1084
1108
  * `alterSchema` / `createIndex` / ordinary writes — so the overlay's layer chain and
1085
1109
  * savepoint snapshots survive the ALTER and `rollback to savepoint` keeps
1086
1110
  * distinguishing rows staged before the savepoint from rows staged after it (see
1087
1111
  * {@link applyIndexChangeToOverlays} for why the old rebuild — copying staged rows
1088
- * into a fresh staging table — destroyed that distinction). Per change type:
1089
- *
1090
- * - ADD / DROP / RENAME COLUMN: {@link forwardColumnShapeToOverlay}. ADD COLUMN
1091
- * backfills each staged row exactly as the committed path does (literal default,
1092
- * per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
1093
- * - ALTER COLUMN: {@link forwardAlterColumnToOverlay}. `set data type` / `set collate`
1094
- * / `set default` forward through the overlay's `alterSchema` (the overlay module
1095
- * converts / re-keys its open layers itself), with one preparation step: a `set collate`
1096
- * that re-keys the PRIMARY KEY first discards any deletion markers the new key collapses
1097
- * onto another staged row ({@link dropCollapsedPkRekeyMarkers}). `set not null` is
1098
- * withheld from the overlay and the staged live rows' NULLs are backfilled via ordinary
1099
- * overlay writes instead.
1100
- * - ADD / DROP / RENAME CONSTRAINT: {@link forwardAddConstraintToOverlay} /
1101
- * {@link forwardConstraintNameChangeToOverlay}. A UNIQUE lands as a
1102
- * tombstone-narrowed unique index; CHECK forwards verbatim; FOREIGN KEY does not
1103
- * forward at all.
1104
- * - ALTER PRIMARY KEY: no overlay can follow (its layer trees are keyed by the old
1105
- * primary key) — the issuer with staged rows is rejected before the underlying
1106
- * mutates, a foreign overlay with staged rows is poisoned, and a clean overlay is
1107
- * swapped for a fresh staging table ({@link replaceOverlayForPrimaryKeyChange}).
1112
+ * into a fresh staging table — destroyed that distinction).
1113
+ *
1114
+ * ALTER PRIMARY KEY is the exception: no overlay can follow it (its layer trees are keyed
1115
+ * by the old primary key), so the issuer with staged rows is rejected before the underlying
1116
+ * mutates, a foreign overlay with staged rows is poisoned, and a clean overlay is swapped
1117
+ * for a fresh staging table ({@link replaceOverlayForPrimaryKeyChange}).
1108
1118
  *
1109
1119
  * **Atomicity guarantee.** DDL through Quereus is not transaction-scoped and the
1110
1120
  * underlying (shared, committed) base auto-commits its mutation immediately —
@@ -1158,40 +1168,41 @@ export class IsolationModule {
1158
1168
  // poisoned overlay whose schema may be a stale pre-alter layout.
1159
1169
  const toMigrate = ownEntry ? [ownEntry, ...foreign] : foreign;
1160
1170
  // ALTER PRIMARY KEY cannot be carried by any overlay: the staging table's layer
1161
- // trees are keyed by the OLD primary key, a staged tombstone identifies the row it
1162
- // deletes BY that key, and the (memory) overlay module rejects in-place PK
1163
- // alteration. When the ISSUER's own transaction has staged rows, reject up front —
1171
+ // trees are keyed by the OLD primary key, and a staged tombstone identifies the row
1172
+ // it deletes BY that key — under a new key its identity columns may be placeholder
1173
+ // NULLs. (The memory overlay module can now re-key its OWN trees in place, but that
1174
+ // does not repair the tombstone representation, so the refusal stands.) When the
1175
+ // ISSUER's own transaction has staged rows, reject up front —
1164
1176
  // before the underlying mutates — so the ALTER fails atomically rather than
1165
1177
  // stranding rows it cannot re-key. Foreign overlays with staged rows are poisoned
1166
1178
  // after the underlying applies, and clean overlays are swapped for a fresh staging
1167
1179
  // table (see replaceOverlayForPrimaryKeyChange).
1180
+ //
1181
+ // BUSY, not UNSUPPORTED: this is retryable pending state, not a missing capability.
1182
+ // The engine treats an UNSUPPORTED `alterTable` refusal as "use the shadow-table
1183
+ // rebuild fallback" (see runAlterPrimaryKey in runtime/emit/alter-table.ts), which
1184
+ // copies committed rows only — it would silently drop this transaction's staged
1185
+ // writes. See docs/module-authoring.md § alterPrimaryKey.
1186
+ //
1187
+ // NOTE: an already-poisoned own overlay leaves `ownEntry` undefined (skipped above), so this
1188
+ // refusal never fires for it — harmless while poison is terminal (rollback is the only exit,
1189
+ // and it discards those old-key rows). If poison ever becomes recoverable, this check has to
1190
+ // consult the poisoned own overlay too.
1168
1191
  if (change.type === 'alterPrimaryKey' && ownEntry?.[1].hasChanges) {
1169
- throw new QuereusError(`Cannot alter the primary key of '${schemaName}.${tableName}' while this transaction has uncommitted changes staged for it; commit or roll back first.`, StatusCode.UNSUPPORTED);
1170
- }
1171
- this.assertColumnNameNotTombstone(schemaName, tableName, change);
1172
- // Build the addColumn backfill context up front (undefined for other change types).
1173
- // Derived purely from `change` + the session nullability option — no post-alter
1174
- // schema needed — so it is valid here, before the underlying is mutated, and the
1175
- // same context drives the post-mutation migration.
1176
- const addColumnCtx = this.deriveAddColumnBackfill(change, db, tableName);
1177
- // Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
1178
- // with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
1179
- // from a to-be-migrated overlay's PRE-alter schema — the same layout every migrated overlay
1180
- // shares.
1181
- const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate);
1182
- // Build the setDataType conversion context (undefined unless this is a value-rewriting
1183
- // retype with overlays to convert). Read from the same PRE-alter overlay schema as above;
1184
- // `inferType` cannot throw, so deriving it before the underlying mutation is safe.
1185
- const setDataTypeCtx = this.deriveSetDataTypeConvert(change, toMigrate);
1186
- // Build the primary-key re-key context (undefined unless this is a collation change on a
1187
- // PK member with overlays to carry). Read from the same PRE-alter overlay schema as above.
1188
- const pkRekeyCtx = this.derivePkRekey(change, db, tableName, toMigrate);
1192
+ throw new QuereusError(`Cannot alter the primary key of '${schemaName}.${tableName}' while this transaction has uncommitted changes staged for it; commit or roll back first.`, StatusCode.BUSY);
1193
+ }
1194
+ assertColumnNameNotTombstone(this, schemaName, tableName, change);
1195
+ // Precompute every per-change-type migration constant, from the PRE-alter schema — so the
1196
+ // whole plan is valid here, before the underlying is mutated, and the same plan drives the
1197
+ // post-mutation migration. The attribute arms read a to-be-migrated overlay's schema, never
1198
+ // a skipped poisoned overlay's (possibly stale pre-alter) one.
1199
+ const migration = deriveAlterMigrationPlan(change, db, tableName, toMigrate);
1189
1200
  // Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
1190
1201
  // Any throw here (CONSTRAINT backfill, MISMATCH conversion, or INTERNAL tombstone guard)
1191
1202
  // propagates while underlying + catalog + every overlay are still untouched — the companion
1192
1203
  // ticket's atomic-abort guarantee, preserved unchanged for the issuer.
1193
1204
  if (ownEntry) {
1194
- await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
1205
+ await validateOverlayMigration(this, ownEntry[1], migration);
1195
1206
  }
1196
1207
  const underlyingState = this.getUnderlyingState(schemaName, tableName);
1197
1208
  // Hand the underlying the issuer's effective rows so its own row-content checks
@@ -1214,9 +1225,10 @@ export class IsolationModule {
1214
1225
  // committed row it deletes in `issuerEffectiveRows`'s merged stream — so the effective
1215
1226
  // rows are SNAPSHOTTED first, and the underlying judges the pre-drop view.
1216
1227
  let droppedMarkerRows = [];
1217
- if (pkRekeyCtx && ownEntry && ownEntry[1].hasChanges && ownEntry[1].overlayTable.alterSchema) {
1228
+ if (migration.pkRekey && ownEntry && ownEntry[1].hasChanges && ownEntry[1].overlayTable.alterSchema) {
1218
1229
  const ownOverlayState = ownEntry[1];
1219
- const plan = await this.planPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx);
1230
+ const pkRekeyCtx = migration.pkRekey;
1231
+ const plan = await planPkRekeyMarkerDrops(this, ownOverlayState, pkRekeyCtx);
1220
1232
  if (plan.length > 0) {
1221
1233
  if (rowSource) {
1222
1234
  // NOTE: materializes the issuer's whole effective row set for this one ALTER.
@@ -1229,7 +1241,7 @@ export class IsolationModule {
1229
1241
  rowSource = () => (async function* () { yield* snapshot; })();
1230
1242
  }
1231
1243
  droppedMarkerRows = plan;
1232
- await this.applyPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx, plan);
1244
+ await applyPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx, plan);
1233
1245
  }
1234
1246
  try {
1235
1247
  // NOTE: unenforced contract — an overlay module that ignores `validateOnly` (see
@@ -1240,7 +1252,7 @@ export class IsolationModule {
1240
1252
  await ownOverlayState.overlayTable.alterSchema(change, true);
1241
1253
  }
1242
1254
  catch (e) {
1243
- await this.reinsertPkRekeyMarkers(ownOverlayState, droppedMarkerRows);
1255
+ await reinsertPkRekeyMarkers(ownOverlayState, droppedMarkerRows);
1244
1256
  throw e;
1245
1257
  }
1246
1258
  }
@@ -1250,7 +1262,7 @@ export class IsolationModule {
1250
1262
  }
1251
1263
  catch (e) {
1252
1264
  if (ownEntry)
1253
- await this.reinsertPkRekeyMarkers(ownEntry[1], droppedMarkerRows);
1265
+ await reinsertPkRekeyMarkers(ownEntry[1], droppedMarkerRows);
1254
1266
  throw e;
1255
1267
  }
1256
1268
  // The cached underlying VirtualTable's `tableSchema` is a construction-time
@@ -1262,40 +1274,17 @@ export class IsolationModule {
1262
1274
  if (underlyingState)
1263
1275
  underlyingState.underlyingTable.tableSchema = updated;
1264
1276
  const ddlDescription = `alter table (${change.type})`;
1265
- // Carry one overlay to the post-alter schema, IN PLACE, so the overlay's layer
1266
- // chain and savepoint snapshots stay intact (see the method doc for the per-type
1267
- // routes). The `const` re-captures pin each case's narrowed change type for the
1268
- // apply closure.
1277
+ // Carry one overlay to the post-alter schema. ALTER PRIMARY KEY swaps the overlay
1278
+ // outright; every other change type adopts IN PLACE via `alter-migration.ts`, so the
1279
+ // overlay's layer chain and savepoint snapshots stay intact. The `const` re-capture
1280
+ // pins the narrowed (non-PK) change type for the apply closure.
1269
1281
  const migrateOverlay = async (key, state, isIssuer) => {
1270
- switch (change.type) {
1271
- case 'addColumn':
1272
- case 'dropColumn':
1273
- case 'renameColumn':
1274
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardColumnShapeToOverlay(state, change, addColumnCtx));
1275
- break;
1276
- case 'alterColumn': {
1277
- const alterColumnChange = change;
1278
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAlterColumnToOverlay(state, alterColumnChange, setNotNullCtx, pkRekeyCtx));
1279
- break;
1280
- }
1281
- case 'addConstraint': {
1282
- const addConstraintChange = change;
1283
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAddConstraintToOverlay(state, addConstraintChange, updated));
1284
- break;
1285
- }
1286
- case 'dropConstraint':
1287
- case 'renameConstraint': {
1288
- const nameChange = change;
1289
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardConstraintNameChangeToOverlay(state, nameChange));
1290
- break;
1291
- }
1292
- case 'alterPrimaryKey':
1293
- await this.replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updated, change);
1294
- break;
1295
- default: {
1296
- const _exhaustive = change;
1297
- }
1282
+ if (change.type === 'alterPrimaryKey') {
1283
+ await this.replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updated, change);
1284
+ return;
1298
1285
  }
1286
+ const inPlaceChange = change;
1287
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => migrateOverlayForward(this, state, inPlaceChange, migration, updated));
1299
1288
  };
1300
1289
  // Migrate the issuer's own overlay (already validated above). Its NOT NULL /
1301
1290
  // tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
@@ -1316,11 +1305,11 @@ export class IsolationModule {
1316
1305
  // overlay, so one bad foreign overlay poisons only itself; healthy peers still migrate.
1317
1306
  for (const [key, oldState] of foreign) {
1318
1307
  try {
1319
- await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
1308
+ await validateOverlayMigration(this, oldState, migration);
1320
1309
  }
1321
1310
  catch (e) {
1322
1311
  if (e instanceof QuereusError && (e.code === StatusCode.CONSTRAINT || e.code === StatusCode.MISMATCH)) {
1323
- oldState.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
1312
+ oldState.poison = { message: buildAlterPoisonMessage(schemaName, tableName, change) };
1324
1313
  continue; // poisoned — do NOT migrate; leave pre-alter rows in place
1325
1314
  }
1326
1315
  throw e;
@@ -1329,61 +1318,6 @@ export class IsolationModule {
1329
1318
  }
1330
1319
  return updated;
1331
1320
  }
1332
- /**
1333
- * Rejects an ADD / RENAME COLUMN that would give a base column the same name as the
1334
- * overlay's private tombstone flag, BEFORE `underlying.alterTable` runs.
1335
- *
1336
- * Every overlay carries one extra column named {@link tombstoneColumn}, so a base column
1337
- * of that name collides with it. Forwarding the change to an open overlay makes the
1338
- * overlay module raise a duplicate-name `ERROR`, which is not a data condition and so
1339
- * rethrows out of {@link applyInPlaceOverlayChange} — after the underlying has already
1340
- * (irreversibly) applied. The catalog then keeps the pre-alter column set while the base
1341
- * holds the new one, and the next write to the table lands values against the wrong
1342
- * columns. Rejecting up front keeps the atomic-abort guarantee this method documents.
1343
- *
1344
- * NOTE: the check is unconditional — not gated on an overlay existing — so the answer
1345
- * does not depend on whether some connection happens to have a transaction open. That
1346
- * also stops a table from acquiring the colliding name while idle and hitting
1347
- * {@link createOverlaySchema}'s (silent) duplicate at the next BEGIN. A CREATE TABLE
1348
- * declaring the name still gets that duplicate — tracked separately.
1349
- */
1350
- assertColumnNameNotTombstone(schemaName, tableName, change) {
1351
- const newName = change.type === 'addColumn' ? change.columnDef.name
1352
- : change.type === 'renameColumn' ? change.newName
1353
- : undefined;
1354
- if (newName?.toLowerCase() !== this.tombstoneColumn.toLowerCase())
1355
- return;
1356
- throw new QuereusError(`Cannot name a column of '${schemaName}.${tableName}' '${newName}': the transaction-isolation layer reserves that name for its per-connection overlay's deletion marker.`, StatusCode.UNSUPPORTED);
1357
- }
1358
- /**
1359
- * Builds the poison message stamped onto a foreign overlay whose backfill could not
1360
- * satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
1361
- * schema.table and the offending column so the owning connection's eventual
1362
- * read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
1363
- * path (a new NOT-NULL column with no usable default), the `set not null` tightening
1364
- * path (a staged NULL with no usable default), the `set data type` path (a staged value
1365
- * that cannot be converted to the new type), and the primary-key `set collate` path (two
1366
- * staged live rows colliding under the new collation); other change types never reach
1367
- * here but are handled defensively.
1368
- */
1369
- buildAlterPoisonMessage(schemaName, tableName, change) {
1370
- if (change.type === 'addColumn') {
1371
- return `ALTER on '${schemaName}.${tableName}' added column '${change.columnDef.name}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
1372
- }
1373
- if (change.type === 'alterColumn') {
1374
- if (change.setDataType !== undefined) {
1375
- return `ALTER on '${schemaName}.${tableName}' changed the data type of column '${change.columnName}' to ${change.setDataType}, which this connection's uncommitted row cannot be converted to; roll back this transaction.`;
1376
- }
1377
- if (change.setCollation !== undefined) {
1378
- return `ALTER on '${schemaName}.${tableName}' changed the collation of primary-key column '${change.columnName}', under which this connection's uncommitted rows collide; roll back this transaction.`;
1379
- }
1380
- return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
1381
- }
1382
- if (change.type === 'alterPrimaryKey') {
1383
- return `ALTER on '${schemaName}.${tableName}' changed the table's primary key, which this connection's uncommitted rows cannot follow; roll back this transaction.`;
1384
- }
1385
- return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
1386
- }
1387
1321
  /**
1388
1322
  * Renames a table through the isolation layer.
1389
1323
  *
@@ -1500,706 +1434,6 @@ export class IsolationModule {
1500
1434
  }
1501
1435
  return oldKeys.length;
1502
1436
  }
1503
- // NOTE: the ALTER overlay pre-validation machinery (the three derive* helpers,
1504
- // validateOverlayMigration, buildAlterPoisonMessage, and the per-type forward* helpers below)
1505
- // threads one context parameter per value-rewriting attribute. If a fourth attribute ever
1506
- // needs a context, extract the cluster into its own module and pass a single context object
1507
- // instead of a widening parameter list.
1508
- /**
1509
- * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
1510
- * the folded literal DEFAULT (the `tryFoldLiteral` of the DEFAULT expr, or `null`
1511
- * when there is no DEFAULT or it folds to NULL), the engine-supplied per-row
1512
- * evaluator (present only for a non-foldable `new.<col>` default), and whether
1513
- * the new column is NOT NULL. Returns undefined for every non-`addColumn` change
1514
- * so the row loop appends nothing.
1515
- *
1516
- * The new column's nullability is resolved exactly as both underlyings resolve it
1517
- * (`columnDefToSchema(columnDef, default_column_nullability === 'not_null')`) so the
1518
- * pre-validation cannot drift from what the underlying will enforce. Because it is
1519
- * derived purely from `change` + the session option — not the post-alter schema,
1520
- * which does not exist until `underlying.alterTable` runs — this can be built
1521
- * BEFORE the irreversible underlying mutation and reused by the migration after.
1522
- */
1523
- deriveAddColumnBackfill(change, db, tableName) {
1524
- if (change.type !== 'addColumn')
1525
- return undefined;
1526
- const defaultExpr = change.columnDef.constraints?.find(c => c.type === 'default')?.expr;
1527
- // tryFoldLiteral returns undefined for a non-foldable expr and null for one that
1528
- // folds to NULL; collapse both to null (the no-usable-literal default).
1529
- const foldedDefault = defaultExpr ? (tryFoldLiteral(defaultExpr) ?? null) : null;
1530
- const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
1531
- // Thread the session `default_collation` for symmetry with the underlying memory/store
1532
- // ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
1533
- // underlying materializes the real column), so it does not affect collation here — but
1534
- // keeping the call signature identical avoids drift and is correct for any future reader.
1535
- const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
1536
- return {
1537
- foldedDefault,
1538
- evaluator: change.backfillEvaluator,
1539
- newColNotNull: newColumn.notNull,
1540
- newColName: newColumn.name,
1541
- tableName,
1542
- };
1543
- }
1544
- /**
1545
- * Precomputes the per-ALTER constants an `alter column … set not null` overlay migration needs:
1546
- * the now-NOT-NULL column's index and the folded literal DEFAULT (with `hasDefault` gating
1547
- * backfill vs reject). Returns undefined unless this is a NOT NULL *tightening*
1548
- * (`setNotNull: true`) with at least one overlay to migrate — a DROP NOT NULL loosens and
1549
- * needs no staged-row work, and with no overlays there is nothing to backfill or reject.
1550
- *
1551
- * `change` for `set not null` carries no default expression, so the DEFAULT is read from the
1552
- * column's PRE-alter schema (via a to-be-migrated overlay — the same layout every migrated
1553
- * overlay shares). Folded exactly as
1554
- * {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
1555
- * cannot drift from what the underlying enforces over its committed rows.
1556
- */
1557
- deriveSetNotNullBackfill(change, toMigrate) {
1558
- if (change.type !== 'alterColumn' || change.setNotNull !== true)
1559
- return undefined;
1560
- if (toMigrate.length === 0)
1561
- return undefined;
1562
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1563
- if (!overlaySchema)
1564
- return undefined;
1565
- const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1566
- if (colIndex === undefined)
1567
- return undefined;
1568
- const defaultExpr = overlaySchema.columns[colIndex]?.defaultValue;
1569
- // tryFoldLiteral returns undefined for a non-foldable expr and null for one that folds to
1570
- // NULL; both mean "no usable literal default" — the staged NULL must reject, not backfill.
1571
- const folded = defaultExpr ? tryFoldLiteral(defaultExpr) : undefined;
1572
- const hasDefault = folded !== undefined && folded !== null;
1573
- return {
1574
- colIndex,
1575
- foldedDefault: hasDefault ? folded : null,
1576
- hasDefault,
1577
- colName: change.columnName,
1578
- };
1579
- }
1580
- /**
1581
- * Precomputes the per-ALTER constants an `alter column … set data type` overlay conversion
1582
- * needs: the retyped column's index and a per-value `convert`. Returns undefined unless this
1583
- * is a retype with at least one overlay to convert, and undefined for an ALIAS retype (the
1584
- * new logical type IS the old type object — `inferType` flattens `varchar(50)` to
1585
- * `TEXT_TYPE`) — both underlyings gate their value rewrite on that exact identity
1586
- * comparison, so mirroring it here keeps overlay and committed rows moving together should
1587
- * "what counts as a value-rewriting retype" ever change. A same-storage-class retype between
1588
- * different types (text → date) DOES convert: staged values are validated and normalized to
1589
- * the new type's canonical spelling, exactly as the underlyings treat their committed rows.
1590
- *
1591
- * `inferType` never throws (an unknown type name falls through SQLite-style affinity rules),
1592
- * so deriving this BEFORE the underlying mutation is safe. `convert` is the literal mirror of
1593
- * `MemoryTableManager.alterColumn` / `StoreModule.alterColumnSetDataType`: `validateAndParse`,
1594
- * rethrowing failure as the same MISMATCH message, so the two legs cannot drift.
1595
- */
1596
- deriveSetDataTypeConvert(change, toMigrate) {
1597
- if (change.type !== 'alterColumn' || change.setDataType === undefined)
1598
- return undefined;
1599
- if (toMigrate.length === 0)
1600
- return undefined;
1601
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1602
- if (!overlaySchema)
1603
- return undefined;
1604
- const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1605
- if (colIndex === undefined)
1606
- return undefined;
1607
- const oldCol = overlaySchema.columns[colIndex];
1608
- if (!oldCol)
1609
- return undefined;
1610
- const newLogicalType = inferType(change.setDataType);
1611
- // Alias retype (same logical type object): the underlying rewrites nothing, so neither do we.
1612
- if (newLogicalType === oldCol.logicalType)
1613
- return undefined;
1614
- const setDataType = change.setDataType;
1615
- const columnName = change.columnName;
1616
- return {
1617
- colIndex,
1618
- convert: (v) => {
1619
- try {
1620
- return validateAndParse(v, newLogicalType, columnName);
1621
- }
1622
- catch {
1623
- throw new QuereusError(`Cannot convert value in '${columnName}' to ${setDataType}`, StatusCode.MISMATCH);
1624
- }
1625
- },
1626
- };
1627
- }
1628
- /**
1629
- * Precomputes the per-ALTER constants a primary-key re-keying `alter column … set collate`
1630
- * overlay migration needs: the PK column positions and a key serializer built over the
1631
- * POST-change collation — the PRE-alter overlay schema with the named column's collation
1632
- * replaced. The overlay's PK definition mirrors the base's (the tombstone flag is appended
1633
- * after the data columns), so the synthesized schema keys exactly as the re-keyed base will.
1634
- *
1635
- * Returns undefined unless the named column is a PRIMARY KEY member and the normalized
1636
- * collation actually differs from the column's current one — mirroring the underlying's
1637
- * metadata-only gate (`MemoryTableManager.alterColumn`'s `nameMatches`), so overlay and
1638
- * committed rows re-key together or not at all. `validateCollationForType` can throw for an
1639
- * unknown collation, but the engine already validated the name before dispatching the ALTER,
1640
- * and a direct module caller gets the same pre-mutation rejection the underlying would give.
1641
- */
1642
- derivePkRekey(change, db, tableName, toMigrate) {
1643
- if (change.type !== 'alterColumn' || change.setCollation === undefined)
1644
- return undefined;
1645
- if (toMigrate.length === 0)
1646
- return undefined;
1647
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1648
- if (!overlaySchema)
1649
- return undefined;
1650
- const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1651
- if (colIndex === undefined)
1652
- return undefined;
1653
- if (!overlaySchema.primaryKeyDefinition.some(def => def.index === colIndex))
1654
- return undefined;
1655
- const oldCol = overlaySchema.columns[colIndex];
1656
- if (!oldCol)
1657
- return undefined;
1658
- const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, (n) => db.isCollationRegistered(n));
1659
- if (normalized === (oldCol.collation || 'BINARY'))
1660
- return undefined; // metadata-only — the underlying re-keys nothing
1661
- const postChangeSchema = {
1662
- ...overlaySchema,
1663
- columns: overlaySchema.columns.map((c, i) => (i === colIndex ? { ...c, collation: normalized } : c)),
1664
- };
1665
- return {
1666
- pkIndices: overlaySchema.primaryKeyDefinition.map(def => def.index),
1667
- keyOf: makePkKeySerializer(db, postChangeSchema),
1668
- tableName,
1669
- };
1670
- }
1671
- /**
1672
- * Buckets an overlay's staged rows by their PRIMARY KEY as the pending `set collate` will
1673
- * re-key it: one {@link PkRekeyGroup} per post-change key. Shared by the validation dry run
1674
- * (which refuses a group holding two live rows) and the migrate-step marker drop (which
1675
- * discards collapsed markers), so the two passes cannot disagree about what collapses.
1676
- *
1677
- * NOTE: materializes one serialized key + one PK tuple per staged row (plus the marker rows
1678
- * themselves), once per pass of one ALTER. If an overlay with very many staged rows ever
1679
- * makes this ALTER slow, group lazily or reuse the merge `effectiveRowsFor` already builds.
1680
- */
1681
- async collectPkRekeyGroups(overlay, tombstoneIdx, ctx) {
1682
- const groups = new Map();
1683
- for await (const row of overlay.query(makeFullScanFilterInfo())) {
1684
- const pk = ctx.pkIndices.map(i => row[i]);
1685
- const key = ctx.keyOf(pk);
1686
- let group = groups.get(key);
1687
- if (!group) {
1688
- group = { livePks: [], markerRows: [] };
1689
- groups.set(key, group);
1690
- }
1691
- if (row[tombstoneIdx] === 1)
1692
- group.markerRows.push(row);
1693
- else
1694
- group.livePks.push(pk);
1695
- }
1696
- return groups;
1697
- }
1698
- /**
1699
- * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
1700
- * so the caller can run it for every affected overlay BEFORE the irreversible
1701
- * `underlying.alterTable` (see {@link alterTable}). It exercises the EXACT code
1702
- * paths the real migration uses — the tombstone-present guard and, for addColumn,
1703
- * `computeAddColumnValue` per staged row — so a dry-run pass and the subsequent
1704
- * migrate pass cannot diverge:
1705
- *
1706
- * - A clean overlay (`!hasChanges`) stages no rows, so there is nothing to validate.
1707
- * - A missing tombstone column throws INTERNAL here, before the underlying is touched.
1708
- * - For addColumn, each staged row runs through `computeAddColumnValue`: tombstone
1709
- * rows short-circuit to `null` (the evaluator never runs), and a NOT-NULL-violating
1710
- * evaluated row throws CONSTRAINT here, atomically. Computed values are discarded.
1711
- *
1712
- * For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
1713
- * non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
1714
- * aborts atomically before the underlying mutates; for a foreign overlay the caller maps it to
1715
- * poison. With a usable DEFAULT the staged NULLs are backfilled by
1716
- * {@link backfillStagedNotNull}, so nothing is rejected here.
1717
- *
1718
- * For `set data type` (`setDataTypeCtx`), every staged non-NULL value is run through the
1719
- * conversion and the result discarded, so an unconvertible one throws MISMATCH here. For the
1720
- * ISSUER this is belt-and-braces — the underlying's own pre-mutation pass walks the same rows
1721
- * via {@link issuerEffectiveRows} — EXCEPT when an outer wrapper supplied `rows`, in which case
1722
- * this is the only check. For a FOREIGN overlay it is the only pass that ever sees those rows,
1723
- * and it must run before the migration so a failure becomes poison rather than a half-migrated
1724
- * overlay.
1725
- *
1726
- * For a primary-key re-keying `set collate` (`pkRekeyCtx`), the staged rows are grouped by
1727
- * their POST-change key: a deletion marker sharing a key with one live row is that row's
1728
- * before-image (the migrate step discards it — {@link dropCollapsedPkRekeyMarkers}), and
1729
- * markers alone on one key are one deletion — neither is a duplicate. Two LIVE rows on one
1730
- * key ARE, so that throws CONSTRAINT here, mirroring the underlying's own collision message.
1731
- * For the ISSUER this is belt-and-braces (the underlying's pre-mutation pass judges the same
1732
- * staged rows via {@link issuerEffectiveRows}, though this check fires first); for a FOREIGN
1733
- * overlay it is the only pass that sees those rows, and its throw becomes poison.
1734
- *
1735
- * Non-addColumn / non-tightening / non-retype / non-re-key changes (`addColumnCtx ===
1736
- * undefined`, no reject-mode `setNotNullCtx`, no `setDataTypeCtx`, no `pkRekeyCtx`) only run
1737
- * the tombstone guard; their row translation appends/removes nothing fallible on data grounds.
1738
- */
1739
- async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx) {
1740
- const oldOverlay = oldState.overlayTable;
1741
- const oldOverlaySchema = oldOverlay.tableSchema;
1742
- // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
1743
- // schema stages nothing and runs none of the fallible checks.
1744
- if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
1745
- return;
1746
- const oldTombstoneIdx = this.requireTombstoneIndex(oldOverlaySchema);
1747
- if (addColumnCtx) {
1748
- for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1749
- // Discard the result — this is validation only. A NOT NULL violation throws here.
1750
- await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
1751
- }
1752
- return;
1753
- }
1754
- // SET NOT NULL with no usable DEFAULT: reject a staged NULL the forward could not fill.
1755
- // (With a DEFAULT there is nothing to reject — backfillStagedNotNull fills instead.)
1756
- if (setNotNullCtx && !setNotNullCtx.hasDefault) {
1757
- for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1758
- if (oldRow[setNotNullCtx.colIndex] === null) {
1759
- throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
1760
- }
1761
- }
1762
- return;
1763
- }
1764
- // SET DATA TYPE: prove every staged value convertible before anything is rewritten. NULLs
1765
- // are left untouched by the conversion (the underlying's `convertNulls` is false for a retype).
1766
- if (setDataTypeCtx) {
1767
- for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1768
- const value = oldRow[setDataTypeCtx.colIndex];
1769
- if (value !== null)
1770
- setDataTypeCtx.convert(value); // discard — validation only
1771
- }
1772
- return;
1773
- }
1774
- // SET COLLATE re-keying the PRIMARY KEY: two staged LIVE rows landing on one post-change
1775
- // key are a real duplicate — refuse before the underlying re-keys (atomic for the issuer,
1776
- // poison for a foreign overlay via the caller). Marker/live and marker/marker groups
1777
- // collapse cleanly and are handled by the migrate step, not rejected here.
1778
- if (pkRekeyCtx) {
1779
- for (const group of (await this.collectPkRekeyGroups(oldOverlay, oldTombstoneIdx, pkRekeyCtx)).values()) {
1780
- if (group.livePks.length < 2)
1781
- continue;
1782
- const keyDesc = group.livePks[1].map(formatKeyValue).join(', ');
1783
- throw new QuereusError(`UNIQUE constraint failed: ${pkRekeyCtx.tableName} primary key collides under new collation (key: ${keyDesc})`, StatusCode.CONSTRAINT);
1784
- }
1785
- }
1786
- }
1787
- /**
1788
- * The staged rows an overlay holds that represent LIVE data, i.e. every row except the
1789
- * tombstones — those carry placeholder NULLs at every data column and must never be fed to a
1790
- * per-value check, which would read a NULL that is not the row's real value.
1791
- */
1792
- async *stagedLiveRows(overlay, tombstoneIdx) {
1793
- for await (const row of overlay.query(makeFullScanFilterInfo())) {
1794
- if (row[tombstoneIdx] !== 1)
1795
- yield row;
1796
- }
1797
- }
1798
- /**
1799
- * Computes one staged row's value for a freshly added column, mirroring the
1800
- * committed-row backfill (see `base.ts` `recreatePrimaryTreeWithNewColumn` and
1801
- * `store-module.ts` `migrateRows`):
1802
- *
1803
- * - Tombstone rows carry NULL placeholders and their appended value is never read,
1804
- * so append `null` and never run the evaluator against them (it could reference
1805
- * NULL siblings or spuriously trip the NOT NULL check).
1806
- * - With a per-row evaluator, derive the value from the existing-columns slice and
1807
- * enforce NOT NULL on that path only (a literal/NULL default's nullability is gated
1808
- * up-front by the engine, exactly as `base.ts` does).
1809
- * - Otherwise use the folded literal default.
1810
- */
1811
- async computeAddColumnValue(ctx, oldRow, oldTombstoneIdx) {
1812
- if (oldRow[oldTombstoneIdx] === 1)
1813
- return null;
1814
- if (ctx.evaluator) {
1815
- const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
1816
- const value = await ctx.evaluator(data);
1817
- if (ctx.newColNotNull && value === null) {
1818
- throw new QuereusError(`NOT NULL constraint failed: backfilling column '${ctx.tableName}.${ctx.newColName}' produced NULL for a staged row`, StatusCode.CONSTRAINT);
1819
- }
1820
- return value;
1821
- }
1822
- return ctx.foldedDefault;
1823
- }
1824
- /**
1825
- * Forwards one column-shape change (ADD / DROP / RENAME COLUMN) to an already-open
1826
- * overlay IN PLACE via the overlay's optional `alterSchema`. The overlay module (the
1827
- * memory module by default) reshapes its open transaction layers with their savepoint
1828
- * snapshots intact, so `rollback to savepoint` keeps distinguishing rows staged before
1829
- * the savepoint from rows staged after it — the rebuild path destroyed that
1830
- * distinction in both directions (see {@link applyIndexChangeToOverlays}).
1831
- *
1832
- * A missing `alterSchema` is treated as a no-op, the way the index paths treat a
1833
- * missing `createIndex` / `dropIndex`.
1834
- *
1835
- * `dropColumn` / `renameColumn` forward the caller's change unchanged. `addColumn`
1836
- * forwards an overlay-flavoured copy — see {@link buildOverlayAddColumnChange}.
1837
- */
1838
- async forwardColumnShapeToOverlay(overlayState, change, addColumnCtx) {
1839
- const overlayTable = overlayState.overlayTable;
1840
- if (!overlayTable.alterSchema)
1841
- return;
1842
- // `deriveAddColumnBackfill` returns a context for every addColumn, so the assertion
1843
- // cannot fire when the change type matches.
1844
- const overlayChange = change.type === 'addColumn'
1845
- ? this.buildOverlayAddColumnChange(change, addColumnCtx, overlayTable)
1846
- : change;
1847
- await overlayTable.alterSchema(overlayChange);
1848
- }
1849
- /**
1850
- * The overlay-flavoured form of one `addColumn` change:
1851
- *
1852
- * - `insertAtIndex` = the overlay's current tombstone column index, so the new data
1853
- * column lands ahead of the tombstone flag and the flag stays LAST — the layout
1854
- * every read/write path of this package assumes. A bare forward would append after
1855
- * the flag and every value written to the new column would be dropped on read.
1856
- * A caller that named its own position keeps it: the overlay's data columns mirror
1857
- * the base's one-for-one below the flag, so the base's index is the overlay's too.
1858
- * (SQL always appends, so today the caller never names one — but silently ignoring
1859
- * it would diverge the two layouts.)
1860
- * - The column definition is made NULLABLE. The overlay's ADD re-runs the overlay
1861
- * module's own NOT NULL validation against a different row population than the
1862
- * base's: tombstone rows carry placeholder NULL in every non-PK column, so a
1863
- * NOT NULL column the base already accepted would be rejected here. The base's
1864
- * NOT NULL is still enforced where it belongs — by the engine's pre-mutation
1865
- * `validateNotNullBackfill` and the underlying's own ADD (both judged the issuer's
1866
- * effective rows), and per staged row by {@link computeAddColumnValue}'s
1867
- * evaluator-path check.
1868
- * - `backfillEvaluator` receives an OVERLAY row (data columns plus the flag) and
1869
- * routes through {@link computeAddColumnValue} — the same helper
1870
- * {@link validateOverlayMigration} dry-runs, so validation and forward cannot
1871
- * drift: NULL for a tombstone row, else the engine's per-row `new.<col>` evaluator
1872
- * over the flag-stripped row, or the folded literal default.
1873
- */
1874
- buildOverlayAddColumnChange(change, addColumnCtx, overlayTable) {
1875
- const tombstoneIdx = overlayTable.tableSchema?.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1876
- if (tombstoneIdx === undefined) {
1877
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1878
- }
1879
- const constraints = (change.columnDef.constraints ?? []).filter(c => c.type !== 'notNull');
1880
- if (!constraints.some(c => c.type === 'null'))
1881
- constraints.push({ type: 'null' });
1882
- return {
1883
- ...change,
1884
- columnDef: { ...change.columnDef, constraints },
1885
- insertAtIndex: change.insertAtIndex ?? tombstoneIdx,
1886
- backfillEvaluator: (overlayRow) => this.computeAddColumnValue(addColumnCtx, overlayRow, tombstoneIdx),
1887
- };
1888
- }
1889
- /**
1890
- * Forwards one ALTER COLUMN attribute change to an already-open overlay IN PLACE, with the
1891
- * NOT NULL attribute deliberately withheld.
1892
- *
1893
- * `set data type` / `set collate` / `set default` forward through the overlay's own
1894
- * `alterSchema`: the overlay module converts / re-keys its open transaction layers with
1895
- * their savepoint snapshots intact, and NULLs pass a retype untouched, so tombstone rows —
1896
- * placeholder NULLs at every non-key data column — ride through unharmed. One preparation
1897
- * step precedes a PRIMARY-KEY re-keying `set collate` (`pkRekeyCtx`): deletion markers the
1898
- * re-key collapses onto another staged row are discarded first
1899
- * ({@link dropCollapsedPkRekeyMarkers}) — the overlay's PK deliberately covers markers, so
1900
- * without the drop its own `alterSchema` would reject the collapsed pair as a PK collision.
1901
- *
1902
- * Neither NOT NULL direction forwards, and the overlay's `notNull` flag is left where it
1903
- * stood when the overlay was created:
1904
- *
1905
- * - **Tightening (`set not null`).** Forwarding would have the overlay module tighten a
1906
- * column whose tombstone rows carry NULL at every non-PK data column, so it would either
1907
- * backfill a deletion marker's placeholder NULL from the DEFAULT (corrupting a row that
1908
- * is not a row) or reject it outright when no usable DEFAULT exists. The base's NOT NULL
1909
- * is enforced where it belongs — by the underlying over the issuer's effective rows, and
1910
- * per overlay by {@link validateOverlayMigration} — and the staged LIVE rows' NULLs are
1911
- * filled here via {@link backfillStagedNotNull}.
1912
- * - **Relaxing (`drop not null`).** Nothing to do: the overlay never enforced the flag, and
1913
- * the rows it stages are written with `preCoerced: true` through the overlay module's own
1914
- * write path, which does not consult column nullability at all.
1915
- *
1916
- * NOTE: both directions therefore leave the overlay's `notNull` flag drifted from the base
1917
- * for the rest of the transaction. Inert today — nothing on the overlay path reads it. The
1918
- * one consumer that would, `MemoryTableModule.getBestAccessPlan` (which prunes `IS NULL` on
1919
- * a NOT NULL column to an empty result), is unreachable because the isolation layer builds
1920
- * the overlay's `FilterInfo` itself (see `filter-info.ts`) instead of planning against the
1921
- * overlay module. If overlay scans ever route through the overlay module's planner hook, or
1922
- * its write path starts enforcing nullability, this has to become a real forward that
1923
- * excludes tombstones.
1924
- *
1925
- * The runtime admits exactly one attribute per ALTER COLUMN, so a `setNotNull` change
1926
- * carries nothing else to forward. A missing overlay `alterSchema` is a no-op, as in
1927
- * {@link forwardColumnShapeToOverlay}.
1928
- */
1929
- async forwardAlterColumnToOverlay(overlayState, change, setNotNullCtx, pkRekeyCtx) {
1930
- if (change.setNotNull !== undefined) {
1931
- if (change.setNotNull === true && setNotNullCtx?.hasDefault) {
1932
- await this.backfillStagedNotNull(overlayState, setNotNullCtx);
1933
- }
1934
- return;
1935
- }
1936
- if (!overlayState.overlayTable.alterSchema)
1937
- return;
1938
- if (pkRekeyCtx)
1939
- await this.dropCollapsedPkRekeyMarkers(overlayState, pkRekeyCtx);
1940
- await overlayState.overlayTable.alterSchema(change);
1941
- }
1942
- /**
1943
- * Discards the deletion markers a pending PRIMARY-KEY re-key collapses onto another staged
1944
- * row, through the overlay's ordinary write path so its layer chain and savepoint snapshots
1945
- * stay intact. Two shapes collapse (see {@link PkRekeyContext}): a marker sharing its
1946
- * post-change key with a live row is that row's before-image — the live row's flush upsert
1947
- * is the complete net effect, so the marker is dropped; markers alone together on one key
1948
- * are one deletion, so all but the first are dropped. A group holding two live rows was
1949
- * already refused by {@link validateOverlayMigration} before the underlying mutated.
1950
- *
1951
- * Runs BEFORE the re-key is forwarded to the overlay, and deletes by each marker's OLD
1952
- * primary key — the overlay is still keyed under the old collation at that moment.
1953
- *
1954
- * NOTE: like {@link backfillStagedNotNull}'s rewrites, the drops land in the overlay's
1955
- * CURRENT savepoint frame. A later `rollback to savepoint` taken BEFORE this ALTER restores
1956
- * the marker while the table stays re-keyed — the same divergence class that method's NOTE
1957
- * documents (`bug-rolled-back-rows-violate-surviving-ddl`), not a new hole opened here.
1958
- */
1959
- async dropCollapsedPkRekeyMarkers(overlayState, ctx) {
1960
- const plan = await this.planPkRekeyMarkerDrops(overlayState, ctx);
1961
- await this.applyPkRekeyMarkerDrops(overlayState, ctx, plan);
1962
- }
1963
- /**
1964
- * The whole overlay rows of every deletion marker {@link dropCollapsedPkRekeyMarkers} will
1965
- * discard, computed without mutating anything. Re-planning after the drops finds nothing
1966
- * (each group then holds at most one marker and no marker beside a live row), so the
1967
- * issuer path — which drops in tier 2 for the pre-flight — can safely run the shared
1968
- * migrate-step drop again as a no-op.
1969
- */
1970
- async planPkRekeyMarkerDrops(overlayState, ctx) {
1971
- const overlay = overlayState.overlayTable;
1972
- const overlaySchema = overlay.tableSchema;
1973
- if (!overlaySchema || !overlay.query)
1974
- return [];
1975
- const tombstoneIdx = this.requireTombstoneIndex(overlaySchema);
1976
- const toDrop = [];
1977
- for (const group of (await this.collectPkRekeyGroups(overlay, tombstoneIdx, ctx)).values()) {
1978
- // NOTE: the marker-only arm (`slice(1)`) is defensive — no marker-only collapse can
1979
- // currently SURVIVE the ALTER. Two markers share a post-change key only if the rows
1980
- // they shadow did too, and such a pair is refused one level down: by the underlying's
1981
- // re-key (a committed pair) or by the overlay's own (a staged-then-deleted pair). It
1982
- // still runs, and is still undone, on the way to those refusals.
1983
- toDrop.push(...(group.livePks.length > 0 ? group.markerRows : group.markerRows.slice(1)));
1984
- }
1985
- return toDrop;
1986
- }
1987
- /** Deletes each planned marker by its OLD primary key through the overlay's ordinary write path. */
1988
- async applyPkRekeyMarkerDrops(overlayState, ctx, toDrop) {
1989
- const overlay = overlayState.overlayTable;
1990
- const overlaySchema = overlay.tableSchema;
1991
- if (!overlaySchema || toDrop.length === 0)
1992
- return;
1993
- for (const markerRow of toDrop) {
1994
- const pk = ctx.pkIndices.map(i => markerRow[i]);
1995
- const result = await overlay.update({
1996
- operation: 'delete',
1997
- values: undefined,
1998
- oldKeyValues: pk,
1999
- });
2000
- // A bare delete of a staged marker has no data condition to hit, so a non-ok result
2001
- // is a layer-invariant violation — raise INTERNAL (which applyInPlaceOverlayChange
2002
- // rethrows loud for issuer and foreign alike), never poison.
2003
- if (isConstraintViolation(result)) {
2004
- throw new QuereusError(`Dropping a collapsed deletion marker (pk=[${pk.join(', ')}]) from overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.INTERNAL);
2005
- }
2006
- }
2007
- }
2008
- /**
2009
- * Undoes {@link applyPkRekeyMarkerDrops} after a refusal that aborts the whole ALTER —
2010
- * the overlay pre-flight's or the underlying's (see the tier-2 re-key block in
2011
- * {@link alterTable}). Reinserts each marker row verbatim: a marker minted for a committed
2012
- * row carries NULL at every non-key data column, but one made by deleting a row this
2013
- * transaction had already staged keeps that row's values (the convert-to-tombstone branch
2014
- * of `IsolatedTable.update`), so a reconstructed-from-PK row would not restore what was
2015
- * dropped. The reinserts land in the same savepoint frame the drops did, so the pair nets
2016
- * to nothing.
2017
- */
2018
- async reinsertPkRekeyMarkers(overlayState, droppedRows) {
2019
- if (droppedRows.length === 0)
2020
- return;
2021
- const overlay = overlayState.overlayTable;
2022
- const overlaySchema = overlay.tableSchema;
2023
- if (!overlaySchema)
2024
- return;
2025
- for (const markerRow of droppedRows) {
2026
- const result = await overlay.update({ operation: 'insert', values: [...markerRow] });
2027
- if (isConstraintViolation(result)) {
2028
- throw new QuereusError(`Restoring a deletion marker (row=[${markerRow.join(', ')}]) to overlay '${overlaySchema.name}' after a refused ALTER hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.INTERNAL);
2029
- }
2030
- }
2031
- }
2032
- /** The overlay's tombstone-flag column index, or INTERNAL if the schema lost it. */
2033
- requireTombstoneIndex(overlaySchema) {
2034
- const tombstoneIdx = overlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
2035
- if (tombstoneIdx === undefined) {
2036
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
2037
- }
2038
- return tombstoneIdx;
2039
- }
2040
- /**
2041
- * Backfills a `set not null` tightening into an overlay's staged LIVE rows: every
2042
- * non-tombstone row holding NULL at the tightened column is rewritten with the column's
2043
- * folded literal DEFAULT through the overlay's ordinary write path, so the overlay's layer
2044
- * chain and savepoint snapshots stay intact — the overlay-side mirror of the value rewrite
2045
- * the underlying applies to its committed rows. Tombstone rows are never touched
2046
- * ({@link stagedLiveRows}).
2047
- *
2048
- * Only called with a usable DEFAULT ({@link SetNotNullBackfillContext.hasDefault}); the
2049
- * no-DEFAULT case rejects (issuer) or poisons (foreign) in {@link validateOverlayMigration}
2050
- * before any overlay is touched.
2051
- *
2052
- * NOTE: these rewrites land in the overlay's CURRENT savepoint frame — each is a normal
2053
- * staged write. A later `rollback to savepoint` taken BEFORE this ALTER therefore restores
2054
- * the pre-backfill NULL while the column stays NOT NULL (DDL is not transactional here).
2055
- * That is the same class of divergence as any rolled-back row violating surviving DDL — a
2056
- * row un-inserted past an ADD CONSTRAINT, say — tracked by the backlog ticket
2057
- * `bug-rolled-back-rows-violate-surviving-ddl`, not a new hole opened by this forward.
2058
- */
2059
- async backfillStagedNotNull(overlayState, ctx) {
2060
- const overlay = overlayState.overlayTable;
2061
- const overlaySchema = overlay.tableSchema;
2062
- if (!overlaySchema || !overlay.query)
2063
- return;
2064
- const tombstoneIdx = overlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
2065
- if (tombstoneIdx === undefined) {
2066
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
2067
- }
2068
- const pkIndices = overlaySchema.primaryKeyDefinition.map(def => def.index);
2069
- // Materialize before writing: the rewrites mutate the overlay's pending layer the
2070
- // scan is reading through.
2071
- const toFill = [];
2072
- for await (const row of this.stagedLiveRows(overlay, tombstoneIdx)) {
2073
- if (row[ctx.colIndex] === null)
2074
- toFill.push(row);
2075
- }
2076
- for (const row of toFill) {
2077
- const values = row.map((v, i) => (i === ctx.colIndex ? ctx.foldedDefault : v));
2078
- // No `onConflict`, so `UpdateResult` is exactly `ok | constraint`. A constraint here
2079
- // (the filled value colliding under a UNIQUE) is a data condition of this overlay's
2080
- // staged rows — thrown as CONSTRAINT for applyInPlaceOverlayChange to route to
2081
- // INTERNAL (issuer, whose merged rows the underlying's own converted-row UNIQUE
2082
- // re-validation already judged) or poison (foreign).
2083
- const result = await overlay.update({
2084
- operation: 'update',
2085
- values,
2086
- oldKeyValues: pkIndices.map(i => row[i]),
2087
- preCoerced: true,
2088
- });
2089
- if (isConstraintViolation(result)) {
2090
- throw new QuereusError(`Backfilling column '${ctx.colName}' in overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
2091
- }
2092
- }
2093
- }
2094
- /**
2095
- * Forwards one ADD CONSTRAINT to an already-open overlay, per constraint class:
2096
- *
2097
- * - **UNIQUE** is NOT forwarded as a constraint. The AST `TableConstraint` carries no
2098
- * partial-predicate field, so a bare forward would enforce uniqueness over tombstone rows
2099
- * too — a UNIQUE whose columns sit inside the primary key would see two deleted rows
2100
- * (each carrying its PK and placeholder NULLs elsewhere) as duplicates of each other.
2101
- * Instead it lands as a tombstone-narrowed **unique index** through the same route CREATE
2102
- * INDEX takes ({@link installOverlayUniqueConstraint}).
2103
- * - **CHECK** forwards verbatim: the overlay module appends it schema-only (no row scan, no
2104
- * physical structure). Enforcement is engine-side at DML plan time against the user
2105
- * table; the overlay's copy exists so a later DROP / RENAME CONSTRAINT resolves it.
2106
- * - **FOREIGN KEY** is deliberately NOT forwarded. FK enforcement is engine-side
2107
- * (planner-synthesized EXISTS checks) — the overlay never enforces it — and the overlay
2108
- * module's ADD arm validates existing child rows through a catalog query by table name,
2109
- * which the unregistered `_overlay_*` staging table cannot serve. The presence guard in
2110
- * {@link forwardConstraintNameChangeToOverlay} keeps a later DROP / RENAME of the
2111
- * unforwarded FK a clean no-op on the overlay.
2112
- * - **PRIMARY KEY** cannot reach here — both bundled underlyings reject
2113
- * `add constraint … primary key` with UNSUPPORTED before any overlay work — so it asserts
2114
- * INTERNAL rather than leaving a silent gap for a third-party underlying that accepts it
2115
- * (the overlay could not follow; see the alterPrimaryKey handling in {@link alterTable}).
2116
- */
2117
- async forwardAddConstraintToOverlay(overlayState, change, updatedSchema) {
2118
- switch (change.constraint.type) {
2119
- case 'unique':
2120
- await this.installOverlayUniqueConstraint(overlayState, change.constraint, updatedSchema);
2121
- return;
2122
- case 'check':
2123
- if (overlayState.overlayTable.alterSchema)
2124
- await overlayState.overlayTable.alterSchema(change);
2125
- return;
2126
- case 'foreignKey':
2127
- return;
2128
- case 'primaryKey':
2129
- throw new QuereusError(`Isolation layer: the underlying accepted 'add constraint … primary key' on '${updatedSchema.schemaName}.${updatedSchema.name}', which the per-connection overlays cannot follow.`, StatusCode.INTERNAL);
2130
- }
2131
- }
2132
- /**
2133
- * Installs one runtime-added UNIQUE constraint on an overlay as a tombstone-narrowed unique
2134
- * index (see {@link forwardAddConstraintToOverlay} for why a bare constraint forward is
2135
- * wrong). Column indices and per-column collations are read from the post-alter underlying
2136
- * schema — the canonical form, positionally identical to the overlay's data columns (the
2137
- * tombstone flag is appended last). The index is named `constraint name ?? '_uc_<cols>'`,
2138
- * the memory module's own covering-index naming rule (`implicitIndexNameFor`), so the
2139
- * derived UNIQUE the overlay module synthesizes from it resolves under the SAME name a
2140
- * later DROP / RENAME CONSTRAINT forward carries.
2141
- *
2142
- * `MemoryTableManager.createIndex` pre-validates the overlay's effective rows against the
2143
- * narrowed predicate before any mutation, so a staged duplicate raises CONSTRAINT with the
2144
- * overlay untouched — routed by the caller to INTERNAL (the issuer, whose merged rows the
2145
- * underlying already judged via {@link issuerEffectiveRows}) or poison (a foreign overlay).
2146
- * A missing overlay `createIndex` is a no-op, as in the index paths.
2147
- */
2148
- async installOverlayUniqueConstraint(overlayState, constraint, updatedSchema) {
2149
- const overlayTable = overlayState.overlayTable;
2150
- const overlaySchema = overlayTable.tableSchema;
2151
- if (!overlayTable.createIndex || !overlaySchema)
2152
- return;
2153
- const columns = (constraint.columns ?? []).map(col => {
2154
- const idx = updatedSchema.columnIndexMap.get(col.name.toLowerCase());
2155
- if (idx === undefined) {
2156
- throw new QuereusError(`Isolation layer: UNIQUE constraint column '${col.name}' not found on '${updatedSchema.schemaName}.${updatedSchema.name}' after the underlying accepted the constraint.`, StatusCode.INTERNAL);
2157
- }
2158
- return idx;
2159
- });
2160
- const indexName = constraint.name ?? `_uc_${columns.map(i => updatedSchema.columns[i].name).join('_')}`;
2161
- if (this.schemaHasIndex(overlaySchema, indexName))
2162
- return;
2163
- // A runtime-added UNIQUE carries no partial predicate of its own (the AST has no field
2164
- // for one), so the overlay predicate is exactly the live-rows narrowing.
2165
- await overlayTable.createIndex({
2166
- name: indexName,
2167
- columns: columns.map(i => ({ index: i, collation: updatedSchema.columns[i]?.collation })),
2168
- unique: true,
2169
- predicate: this.overlayPredicate(undefined, updatedSchema.name, overlaySchema.name),
2170
- });
2171
- }
2172
- /**
2173
- * Forwards DROP / RENAME CONSTRAINT to an already-open overlay, presence-guarded: a
2174
- * constraint the overlay never carried is skipped silently rather than letting the overlay
2175
- * module's NOTFOUND abort the issuer's already-applied ALTER. The guard is what makes the
2176
- * unforwarded classes safe ({@link forwardAddConstraintToOverlay}: FOREIGN KEY always, and
2177
- * UNIQUE under an overlay module without `createIndex`) — their DROP / RENAME is simply a
2178
- * no-op on the overlay.
2179
- *
2180
- * For a UNIQUE the overlay module drops/renames the constraint AND its covering index in
2181
- * lock-step — both live under the constraint's name (or the `_uc_<cols>` implicit-name
2182
- * rule), whether copied at overlay creation ({@link createOverlaySchema}) or installed by
2183
- * {@link installOverlayUniqueConstraint} — so the base and overlay representations stay
2184
- * resolvable by one name across the whole constraint lifecycle.
2185
- */
2186
- async forwardConstraintNameChangeToOverlay(overlayState, change) {
2187
- const overlayTable = overlayState.overlayTable;
2188
- const overlaySchema = overlayTable.tableSchema;
2189
- if (!overlayTable.alterSchema || !overlaySchema)
2190
- return;
2191
- const name = change.type === 'dropConstraint' ? change.constraintName : change.oldName;
2192
- if (!this.schemaHasNamedConstraint(overlaySchema, name))
2193
- return;
2194
- await overlayTable.alterSchema(change);
2195
- }
2196
- /** Case-insensitive "does `schema` declare a named constraint of ANY class (CHECK / UNIQUE / FOREIGN KEY)". */
2197
- schemaHasNamedConstraint(schema, constraintName) {
2198
- const lower = constraintName.toLowerCase();
2199
- return (schema.checkConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
2200
- || (schema.uniqueConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
2201
- || (schema.foreignKeys ?? []).some(c => c.name?.toLowerCase() === lower);
2202
- }
2203
1437
  /**
2204
1438
  * Carries one overlay across an ALTER PRIMARY KEY, which no overlay can follow in place:
2205
1439
  * its layer BTrees are keyed by the OLD primary key, and a staged tombstone identifies the
@@ -2220,7 +1454,7 @@ export class IsolationModule {
2220
1454
  if (isIssuer) {
2221
1455
  throw new QuereusError(`Isolation layer: the issuer's overlay for '${schemaName}.${tableName}' reached alterPrimaryKey migration with staged rows; the pre-mutation guard in alterTable must reject this first.`, StatusCode.INTERNAL);
2222
1456
  }
2223
- state.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
1457
+ state.poison = { message: buildAlterPoisonMessage(schemaName, tableName, change) };
2224
1458
  return;
2225
1459
  }
2226
1460
  // NOTE: this is the only remaining path that swaps an overlay table rather than adopting
@@ -2324,6 +1558,9 @@ export class IsolationModule {
2324
1558
  * self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
2325
1559
  * cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
2326
1560
  * was created, so every qualifier present is the base name.
1561
+ *
1562
+ * Part of {@link AlterMigrationHost} — `alter-migration.ts` builds an overlay UNIQUE index
1563
+ * through this same seam.
2327
1564
  */
2328
1565
  overlayPredicate(base, baseName, overlayName) {
2329
1566
  const rescoped = base ? rescopePredicateQualifier(base, baseName, overlayName) : undefined;