@quereus/isolation 4.4.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/src/isolated-table.d.ts +7 -5
- package/dist/src/isolated-table.d.ts.map +1 -1
- package/dist/src/isolated-table.js +7 -5
- package/dist/src/isolated-table.js.map +1 -1
- package/dist/src/isolation-module.d.ts +171 -29
- package/dist/src/isolation-module.d.ts.map +1 -1
- package/dist/src/isolation-module.js +398 -53
- package/dist/src/isolation-module.js.map +1 -1
- package/dist/src/isolation-types.d.ts +8 -0
- package/dist/src/isolation-types.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation, inferType, validateAndParse } from '@quereus/quereus';
|
|
1
|
+
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation, inferType, validateAndParse, validateCollationForType, formatKeyValue } from '@quereus/quereus';
|
|
2
2
|
import { IsolatedTable } from './isolated-table.js';
|
|
3
3
|
import { applyOverlayToUnderlying } from './flush.js';
|
|
4
4
|
import { makeFullScanFilterInfo } from './filter-info.js';
|
|
@@ -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
|
|
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`
|
|
@@ -790,7 +791,7 @@ export class IsolationModule {
|
|
|
790
791
|
* covers the data conditions the pre-validation pass rejects before any overlay is touched;
|
|
791
792
|
* this one covers a UNIQUE (or any other) violation raised by the in-place adoption itself.
|
|
792
793
|
*/
|
|
793
|
-
|
|
794
|
+
buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, cause) {
|
|
794
795
|
return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
|
|
795
796
|
+ `uncommitted rows violate (${cause}); roll back this transaction.`;
|
|
796
797
|
}
|
|
@@ -907,8 +908,7 @@ export class IsolationModule {
|
|
|
907
908
|
// Take the index back off the underlying's refreshed schema rather than reusing the
|
|
908
909
|
// caller's object: that is the canonical post-create form (resolved column indices,
|
|
909
910
|
// 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());
|
|
911
|
+
const { schema: updatedSchema, index: created } = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
|
|
912
912
|
await this.applyIndexChangeToOverlays(db, schemaName, tableName, `create index '${indexSchema.name}'`, overlayState => this.createOverlayIndex(overlayState, updatedSchema.name, created));
|
|
913
913
|
}
|
|
914
914
|
/**
|
|
@@ -935,29 +935,34 @@ export class IsolationModule {
|
|
|
935
935
|
await overlayTable.createIndex(this.createOverlayIndexSchema(indexSchema, baseName, overlaySchema.name));
|
|
936
936
|
}
|
|
937
937
|
/**
|
|
938
|
-
* Reads back the underlying table instance's post-`createIndex` schema
|
|
939
|
-
* carries the new index
|
|
938
|
+
* Reads back the underlying table instance's post-`createIndex` schema plus the entry it now
|
|
939
|
+
* carries for the new index — the canonical post-create form each overlay's copy is derived
|
|
940
|
+
* from ({@link createOverlayIndex}).
|
|
940
941
|
*
|
|
941
942
|
* Both bundled underlyings refresh the instance's cached `tableSchema` (memory through
|
|
942
|
-
* `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`)
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
943
|
+
* `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`). A third-party
|
|
944
|
+
* underlying that refreshed only its module-level schema would leave every overlay without
|
|
945
|
+
* the index — silently re-opening the very hole `createIndex`'s forward exists to close — so
|
|
946
|
+
* assert rather than assume.
|
|
946
947
|
*/
|
|
947
948
|
assertIndexPresent(underlyingTable, schemaName, tableName, indexName) {
|
|
948
|
-
const
|
|
949
|
-
const
|
|
950
|
-
if (!
|
|
949
|
+
const schema = underlyingTable.tableSchema;
|
|
950
|
+
const index = schema && this.findSchemaIndex(schema, indexName);
|
|
951
|
+
if (!schema || !index) {
|
|
951
952
|
throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
|
|
952
953
|
+ `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
|
|
953
954
|
+ `report; the underlying module must refresh VirtualTable.tableSchema in createIndex.`, StatusCode.INTERNAL);
|
|
954
955
|
}
|
|
955
|
-
return
|
|
956
|
+
return { schema, index };
|
|
957
|
+
}
|
|
958
|
+
/** `schema`'s index named `indexName` (case-insensitively), if it declares one. */
|
|
959
|
+
findSchemaIndex(schema, indexName) {
|
|
960
|
+
const lower = indexName.toLowerCase();
|
|
961
|
+
return schema.indexes?.find(idx => idx.name.toLowerCase() === lower);
|
|
956
962
|
}
|
|
957
963
|
/** Case-insensitive "does `schema` declare an index named `indexName`". */
|
|
958
964
|
schemaHasIndex(schema, indexName) {
|
|
959
|
-
|
|
960
|
-
return schema.indexes?.some(idx => idx.name.toLowerCase() === lower) ?? false;
|
|
965
|
+
return this.findSchemaIndex(schema, indexName) !== undefined;
|
|
961
966
|
}
|
|
962
967
|
/**
|
|
963
968
|
* Drops an index on the underlying table, then drops it from every per-connection overlay.
|
|
@@ -967,6 +972,11 @@ export class IsolationModule {
|
|
|
967
972
|
* so MemoryTable.tableSchema stays fresh), prefer that. Otherwise fall back
|
|
968
973
|
* to the module-level dropIndex (e.g. StoreModule, which refreshes the
|
|
969
974
|
* StoreTable's cached tableSchema and tears down the index store).
|
|
975
|
+
*
|
|
976
|
+
* Mirrors createIndex's early return when neither hook exists too: an underlying that
|
|
977
|
+
* declares indexes at CREATE TABLE time but cannot drop one still has them, and dropping
|
|
978
|
+
* from the overlays alone would leave each overlay enforcing LESS than the base it flushes
|
|
979
|
+
* into — a silent divergence surfacing only as a commit-time rejection.
|
|
970
980
|
*/
|
|
971
981
|
async dropIndex(db, schemaName, tableName, indexName) {
|
|
972
982
|
const state = this.getUnderlyingState(schemaName, tableName);
|
|
@@ -976,6 +986,9 @@ export class IsolationModule {
|
|
|
976
986
|
else if (this.underlying.dropIndex) {
|
|
977
987
|
await this.underlying.dropIndex(db, schemaName, tableName, indexName);
|
|
978
988
|
}
|
|
989
|
+
else {
|
|
990
|
+
return; // underlying cannot drop indexes; nothing was dropped, nothing to forward
|
|
991
|
+
}
|
|
979
992
|
await this.applyIndexChangeToOverlays(db, schemaName, tableName, `drop index '${indexName}'`, overlayState => this.dropOverlayIndex(overlayState, indexName));
|
|
980
993
|
}
|
|
981
994
|
/**
|
|
@@ -1032,7 +1045,7 @@ export class IsolationModule {
|
|
|
1032
1045
|
* judged by the DDL's own validation pass, so a rejection here means validation and
|
|
1033
1046
|
* migration have drifted — {@link issuerOverlayDriftError}); a foreign overlay →
|
|
1034
1047
|
* poison and leave it untouched so its owner errors and rolls back
|
|
1035
|
-
* ({@link
|
|
1048
|
+
* ({@link buildInPlaceAdoptPoisonMessage}). Any non-CONSTRAINT failure is a layer-invariant
|
|
1036
1049
|
* violation, not a data condition, and rethrows for everyone.
|
|
1037
1050
|
*
|
|
1038
1051
|
* Shared by the index paths ({@link applyIndexChangeToOverlays}) and the ALTER TABLE
|
|
@@ -1043,11 +1056,24 @@ export class IsolationModule {
|
|
|
1043
1056
|
await apply();
|
|
1044
1057
|
}
|
|
1045
1058
|
catch (e) {
|
|
1046
|
-
|
|
1059
|
+
// NOTE: a rethrow here abandons the rest of the caller's overlay walk, and the DDL has
|
|
1060
|
+
// already landed on the shared underlying — so overlays not yet visited neither adopt
|
|
1061
|
+
// the change nor get poisoned. Safe today: every rethrown source is a layer-invariant
|
|
1062
|
+
// violation (INTERNAL), which no overlay could have satisfied anyway. The two
|
|
1063
|
+
// per-overlay DATA conditions — CONSTRAINT, and the BUSY the overlay module's own
|
|
1064
|
+
// primary-key re-key representability check raises — are routed below instead.
|
|
1065
|
+
if (!(e instanceof QuereusError) || (e.code !== StatusCode.CONSTRAINT && e.code !== StatusCode.BUSY))
|
|
1047
1066
|
throw e;
|
|
1048
|
-
if (isIssuer)
|
|
1067
|
+
if (isIssuer) {
|
|
1068
|
+
// A BUSY out of the issuer's own overlay is a representability refusal its
|
|
1069
|
+
// validation pass never claimed to judge — not drift. The tier-2 pre-flight
|
|
1070
|
+
// surfaces it before the underlying mutates for the bundled overlay module, so
|
|
1071
|
+
// this rethrow is reachable only through a host-injected overlay module.
|
|
1072
|
+
if (e.code === StatusCode.BUSY)
|
|
1073
|
+
throw e;
|
|
1049
1074
|
throw this.issuerOverlayDriftError(schemaName, tableName, ddlDescription, e);
|
|
1050
|
-
|
|
1075
|
+
}
|
|
1076
|
+
overlayState.poison = { message: this.buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
|
|
1051
1077
|
}
|
|
1052
1078
|
}
|
|
1053
1079
|
/**
|
|
@@ -1065,9 +1091,12 @@ export class IsolationModule {
|
|
|
1065
1091
|
* backfills each staged row exactly as the committed path does (literal default,
|
|
1066
1092
|
* per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
|
|
1067
1093
|
* - ALTER COLUMN: {@link forwardAlterColumnToOverlay}. `set data type` / `set collate`
|
|
1068
|
-
* / `set default` forward
|
|
1069
|
-
* open layers itself)
|
|
1070
|
-
*
|
|
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.
|
|
1071
1100
|
* - ADD / DROP / RENAME CONSTRAINT: {@link forwardAddConstraintToOverlay} /
|
|
1072
1101
|
* {@link forwardConstraintNameChangeToOverlay}. A UNIQUE lands as a
|
|
1073
1102
|
* tombstone-narrowed unique index; CHECK forwards verbatim; FOREIGN KEY does not
|
|
@@ -1139,6 +1168,7 @@ export class IsolationModule {
|
|
|
1139
1168
|
if (change.type === 'alterPrimaryKey' && ownEntry?.[1].hasChanges) {
|
|
1140
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);
|
|
1141
1170
|
}
|
|
1171
|
+
this.assertColumnNameNotTombstone(schemaName, tableName, change);
|
|
1142
1172
|
// Build the addColumn backfill context up front (undefined for other change types).
|
|
1143
1173
|
// Derived purely from `change` + the session nullability option — no post-alter
|
|
1144
1174
|
// schema needed — so it is valid here, before the underlying is mutated, and the
|
|
@@ -1153,22 +1183,76 @@ export class IsolationModule {
|
|
|
1153
1183
|
// retype with overlays to convert). Read from the same PRE-alter overlay schema as above;
|
|
1154
1184
|
// `inferType` cannot throw, so deriving it before the underlying mutation is safe.
|
|
1155
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);
|
|
1156
1189
|
// Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
|
|
1157
1190
|
// Any throw here (CONSTRAINT backfill, MISMATCH conversion, or INTERNAL tombstone guard)
|
|
1158
1191
|
// propagates while underlying + catalog + every overlay are still untouched — the companion
|
|
1159
1192
|
// ticket's atomic-abort guarantee, preserved unchanged for the issuer.
|
|
1160
1193
|
if (ownEntry) {
|
|
1161
|
-
await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx);
|
|
1194
|
+
await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
|
|
1162
1195
|
}
|
|
1163
1196
|
const underlyingState = this.getUnderlyingState(schemaName, tableName);
|
|
1164
1197
|
// Hand the underlying the issuer's effective rows so its own row-content checks
|
|
1165
1198
|
// (`add constraint … unique`, `alter column … set collate`) see the transaction's
|
|
1166
1199
|
// pending rows and skip the ones it has deleted. An outer wrapper's source wins if
|
|
1167
1200
|
// one was supplied.
|
|
1168
|
-
|
|
1201
|
+
let rowSource = rows ?? (underlyingState
|
|
1169
1202
|
? this.issuerEffectiveRows(db, schemaName, tableName, underlyingState.underlyingTable)
|
|
1170
1203
|
: undefined);
|
|
1171
|
-
|
|
1204
|
+
// Tier 2, PRIMARY-KEY re-key arm: the overlay's own `alterSchema` is itself fallible —
|
|
1205
|
+
// its module re-validates the re-key against the overlay's layer chain (e.g. the memory
|
|
1206
|
+
// module's representability BUSY when a savepoint could restore rows that collide under
|
|
1207
|
+
// the new collation) — and it runs in the migrate step, AFTER the underlying has
|
|
1208
|
+
// irreversibly re-keyed. So dry-run it here, against the exact state the migrate step
|
|
1209
|
+
// will see: drop the collapsed markers first (saving them), pre-flight the overlay's
|
|
1210
|
+
// `alterSchema` in validate-only mode, and on ANY later refusal — the pre-flight's or
|
|
1211
|
+
// the underlying's — reinsert the markers and rethrow with everything net-untouched.
|
|
1212
|
+
//
|
|
1213
|
+
// The drops happen BEFORE `underlying.alterTable`, and a dropped marker un-shadows the
|
|
1214
|
+
// committed row it deletes in `issuerEffectiveRows`'s merged stream — so the effective
|
|
1215
|
+
// rows are SNAPSHOTTED first, and the underlying judges the pre-drop view.
|
|
1216
|
+
let droppedMarkerRows = [];
|
|
1217
|
+
if (pkRekeyCtx && ownEntry && ownEntry[1].hasChanges && ownEntry[1].overlayTable.alterSchema) {
|
|
1218
|
+
const ownOverlayState = ownEntry[1];
|
|
1219
|
+
const plan = await this.planPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx);
|
|
1220
|
+
if (plan.length > 0) {
|
|
1221
|
+
if (rowSource) {
|
|
1222
|
+
// NOTE: materializes the issuer's whole effective row set for this one ALTER.
|
|
1223
|
+
// The underlying's own re-key probe already holds every row it sees (see
|
|
1224
|
+
// MemoryTableManager.makePrimaryKeyProbe's NOTE), so this does not change the
|
|
1225
|
+
// peak's order of magnitude.
|
|
1226
|
+
const snapshot = [];
|
|
1227
|
+
for await (const row of rowSource())
|
|
1228
|
+
snapshot.push(row);
|
|
1229
|
+
rowSource = () => (async function* () { yield* snapshot; })();
|
|
1230
|
+
}
|
|
1231
|
+
droppedMarkerRows = plan;
|
|
1232
|
+
await this.applyPkRekeyMarkerDrops(ownOverlayState, pkRekeyCtx, plan);
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
// NOTE: unenforced contract — an overlay module that ignores `validateOnly` (see
|
|
1236
|
+
// VirtualTable.alterSchema / IsolationModuleConfig.overlay) applies the re-key
|
|
1237
|
+
// here for real, and a later refusal would then reinsert the markers into an
|
|
1238
|
+
// already-re-keyed overlay. If host-injected overlay modules ever become common,
|
|
1239
|
+
// gate this call on a module capability rather than on the documented contract.
|
|
1240
|
+
await ownOverlayState.overlayTable.alterSchema(change, true);
|
|
1241
|
+
}
|
|
1242
|
+
catch (e) {
|
|
1243
|
+
await this.reinsertPkRekeyMarkers(ownOverlayState, droppedMarkerRows);
|
|
1244
|
+
throw e;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
let updated;
|
|
1248
|
+
try {
|
|
1249
|
+
updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
|
|
1250
|
+
}
|
|
1251
|
+
catch (e) {
|
|
1252
|
+
if (ownEntry)
|
|
1253
|
+
await this.reinsertPkRekeyMarkers(ownEntry[1], droppedMarkerRows);
|
|
1254
|
+
throw e;
|
|
1255
|
+
}
|
|
1172
1256
|
// The cached underlying VirtualTable's `tableSchema` is a construction-time
|
|
1173
1257
|
// snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
|
|
1174
1258
|
// underlying manager's schema but not this instance's field. Refresh it so a
|
|
@@ -1191,7 +1275,7 @@ export class IsolationModule {
|
|
|
1191
1275
|
break;
|
|
1192
1276
|
case 'alterColumn': {
|
|
1193
1277
|
const alterColumnChange = change;
|
|
1194
|
-
await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAlterColumnToOverlay(state, alterColumnChange, setNotNullCtx));
|
|
1278
|
+
await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAlterColumnToOverlay(state, alterColumnChange, setNotNullCtx, pkRekeyCtx));
|
|
1195
1279
|
break;
|
|
1196
1280
|
}
|
|
1197
1281
|
case 'addConstraint': {
|
|
@@ -1232,7 +1316,7 @@ export class IsolationModule {
|
|
|
1232
1316
|
// overlay, so one bad foreign overlay poisons only itself; healthy peers still migrate.
|
|
1233
1317
|
for (const [key, oldState] of foreign) {
|
|
1234
1318
|
try {
|
|
1235
|
-
await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx);
|
|
1319
|
+
await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
|
|
1236
1320
|
}
|
|
1237
1321
|
catch (e) {
|
|
1238
1322
|
if (e instanceof QuereusError && (e.code === StatusCode.CONSTRAINT || e.code === StatusCode.MISMATCH)) {
|
|
@@ -1245,15 +1329,42 @@ export class IsolationModule {
|
|
|
1245
1329
|
}
|
|
1246
1330
|
return updated;
|
|
1247
1331
|
}
|
|
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
|
+
}
|
|
1248
1358
|
/**
|
|
1249
1359
|
* Builds the poison message stamped onto a foreign overlay whose backfill could not
|
|
1250
1360
|
* satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
|
|
1251
1361
|
* schema.table and the offending column so the owning connection's eventual
|
|
1252
1362
|
* read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
|
|
1253
1363
|
* path (a new NOT-NULL column with no usable default), the `set not null` tightening
|
|
1254
|
-
* path (a staged NULL with no usable default),
|
|
1255
|
-
* that cannot be converted to the new type)
|
|
1256
|
-
*
|
|
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.
|
|
1257
1368
|
*/
|
|
1258
1369
|
buildAlterPoisonMessage(schemaName, tableName, change) {
|
|
1259
1370
|
if (change.type === 'addColumn') {
|
|
@@ -1263,6 +1374,9 @@ export class IsolationModule {
|
|
|
1263
1374
|
if (change.setDataType !== undefined) {
|
|
1264
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.`;
|
|
1265
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
|
+
}
|
|
1266
1380
|
return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
|
|
1267
1381
|
}
|
|
1268
1382
|
if (change.type === 'alterPrimaryKey') {
|
|
@@ -1511,6 +1625,76 @@ export class IsolationModule {
|
|
|
1511
1625
|
},
|
|
1512
1626
|
};
|
|
1513
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
|
+
}
|
|
1514
1698
|
/**
|
|
1515
1699
|
* Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
|
|
1516
1700
|
* so the caller can run it for every affected overlay BEFORE the irreversible
|
|
@@ -1539,21 +1723,27 @@ export class IsolationModule {
|
|
|
1539
1723
|
* and it must run before the migration so a failure becomes poison rather than a half-migrated
|
|
1540
1724
|
* overlay.
|
|
1541
1725
|
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
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.
|
|
1545
1738
|
*/
|
|
1546
|
-
async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx) {
|
|
1739
|
+
async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx) {
|
|
1547
1740
|
const oldOverlay = oldState.overlayTable;
|
|
1548
1741
|
const oldOverlaySchema = oldOverlay.tableSchema;
|
|
1549
1742
|
// Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
|
|
1550
1743
|
// schema stages nothing and runs none of the fallible checks.
|
|
1551
1744
|
if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
|
|
1552
1745
|
return;
|
|
1553
|
-
const oldTombstoneIdx =
|
|
1554
|
-
if (oldTombstoneIdx === undefined) {
|
|
1555
|
-
throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
|
|
1556
|
-
}
|
|
1746
|
+
const oldTombstoneIdx = this.requireTombstoneIndex(oldOverlaySchema);
|
|
1557
1747
|
if (addColumnCtx) {
|
|
1558
1748
|
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1559
1749
|
// Discard the result — this is validation only. A NOT NULL violation throws here.
|
|
@@ -1579,6 +1769,19 @@ export class IsolationModule {
|
|
|
1579
1769
|
if (value !== null)
|
|
1580
1770
|
setDataTypeCtx.convert(value); // discard — validation only
|
|
1581
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
|
+
}
|
|
1582
1785
|
}
|
|
1583
1786
|
}
|
|
1584
1787
|
/**
|
|
@@ -1650,6 +1853,10 @@ export class IsolationModule {
|
|
|
1650
1853
|
* column lands ahead of the tombstone flag and the flag stays LAST — the layout
|
|
1651
1854
|
* every read/write path of this package assumes. A bare forward would append after
|
|
1652
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.)
|
|
1653
1860
|
* - The column definition is made NULLABLE. The overlay's ADD re-runs the overlay
|
|
1654
1861
|
* module's own NOT NULL validation against a different row population than the
|
|
1655
1862
|
* base's: tombstone rows carry placeholder NULL in every non-PK column, so a
|
|
@@ -1675,7 +1882,7 @@ export class IsolationModule {
|
|
|
1675
1882
|
return {
|
|
1676
1883
|
...change,
|
|
1677
1884
|
columnDef: { ...change.columnDef, constraints },
|
|
1678
|
-
insertAtIndex: tombstoneIdx,
|
|
1885
|
+
insertAtIndex: change.insertAtIndex ?? tombstoneIdx,
|
|
1679
1886
|
backfillEvaluator: (overlayRow) => this.computeAddColumnValue(addColumnCtx, overlayRow, tombstoneIdx),
|
|
1680
1887
|
};
|
|
1681
1888
|
}
|
|
@@ -1686,20 +1893,40 @@ export class IsolationModule {
|
|
|
1686
1893
|
* `set data type` / `set collate` / `set default` forward through the overlay's own
|
|
1687
1894
|
* `alterSchema`: the overlay module converts / re-keys its open transaction layers with
|
|
1688
1895
|
* 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
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
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
|
|
1700
1927
|
* {@link forwardColumnShapeToOverlay}.
|
|
1701
1928
|
*/
|
|
1702
|
-
async forwardAlterColumnToOverlay(overlayState, change, setNotNullCtx) {
|
|
1929
|
+
async forwardAlterColumnToOverlay(overlayState, change, setNotNullCtx, pkRekeyCtx) {
|
|
1703
1930
|
if (change.setNotNull !== undefined) {
|
|
1704
1931
|
if (change.setNotNull === true && setNotNullCtx?.hasDefault) {
|
|
1705
1932
|
await this.backfillStagedNotNull(overlayState, setNotNullCtx);
|
|
@@ -1708,8 +1935,108 @@ export class IsolationModule {
|
|
|
1708
1935
|
}
|
|
1709
1936
|
if (!overlayState.overlayTable.alterSchema)
|
|
1710
1937
|
return;
|
|
1938
|
+
if (pkRekeyCtx)
|
|
1939
|
+
await this.dropCollapsedPkRekeyMarkers(overlayState, pkRekeyCtx);
|
|
1711
1940
|
await overlayState.overlayTable.alterSchema(change);
|
|
1712
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
|
+
}
|
|
1713
2040
|
/**
|
|
1714
2041
|
* Backfills a `set not null` tightening into an overlay's staged LIVE rows: every
|
|
1715
2042
|
* non-tombstone row holding NULL at the tightened column is rewritten with the column's
|
|
@@ -1896,6 +2223,14 @@ export class IsolationModule {
|
|
|
1896
2223
|
state.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
|
|
1897
2224
|
return;
|
|
1898
2225
|
}
|
|
2226
|
+
// NOTE: this is the only remaining path that swaps an overlay table rather than adopting
|
|
2227
|
+
// the change in place, so a registered `IsolatedConnection` keeps the OLD overlay's
|
|
2228
|
+
// connection (captured at construction, isolated-connection.ts) and forwards its
|
|
2229
|
+
// savepoint/rollback calls to a released table. Benign today: the swapped overlay is
|
|
2230
|
+
// clean, so nothing staged is lost, and the fresh overlay registers its own connection
|
|
2231
|
+
// with the `Database` on its first write and receives the broadcasts directly. If this
|
|
2232
|
+
// swap ever becomes reachable with staged rows, or per-connection state moves onto
|
|
2233
|
+
// `IsolatedConnection`, retarget the connection here.
|
|
1899
2234
|
const fresh = await this.overlayModule.create(state.db, this.createOverlaySchema(updatedSchema));
|
|
1900
2235
|
this.connectionOverlays.set(key, { overlayTable: fresh, hasChanges: false, db: state.db });
|
|
1901
2236
|
await this.releaseOverlayTable(state);
|
|
@@ -1921,6 +2256,16 @@ export class IsolationModule {
|
|
|
1921
2256
|
*
|
|
1922
2257
|
* `IsolatedTable.mergedSecondaryIndexQuery` wants exactly the live overlay rows out of
|
|
1923
2258
|
* these indexes, so narrowing them is what it already expects.
|
|
2259
|
+
*
|
|
2260
|
+
* NOTE: a base column already named {@link tombstoneColumn} lands here as a SECOND column
|
|
2261
|
+
* of that name, and `columnIndexMap` resolves the name to the appended flag. Harmless
|
|
2262
|
+
* today — every consumer reaches the flag by index, and reads, merged scans, a unique
|
|
2263
|
+
* secondary index over that column and a staged deletion all behave exactly as they do
|
|
2264
|
+
* for a non-colliding name. If anything ever resolves the overlay's columns BY NAME (a
|
|
2265
|
+
* hand-built overlay predicate, a module that re-derives its own index map from the
|
|
2266
|
+
* column list), give the overlay a collision-free flag name here instead. ALTER into the
|
|
2267
|
+
* name is rejected outright — see {@link assertColumnNameNotTombstone} for why that one
|
|
2268
|
+
* cannot be tolerated.
|
|
1924
2269
|
*/
|
|
1925
2270
|
createOverlaySchema(baseSchema) {
|
|
1926
2271
|
const tombstoneColumn = {
|