@quereus/isolation 4.3.2 → 4.4.1
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 +5 -1
- package/dist/src/flush.d.ts.map +1 -1
- package/dist/src/flush.js +16 -3
- package/dist/src/flush.js.map +1 -1
- package/dist/src/isolated-table.d.ts +16 -4
- package/dist/src/isolated-table.d.ts.map +1 -1
- package/dist/src/isolated-table.js +121 -53
- package/dist/src/isolated-table.js.map +1 -1
- package/dist/src/isolation-module.d.ts +341 -110
- package/dist/src/isolation-module.d.ts.map +1 -1
- package/dist/src/isolation-module.js +645 -274
- package/dist/src/isolation-module.js.map +1 -1
- package/dist/src/overlay-rows.d.ts +10 -1
- package/dist/src/overlay-rows.d.ts.map +1 -1
- package/dist/src/overlay-rows.js +16 -3
- package/dist/src/overlay-rows.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation } from '@quereus/quereus';
|
|
1
|
+
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation, inferType, validateAndParse } 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';
|
|
@@ -616,6 +616,20 @@ export class IsolationModule {
|
|
|
616
616
|
async notifyLensDeployment(db, logicalSchemaName, snapshot) {
|
|
617
617
|
await this.underlying.notifyLensDeployment?.(db, logicalSchemaName, snapshot);
|
|
618
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Forwards the view / materialized-view catalog-persistability veto to the underlying
|
|
621
|
+
* module.
|
|
622
|
+
*
|
|
623
|
+
* `allModules()` yields the REGISTERED module, which is this wrapper when a store is
|
|
624
|
+
* isolated — so without this forward an isolation-wrapped store would never be
|
|
625
|
+
* consulted and would keep the silent-drop bug (a view it cannot persist creates
|
|
626
|
+
* "successfully" and vanishes on reopen). Whether a catalog entry is encodable is a
|
|
627
|
+
* property of the definition text, not of the overlay, so a straight delegate is
|
|
628
|
+
* correct; the throw must propagate (that is the entire point of the hook).
|
|
629
|
+
*/
|
|
630
|
+
assertCatalogObjectPersistable(db, kind, object) {
|
|
631
|
+
this.underlying.assertCatalogObjectPersistable?.(db, kind, object);
|
|
632
|
+
}
|
|
619
633
|
/**
|
|
620
634
|
* Delegates access plan selection to the underlying module.
|
|
621
635
|
* This ensures the query planner knows about indexes and can generate
|
|
@@ -755,70 +769,26 @@ export class IsolationModule {
|
|
|
755
769
|
}
|
|
756
770
|
}
|
|
757
771
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* which must roll back).
|
|
767
|
-
*/
|
|
768
|
-
async insertIntoRebuiltOverlay(newOverlayTable, values, tableName) {
|
|
769
|
-
// No `onConflict` is passed, so the overlay's memory module cannot answer `ignore`;
|
|
770
|
-
// `UpdateResult` is then exactly `ok | constraint`.
|
|
771
|
-
const result = await newOverlayTable.update({ operation: 'insert', values, preCoerced: true });
|
|
772
|
-
if (isConstraintViolation(result)) {
|
|
773
|
-
throw new QuereusError(`Overlay rebuild on '${tableName}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Runs one overlay's rebuild and installs the result, routing a CONSTRAINT failure by who
|
|
778
|
-
* owns the overlay:
|
|
779
|
-
*
|
|
780
|
-
* - **The DDL-issuing connection.** Unreachable: the row source handed to the underlying
|
|
781
|
-
* (see {@link issuerEffectiveRows}) judged a superset of exactly these rows and accepted
|
|
782
|
-
* them, so a rejection here means validation and migration have drifted. Raise INTERNAL —
|
|
783
|
-
* loudly, because the alternative is the silent row loss this guard exists to end.
|
|
784
|
-
* - **A foreign connection.** Reachable and legitimate: its staged rows may violate a
|
|
785
|
-
* constraint another connection just declared. Poison that overlay and leave it
|
|
786
|
-
* unmigrated, so its owner errors on its next read/write/commit and rolls back. The
|
|
787
|
-
* issuer's DDL proceeds. Mirrors the tier-3 NOT NULL handling in {@link alterTable}.
|
|
788
|
-
*
|
|
789
|
-
* A rebuild that throws leaves the OLD overlay installed either way — the new table is
|
|
790
|
-
* simply discarded.
|
|
772
|
+
* The INTERNAL raised when the DDL-ISSUING connection's own overlay rejects the change the
|
|
773
|
+
* DDL just made (routed through {@link applyInPlaceOverlayChange} by the index paths and
|
|
774
|
+
* every ALTER TABLE forward).
|
|
775
|
+
*
|
|
776
|
+
* Unreachable by construction: the row source handed to the underlying (see
|
|
777
|
+
* {@link issuerEffectiveRows}) judged a superset of exactly these rows and accepted them,
|
|
778
|
+
* so a rejection here means validation and migration have drifted. Raise loudly — the
|
|
779
|
+
* alternative is the silent row loss these guards exist to end.
|
|
791
780
|
*/
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
catch (e) {
|
|
798
|
-
if (!(e instanceof QuereusError) || e.code !== StatusCode.CONSTRAINT)
|
|
799
|
-
throw e;
|
|
800
|
-
if (isIssuer) {
|
|
801
|
-
throw new QuereusError(`Isolation layer: rebuilding the issuing connection's overlay for '${schemaName}.${tableName}' after `
|
|
802
|
-
+ `${ddlDescription} raised: ${e.message}. That DDL's validation pass already judged a superset of these `
|
|
803
|
-
+ `rows and accepted them, so validation and migration have drifted.`, StatusCode.INTERNAL, e);
|
|
804
|
-
}
|
|
805
|
-
// Poisoned: the OLD overlay stays installed under `key` (unmigrated, unreleased) so
|
|
806
|
-
// its owner errors and rolls back — do NOT release it here.
|
|
807
|
-
oldState.poison = { message: this.buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
// Rebuild succeeded: the NEW overlay replaces the OLD one under `key`, so the OLD
|
|
811
|
-
// staging table is abandoned — free it (leak sink). The builders already freed a
|
|
812
|
-
// HALF-built new overlay on their own throw path, so nothing is double-released.
|
|
813
|
-
this.connectionOverlays.set(key, rebuilt);
|
|
814
|
-
await this.releaseOverlayTable(oldState);
|
|
781
|
+
issuerOverlayDriftError(schemaName, tableName, ddlDescription, cause) {
|
|
782
|
+
return new QuereusError(`Isolation layer: applying ${ddlDescription} to the issuing connection's overlay for `
|
|
783
|
+
+ `'${schemaName}.${tableName}' raised: ${cause.message}. That DDL's validation pass already judged a `
|
|
784
|
+
+ `superset of these rows and accepted them, so validation and migration have drifted.`, StatusCode.INTERNAL, cause);
|
|
815
785
|
}
|
|
816
786
|
/**
|
|
817
|
-
* Builds the poison message stamped onto a foreign overlay whose staged rows cannot
|
|
818
|
-
*
|
|
819
|
-
* {@link
|
|
820
|
-
* the
|
|
821
|
-
* covers a UNIQUE (or any other) violation raised by the
|
|
787
|
+
* Builds the poison message stamped onto a foreign overlay whose staged rows cannot adopt
|
|
788
|
+
* a constraint another connection's DDL just declared (see
|
|
789
|
+
* {@link applyInPlaceOverlayChange}). Companion to {@link buildAlterPoisonMessage}, which
|
|
790
|
+
* covers the data conditions the pre-validation pass rejects before any overlay is touched;
|
|
791
|
+
* this one covers a UNIQUE (or any other) violation raised by the in-place adoption itself.
|
|
822
792
|
*/
|
|
823
793
|
buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, cause) {
|
|
824
794
|
return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
|
|
@@ -896,9 +866,9 @@ export class IsolationModule {
|
|
|
896
866
|
return this.effectiveRowsFor(db, underlyingTable, overlayState);
|
|
897
867
|
}
|
|
898
868
|
/**
|
|
899
|
-
* Creates an index on the underlying table, then
|
|
900
|
-
*
|
|
901
|
-
* the rest of each open transaction.
|
|
869
|
+
* Creates an index on the underlying table, then hands the same index to every
|
|
870
|
+
* per-connection overlay so it (and, for a UNIQUE index, the constraint derived from it)
|
|
871
|
+
* is enforced for the rest of each open transaction.
|
|
902
872
|
*
|
|
903
873
|
* Two things the underlying cannot do for itself:
|
|
904
874
|
*
|
|
@@ -910,8 +880,8 @@ export class IsolationModule {
|
|
|
910
880
|
* 2. **Enforce the new constraint.** An overlay built before the index knows nothing of it,
|
|
911
881
|
* and `IsolatedTable.findMergedUniqueConflict` only scans the underlying — so a pending
|
|
912
882
|
* row colliding with another pending row is nobody's job until the overlay itself carries
|
|
913
|
-
* the index.
|
|
914
|
-
*
|
|
883
|
+
* the index. Forwarding is also what gives a merged secondary-index scan later in the
|
|
884
|
+
* transaction an overlay that can serve it.
|
|
915
885
|
*
|
|
916
886
|
* We use the stored table instance's createIndex() rather than the module-level method so
|
|
917
887
|
* that the MemoryTable's local tableSchema property stays in sync. That property is what
|
|
@@ -930,12 +900,39 @@ export class IsolationModule {
|
|
|
930
900
|
await this.underlying.createIndex(db, schemaName, tableName, indexSchema, rowSource);
|
|
931
901
|
}
|
|
932
902
|
else {
|
|
933
|
-
return; // underlying does not support indexes; nothing was created, nothing to
|
|
903
|
+
return; // underlying does not support indexes; nothing was created, nothing to forward
|
|
934
904
|
}
|
|
935
905
|
if (!state)
|
|
936
906
|
return;
|
|
907
|
+
// Take the index back off the underlying's refreshed schema rather than reusing the
|
|
908
|
+
// caller's object: that is the canonical post-create form (resolved column indices,
|
|
909
|
+
// any normalization the underlying applied), and the overlay's copy is derived from it.
|
|
937
910
|
const updatedSchema = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
|
|
938
|
-
|
|
911
|
+
const created = updatedSchema.indexes.find(idx => idx.name.toLowerCase() === indexSchema.name.toLowerCase());
|
|
912
|
+
await this.applyIndexChangeToOverlays(db, schemaName, tableName, `create index '${indexSchema.name}'`, overlayState => this.createOverlayIndex(overlayState, updatedSchema.name, created));
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Adds one index to an already-open overlay, in the overlay's own flavor (predicate
|
|
916
|
+
* narrowed to live rows and rescoped onto the overlay's table name — see
|
|
917
|
+
* {@link createOverlayIndexSchema}).
|
|
918
|
+
*
|
|
919
|
+
* A UNIQUE index the overlay's staged rows violate raises `CONSTRAINT` out of
|
|
920
|
+
* `MemoryTableManager.createIndex`'s pre-validation pass, which
|
|
921
|
+
* {@link applyIndexChangeToOverlays} routes to INTERNAL (issuer) or poison (foreign).
|
|
922
|
+
* The pass runs before any mutation, so a rejected overlay is left exactly as it was.
|
|
923
|
+
*
|
|
924
|
+
* No-ops when the overlay module has no index support, or when the overlay already
|
|
925
|
+
* carries an index of that name (an overlay built after the underlying's create already
|
|
926
|
+
* copied it in through {@link createOverlaySchema}).
|
|
927
|
+
*/
|
|
928
|
+
async createOverlayIndex(overlayState, baseName, indexSchema) {
|
|
929
|
+
const overlayTable = overlayState.overlayTable;
|
|
930
|
+
const overlaySchema = overlayTable.tableSchema;
|
|
931
|
+
if (!overlayTable.createIndex || !overlaySchema)
|
|
932
|
+
return;
|
|
933
|
+
if (this.schemaHasIndex(overlaySchema, indexSchema.name))
|
|
934
|
+
return;
|
|
935
|
+
await overlayTable.createIndex(this.createOverlayIndexSchema(indexSchema, baseName, overlaySchema.name));
|
|
939
936
|
}
|
|
940
937
|
/**
|
|
941
938
|
* Reads back the underlying table instance's post-`createIndex` schema, asserting it now
|
|
@@ -949,7 +946,7 @@ export class IsolationModule {
|
|
|
949
946
|
*/
|
|
950
947
|
assertIndexPresent(underlyingTable, schemaName, tableName, indexName) {
|
|
951
948
|
const updatedSchema = underlyingTable.tableSchema;
|
|
952
|
-
const present = updatedSchema
|
|
949
|
+
const present = updatedSchema && this.schemaHasIndex(updatedSchema, indexName);
|
|
953
950
|
if (!updatedSchema || !present) {
|
|
954
951
|
throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
|
|
955
952
|
+ `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
|
|
@@ -957,26 +954,19 @@ export class IsolationModule {
|
|
|
957
954
|
}
|
|
958
955
|
return updatedSchema;
|
|
959
956
|
}
|
|
957
|
+
/** Case-insensitive "does `schema` declare an index named `indexName`". */
|
|
958
|
+
schemaHasIndex(schema, indexName) {
|
|
959
|
+
const lower = indexName.toLowerCase();
|
|
960
|
+
return schema.indexes?.some(idx => idx.name.toLowerCase() === lower) ?? false;
|
|
961
|
+
}
|
|
960
962
|
/**
|
|
961
|
-
* Drops an index on the underlying table.
|
|
963
|
+
* Drops an index on the underlying table, then drops it from every per-connection overlay.
|
|
962
964
|
*
|
|
963
965
|
* Mirrors createIndex: when the underlying VirtualTable exposes an
|
|
964
966
|
* instance-level dropIndex (e.g. MemoryTable, which forwards to its manager
|
|
965
967
|
* so MemoryTable.tableSchema stays fresh), prefer that. Otherwise fall back
|
|
966
968
|
* to the module-level dropIndex (e.g. StoreModule, which refreshes the
|
|
967
969
|
* StoreTable's cached tableSchema and tears down the index store).
|
|
968
|
-
*
|
|
969
|
-
* Any per-connection overlay that already exists for this table is
|
|
970
|
-
* rebuilt under the post-drop schema, preserving staged rows. A bare
|
|
971
|
-
* forward to `overlay.dropIndex` is insufficient: when the overlay's
|
|
972
|
-
* MemoryTable has an active write `TransactionLayer`, its
|
|
973
|
-
* `tableSchemaAtCreation` is frozen at layer-creation time, so the
|
|
974
|
-
* synthesized UNIQUE constraint keeps firing inside the overlay's
|
|
975
|
-
* own UC check on the next write even after the manager's schema is
|
|
976
|
-
* refreshed. Rebuilding gives the new MemoryTable a fresh
|
|
977
|
-
* transaction layer that captures the post-drop schema. Overlays
|
|
978
|
-
* created AFTER this point inherit the post-drop schema from the
|
|
979
|
-
* underlying at ensureOverlay time.
|
|
980
970
|
*/
|
|
981
971
|
async dropIndex(db, schemaName, tableName, indexName) {
|
|
982
972
|
const state = this.getUnderlyingState(schemaName, tableName);
|
|
@@ -986,78 +976,114 @@ export class IsolationModule {
|
|
|
986
976
|
else if (this.underlying.dropIndex) {
|
|
987
977
|
await this.underlying.dropIndex(db, schemaName, tableName, indexName);
|
|
988
978
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
979
|
+
await this.applyIndexChangeToOverlays(db, schemaName, tableName, `drop index '${indexName}'`, overlayState => this.dropOverlayIndex(overlayState, indexName));
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Drops one index (and the UNIQUE constraint derived from it) from an already-open
|
|
983
|
+
* overlay. No-ops when the overlay module has no index support, or when the overlay never
|
|
984
|
+
* carried the index.
|
|
985
|
+
*/
|
|
986
|
+
async dropOverlayIndex(overlayState, indexName) {
|
|
987
|
+
const overlayTable = overlayState.overlayTable;
|
|
988
|
+
const overlaySchema = overlayTable.tableSchema;
|
|
989
|
+
if (!overlayTable.dropIndex || !overlaySchema)
|
|
994
990
|
return;
|
|
995
|
-
|
|
991
|
+
if (!this.schemaHasIndex(overlaySchema, indexName))
|
|
992
|
+
return;
|
|
993
|
+
await overlayTable.dropIndex(indexName);
|
|
996
994
|
}
|
|
997
995
|
/**
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
996
|
+
* Applies an index change (CREATE INDEX or DROP INDEX) IN PLACE to every non-poisoned
|
|
997
|
+
* per-connection overlay of one table.
|
|
998
|
+
*
|
|
999
|
+
* In place, not by rebuild. Both paths used to discard the overlay and copy its staged
|
|
1000
|
+
* rows into a fresh `MemoryTable`, which silently destroyed the overlay's savepoint chain:
|
|
1001
|
+
* the copy's first write lazily registers the new overlay's connection, and
|
|
1002
|
+
* `Database.registerConnection` replays `begin()` plus the whole active savepoint stack
|
|
1003
|
+
* BEFORE the copy runs — so every copied row landed ABOVE the replayed savepoint and the
|
|
1004
|
+
* next `rollback to savepoint` discarded rows staged long before that savepoint was taken
|
|
1005
|
+
* (`bug-isolation-index-ddl-rebuild-drops-savepoint-writes`).
|
|
1006
|
+
*
|
|
1007
|
+
* The rebuild was originally forced by a memory-module limitation — an open write
|
|
1008
|
+
* `TransactionLayer` froze its schema at creation, so a bare `overlay.dropIndex` left the
|
|
1009
|
+
* synthesized UNIQUE constraint firing inside the layer. `TransactionLayer.adoptSchema`
|
|
1010
|
+
* now has both an additive and a removal branch, and `MemoryTableManager` calls it for
|
|
1011
|
+
* both index directions, so an open layer adopts the change with its savepoint snapshots
|
|
1012
|
+
* intact.
|
|
1013
|
+
*
|
|
1014
|
+
* CONSTRAINT is routed by who owns the overlay via {@link applyInPlaceOverlayChange},
|
|
1015
|
+
* shared with every ALTER TABLE forward.
|
|
1016
|
+
*
|
|
1017
|
+
* A poisoned overlay is skipped: it holds rows in a pre-ALTER column layout and its owner
|
|
1018
|
+
* must roll back regardless, so there is nothing to keep enforcing for it.
|
|
1007
1019
|
*/
|
|
1008
|
-
async
|
|
1020
|
+
async applyIndexChangeToOverlays(db, schemaName, tableName, ddlDescription, apply) {
|
|
1009
1021
|
const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
1010
1022
|
for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
|
|
1011
1023
|
const overlayState = this.connectionOverlays.get(key);
|
|
1012
1024
|
if (overlayState.poison)
|
|
1013
1025
|
continue;
|
|
1014
|
-
await this.
|
|
1026
|
+
await this.applyInPlaceOverlayChange(key === ownKey, overlayState, schemaName, tableName, ddlDescription, () => apply(overlayState));
|
|
1015
1027
|
}
|
|
1016
1028
|
}
|
|
1017
1029
|
/**
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* the
|
|
1021
|
-
*
|
|
1030
|
+
* Applies one in-place DDL mutation to a single overlay, routing a CONSTRAINT failure
|
|
1031
|
+
* by who owns the overlay: the issuer's own overlay → INTERNAL (its rows were already
|
|
1032
|
+
* judged by the DDL's own validation pass, so a rejection here means validation and
|
|
1033
|
+
* migration have drifted — {@link issuerOverlayDriftError}); a foreign overlay →
|
|
1034
|
+
* poison and leave it untouched so its owner errors and rolls back
|
|
1035
|
+
* ({@link buildRebuildPoisonMessage}). Any non-CONSTRAINT failure is a layer-invariant
|
|
1036
|
+
* violation, not a data condition, and rethrows for everyone.
|
|
1037
|
+
*
|
|
1038
|
+
* Shared by the index paths ({@link applyIndexChangeToOverlays}) and the ALTER TABLE
|
|
1039
|
+
* column-shape forwards ({@link alterTable}), so the two cannot drift on error routing.
|
|
1022
1040
|
*/
|
|
1023
|
-
async
|
|
1024
|
-
const oldOverlay = oldState.overlayTable;
|
|
1025
|
-
const newOverlaySchema = this.createOverlaySchema(updatedSchema);
|
|
1026
|
-
const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
|
|
1027
|
-
const newState = { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges, db };
|
|
1041
|
+
async applyInPlaceOverlayChange(isIssuer, overlayState, schemaName, tableName, ddlDescription, apply) {
|
|
1028
1042
|
try {
|
|
1029
|
-
|
|
1030
|
-
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1031
|
-
await this.insertIntoRebuiltOverlay(newOverlayTable, oldRow, updatedSchema.name);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1043
|
+
await apply();
|
|
1034
1044
|
}
|
|
1035
1045
|
catch (e) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1046
|
+
if (!(e instanceof QuereusError) || e.code !== StatusCode.CONSTRAINT)
|
|
1047
|
+
throw e;
|
|
1048
|
+
if (isIssuer)
|
|
1049
|
+
throw this.issuerOverlayDriftError(schemaName, tableName, ddlDescription, e);
|
|
1050
|
+
overlayState.poison = { message: this.buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
|
|
1041
1051
|
}
|
|
1042
|
-
return newState;
|
|
1043
1052
|
}
|
|
1044
1053
|
/**
|
|
1045
|
-
* Delegates ALTER TABLE to the underlying module and
|
|
1054
|
+
* Delegates ALTER TABLE to the underlying module and carries any per-connection
|
|
1046
1055
|
* overlays to the post-alter schema without discarding staged rows.
|
|
1047
1056
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1057
|
+
* Every change type forwards to each overlay IN PLACE — through the overlay's own
|
|
1058
|
+
* `alterSchema` / `createIndex` / ordinary writes — so the overlay's layer chain and
|
|
1059
|
+
* savepoint snapshots survive the ALTER and `rollback to savepoint` keeps
|
|
1060
|
+
* distinguishing rows staged before the savepoint from rows staged after it (see
|
|
1061
|
+
* {@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}).
|
|
1053
1079
|
*
|
|
1054
1080
|
* **Atomicity guarantee.** DDL through Quereus is not transaction-scoped and the
|
|
1055
1081
|
* underlying (shared, committed) base auto-commits its mutation immediately —
|
|
1056
1082
|
* there is no frame to unwind, and `dropColumn` / type-converting `alterColumn`
|
|
1057
1083
|
* are lossy and not invertible, so "revert the underlying on overlay-migration
|
|
1058
1084
|
* failure" is not viable. Instead this method **pre-validates** every affected
|
|
1059
|
-
* overlay's
|
|
1060
|
-
* BEFORE calling `underlying.alterTable`. A rejection therefore fires while the
|
|
1085
|
+
* overlay's migration (the per-row NOT NULL check, the per-value retype conversion, and
|
|
1086
|
+
* the tombstone-present guard) BEFORE calling `underlying.alterTable`. A rejection therefore fires while the
|
|
1061
1087
|
* underlying, the schema catalog, and every overlay are still untouched, so the
|
|
1062
1088
|
* ALTER either fails clean or fully applies — base/catalog can no longer diverge.
|
|
1063
1089
|
* This mirrors the engine's pre-mutation `validateNotNullBackfill` in
|
|
@@ -1098,16 +1124,20 @@ export class IsolationModule {
|
|
|
1098
1124
|
foreign.push([key, state]);
|
|
1099
1125
|
}
|
|
1100
1126
|
}
|
|
1101
|
-
// Overlays we will actually migrate forward (issuer-own first). The
|
|
1102
|
-
//
|
|
1103
|
-
// overlay whose schema may be a stale pre-alter layout.
|
|
1127
|
+
// Overlays we will actually migrate forward (issuer-own first). The setNotNull /
|
|
1128
|
+
// setDataType contexts below are probed from one of these, never from a skipped
|
|
1129
|
+
// poisoned overlay whose schema may be a stale pre-alter layout.
|
|
1104
1130
|
const toMigrate = ownEntry ? [ownEntry, ...foreign] : foreign;
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1131
|
+
// 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 —
|
|
1135
|
+
// before the underlying mutates — so the ALTER fails atomically rather than
|
|
1136
|
+
// stranding rows it cannot re-key. Foreign overlays with staged rows are poisoned
|
|
1137
|
+
// after the underlying applies, and clean overlays are swapped for a fresh staging
|
|
1138
|
+
// table (see replaceOverlayForPrimaryKeyChange).
|
|
1139
|
+
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);
|
|
1111
1141
|
}
|
|
1112
1142
|
// Build the addColumn backfill context up front (undefined for other change types).
|
|
1113
1143
|
// Derived purely from `change` + the session nullability option — no post-alter
|
|
@@ -1117,14 +1147,18 @@ export class IsolationModule {
|
|
|
1117
1147
|
// Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
|
|
1118
1148
|
// with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
|
|
1119
1149
|
// from a to-be-migrated overlay's PRE-alter schema — the same layout every migrated overlay
|
|
1120
|
-
// shares
|
|
1121
|
-
const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate
|
|
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);
|
|
1122
1156
|
// Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
|
|
1123
|
-
// Any throw here (CONSTRAINT backfill or INTERNAL tombstone guard)
|
|
1124
|
-
// underlying + catalog + every overlay are still untouched — the companion
|
|
1125
|
-
// atomic-abort guarantee, preserved unchanged for the issuer.
|
|
1157
|
+
// Any throw here (CONSTRAINT backfill, MISMATCH conversion, or INTERNAL tombstone guard)
|
|
1158
|
+
// propagates while underlying + catalog + every overlay are still untouched — the companion
|
|
1159
|
+
// ticket's atomic-abort guarantee, preserved unchanged for the issuer.
|
|
1126
1160
|
if (ownEntry) {
|
|
1127
|
-
await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx);
|
|
1161
|
+
await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx);
|
|
1128
1162
|
}
|
|
1129
1163
|
const underlyingState = this.getUnderlyingState(schemaName, tableName);
|
|
1130
1164
|
// Hand the underlying the issuer's effective rows so its own row-content checks
|
|
@@ -1144,32 +1178,70 @@ export class IsolationModule {
|
|
|
1144
1178
|
if (underlyingState)
|
|
1145
1179
|
underlyingState.underlyingTable.tableSchema = updated;
|
|
1146
1180
|
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.
|
|
1185
|
+
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
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1147
1216
|
// Migrate the issuer's own overlay (already validated above). Its NOT NULL /
|
|
1148
1217
|
// tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
|
|
1149
|
-
// rejection, which `rowSource` already judged —
|
|
1218
|
+
// rejection, which `rowSource` already judged — the CONSTRAINT routing raises
|
|
1150
1219
|
// INTERNAL if one fires anyway rather than dropping the row.
|
|
1151
1220
|
if (ownEntry) {
|
|
1152
|
-
await
|
|
1221
|
+
await migrateOverlay(ownEntry[0], ownEntry[1], true);
|
|
1153
1222
|
}
|
|
1154
1223
|
// Tier 3: per FOREIGN overlay, validate then migrate — but a per-row NOT NULL
|
|
1155
1224
|
// (CONSTRAINT) failure poisons that one overlay instead of aborting the issuer's
|
|
1156
1225
|
// ALTER, as does a UNIQUE the migration itself raises (its staged rows may violate a
|
|
1157
|
-
// constraint the issuer just declared)
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1226
|
+
// constraint the issuer just declared) and an unconvertible staged value under a retype
|
|
1227
|
+
// (MISMATCH). Those three are data conditions of ONE connection's uncommitted rows, and
|
|
1228
|
+
// the underlying has already been mutated by this point, so rethrowing any of them would
|
|
1229
|
+
// abort the issuer's ALTER after the fact — exactly the divergence this tiering exists to
|
|
1230
|
+
// prevent. An INTERNAL failure (e.g. missing tombstone column) is a layer-invariant
|
|
1231
|
+
// violation, not a data condition, so it rethrows loud for everyone. Both phases run per
|
|
1232
|
+
// overlay, so one bad foreign overlay poisons only itself; healthy peers still migrate.
|
|
1161
1233
|
for (const [key, oldState] of foreign) {
|
|
1162
1234
|
try {
|
|
1163
|
-
await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx);
|
|
1235
|
+
await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx);
|
|
1164
1236
|
}
|
|
1165
1237
|
catch (e) {
|
|
1166
|
-
if (e instanceof QuereusError && e.code === StatusCode.CONSTRAINT) {
|
|
1238
|
+
if (e instanceof QuereusError && (e.code === StatusCode.CONSTRAINT || e.code === StatusCode.MISMATCH)) {
|
|
1167
1239
|
oldState.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
|
|
1168
1240
|
continue; // poisoned — do NOT migrate; leave pre-alter rows in place
|
|
1169
1241
|
}
|
|
1170
1242
|
throw e;
|
|
1171
1243
|
}
|
|
1172
|
-
await
|
|
1244
|
+
await migrateOverlay(key, oldState, false);
|
|
1173
1245
|
}
|
|
1174
1246
|
return updated;
|
|
1175
1247
|
}
|
|
@@ -1178,8 +1250,9 @@ export class IsolationModule {
|
|
|
1178
1250
|
* satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
|
|
1179
1251
|
* schema.table and the offending column so the owning connection's eventual
|
|
1180
1252
|
* read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
|
|
1181
|
-
* path (a new NOT-NULL column with no usable default)
|
|
1182
|
-
* path (a staged NULL with no usable default)
|
|
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
|
|
1183
1256
|
* are handled defensively.
|
|
1184
1257
|
*/
|
|
1185
1258
|
buildAlterPoisonMessage(schemaName, tableName, change) {
|
|
@@ -1187,8 +1260,14 @@ export class IsolationModule {
|
|
|
1187
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.`;
|
|
1188
1261
|
}
|
|
1189
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
|
+
}
|
|
1190
1266
|
return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
|
|
1191
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
|
+
}
|
|
1192
1271
|
return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
|
|
1193
1272
|
}
|
|
1194
1273
|
/**
|
|
@@ -1307,48 +1386,11 @@ export class IsolationModule {
|
|
|
1307
1386
|
}
|
|
1308
1387
|
return oldKeys.length;
|
|
1309
1388
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
* {@link insertIntoRebuiltOverlay} rather than being silently discarded; the caller
|
|
1316
|
-
* ({@link adoptRebuiltOverlay}) maps that to INTERNAL or poison.
|
|
1317
|
-
*/
|
|
1318
|
-
async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx, setNotNullCtx) {
|
|
1319
|
-
const oldOverlay = oldState.overlayTable;
|
|
1320
|
-
const oldOverlaySchema = oldOverlay.tableSchema;
|
|
1321
|
-
const newOverlaySchema = this.createOverlaySchema(updatedSchema);
|
|
1322
|
-
const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
|
|
1323
|
-
const newState = { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges, db };
|
|
1324
|
-
try {
|
|
1325
|
-
if (oldState.hasChanges && oldOverlaySchema && oldOverlay.query) {
|
|
1326
|
-
const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
|
|
1327
|
-
if (oldTombstoneIdx === undefined) {
|
|
1328
|
-
throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
|
|
1329
|
-
}
|
|
1330
|
-
// `addColumnCtx` (folded literal default, the per-row evaluator, and the new
|
|
1331
|
-
// column's NOT NULL flag) was precomputed once per ALTER by the caller and already
|
|
1332
|
-
// dry-run validated against these same staged rows; undefined for non-addColumn
|
|
1333
|
-
// change types, which append nothing to staged rows.
|
|
1334
|
-
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1335
|
-
const addColumnValue = addColumnCtx
|
|
1336
|
-
? await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx)
|
|
1337
|
-
: undefined;
|
|
1338
|
-
const newRow = this.translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue, setNotNullCtx);
|
|
1339
|
-
await this.insertIntoRebuiltOverlay(newOverlayTable, newRow, updatedSchema.name);
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
catch (e) {
|
|
1344
|
-
// Any throw after create abandons this freshly built overlay (adoptRebuiltOverlay
|
|
1345
|
-
// keeps the OLD one installed on its throw path and never holds this handle), so
|
|
1346
|
-
// free it here or it leaks.
|
|
1347
|
-
await this.releaseOverlayTable(newState);
|
|
1348
|
-
throw e;
|
|
1349
|
-
}
|
|
1350
|
-
return newState;
|
|
1351
|
-
}
|
|
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.
|
|
1352
1394
|
/**
|
|
1353
1395
|
* Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
|
|
1354
1396
|
* the folded literal DEFAULT (the `tryFoldLiteral` of the DEFAULT expr, or `null`
|
|
@@ -1393,12 +1435,12 @@ export class IsolationModule {
|
|
|
1393
1435
|
* needs no staged-row work, and with no overlays there is nothing to backfill or reject.
|
|
1394
1436
|
*
|
|
1395
1437
|
* `change` for `set not null` carries no default expression, so the DEFAULT is read from the
|
|
1396
|
-
* column's PRE-alter schema (via a to-be-migrated overlay — the same
|
|
1397
|
-
*
|
|
1438
|
+
* column's PRE-alter schema (via a to-be-migrated overlay — the same layout every migrated
|
|
1439
|
+
* overlay shares). Folded exactly as
|
|
1398
1440
|
* {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
|
|
1399
1441
|
* cannot drift from what the underlying enforces over its committed rows.
|
|
1400
1442
|
*/
|
|
1401
|
-
deriveSetNotNullBackfill(change, toMigrate
|
|
1443
|
+
deriveSetNotNullBackfill(change, toMigrate) {
|
|
1402
1444
|
if (change.type !== 'alterColumn' || change.setNotNull !== true)
|
|
1403
1445
|
return undefined;
|
|
1404
1446
|
if (toMigrate.length === 0)
|
|
@@ -1419,7 +1461,54 @@ export class IsolationModule {
|
|
|
1419
1461
|
foldedDefault: hasDefault ? folded : null,
|
|
1420
1462
|
hasDefault,
|
|
1421
1463
|
colName: change.columnName,
|
|
1422
|
-
|
|
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
|
+
},
|
|
1423
1512
|
};
|
|
1424
1513
|
}
|
|
1425
1514
|
/**
|
|
@@ -1439,14 +1528,22 @@ export class IsolationModule {
|
|
|
1439
1528
|
* For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
|
|
1440
1529
|
* non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
|
|
1441
1530
|
* aborts atomically before the underlying mutates; for a foreign overlay the caller maps it to
|
|
1442
|
-
* poison. With a usable DEFAULT the staged NULLs are backfilled by
|
|
1443
|
-
* so nothing is rejected here.
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
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.
|
|
1448
1545
|
*/
|
|
1449
|
-
async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx) {
|
|
1546
|
+
async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx) {
|
|
1450
1547
|
const oldOverlay = oldState.overlayTable;
|
|
1451
1548
|
const oldOverlaySchema = oldOverlay.tableSchema;
|
|
1452
1549
|
// Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
|
|
@@ -1464,16 +1561,35 @@ export class IsolationModule {
|
|
|
1464
1561
|
}
|
|
1465
1562
|
return;
|
|
1466
1563
|
}
|
|
1467
|
-
// SET NOT NULL with no usable DEFAULT: reject a staged NULL the
|
|
1468
|
-
// (With a DEFAULT there is nothing to reject —
|
|
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.)
|
|
1469
1566
|
if (setNotNullCtx && !setNotNullCtx.hasDefault) {
|
|
1470
|
-
for await (const oldRow of
|
|
1471
|
-
if (oldRow[oldTombstoneIdx] === 1)
|
|
1472
|
-
continue; // tombstone: placeholder NULLs, not a row
|
|
1567
|
+
for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
|
|
1473
1568
|
if (oldRow[setNotNullCtx.colIndex] === null) {
|
|
1474
1569
|
throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
|
|
1475
1570
|
}
|
|
1476
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;
|
|
1477
1593
|
}
|
|
1478
1594
|
}
|
|
1479
1595
|
/**
|
|
@@ -1503,63 +1619,295 @@ export class IsolationModule {
|
|
|
1503
1619
|
return ctx.foldedDefault;
|
|
1504
1620
|
}
|
|
1505
1621
|
/**
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
* `
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
+
/**
|
|
1877
|
+
* Carries one overlay across an ALTER PRIMARY KEY, which no overlay can follow in place:
|
|
1878
|
+
* its layer BTrees are keyed by the OLD primary key, and a staged tombstone identifies the
|
|
1879
|
+
* row it deletes BY that key — under a new key a tombstone's identity columns may be
|
|
1880
|
+
* placeholder NULLs, i.e. garbage. So:
|
|
1881
|
+
*
|
|
1882
|
+
* - An overlay WITH staged rows is either the ISSUER's — unreachable, {@link alterTable}
|
|
1883
|
+
* rejected it before the underlying mutated, so assert INTERNAL — or FOREIGN, which is
|
|
1884
|
+
* poisoned exactly like a foreign overlay whose staged values a retype cannot convert.
|
|
1885
|
+
* - A CLEAN overlay stages nothing; it is swapped for a fresh empty staging table built
|
|
1886
|
+
* from the post-alter schema, so later writes in this transaction key by the new primary
|
|
1887
|
+
* key. The fresh table's connection registers lazily at its first write, replaying the
|
|
1888
|
+
* active savepoint stack — correct for a table with no pre-existing staged rows (the
|
|
1889
|
+
* replay hazard is only ever about rows copied beneath it, and there are none).
|
|
1890
|
+
*/
|
|
1891
|
+
async replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updatedSchema, change) {
|
|
1892
|
+
if (state.hasChanges) {
|
|
1893
|
+
if (isIssuer) {
|
|
1894
|
+
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);
|
|
1553
1895
|
}
|
|
1896
|
+
state.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
|
|
1897
|
+
return;
|
|
1554
1898
|
}
|
|
1555
|
-
|
|
1899
|
+
const fresh = await this.overlayModule.create(state.db, this.createOverlaySchema(updatedSchema));
|
|
1900
|
+
this.connectionOverlays.set(key, { overlayTable: fresh, hasChanges: false, db: state.db });
|
|
1901
|
+
await this.releaseOverlayTable(state);
|
|
1556
1902
|
}
|
|
1557
1903
|
/**
|
|
1558
1904
|
* Creates overlay schema from underlying schema.
|
|
1559
1905
|
* Adds tombstone column and uses unique name to avoid conflicts.
|
|
1560
1906
|
*
|
|
1561
|
-
* Called by IsolatedTable when lazily creating its overlay, and by the
|
|
1562
|
-
*
|
|
1907
|
+
* Called by IsolatedTable when lazily creating its overlay, and by the ALTER PRIMARY KEY
|
|
1908
|
+
* clean-overlay swap (`replaceOverlayForPrimaryKeyChange`). Index DDL and every other ALTER
|
|
1909
|
+
* adopt in place — a new UNIQUE arrives through {@link createOverlayIndexSchema} /
|
|
1910
|
+
* {@link overlayPredicate}, which this method shares.
|
|
1563
1911
|
*
|
|
1564
1912
|
* Every copied secondary index — and every copied UNIQUE constraint, including the ones
|
|
1565
1913
|
* a UNIQUE index derives — is narrowed to a PARTIAL structure over live rows only
|
|
@@ -1594,25 +1942,48 @@ export class IsolationModule {
|
|
|
1594
1942
|
// Use unique ID to avoid conflicts when multiple overlays exist
|
|
1595
1943
|
const overlayId = generateOverlayId();
|
|
1596
1944
|
const overlayName = `_overlay_${baseSchema.name}_${overlayId}`;
|
|
1597
|
-
const liveOnly = this.liveRowPredicate();
|
|
1598
|
-
// A partial-index / UNIQUE predicate copied from the base carries a self-qualifier
|
|
1599
|
-
// bound to the base table's name (e.g. `where t.v > 0`). The overlay renames the table
|
|
1600
|
-
// to `overlayName`, so that qualifier now names a DIFFERENT table than the overlay's
|
|
1601
|
-
// MemoryIndex is scoped to — and `compilePredicate` rejects a foreign qualifier at
|
|
1602
|
-
// index-build time (see partial-index-predicate table-qualifier rejection). Rescope the
|
|
1603
|
-
// self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
|
|
1604
|
-
// cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
|
|
1605
|
-
// was created, so every qualifier present is the base name.
|
|
1606
|
-
const rescope = (p) => p ? rescopePredicateQualifier(p, baseSchema.name, overlayName) : undefined;
|
|
1607
1945
|
return {
|
|
1608
1946
|
...baseSchema,
|
|
1609
1947
|
name: overlayName,
|
|
1610
1948
|
columns: newColumns,
|
|
1611
1949
|
columnIndexMap: newColumnIndexMap,
|
|
1612
|
-
indexes: baseSchema.indexes?.map(idx => (
|
|
1613
|
-
uniqueConstraints: baseSchema.uniqueConstraints?.map(uc => ({
|
|
1950
|
+
indexes: baseSchema.indexes?.map(idx => this.createOverlayIndexSchema(idx, baseSchema.name, overlayName)),
|
|
1951
|
+
uniqueConstraints: baseSchema.uniqueConstraints?.map(uc => ({
|
|
1952
|
+
...uc,
|
|
1953
|
+
predicate: this.overlayPredicate(uc.predicate, baseSchema.name, overlayName),
|
|
1954
|
+
})),
|
|
1614
1955
|
};
|
|
1615
1956
|
}
|
|
1957
|
+
/**
|
|
1958
|
+
* The overlay-flavored form of one base `IndexSchema`: same name and columns, predicate
|
|
1959
|
+
* narrowed to live rows and rescoped onto the overlay's table name (see
|
|
1960
|
+
* {@link overlayPredicate}).
|
|
1961
|
+
*
|
|
1962
|
+
* Shared by {@link createOverlaySchema}, which maps the whole index set when an overlay is
|
|
1963
|
+
* first created, and {@link createIndex}, which hands a single index to an overlay that is
|
|
1964
|
+
* already open — so both produce structurally identical entries, and the UNIQUE constraint
|
|
1965
|
+
* `MemoryTableManager.createIndex` synthesizes from a unique index inherits the same
|
|
1966
|
+
* predicate either way.
|
|
1967
|
+
*/
|
|
1968
|
+
createOverlayIndexSchema(idx, baseName, overlayName) {
|
|
1969
|
+
return { ...idx, predicate: this.overlayPredicate(idx.predicate, baseName, overlayName) };
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* `<base predicate, rescoped to the overlay> AND <tombstone> = 0`.
|
|
1973
|
+
*
|
|
1974
|
+
* A partial-index / UNIQUE predicate copied from the base carries a self-qualifier bound to
|
|
1975
|
+
* the base table's name (e.g. `where t.v > 0`). The overlay renames the table to
|
|
1976
|
+
* `overlayName`, so that qualifier now names a DIFFERENT table than the overlay's
|
|
1977
|
+
* MemoryIndex is scoped to — and `compilePredicate` rejects a foreign qualifier at
|
|
1978
|
+
* index-build time (see partial-index-predicate table-qualifier rejection). Rescope the
|
|
1979
|
+
* self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
|
|
1980
|
+
* cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
|
|
1981
|
+
* was created, so every qualifier present is the base name.
|
|
1982
|
+
*/
|
|
1983
|
+
overlayPredicate(base, baseName, overlayName) {
|
|
1984
|
+
const rescoped = base ? rescopePredicateQualifier(base, baseName, overlayName) : undefined;
|
|
1985
|
+
return andPredicate(rescoped, this.liveRowPredicate());
|
|
1986
|
+
}
|
|
1616
1987
|
/**
|
|
1617
1988
|
* `<tombstoneColumn> = 0` — the partial-structure predicate that scopes an overlay index
|
|
1618
1989
|
* or UNIQUE constraint to live rows. Built as an AST rather than parsed from text because
|