@quereus/isolation 4.4.1 → 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 } 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.
@@ -286,7 +286,8 @@ export class IsolationModule {
286
286
  * overlay module's table registry rather than leaking there for the life of the
287
287
  * `Database`. This is the single sink every overlay-discard path funnels through —
288
288
  * without it, `MemoryTableModule.tables` accumulates one dead `_overlay_<table>_<id>`
289
- * entry per writing transaction (and one more per rebuild), unbounded.
289
+ * entry per writing transaction (and one more per `alterPrimaryKey` overlay swap — the
290
+ * only DDL path left that replaces an overlay rather than adopting in place), unbounded.
290
291
  *
291
292
  * `MemoryTableManager.destroy` rolls back the overlay's own pending layer and clears its
292
293
  * connections; a later db-side teardown of the (now-detached) `MemoryVirtualTableConnection`
@@ -616,6 +617,21 @@ export class IsolationModule {
616
617
  async notifyLensDeployment(db, logicalSchemaName, snapshot) {
617
618
  await this.underlying.notifyLensDeployment?.(db, logicalSchemaName, snapshot);
618
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
+ }
619
635
  /**
620
636
  * Forwards the view / materialized-view catalog-persistability veto to the underlying
621
637
  * module.
@@ -790,7 +806,7 @@ export class IsolationModule {
790
806
  * covers the data conditions the pre-validation pass rejects before any overlay is touched;
791
807
  * this one covers a UNIQUE (or any other) violation raised by the in-place adoption itself.
792
808
  */
793
- buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, cause) {
809
+ buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, cause) {
794
810
  return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
795
811
  + `uncommitted rows violate (${cause}); roll back this transaction.`;
796
812
  }
@@ -907,8 +923,7 @@ export class IsolationModule {
907
923
  // Take the index back off the underlying's refreshed schema rather than reusing the
908
924
  // caller's object: that is the canonical post-create form (resolved column indices,
909
925
  // any normalization the underlying applied), and the overlay's copy is derived from it.
910
- const updatedSchema = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
911
- const created = updatedSchema.indexes.find(idx => idx.name.toLowerCase() === indexSchema.name.toLowerCase());
926
+ const { schema: updatedSchema, index: created } = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
912
927
  await this.applyIndexChangeToOverlays(db, schemaName, tableName, `create index '${indexSchema.name}'`, overlayState => this.createOverlayIndex(overlayState, updatedSchema.name, created));
913
928
  }
914
929
  /**
@@ -935,29 +950,34 @@ export class IsolationModule {
935
950
  await overlayTable.createIndex(this.createOverlayIndexSchema(indexSchema, baseName, overlaySchema.name));
936
951
  }
937
952
  /**
938
- * Reads back the underlying table instance's post-`createIndex` schema, asserting it now
939
- * carries the new index.
953
+ * Reads back the underlying table instance's post-`createIndex` schema plus the entry it now
954
+ * carries for the new index — the canonical post-create form each overlay's copy is derived
955
+ * from ({@link createOverlayIndex}).
940
956
  *
941
957
  * Both bundled underlyings refresh the instance's cached `tableSchema` (memory through
942
- * `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`), and the overlay
943
- * rebuild below copies its index/constraint set from it. A third-party underlying that
944
- * refreshed only its module-level schema would silently rebuild overlays under the PRE-index
945
- * schema, re-opening the very hole this method exists to close — so assert rather than assume.
958
+ * `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`). A third-party
959
+ * underlying that refreshed only its module-level schema would leave every overlay without
960
+ * the index — silently re-opening the very hole `createIndex`'s forward exists to close — so
961
+ * assert rather than assume.
946
962
  */
947
963
  assertIndexPresent(underlyingTable, schemaName, tableName, indexName) {
948
- const updatedSchema = underlyingTable.tableSchema;
949
- const present = updatedSchema && this.schemaHasIndex(updatedSchema, indexName);
950
- if (!updatedSchema || !present) {
964
+ const schema = underlyingTable.tableSchema;
965
+ const index = schema && this.findSchemaIndex(schema, indexName);
966
+ if (!schema || !index) {
951
967
  throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
952
968
  + `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
953
969
  + `report; the underlying module must refresh VirtualTable.tableSchema in createIndex.`, StatusCode.INTERNAL);
954
970
  }
955
- return updatedSchema;
971
+ return { schema, index };
956
972
  }
957
- /** Case-insensitive "does `schema` declare an index named `indexName`". */
958
- schemaHasIndex(schema, indexName) {
973
+ /** `schema`'s index named `indexName` (case-insensitively), if it declares one. */
974
+ findSchemaIndex(schema, indexName) {
959
975
  const lower = indexName.toLowerCase();
960
- return schema.indexes?.some(idx => idx.name.toLowerCase() === lower) ?? false;
976
+ return schema.indexes?.find(idx => idx.name.toLowerCase() === lower);
977
+ }
978
+ /** Case-insensitive "does `schema` declare an index named `indexName`". Part of {@link AlterMigrationHost}. */
979
+ schemaHasIndex(schema, indexName) {
980
+ return this.findSchemaIndex(schema, indexName) !== undefined;
961
981
  }
962
982
  /**
963
983
  * Drops an index on the underlying table, then drops it from every per-connection overlay.
@@ -967,6 +987,11 @@ export class IsolationModule {
967
987
  * so MemoryTable.tableSchema stays fresh), prefer that. Otherwise fall back
968
988
  * to the module-level dropIndex (e.g. StoreModule, which refreshes the
969
989
  * StoreTable's cached tableSchema and tears down the index store).
990
+ *
991
+ * Mirrors createIndex's early return when neither hook exists too: an underlying that
992
+ * declares indexes at CREATE TABLE time but cannot drop one still has them, and dropping
993
+ * from the overlays alone would leave each overlay enforcing LESS than the base it flushes
994
+ * into — a silent divergence surfacing only as a commit-time rejection.
970
995
  */
971
996
  async dropIndex(db, schemaName, tableName, indexName) {
972
997
  const state = this.getUnderlyingState(schemaName, tableName);
@@ -976,6 +1001,9 @@ export class IsolationModule {
976
1001
  else if (this.underlying.dropIndex) {
977
1002
  await this.underlying.dropIndex(db, schemaName, tableName, indexName);
978
1003
  }
1004
+ else {
1005
+ return; // underlying cannot drop indexes; nothing was dropped, nothing to forward
1006
+ }
979
1007
  await this.applyIndexChangeToOverlays(db, schemaName, tableName, `drop index '${indexName}'`, overlayState => this.dropOverlayIndex(overlayState, indexName));
980
1008
  }
981
1009
  /**
@@ -1032,7 +1060,7 @@ export class IsolationModule {
1032
1060
  * judged by the DDL's own validation pass, so a rejection here means validation and
1033
1061
  * migration have drifted — {@link issuerOverlayDriftError}); a foreign overlay →
1034
1062
  * poison and leave it untouched so its owner errors and rolls back
1035
- * ({@link buildRebuildPoisonMessage}). Any non-CONSTRAINT failure is a layer-invariant
1063
+ * ({@link buildInPlaceAdoptPoisonMessage}). Any non-CONSTRAINT failure is a layer-invariant
1036
1064
  * violation, not a data condition, and rethrows for everyone.
1037
1065
  *
1038
1066
  * Shared by the index paths ({@link applyIndexChangeToOverlays}) and the ALTER TABLE
@@ -1043,39 +1071,50 @@ export class IsolationModule {
1043
1071
  await apply();
1044
1072
  }
1045
1073
  catch (e) {
1046
- if (!(e instanceof QuereusError) || e.code !== StatusCode.CONSTRAINT)
1074
+ // NOTE: a rethrow here abandons the rest of the caller's overlay walk, and the DDL has
1075
+ // already landed on the shared underlying — so overlays not yet visited neither adopt
1076
+ // the change nor get poisoned. Safe today: every rethrown source is a layer-invariant
1077
+ // violation (INTERNAL), which no overlay could have satisfied anyway. The two
1078
+ // per-overlay DATA conditions — CONSTRAINT, and the BUSY the overlay module's own
1079
+ // primary-key re-key representability check raises — are routed below instead.
1080
+ if (!(e instanceof QuereusError) || (e.code !== StatusCode.CONSTRAINT && e.code !== StatusCode.BUSY))
1047
1081
  throw e;
1048
- if (isIssuer)
1082
+ if (isIssuer) {
1083
+ // A BUSY out of the issuer's own overlay is a representability refusal its
1084
+ // validation pass never claimed to judge — not drift. The tier-2 pre-flight
1085
+ // surfaces it before the underlying mutates for the bundled overlay module, so
1086
+ // this rethrow is reachable only through a host-injected overlay module.
1087
+ if (e.code === StatusCode.BUSY)
1088
+ throw e;
1049
1089
  throw this.issuerOverlayDriftError(schemaName, tableName, ddlDescription, e);
1050
- overlayState.poison = { message: this.buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
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.
1097
+ overlayState.poison = { message: this.buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
1051
1098
  }
1052
1099
  }
1053
1100
  /**
1054
1101
  * Delegates ALTER TABLE to the underlying module and carries any per-connection
1055
1102
  * overlays to the post-alter schema without discarding staged rows.
1056
1103
  *
1057
- * 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
1058
1108
  * `alterSchema` / `createIndex` / ordinary writes — so the overlay's layer chain and
1059
1109
  * savepoint snapshots survive the ALTER and `rollback to savepoint` keeps
1060
1110
  * distinguishing rows staged before the savepoint from rows staged after it (see
1061
1111
  * {@link applyIndexChangeToOverlays} for why the old rebuild — copying staged rows
1062
- * into a fresh staging table — destroyed that distinction). Per change type:
1063
- *
1064
- * - ADD / DROP / RENAME COLUMN: {@link forwardColumnShapeToOverlay}. ADD COLUMN
1065
- * backfills each staged row exactly as the committed path does (literal default,
1066
- * per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
1067
- * - ALTER COLUMN: {@link forwardAlterColumnToOverlay}. `set data type` / `set collate`
1068
- * / `set default` forward straight through (the overlay module converts / re-keys its
1069
- * open layers itself); `set not null` is withheld from the overlay and the staged
1070
- * live rows' NULLs are backfilled via ordinary overlay writes instead.
1071
- * - ADD / DROP / RENAME CONSTRAINT: {@link forwardAddConstraintToOverlay} /
1072
- * {@link forwardConstraintNameChangeToOverlay}. A UNIQUE lands as a
1073
- * tombstone-narrowed unique index; CHECK forwards verbatim; FOREIGN KEY does not
1074
- * forward at all.
1075
- * - ALTER PRIMARY KEY: no overlay can follow (its layer trees are keyed by the old
1076
- * primary key) — the issuer with staged rows is rejected before the underlying
1077
- * mutates, a foreign overlay with staged rows is poisoned, and a clean overlay is
1078
- * 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}).
1079
1118
  *
1080
1119
  * **Atomicity guarantee.** DDL through Quereus is not transaction-scoped and the
1081
1120
  * underlying (shared, committed) base auto-commits its mutation immediately —
@@ -1129,46 +1168,103 @@ export class IsolationModule {
1129
1168
  // poisoned overlay whose schema may be a stale pre-alter layout.
1130
1169
  const toMigrate = ownEntry ? [ownEntry, ...foreign] : foreign;
1131
1170
  // ALTER PRIMARY KEY cannot be carried by any overlay: the staging table's layer
1132
- // trees are keyed by the OLD primary key, a staged tombstone identifies the row it
1133
- // deletes BY that key, and the (memory) overlay module rejects in-place PK
1134
- // 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 —
1135
1176
  // before the underlying mutates — so the ALTER fails atomically rather than
1136
1177
  // stranding rows it cannot re-key. Foreign overlays with staged rows are poisoned
1137
1178
  // after the underlying applies, and clean overlays are swapped for a fresh staging
1138
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.
1139
1191
  if (change.type === 'alterPrimaryKey' && ownEntry?.[1].hasChanges) {
1140
- 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);
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);
1141
1193
  }
1142
- // Build the addColumn backfill context up front (undefined for other change types).
1143
- // Derived purely from `change` + the session nullability option — no post-alter
1144
- // schema needed — so it is valid here, before the underlying is mutated, and the
1145
- // same context drives the post-mutation migration.
1146
- const addColumnCtx = this.deriveAddColumnBackfill(change, db, tableName);
1147
- // Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
1148
- // with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
1149
- // from a to-be-migrated overlay's PRE-alter schema — the same layout every migrated overlay
1150
- // shares.
1151
- const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate);
1152
- // Build the setDataType conversion context (undefined unless this is a value-rewriting
1153
- // retype with overlays to convert). Read from the same PRE-alter overlay schema as above;
1154
- // `inferType` cannot throw, so deriving it before the underlying mutation is safe.
1155
- const setDataTypeCtx = this.deriveSetDataTypeConvert(change, toMigrate);
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);
1156
1200
  // Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
1157
1201
  // Any throw here (CONSTRAINT backfill, MISMATCH conversion, or INTERNAL tombstone guard)
1158
1202
  // propagates while underlying + catalog + every overlay are still untouched — the companion
1159
1203
  // ticket's atomic-abort guarantee, preserved unchanged for the issuer.
1160
1204
  if (ownEntry) {
1161
- await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx);
1205
+ await validateOverlayMigration(this, ownEntry[1], migration);
1162
1206
  }
1163
1207
  const underlyingState = this.getUnderlyingState(schemaName, tableName);
1164
1208
  // Hand the underlying the issuer's effective rows so its own row-content checks
1165
1209
  // (`add constraint … unique`, `alter column … set collate`) see the transaction's
1166
1210
  // pending rows and skip the ones it has deleted. An outer wrapper's source wins if
1167
1211
  // one was supplied.
1168
- const rowSource = rows ?? (underlyingState
1212
+ let rowSource = rows ?? (underlyingState
1169
1213
  ? this.issuerEffectiveRows(db, schemaName, tableName, underlyingState.underlyingTable)
1170
1214
  : undefined);
1171
- const updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
1215
+ // Tier 2, PRIMARY-KEY re-key arm: the overlay's own `alterSchema` is itself fallible —
1216
+ // its module re-validates the re-key against the overlay's layer chain (e.g. the memory
1217
+ // module's representability BUSY when a savepoint could restore rows that collide under
1218
+ // the new collation) — and it runs in the migrate step, AFTER the underlying has
1219
+ // irreversibly re-keyed. So dry-run it here, against the exact state the migrate step
1220
+ // will see: drop the collapsed markers first (saving them), pre-flight the overlay's
1221
+ // `alterSchema` in validate-only mode, and on ANY later refusal — the pre-flight's or
1222
+ // the underlying's — reinsert the markers and rethrow with everything net-untouched.
1223
+ //
1224
+ // The drops happen BEFORE `underlying.alterTable`, and a dropped marker un-shadows the
1225
+ // committed row it deletes in `issuerEffectiveRows`'s merged stream — so the effective
1226
+ // rows are SNAPSHOTTED first, and the underlying judges the pre-drop view.
1227
+ let droppedMarkerRows = [];
1228
+ if (migration.pkRekey && ownEntry && ownEntry[1].hasChanges && ownEntry[1].overlayTable.alterSchema) {
1229
+ const ownOverlayState = ownEntry[1];
1230
+ const pkRekeyCtx = migration.pkRekey;
1231
+ const plan = await planPkRekeyMarkerDrops(this, ownOverlayState, pkRekeyCtx);
1232
+ if (plan.length > 0) {
1233
+ if (rowSource) {
1234
+ // NOTE: materializes the issuer's whole effective row set for this one ALTER.
1235
+ // The underlying's own re-key probe already holds every row it sees (see
1236
+ // MemoryTableManager.makePrimaryKeyProbe's NOTE), so this does not change the
1237
+ // peak's order of magnitude.
1238
+ const snapshot = [];
1239
+ for await (const row of rowSource())
1240
+ snapshot.push(row);
1241
+ rowSource = () => (async function* () { yield* snapshot; })();
1242
+ }
1243
+ droppedMarkerRows = plan;
1244
+ await applyPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx, plan);
1245
+ }
1246
+ try {
1247
+ // NOTE: unenforced contract — an overlay module that ignores `validateOnly` (see
1248
+ // VirtualTable.alterSchema / IsolationModuleConfig.overlay) applies the re-key
1249
+ // here for real, and a later refusal would then reinsert the markers into an
1250
+ // already-re-keyed overlay. If host-injected overlay modules ever become common,
1251
+ // gate this call on a module capability rather than on the documented contract.
1252
+ await ownOverlayState.overlayTable.alterSchema(change, true);
1253
+ }
1254
+ catch (e) {
1255
+ await reinsertPkRekeyMarkers(ownOverlayState, droppedMarkerRows);
1256
+ throw e;
1257
+ }
1258
+ }
1259
+ let updated;
1260
+ try {
1261
+ updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
1262
+ }
1263
+ catch (e) {
1264
+ if (ownEntry)
1265
+ await reinsertPkRekeyMarkers(ownEntry[1], droppedMarkerRows);
1266
+ throw e;
1267
+ }
1172
1268
  // The cached underlying VirtualTable's `tableSchema` is a construction-time
1173
1269
  // snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
1174
1270
  // underlying manager's schema but not this instance's field. Refresh it so a
@@ -1178,40 +1274,17 @@ export class IsolationModule {
1178
1274
  if (underlyingState)
1179
1275
  underlyingState.underlyingTable.tableSchema = updated;
1180
1276
  const ddlDescription = `alter table (${change.type})`;
1181
- // Carry one overlay to the post-alter schema, IN PLACE, so the overlay's layer
1182
- // chain and savepoint snapshots stay intact (see the method doc for the per-type
1183
- // routes). The `const` re-captures pin each case's narrowed change type for the
1184
- // 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.
1185
1281
  const migrateOverlay = async (key, state, isIssuer) => {
1186
- switch (change.type) {
1187
- case 'addColumn':
1188
- case 'dropColumn':
1189
- case 'renameColumn':
1190
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardColumnShapeToOverlay(state, change, addColumnCtx));
1191
- break;
1192
- case 'alterColumn': {
1193
- const alterColumnChange = change;
1194
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAlterColumnToOverlay(state, alterColumnChange, setNotNullCtx));
1195
- break;
1196
- }
1197
- case 'addConstraint': {
1198
- const addConstraintChange = change;
1199
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAddConstraintToOverlay(state, addConstraintChange, updated));
1200
- break;
1201
- }
1202
- case 'dropConstraint':
1203
- case 'renameConstraint': {
1204
- const nameChange = change;
1205
- await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardConstraintNameChangeToOverlay(state, nameChange));
1206
- break;
1207
- }
1208
- case 'alterPrimaryKey':
1209
- await this.replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updated, change);
1210
- break;
1211
- default: {
1212
- const _exhaustive = change;
1213
- }
1282
+ if (change.type === 'alterPrimaryKey') {
1283
+ await this.replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updated, change);
1284
+ return;
1214
1285
  }
1286
+ const inPlaceChange = change;
1287
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => migrateOverlayForward(this, state, inPlaceChange, migration, updated));
1215
1288
  };
1216
1289
  // Migrate the issuer's own overlay (already validated above). Its NOT NULL /
1217
1290
  // tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
@@ -1232,11 +1305,11 @@ export class IsolationModule {
1232
1305
  // overlay, so one bad foreign overlay poisons only itself; healthy peers still migrate.
1233
1306
  for (const [key, oldState] of foreign) {
1234
1307
  try {
1235
- await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx);
1308
+ await validateOverlayMigration(this, oldState, migration);
1236
1309
  }
1237
1310
  catch (e) {
1238
1311
  if (e instanceof QuereusError && (e.code === StatusCode.CONSTRAINT || e.code === StatusCode.MISMATCH)) {
1239
- oldState.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
1312
+ oldState.poison = { message: buildAlterPoisonMessage(schemaName, tableName, change) };
1240
1313
  continue; // poisoned — do NOT migrate; leave pre-alter rows in place
1241
1314
  }
1242
1315
  throw e;
@@ -1245,31 +1318,6 @@ export class IsolationModule {
1245
1318
  }
1246
1319
  return updated;
1247
1320
  }
1248
- /**
1249
- * Builds the poison message stamped onto a foreign overlay whose backfill could not
1250
- * satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
1251
- * schema.table and the offending column so the owning connection's eventual
1252
- * read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
1253
- * path (a new NOT-NULL column with no usable default), the `set not null` tightening
1254
- * path (a staged NULL with no usable default), and the `set data type` path (a staged value
1255
- * that cannot be converted to the new type); other change types never reach here but
1256
- * are handled defensively.
1257
- */
1258
- buildAlterPoisonMessage(schemaName, tableName, change) {
1259
- if (change.type === 'addColumn') {
1260
- return `ALTER on '${schemaName}.${tableName}' added column '${change.columnDef.name}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
1261
- }
1262
- if (change.type === 'alterColumn') {
1263
- if (change.setDataType !== undefined) {
1264
- 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.`;
1265
- }
1266
- return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
1267
- }
1268
- if (change.type === 'alterPrimaryKey') {
1269
- return `ALTER on '${schemaName}.${tableName}' changed the table's primary key, which this connection's uncommitted rows cannot follow; roll back this transaction.`;
1270
- }
1271
- return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
1272
- }
1273
1321
  /**
1274
1322
  * Renames a table through the isolation layer.
1275
1323
  *
@@ -1386,493 +1434,6 @@ export class IsolationModule {
1386
1434
  }
1387
1435
  return oldKeys.length;
1388
1436
  }
1389
- // NOTE: the ALTER overlay pre-validation machinery (the three derive* helpers,
1390
- // validateOverlayMigration, buildAlterPoisonMessage, and the per-type forward* helpers below)
1391
- // threads one context parameter per value-rewriting attribute. If a fourth attribute ever
1392
- // needs a context, extract the cluster into its own module and pass a single context object
1393
- // instead of a widening parameter list.
1394
- /**
1395
- * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
1396
- * the folded literal DEFAULT (the `tryFoldLiteral` of the DEFAULT expr, or `null`
1397
- * when there is no DEFAULT or it folds to NULL), the engine-supplied per-row
1398
- * evaluator (present only for a non-foldable `new.<col>` default), and whether
1399
- * the new column is NOT NULL. Returns undefined for every non-`addColumn` change
1400
- * so the row loop appends nothing.
1401
- *
1402
- * The new column's nullability is resolved exactly as both underlyings resolve it
1403
- * (`columnDefToSchema(columnDef, default_column_nullability === 'not_null')`) so the
1404
- * pre-validation cannot drift from what the underlying will enforce. Because it is
1405
- * derived purely from `change` + the session option — not the post-alter schema,
1406
- * which does not exist until `underlying.alterTable` runs — this can be built
1407
- * BEFORE the irreversible underlying mutation and reused by the migration after.
1408
- */
1409
- deriveAddColumnBackfill(change, db, tableName) {
1410
- if (change.type !== 'addColumn')
1411
- return undefined;
1412
- const defaultExpr = change.columnDef.constraints?.find(c => c.type === 'default')?.expr;
1413
- // tryFoldLiteral returns undefined for a non-foldable expr and null for one that
1414
- // folds to NULL; collapse both to null (the no-usable-literal default).
1415
- const foldedDefault = defaultExpr ? (tryFoldLiteral(defaultExpr) ?? null) : null;
1416
- const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
1417
- // Thread the session `default_collation` for symmetry with the underlying memory/store
1418
- // ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
1419
- // underlying materializes the real column), so it does not affect collation here — but
1420
- // keeping the call signature identical avoids drift and is correct for any future reader.
1421
- const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
1422
- return {
1423
- foldedDefault,
1424
- evaluator: change.backfillEvaluator,
1425
- newColNotNull: newColumn.notNull,
1426
- newColName: newColumn.name,
1427
- tableName,
1428
- };
1429
- }
1430
- /**
1431
- * Precomputes the per-ALTER constants an `alter column … set not null` overlay migration needs:
1432
- * the now-NOT-NULL column's index and the folded literal DEFAULT (with `hasDefault` gating
1433
- * backfill vs reject). Returns undefined unless this is a NOT NULL *tightening*
1434
- * (`setNotNull: true`) with at least one overlay to migrate — a DROP NOT NULL loosens and
1435
- * needs no staged-row work, and with no overlays there is nothing to backfill or reject.
1436
- *
1437
- * `change` for `set not null` carries no default expression, so the DEFAULT is read from the
1438
- * column's PRE-alter schema (via a to-be-migrated overlay — the same layout every migrated
1439
- * overlay shares). Folded exactly as
1440
- * {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
1441
- * cannot drift from what the underlying enforces over its committed rows.
1442
- */
1443
- deriveSetNotNullBackfill(change, toMigrate) {
1444
- if (change.type !== 'alterColumn' || change.setNotNull !== true)
1445
- return undefined;
1446
- if (toMigrate.length === 0)
1447
- return undefined;
1448
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1449
- if (!overlaySchema)
1450
- return undefined;
1451
- const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1452
- if (colIndex === undefined)
1453
- return undefined;
1454
- const defaultExpr = overlaySchema.columns[colIndex]?.defaultValue;
1455
- // tryFoldLiteral returns undefined for a non-foldable expr and null for one that folds to
1456
- // NULL; both mean "no usable literal default" — the staged NULL must reject, not backfill.
1457
- const folded = defaultExpr ? tryFoldLiteral(defaultExpr) : undefined;
1458
- const hasDefault = folded !== undefined && folded !== null;
1459
- return {
1460
- colIndex,
1461
- foldedDefault: hasDefault ? folded : null,
1462
- hasDefault,
1463
- colName: change.columnName,
1464
- };
1465
- }
1466
- /**
1467
- * Precomputes the per-ALTER constants an `alter column … set data type` overlay conversion
1468
- * needs: the retyped column's index and a per-value `convert`. Returns undefined unless this
1469
- * is a retype with at least one overlay to convert, and undefined for an ALIAS retype (the
1470
- * new logical type IS the old type object — `inferType` flattens `varchar(50)` to
1471
- * `TEXT_TYPE`) — both underlyings gate their value rewrite on that exact identity
1472
- * comparison, so mirroring it here keeps overlay and committed rows moving together should
1473
- * "what counts as a value-rewriting retype" ever change. A same-storage-class retype between
1474
- * different types (text → date) DOES convert: staged values are validated and normalized to
1475
- * the new type's canonical spelling, exactly as the underlyings treat their committed rows.
1476
- *
1477
- * `inferType` never throws (an unknown type name falls through SQLite-style affinity rules),
1478
- * so deriving this BEFORE the underlying mutation is safe. `convert` is the literal mirror of
1479
- * `MemoryTableManager.alterColumn` / `StoreModule.alterColumnSetDataType`: `validateAndParse`,
1480
- * rethrowing failure as the same MISMATCH message, so the two legs cannot drift.
1481
- */
1482
- deriveSetDataTypeConvert(change, toMigrate) {
1483
- if (change.type !== 'alterColumn' || change.setDataType === undefined)
1484
- return undefined;
1485
- if (toMigrate.length === 0)
1486
- return undefined;
1487
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1488
- if (!overlaySchema)
1489
- return undefined;
1490
- const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1491
- if (colIndex === undefined)
1492
- return undefined;
1493
- const oldCol = overlaySchema.columns[colIndex];
1494
- if (!oldCol)
1495
- return undefined;
1496
- const newLogicalType = inferType(change.setDataType);
1497
- // Alias retype (same logical type object): the underlying rewrites nothing, so neither do we.
1498
- if (newLogicalType === oldCol.logicalType)
1499
- return undefined;
1500
- const setDataType = change.setDataType;
1501
- const columnName = change.columnName;
1502
- return {
1503
- colIndex,
1504
- convert: (v) => {
1505
- try {
1506
- return validateAndParse(v, newLogicalType, columnName);
1507
- }
1508
- catch {
1509
- throw new QuereusError(`Cannot convert value in '${columnName}' to ${setDataType}`, StatusCode.MISMATCH);
1510
- }
1511
- },
1512
- };
1513
- }
1514
- /**
1515
- * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
1516
- * so the caller can run it for every affected overlay BEFORE the irreversible
1517
- * `underlying.alterTable` (see {@link alterTable}). It exercises the EXACT code
1518
- * paths the real migration uses — the tombstone-present guard and, for addColumn,
1519
- * `computeAddColumnValue` per staged row — so a dry-run pass and the subsequent
1520
- * migrate pass cannot diverge:
1521
- *
1522
- * - A clean overlay (`!hasChanges`) stages no rows, so there is nothing to validate.
1523
- * - A missing tombstone column throws INTERNAL here, before the underlying is touched.
1524
- * - For addColumn, each staged row runs through `computeAddColumnValue`: tombstone
1525
- * rows short-circuit to `null` (the evaluator never runs), and a NOT-NULL-violating
1526
- * evaluated row throws CONSTRAINT here, atomically. Computed values are discarded.
1527
- *
1528
- * For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
1529
- * non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
1530
- * aborts atomically before the underlying mutates; for a foreign overlay the caller maps it to
1531
- * poison. With a usable DEFAULT the staged NULLs are backfilled by
1532
- * {@link backfillStagedNotNull}, so nothing is rejected here.
1533
- *
1534
- * For `set data type` (`setDataTypeCtx`), every staged non-NULL value is run through the
1535
- * conversion and the result discarded, so an unconvertible one throws MISMATCH here. For the
1536
- * ISSUER this is belt-and-braces — the underlying's own pre-mutation pass walks the same rows
1537
- * via {@link issuerEffectiveRows} — EXCEPT when an outer wrapper supplied `rows`, in which case
1538
- * this is the only check. For a FOREIGN overlay it is the only pass that ever sees those rows,
1539
- * and it must run before the migration so a failure becomes poison rather than a half-migrated
1540
- * overlay.
1541
- *
1542
- * Non-addColumn / non-tightening / non-retype changes (`addColumnCtx === undefined`, no
1543
- * reject-mode `setNotNullCtx`, no `setDataTypeCtx`) only run the tombstone guard; their row
1544
- * translation appends/removes nothing fallible on data grounds.
1545
- */
1546
- async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx) {
1547
- const oldOverlay = oldState.overlayTable;
1548
- const oldOverlaySchema = oldOverlay.tableSchema;
1549
- // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
1550
- // schema stages nothing and runs none of the fallible checks.
1551
- if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
1552
- return;
1553
- const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1554
- if (oldTombstoneIdx === undefined) {
1555
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1556
- }
1557
- if (addColumnCtx) {
1558
- for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1559
- // Discard the result — this is validation only. A NOT NULL violation throws here.
1560
- await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
1561
- }
1562
- return;
1563
- }
1564
- // SET NOT NULL with no usable DEFAULT: reject a staged NULL the forward could not fill.
1565
- // (With a DEFAULT there is nothing to reject — backfillStagedNotNull fills instead.)
1566
- if (setNotNullCtx && !setNotNullCtx.hasDefault) {
1567
- for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1568
- if (oldRow[setNotNullCtx.colIndex] === null) {
1569
- throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
1570
- }
1571
- }
1572
- return;
1573
- }
1574
- // SET DATA TYPE: prove every staged value convertible before anything is rewritten. NULLs
1575
- // are left untouched by the conversion (the underlying's `convertNulls` is false for a retype).
1576
- if (setDataTypeCtx) {
1577
- for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1578
- const value = oldRow[setDataTypeCtx.colIndex];
1579
- if (value !== null)
1580
- setDataTypeCtx.convert(value); // discard — validation only
1581
- }
1582
- }
1583
- }
1584
- /**
1585
- * The staged rows an overlay holds that represent LIVE data, i.e. every row except the
1586
- * tombstones — those carry placeholder NULLs at every data column and must never be fed to a
1587
- * per-value check, which would read a NULL that is not the row's real value.
1588
- */
1589
- async *stagedLiveRows(overlay, tombstoneIdx) {
1590
- for await (const row of overlay.query(makeFullScanFilterInfo())) {
1591
- if (row[tombstoneIdx] !== 1)
1592
- yield row;
1593
- }
1594
- }
1595
- /**
1596
- * Computes one staged row's value for a freshly added column, mirroring the
1597
- * committed-row backfill (see `base.ts` `recreatePrimaryTreeWithNewColumn` and
1598
- * `store-module.ts` `migrateRows`):
1599
- *
1600
- * - Tombstone rows carry NULL placeholders and their appended value is never read,
1601
- * so append `null` and never run the evaluator against them (it could reference
1602
- * NULL siblings or spuriously trip the NOT NULL check).
1603
- * - With a per-row evaluator, derive the value from the existing-columns slice and
1604
- * enforce NOT NULL on that path only (a literal/NULL default's nullability is gated
1605
- * up-front by the engine, exactly as `base.ts` does).
1606
- * - Otherwise use the folded literal default.
1607
- */
1608
- async computeAddColumnValue(ctx, oldRow, oldTombstoneIdx) {
1609
- if (oldRow[oldTombstoneIdx] === 1)
1610
- return null;
1611
- if (ctx.evaluator) {
1612
- const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
1613
- const value = await ctx.evaluator(data);
1614
- if (ctx.newColNotNull && value === null) {
1615
- throw new QuereusError(`NOT NULL constraint failed: backfilling column '${ctx.tableName}.${ctx.newColName}' produced NULL for a staged row`, StatusCode.CONSTRAINT);
1616
- }
1617
- return value;
1618
- }
1619
- return ctx.foldedDefault;
1620
- }
1621
- /**
1622
- * Forwards one column-shape change (ADD / DROP / RENAME COLUMN) to an already-open
1623
- * overlay IN PLACE via the overlay's optional `alterSchema`. The overlay module (the
1624
- * memory module by default) reshapes its open transaction layers with their savepoint
1625
- * snapshots intact, so `rollback to savepoint` keeps distinguishing rows staged before
1626
- * the savepoint from rows staged after it — the rebuild path destroyed that
1627
- * distinction in both directions (see {@link applyIndexChangeToOverlays}).
1628
- *
1629
- * A missing `alterSchema` is treated as a no-op, the way the index paths treat a
1630
- * missing `createIndex` / `dropIndex`.
1631
- *
1632
- * `dropColumn` / `renameColumn` forward the caller's change unchanged. `addColumn`
1633
- * forwards an overlay-flavoured copy — see {@link buildOverlayAddColumnChange}.
1634
- */
1635
- async forwardColumnShapeToOverlay(overlayState, change, addColumnCtx) {
1636
- const overlayTable = overlayState.overlayTable;
1637
- if (!overlayTable.alterSchema)
1638
- return;
1639
- // `deriveAddColumnBackfill` returns a context for every addColumn, so the assertion
1640
- // cannot fire when the change type matches.
1641
- const overlayChange = change.type === 'addColumn'
1642
- ? this.buildOverlayAddColumnChange(change, addColumnCtx, overlayTable)
1643
- : change;
1644
- await overlayTable.alterSchema(overlayChange);
1645
- }
1646
- /**
1647
- * The overlay-flavoured form of one `addColumn` change:
1648
- *
1649
- * - `insertAtIndex` = the overlay's current tombstone column index, so the new data
1650
- * column lands ahead of the tombstone flag and the flag stays LAST — the layout
1651
- * every read/write path of this package assumes. A bare forward would append after
1652
- * the flag and every value written to the new column would be dropped on read.
1653
- * - The column definition is made NULLABLE. The overlay's ADD re-runs the overlay
1654
- * module's own NOT NULL validation against a different row population than the
1655
- * base's: tombstone rows carry placeholder NULL in every non-PK column, so a
1656
- * NOT NULL column the base already accepted would be rejected here. The base's
1657
- * NOT NULL is still enforced where it belongs — by the engine's pre-mutation
1658
- * `validateNotNullBackfill` and the underlying's own ADD (both judged the issuer's
1659
- * effective rows), and per staged row by {@link computeAddColumnValue}'s
1660
- * evaluator-path check.
1661
- * - `backfillEvaluator` receives an OVERLAY row (data columns plus the flag) and
1662
- * routes through {@link computeAddColumnValue} — the same helper
1663
- * {@link validateOverlayMigration} dry-runs, so validation and forward cannot
1664
- * drift: NULL for a tombstone row, else the engine's per-row `new.<col>` evaluator
1665
- * over the flag-stripped row, or the folded literal default.
1666
- */
1667
- buildOverlayAddColumnChange(change, addColumnCtx, overlayTable) {
1668
- const tombstoneIdx = overlayTable.tableSchema?.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1669
- if (tombstoneIdx === undefined) {
1670
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1671
- }
1672
- const constraints = (change.columnDef.constraints ?? []).filter(c => c.type !== 'notNull');
1673
- if (!constraints.some(c => c.type === 'null'))
1674
- constraints.push({ type: 'null' });
1675
- return {
1676
- ...change,
1677
- columnDef: { ...change.columnDef, constraints },
1678
- insertAtIndex: tombstoneIdx,
1679
- backfillEvaluator: (overlayRow) => this.computeAddColumnValue(addColumnCtx, overlayRow, tombstoneIdx),
1680
- };
1681
- }
1682
- /**
1683
- * Forwards one ALTER COLUMN attribute change to an already-open overlay IN PLACE, with the
1684
- * NOT NULL attribute deliberately withheld.
1685
- *
1686
- * `set data type` / `set collate` / `set default` forward through the overlay's own
1687
- * `alterSchema`: the overlay module converts / re-keys its open transaction layers with
1688
- * their savepoint snapshots intact, and NULLs pass a retype untouched, so tombstone rows —
1689
- * placeholder NULLs at every non-key data column — ride through unharmed.
1690
- *
1691
- * `set not null` must NOT forward: the overlay's copy of the column stays nullable.
1692
- * Tombstone rows carry NULL at every non-PK data column, so the overlay module's own
1693
- * tightening would backfill a deletion marker's placeholder NULL from the DEFAULT
1694
- * (corrupting a row that is not a row) or reject it outright when no usable DEFAULT
1695
- * exists. The base's NOT NULL is enforced where it belongs — by the underlying over the
1696
- * issuer's effective rows, and per overlay by {@link validateOverlayMigration} — and the
1697
- * staged LIVE rows' NULLs are filled here via {@link backfillStagedNotNull}. The runtime
1698
- * admits exactly one attribute per ALTER COLUMN, so a `setNotNull` change carries nothing
1699
- * else to forward. A missing overlay `alterSchema` is a no-op, as in
1700
- * {@link forwardColumnShapeToOverlay}.
1701
- */
1702
- async forwardAlterColumnToOverlay(overlayState, change, setNotNullCtx) {
1703
- if (change.setNotNull !== undefined) {
1704
- if (change.setNotNull === true && setNotNullCtx?.hasDefault) {
1705
- await this.backfillStagedNotNull(overlayState, setNotNullCtx);
1706
- }
1707
- return;
1708
- }
1709
- if (!overlayState.overlayTable.alterSchema)
1710
- return;
1711
- await overlayState.overlayTable.alterSchema(change);
1712
- }
1713
- /**
1714
- * Backfills a `set not null` tightening into an overlay's staged LIVE rows: every
1715
- * non-tombstone row holding NULL at the tightened column is rewritten with the column's
1716
- * folded literal DEFAULT through the overlay's ordinary write path, so the overlay's layer
1717
- * chain and savepoint snapshots stay intact — the overlay-side mirror of the value rewrite
1718
- * the underlying applies to its committed rows. Tombstone rows are never touched
1719
- * ({@link stagedLiveRows}).
1720
- *
1721
- * Only called with a usable DEFAULT ({@link SetNotNullBackfillContext.hasDefault}); the
1722
- * no-DEFAULT case rejects (issuer) or poisons (foreign) in {@link validateOverlayMigration}
1723
- * before any overlay is touched.
1724
- *
1725
- * NOTE: these rewrites land in the overlay's CURRENT savepoint frame — each is a normal
1726
- * staged write. A later `rollback to savepoint` taken BEFORE this ALTER therefore restores
1727
- * the pre-backfill NULL while the column stays NOT NULL (DDL is not transactional here).
1728
- * That is the same class of divergence as any rolled-back row violating surviving DDL — a
1729
- * row un-inserted past an ADD CONSTRAINT, say — tracked by the backlog ticket
1730
- * `bug-rolled-back-rows-violate-surviving-ddl`, not a new hole opened by this forward.
1731
- */
1732
- async backfillStagedNotNull(overlayState, ctx) {
1733
- const overlay = overlayState.overlayTable;
1734
- const overlaySchema = overlay.tableSchema;
1735
- if (!overlaySchema || !overlay.query)
1736
- return;
1737
- const tombstoneIdx = overlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1738
- if (tombstoneIdx === undefined) {
1739
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1740
- }
1741
- const pkIndices = overlaySchema.primaryKeyDefinition.map(def => def.index);
1742
- // Materialize before writing: the rewrites mutate the overlay's pending layer the
1743
- // scan is reading through.
1744
- const toFill = [];
1745
- for await (const row of this.stagedLiveRows(overlay, tombstoneIdx)) {
1746
- if (row[ctx.colIndex] === null)
1747
- toFill.push(row);
1748
- }
1749
- for (const row of toFill) {
1750
- const values = row.map((v, i) => (i === ctx.colIndex ? ctx.foldedDefault : v));
1751
- // No `onConflict`, so `UpdateResult` is exactly `ok | constraint`. A constraint here
1752
- // (the filled value colliding under a UNIQUE) is a data condition of this overlay's
1753
- // staged rows — thrown as CONSTRAINT for applyInPlaceOverlayChange to route to
1754
- // INTERNAL (issuer, whose merged rows the underlying's own converted-row UNIQUE
1755
- // re-validation already judged) or poison (foreign).
1756
- const result = await overlay.update({
1757
- operation: 'update',
1758
- values,
1759
- oldKeyValues: pkIndices.map(i => row[i]),
1760
- preCoerced: true,
1761
- });
1762
- if (isConstraintViolation(result)) {
1763
- throw new QuereusError(`Backfilling column '${ctx.colName}' in overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
1764
- }
1765
- }
1766
- }
1767
- /**
1768
- * Forwards one ADD CONSTRAINT to an already-open overlay, per constraint class:
1769
- *
1770
- * - **UNIQUE** is NOT forwarded as a constraint. The AST `TableConstraint` carries no
1771
- * partial-predicate field, so a bare forward would enforce uniqueness over tombstone rows
1772
- * too — a UNIQUE whose columns sit inside the primary key would see two deleted rows
1773
- * (each carrying its PK and placeholder NULLs elsewhere) as duplicates of each other.
1774
- * Instead it lands as a tombstone-narrowed **unique index** through the same route CREATE
1775
- * INDEX takes ({@link installOverlayUniqueConstraint}).
1776
- * - **CHECK** forwards verbatim: the overlay module appends it schema-only (no row scan, no
1777
- * physical structure). Enforcement is engine-side at DML plan time against the user
1778
- * table; the overlay's copy exists so a later DROP / RENAME CONSTRAINT resolves it.
1779
- * - **FOREIGN KEY** is deliberately NOT forwarded. FK enforcement is engine-side
1780
- * (planner-synthesized EXISTS checks) — the overlay never enforces it — and the overlay
1781
- * module's ADD arm validates existing child rows through a catalog query by table name,
1782
- * which the unregistered `_overlay_*` staging table cannot serve. The presence guard in
1783
- * {@link forwardConstraintNameChangeToOverlay} keeps a later DROP / RENAME of the
1784
- * unforwarded FK a clean no-op on the overlay.
1785
- * - **PRIMARY KEY** cannot reach here — both bundled underlyings reject
1786
- * `add constraint … primary key` with UNSUPPORTED before any overlay work — so it asserts
1787
- * INTERNAL rather than leaving a silent gap for a third-party underlying that accepts it
1788
- * (the overlay could not follow; see the alterPrimaryKey handling in {@link alterTable}).
1789
- */
1790
- async forwardAddConstraintToOverlay(overlayState, change, updatedSchema) {
1791
- switch (change.constraint.type) {
1792
- case 'unique':
1793
- await this.installOverlayUniqueConstraint(overlayState, change.constraint, updatedSchema);
1794
- return;
1795
- case 'check':
1796
- if (overlayState.overlayTable.alterSchema)
1797
- await overlayState.overlayTable.alterSchema(change);
1798
- return;
1799
- case 'foreignKey':
1800
- return;
1801
- case 'primaryKey':
1802
- 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);
1803
- }
1804
- }
1805
- /**
1806
- * Installs one runtime-added UNIQUE constraint on an overlay as a tombstone-narrowed unique
1807
- * index (see {@link forwardAddConstraintToOverlay} for why a bare constraint forward is
1808
- * wrong). Column indices and per-column collations are read from the post-alter underlying
1809
- * schema — the canonical form, positionally identical to the overlay's data columns (the
1810
- * tombstone flag is appended last). The index is named `constraint name ?? '_uc_<cols>'`,
1811
- * the memory module's own covering-index naming rule (`implicitIndexNameFor`), so the
1812
- * derived UNIQUE the overlay module synthesizes from it resolves under the SAME name a
1813
- * later DROP / RENAME CONSTRAINT forward carries.
1814
- *
1815
- * `MemoryTableManager.createIndex` pre-validates the overlay's effective rows against the
1816
- * narrowed predicate before any mutation, so a staged duplicate raises CONSTRAINT with the
1817
- * overlay untouched — routed by the caller to INTERNAL (the issuer, whose merged rows the
1818
- * underlying already judged via {@link issuerEffectiveRows}) or poison (a foreign overlay).
1819
- * A missing overlay `createIndex` is a no-op, as in the index paths.
1820
- */
1821
- async installOverlayUniqueConstraint(overlayState, constraint, updatedSchema) {
1822
- const overlayTable = overlayState.overlayTable;
1823
- const overlaySchema = overlayTable.tableSchema;
1824
- if (!overlayTable.createIndex || !overlaySchema)
1825
- return;
1826
- const columns = (constraint.columns ?? []).map(col => {
1827
- const idx = updatedSchema.columnIndexMap.get(col.name.toLowerCase());
1828
- if (idx === undefined) {
1829
- 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);
1830
- }
1831
- return idx;
1832
- });
1833
- const indexName = constraint.name ?? `_uc_${columns.map(i => updatedSchema.columns[i].name).join('_')}`;
1834
- if (this.schemaHasIndex(overlaySchema, indexName))
1835
- return;
1836
- // A runtime-added UNIQUE carries no partial predicate of its own (the AST has no field
1837
- // for one), so the overlay predicate is exactly the live-rows narrowing.
1838
- await overlayTable.createIndex({
1839
- name: indexName,
1840
- columns: columns.map(i => ({ index: i, collation: updatedSchema.columns[i]?.collation })),
1841
- unique: true,
1842
- predicate: this.overlayPredicate(undefined, updatedSchema.name, overlaySchema.name),
1843
- });
1844
- }
1845
- /**
1846
- * Forwards DROP / RENAME CONSTRAINT to an already-open overlay, presence-guarded: a
1847
- * constraint the overlay never carried is skipped silently rather than letting the overlay
1848
- * module's NOTFOUND abort the issuer's already-applied ALTER. The guard is what makes the
1849
- * unforwarded classes safe ({@link forwardAddConstraintToOverlay}: FOREIGN KEY always, and
1850
- * UNIQUE under an overlay module without `createIndex`) — their DROP / RENAME is simply a
1851
- * no-op on the overlay.
1852
- *
1853
- * For a UNIQUE the overlay module drops/renames the constraint AND its covering index in
1854
- * lock-step — both live under the constraint's name (or the `_uc_<cols>` implicit-name
1855
- * rule), whether copied at overlay creation ({@link createOverlaySchema}) or installed by
1856
- * {@link installOverlayUniqueConstraint} — so the base and overlay representations stay
1857
- * resolvable by one name across the whole constraint lifecycle.
1858
- */
1859
- async forwardConstraintNameChangeToOverlay(overlayState, change) {
1860
- const overlayTable = overlayState.overlayTable;
1861
- const overlaySchema = overlayTable.tableSchema;
1862
- if (!overlayTable.alterSchema || !overlaySchema)
1863
- return;
1864
- const name = change.type === 'dropConstraint' ? change.constraintName : change.oldName;
1865
- if (!this.schemaHasNamedConstraint(overlaySchema, name))
1866
- return;
1867
- await overlayTable.alterSchema(change);
1868
- }
1869
- /** Case-insensitive "does `schema` declare a named constraint of ANY class (CHECK / UNIQUE / FOREIGN KEY)". */
1870
- schemaHasNamedConstraint(schema, constraintName) {
1871
- const lower = constraintName.toLowerCase();
1872
- return (schema.checkConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
1873
- || (schema.uniqueConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
1874
- || (schema.foreignKeys ?? []).some(c => c.name?.toLowerCase() === lower);
1875
- }
1876
1437
  /**
1877
1438
  * Carries one overlay across an ALTER PRIMARY KEY, which no overlay can follow in place:
1878
1439
  * its layer BTrees are keyed by the OLD primary key, and a staged tombstone identifies the
@@ -1893,9 +1454,17 @@ export class IsolationModule {
1893
1454
  if (isIssuer) {
1894
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);
1895
1456
  }
1896
- state.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
1457
+ state.poison = { message: buildAlterPoisonMessage(schemaName, tableName, change) };
1897
1458
  return;
1898
1459
  }
1460
+ // NOTE: this is the only remaining path that swaps an overlay table rather than adopting
1461
+ // the change in place, so a registered `IsolatedConnection` keeps the OLD overlay's
1462
+ // connection (captured at construction, isolated-connection.ts) and forwards its
1463
+ // savepoint/rollback calls to a released table. Benign today: the swapped overlay is
1464
+ // clean, so nothing staged is lost, and the fresh overlay registers its own connection
1465
+ // with the `Database` on its first write and receives the broadcasts directly. If this
1466
+ // swap ever becomes reachable with staged rows, or per-connection state moves onto
1467
+ // `IsolatedConnection`, retarget the connection here.
1899
1468
  const fresh = await this.overlayModule.create(state.db, this.createOverlaySchema(updatedSchema));
1900
1469
  this.connectionOverlays.set(key, { overlayTable: fresh, hasChanges: false, db: state.db });
1901
1470
  await this.releaseOverlayTable(state);
@@ -1921,6 +1490,16 @@ export class IsolationModule {
1921
1490
  *
1922
1491
  * `IsolatedTable.mergedSecondaryIndexQuery` wants exactly the live overlay rows out of
1923
1492
  * these indexes, so narrowing them is what it already expects.
1493
+ *
1494
+ * NOTE: a base column already named {@link tombstoneColumn} lands here as a SECOND column
1495
+ * of that name, and `columnIndexMap` resolves the name to the appended flag. Harmless
1496
+ * today — every consumer reaches the flag by index, and reads, merged scans, a unique
1497
+ * secondary index over that column and a staged deletion all behave exactly as they do
1498
+ * for a non-colliding name. If anything ever resolves the overlay's columns BY NAME (a
1499
+ * hand-built overlay predicate, a module that re-derives its own index map from the
1500
+ * column list), give the overlay a collision-free flag name here instead. ALTER into the
1501
+ * name is rejected outright — see {@link assertColumnNameNotTombstone} for why that one
1502
+ * cannot be tolerated.
1924
1503
  */
1925
1504
  createOverlaySchema(baseSchema) {
1926
1505
  const tombstoneColumn = {
@@ -1979,6 +1558,9 @@ export class IsolationModule {
1979
1558
  * self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
1980
1559
  * cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
1981
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.
1982
1564
  */
1983
1565
  overlayPredicate(base, baseName, overlayName) {
1984
1566
  const rescoped = base ? rescopePredicateQualifier(base, baseName, overlayName) : undefined;