@quereus/isolation 4.4.0 → 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.
@@ -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, 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 rebuild), unbounded.
289
+ * entry per writing transaction (and one more per `alterPrimaryKey` overlay swap — the
290
+ * only DDL path left that replaces an overlay rather than adopting in place), unbounded.
290
291
  *
291
292
  * `MemoryTableManager.destroy` rolls back the overlay's own pending layer and clears its
292
293
  * connections; a later db-side teardown of the (now-detached) `MemoryVirtualTableConnection`
@@ -616,6 +617,20 @@ export class IsolationModule {
616
617
  async notifyLensDeployment(db, logicalSchemaName, snapshot) {
617
618
  await this.underlying.notifyLensDeployment?.(db, logicalSchemaName, snapshot);
618
619
  }
620
+ /**
621
+ * Forwards the view / materialized-view catalog-persistability veto to the underlying
622
+ * module.
623
+ *
624
+ * `allModules()` yields the REGISTERED module, which is this wrapper when a store is
625
+ * isolated — so without this forward an isolation-wrapped store would never be
626
+ * consulted and would keep the silent-drop bug (a view it cannot persist creates
627
+ * "successfully" and vanishes on reopen). Whether a catalog entry is encodable is a
628
+ * property of the definition text, not of the overlay, so a straight delegate is
629
+ * correct; the throw must propagate (that is the entire point of the hook).
630
+ */
631
+ assertCatalogObjectPersistable(db, kind, object) {
632
+ this.underlying.assertCatalogObjectPersistable?.(db, kind, object);
633
+ }
619
634
  /**
620
635
  * Delegates access plan selection to the underlying module.
621
636
  * This ensures the query planner knows about indexes and can generate
@@ -755,72 +770,28 @@ export class IsolationModule {
755
770
  }
756
771
  }
757
772
  /**
758
- * Inserts one staged row into a freshly built overlay, raising on anything but success.
759
- *
760
- * `MemoryTable.update` RETURNS a `constraint` status rather than throwing it. Every overlay
761
- * rebuild loop ignored that return, so a row the new schema forbids was dropped on the floor
762
- * and the transaction committed without it — silent data loss, reachable today by
763
- * `alter table … add constraint … unique` over pending duplicate rows. Convert it into a
764
- * throw; {@link adoptRebuiltOverlay} decides whether that means INTERNAL (the issuer, whose
765
- * rows the DDL's own validation pass already accepted) or poison (a foreign connection,
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.
773
+ * The INTERNAL raised when the DDL-ISSUING connection's own overlay rejects the change the
774
+ * DDL just made (routed through {@link applyInPlaceOverlayChange} by the index paths and
775
+ * every ALTER TABLE forward).
776
+ *
777
+ * Unreachable by construction: the row source handed to the underlying (see
778
+ * {@link issuerEffectiveRows}) judged a superset of exactly these rows and accepted them,
779
+ * so a rejection here means validation and migration have drifted. Raise loudly — the
780
+ * alternative is the silent row loss these guards exist to end.
791
781
  */
792
- async adoptRebuiltOverlay(key, oldState, isIssuer, schemaName, tableName, ddlDescription, rebuild) {
793
- let rebuilt;
794
- try {
795
- rebuilt = await rebuild();
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);
782
+ issuerOverlayDriftError(schemaName, tableName, ddlDescription, cause) {
783
+ return new QuereusError(`Isolation layer: applying ${ddlDescription} to the issuing connection's overlay for `
784
+ + `'${schemaName}.${tableName}' raised: ${cause.message}. That DDL's validation pass already judged a `
785
+ + `superset of these rows and accepted them, so validation and migration have drifted.`, StatusCode.INTERNAL, cause);
815
786
  }
816
787
  /**
817
- * Builds the poison message stamped onto a foreign overlay whose staged rows cannot be
818
- * migrated under a constraint another connection's DDL just declared (see
819
- * {@link adoptRebuiltOverlay}). Companion to {@link buildAlterPoisonMessage}, which covers
820
- * the `addColumn` NOT NULL backfill rejected before the underlying is touched; this one
821
- * covers a UNIQUE (or any other) violation raised by the rebuild itself.
788
+ * Builds the poison message stamped onto a foreign overlay whose staged rows cannot adopt
789
+ * a constraint another connection's DDL just declared (see
790
+ * {@link applyInPlaceOverlayChange}). Companion to {@link buildAlterPoisonMessage}, which
791
+ * covers the data conditions the pre-validation pass rejects before any overlay is touched;
792
+ * this one covers a UNIQUE (or any other) violation raised by the in-place adoption itself.
822
793
  */
823
- buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, cause) {
794
+ buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, cause) {
824
795
  return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
825
796
  + `uncommitted rows violate (${cause}); roll back this transaction.`;
826
797
  }
@@ -896,9 +867,9 @@ export class IsolationModule {
896
867
  return this.effectiveRowsFor(db, underlyingTable, overlayState);
897
868
  }
898
869
  /**
899
- * Creates an index on the underlying table, then rebuilds every per-connection overlay so
900
- * the new index (and, for a UNIQUE index, the constraint derived from it) is enforced for
901
- * the rest of each open transaction.
870
+ * Creates an index on the underlying table, then hands the same index to every
871
+ * per-connection overlay so it (and, for a UNIQUE index, the constraint derived from it)
872
+ * is enforced for the rest of each open transaction.
902
873
  *
903
874
  * Two things the underlying cannot do for itself:
904
875
  *
@@ -910,8 +881,8 @@ export class IsolationModule {
910
881
  * 2. **Enforce the new constraint.** An overlay built before the index knows nothing of it,
911
882
  * and `IsolatedTable.findMergedUniqueConflict` only scans the underlying — so a pending
912
883
  * row colliding with another pending row is nobody's job until the overlay itself carries
913
- * the index. Hence the rebuild, which is also what gives a merged secondary-index scan
914
- * later in the transaction an overlay that can serve it.
884
+ * the index. Forwarding is also what gives a merged secondary-index scan later in the
885
+ * transaction an overlay that can serve it.
915
886
  *
916
887
  * We use the stored table instance's createIndex() rather than the module-level method so
917
888
  * that the MemoryTable's local tableSchema property stays in sync. That property is what
@@ -930,35 +901,71 @@ export class IsolationModule {
930
901
  await this.underlying.createIndex(db, schemaName, tableName, indexSchema, rowSource);
931
902
  }
932
903
  else {
933
- return; // underlying does not support indexes; nothing was created, nothing to rebuild
904
+ return; // underlying does not support indexes; nothing was created, nothing to forward
934
905
  }
935
906
  if (!state)
936
907
  return;
937
- const updatedSchema = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
938
- await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `create index '${indexSchema.name}'`);
908
+ // Take the index back off the underlying's refreshed schema rather than reusing the
909
+ // caller's object: that is the canonical post-create form (resolved column indices,
910
+ // any normalization the underlying applied), and the overlay's copy is derived from it.
911
+ const { schema: updatedSchema, index: created } = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
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
- * Reads back the underlying table instance's post-`createIndex` schema, asserting it now
942
- * 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}).
943
941
  *
944
942
  * Both bundled underlyings refresh the instance's cached `tableSchema` (memory through
945
- * `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`), and the overlay
946
- * rebuild below copies its index/constraint set from it. A third-party underlying that
947
- * refreshed only its module-level schema would silently rebuild overlays under the PRE-index
948
- * schema, re-opening the very hole this method exists to close — so assert rather than assume.
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.
949
947
  */
950
948
  assertIndexPresent(underlyingTable, schemaName, tableName, indexName) {
951
- const updatedSchema = underlyingTable.tableSchema;
952
- const present = updatedSchema?.indexes?.some(idx => idx.name.toLowerCase() === indexName.toLowerCase());
953
- if (!updatedSchema || !present) {
949
+ const schema = underlyingTable.tableSchema;
950
+ const index = schema && this.findSchemaIndex(schema, indexName);
951
+ if (!schema || !index) {
954
952
  throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
955
953
  + `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
956
954
  + `report; the underlying module must refresh VirtualTable.tableSchema in createIndex.`, StatusCode.INTERNAL);
957
955
  }
958
- return updatedSchema;
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);
962
+ }
963
+ /** Case-insensitive "does `schema` declare an index named `indexName`". */
964
+ schemaHasIndex(schema, indexName) {
965
+ return this.findSchemaIndex(schema, indexName) !== undefined;
959
966
  }
960
967
  /**
961
- * Drops an index on the underlying table.
968
+ * Drops an index on the underlying table, then drops it from every per-connection overlay.
962
969
  *
963
970
  * Mirrors createIndex: when the underlying VirtualTable exposes an
964
971
  * instance-level dropIndex (e.g. MemoryTable, which forwards to its manager
@@ -966,17 +973,10 @@ export class IsolationModule {
966
973
  * to the module-level dropIndex (e.g. StoreModule, which refreshes the
967
974
  * StoreTable's cached tableSchema and tears down the index store).
968
975
  *
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.
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.
980
980
  */
981
981
  async dropIndex(db, schemaName, tableName, indexName) {
982
982
  const state = this.getUnderlyingState(schemaName, tableName);
@@ -986,78 +986,133 @@ export class IsolationModule {
986
986
  else if (this.underlying.dropIndex) {
987
987
  await this.underlying.dropIndex(db, schemaName, tableName, indexName);
988
988
  }
989
- // After the underlying drop, state.underlyingTable.tableSchema reflects the
990
- // post-drop schema. Rebuild every affected overlay against that schema so
991
- // the synthesized UC is fully gone from the overlay's transaction layer.
992
- const updatedSchema = state?.underlyingTable.tableSchema;
993
- if (!updatedSchema)
989
+ else {
990
+ return; // underlying cannot drop indexes; nothing was dropped, nothing to forward
991
+ }
992
+ await this.applyIndexChangeToOverlays(db, schemaName, tableName, `drop index '${indexName}'`, overlayState => this.dropOverlayIndex(overlayState, indexName));
993
+ }
994
+ /**
995
+ * Drops one index (and the UNIQUE constraint derived from it) from an already-open
996
+ * overlay. No-ops when the overlay module has no index support, or when the overlay never
997
+ * carried the index.
998
+ */
999
+ async dropOverlayIndex(overlayState, indexName) {
1000
+ const overlayTable = overlayState.overlayTable;
1001
+ const overlaySchema = overlayTable.tableSchema;
1002
+ if (!overlayTable.dropIndex || !overlaySchema)
1003
+ return;
1004
+ if (!this.schemaHasIndex(overlaySchema, indexName))
994
1005
  return;
995
- await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `drop index '${indexName}'`);
1006
+ await overlayTable.dropIndex(indexName);
996
1007
  }
997
1008
  /**
998
- * Rebuilds every non-poisoned per-connection overlay of one table under a schema whose
999
- * index / constraint set just changed (CREATE INDEX or DROP INDEX). Shared because the two
1000
- * directions differ only in which structures the new schema carries — the column layout is
1001
- * identical either way, so staged rows copy verbatim.
1002
- *
1003
- * A poisoned overlay (from a cross-connection ALTER, or a DROP TABLE) holds rows in the
1004
- * pre-alter column layout, narrower/wider than `updatedSchema`. Rebuilding it would copy
1005
- * layout-mismatched rows AND drop the poison flag (the new state carries none), silently
1006
- * un-poisoning a connection that must still roll back. Leave it as-is.
1009
+ * Applies an index change (CREATE INDEX or DROP INDEX) IN PLACE to every non-poisoned
1010
+ * per-connection overlay of one table.
1011
+ *
1012
+ * In place, not by rebuild. Both paths used to discard the overlay and copy its staged
1013
+ * rows into a fresh `MemoryTable`, which silently destroyed the overlay's savepoint chain:
1014
+ * the copy's first write lazily registers the new overlay's connection, and
1015
+ * `Database.registerConnection` replays `begin()` plus the whole active savepoint stack
1016
+ * BEFORE the copy runs — so every copied row landed ABOVE the replayed savepoint and the
1017
+ * next `rollback to savepoint` discarded rows staged long before that savepoint was taken
1018
+ * (`bug-isolation-index-ddl-rebuild-drops-savepoint-writes`).
1019
+ *
1020
+ * The rebuild was originally forced by a memory-module limitation — an open write
1021
+ * `TransactionLayer` froze its schema at creation, so a bare `overlay.dropIndex` left the
1022
+ * synthesized UNIQUE constraint firing inside the layer. `TransactionLayer.adoptSchema`
1023
+ * now has both an additive and a removal branch, and `MemoryTableManager` calls it for
1024
+ * both index directions, so an open layer adopts the change with its savepoint snapshots
1025
+ * intact.
1026
+ *
1027
+ * CONSTRAINT is routed by who owns the overlay via {@link applyInPlaceOverlayChange},
1028
+ * shared with every ALTER TABLE forward.
1029
+ *
1030
+ * A poisoned overlay is skipped: it holds rows in a pre-ALTER column layout and its owner
1031
+ * must roll back regardless, so there is nothing to keep enforcing for it.
1007
1032
  */
1008
- async rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, ddlDescription) {
1033
+ async applyIndexChangeToOverlays(db, schemaName, tableName, ddlDescription, apply) {
1009
1034
  const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
1010
1035
  for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
1011
1036
  const overlayState = this.connectionOverlays.get(key);
1012
1037
  if (overlayState.poison)
1013
1038
  continue;
1014
- await this.adoptRebuiltOverlay(key, overlayState, key === ownKey, schemaName, tableName, ddlDescription, () => this.rebuildOverlayForIndexChange(db, overlayState, updatedSchema));
1039
+ await this.applyInPlaceOverlayChange(key === ownKey, overlayState, schemaName, tableName, ddlDescription, () => apply(overlayState));
1015
1040
  }
1016
1041
  }
1017
1042
  /**
1018
- * Rebuilds an overlay table under a post-CREATE/DROP-INDEX schema, preserving staged rows
1019
- * (including tombstones). Column layout is unchanged by either, so rows copy verbatim — but
1020
- * the new overlay's index/constraint set is not, so an insert here can legitimately raise
1021
- * UNIQUE (a foreign connection staged two rows the new index forbids).
1043
+ * Applies one in-place DDL mutation to a single overlay, routing a CONSTRAINT failure
1044
+ * by who owns the overlay: the issuer's own overlay → INTERNAL (its rows were already
1045
+ * judged by the DDL's own validation pass, so a rejection here means validation and
1046
+ * migration have drifted — {@link issuerOverlayDriftError}); a foreign overlay →
1047
+ * poison and leave it untouched so its owner errors and rolls back
1048
+ * ({@link buildInPlaceAdoptPoisonMessage}). Any non-CONSTRAINT failure is a layer-invariant
1049
+ * violation, not a data condition, and rethrows for everyone.
1050
+ *
1051
+ * Shared by the index paths ({@link applyIndexChangeToOverlays}) and the ALTER TABLE
1052
+ * column-shape forwards ({@link alterTable}), so the two cannot drift on error routing.
1022
1053
  */
1023
- async rebuildOverlayForIndexChange(db, oldState, updatedSchema) {
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 };
1054
+ async applyInPlaceOverlayChange(isIssuer, overlayState, schemaName, tableName, ddlDescription, apply) {
1028
1055
  try {
1029
- if (oldState.hasChanges && oldOverlay.query) {
1030
- for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1031
- await this.insertIntoRebuiltOverlay(newOverlayTable, oldRow, updatedSchema.name);
1032
- }
1033
- }
1056
+ await apply();
1034
1057
  }
1035
1058
  catch (e) {
1036
- // A mid-copy throw (e.g. a UNIQUE the new index forbids) abandons this freshly
1037
- // built overlay: adoptRebuiltOverlay keeps the OLD overlay installed on its throw
1038
- // path and never sees this handle, so free it here or it leaks.
1039
- await this.releaseOverlayTable(newState);
1040
- throw e;
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))
1066
+ throw e;
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;
1074
+ throw this.issuerOverlayDriftError(schemaName, tableName, ddlDescription, e);
1075
+ }
1076
+ overlayState.poison = { message: this.buildInPlaceAdoptPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
1041
1077
  }
1042
- return newState;
1043
1078
  }
1044
1079
  /**
1045
- * Delegates ALTER TABLE to the underlying module and migrates any per-connection
1080
+ * Delegates ALTER TABLE to the underlying module and carries any per-connection
1046
1081
  * overlays to the post-alter schema without discarding staged rows.
1047
1082
  *
1048
- * ADD COLUMN — appends the new column's value to each overlay row's data columns,
1049
- * backfilled per row exactly as the committed path does (literal default,
1050
- * per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
1051
- * DROP COLUMN — removes the dropped column from each overlay row.
1052
- * RENAME / ALTER COLUMN — data column indices are unchanged; only schema metadata rotates.
1083
+ * Every change type forwards to each overlay IN PLACE — through the overlay's own
1084
+ * `alterSchema` / `createIndex` / ordinary writes — so the overlay's layer chain and
1085
+ * savepoint snapshots survive the ALTER and `rollback to savepoint` keeps
1086
+ * distinguishing rows staged before the savepoint from rows staged after it (see
1087
+ * {@link applyIndexChangeToOverlays} for why the old rebuild — copying staged rows
1088
+ * into a fresh staging table — destroyed that distinction). Per change type:
1089
+ *
1090
+ * - ADD / DROP / RENAME COLUMN: {@link forwardColumnShapeToOverlay}. ADD COLUMN
1091
+ * backfills each staged row exactly as the committed path does (literal default,
1092
+ * per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
1093
+ * - ALTER COLUMN: {@link forwardAlterColumnToOverlay}. `set data type` / `set collate`
1094
+ * / `set default` forward through the overlay's `alterSchema` (the overlay module
1095
+ * converts / re-keys its open layers itself), with one preparation step: a `set collate`
1096
+ * that re-keys the PRIMARY KEY first discards any deletion markers the new key collapses
1097
+ * onto another staged row ({@link dropCollapsedPkRekeyMarkers}). `set not null` is
1098
+ * withheld from the overlay and the staged live rows' NULLs are backfilled via ordinary
1099
+ * overlay writes instead.
1100
+ * - ADD / DROP / RENAME CONSTRAINT: {@link forwardAddConstraintToOverlay} /
1101
+ * {@link forwardConstraintNameChangeToOverlay}. A UNIQUE lands as a
1102
+ * tombstone-narrowed unique index; CHECK forwards verbatim; FOREIGN KEY does not
1103
+ * forward at all.
1104
+ * - ALTER PRIMARY KEY: no overlay can follow (its layer trees are keyed by the old
1105
+ * primary key) — the issuer with staged rows is rejected before the underlying
1106
+ * mutates, a foreign overlay with staged rows is poisoned, and a clean overlay is
1107
+ * swapped for a fresh staging table ({@link replaceOverlayForPrimaryKeyChange}).
1053
1108
  *
1054
1109
  * **Atomicity guarantee.** DDL through Quereus is not transaction-scoped and the
1055
1110
  * underlying (shared, committed) base auto-commits its mutation immediately —
1056
1111
  * there is no frame to unwind, and `dropColumn` / type-converting `alterColumn`
1057
1112
  * are lossy and not invertible, so "revert the underlying on overlay-migration
1058
1113
  * failure" is not viable. Instead this method **pre-validates** every affected
1059
- * overlay's backfill (the per-row NOT NULL check and the tombstone-present guard)
1060
- * BEFORE calling `underlying.alterTable`. A rejection therefore fires while the
1114
+ * overlay's migration (the per-row NOT NULL check, the per-value retype conversion, and
1115
+ * the tombstone-present guard) BEFORE calling `underlying.alterTable`. A rejection therefore fires while the
1061
1116
  * underlying, the schema catalog, and every overlay are still untouched, so the
1062
1117
  * ALTER either fails clean or fully applies — base/catalog can no longer diverge.
1063
1118
  * This mirrors the engine's pre-mutation `validateNotNullBackfill` in
@@ -1098,17 +1153,22 @@ export class IsolationModule {
1098
1153
  foreign.push([key, state]);
1099
1154
  }
1100
1155
  }
1101
- // Overlays we will actually migrate forward (issuer-own first). The shared
1102
- // dropColumn index is probed from one of these, never from a skipped poisoned
1103
- // overlay whose schema may be a stale pre-alter layout.
1156
+ // Overlays we will actually migrate forward (issuer-own first). The setNotNull /
1157
+ // setDataType contexts below are probed from one of these, never from a skipped
1158
+ // poisoned overlay whose schema may be a stale pre-alter layout.
1104
1159
  const toMigrate = ownEntry ? [ownEntry, ...foreign] : foreign;
1105
- // For dropColumn we need the pre-alter column index, readable from any
1106
- // to-be-migrated overlay's schema.
1107
- let dropColumnIdx;
1108
- if (change.type === 'dropColumn' && toMigrate.length > 0) {
1109
- const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1110
- dropColumnIdx = overlaySchema?.columnIndexMap.get(change.columnName.toLowerCase());
1160
+ // ALTER PRIMARY KEY cannot be carried by any overlay: the staging table's layer
1161
+ // trees are keyed by the OLD primary key, a staged tombstone identifies the row it
1162
+ // deletes BY that key, and the (memory) overlay module rejects in-place PK
1163
+ // alteration. When the ISSUER's own transaction has staged rows, reject up front —
1164
+ // before the underlying mutates — so the ALTER fails atomically rather than
1165
+ // stranding rows it cannot re-key. Foreign overlays with staged rows are poisoned
1166
+ // after the underlying applies, and clean overlays are swapped for a fresh staging
1167
+ // table (see replaceOverlayForPrimaryKeyChange).
1168
+ if (change.type === 'alterPrimaryKey' && ownEntry?.[1].hasChanges) {
1169
+ throw new QuereusError(`Cannot alter the primary key of '${schemaName}.${tableName}' while this transaction has uncommitted changes staged for it; commit or roll back first.`, StatusCode.UNSUPPORTED);
1111
1170
  }
1171
+ this.assertColumnNameNotTombstone(schemaName, tableName, change);
1112
1172
  // Build the addColumn backfill context up front (undefined for other change types).
1113
1173
  // Derived purely from `change` + the session nullability option — no post-alter
1114
1174
  // schema needed — so it is valid here, before the underlying is mutated, and the
@@ -1117,24 +1177,82 @@ export class IsolationModule {
1117
1177
  // Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
1118
1178
  // with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
1119
1179
  // from a to-be-migrated overlay's PRE-alter schema — the same layout every migrated overlay
1120
- // shares, and the same source `dropColumnIdx` uses above.
1121
- const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate, tableName);
1180
+ // shares.
1181
+ const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate);
1182
+ // Build the setDataType conversion context (undefined unless this is a value-rewriting
1183
+ // retype with overlays to convert). Read from the same PRE-alter overlay schema as above;
1184
+ // `inferType` cannot throw, so deriving it before the underlying mutation is safe.
1185
+ const setDataTypeCtx = this.deriveSetDataTypeConvert(change, toMigrate);
1186
+ // Build the primary-key re-key context (undefined unless this is a collation change on a
1187
+ // PK member with overlays to carry). Read from the same PRE-alter overlay schema as above.
1188
+ const pkRekeyCtx = this.derivePkRekey(change, db, tableName, toMigrate);
1122
1189
  // Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
1123
- // Any throw here (CONSTRAINT backfill or INTERNAL tombstone guard) propagates while
1124
- // underlying + catalog + every overlay are still untouched — the companion ticket's
1125
- // atomic-abort guarantee, preserved unchanged for the issuer.
1190
+ // Any throw here (CONSTRAINT backfill, MISMATCH conversion, or INTERNAL tombstone guard)
1191
+ // propagates while underlying + catalog + every overlay are still untouched — the companion
1192
+ // ticket's atomic-abort guarantee, preserved unchanged for the issuer.
1126
1193
  if (ownEntry) {
1127
- await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx);
1194
+ await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
1128
1195
  }
1129
1196
  const underlyingState = this.getUnderlyingState(schemaName, tableName);
1130
1197
  // Hand the underlying the issuer's effective rows so its own row-content checks
1131
1198
  // (`add constraint … unique`, `alter column … set collate`) see the transaction's
1132
1199
  // pending rows and skip the ones it has deleted. An outer wrapper's source wins if
1133
1200
  // one was supplied.
1134
- const rowSource = rows ?? (underlyingState
1201
+ let rowSource = rows ?? (underlyingState
1135
1202
  ? this.issuerEffectiveRows(db, schemaName, tableName, underlyingState.underlyingTable)
1136
1203
  : undefined);
1137
- const updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
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
+ }
1138
1256
  // The cached underlying VirtualTable's `tableSchema` is a construction-time
1139
1257
  // snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
1140
1258
  // underlying manager's schema but not this instance's field. Refresh it so a
@@ -1144,51 +1262,126 @@ export class IsolationModule {
1144
1262
  if (underlyingState)
1145
1263
  underlyingState.underlyingTable.tableSchema = updated;
1146
1264
  const ddlDescription = `alter table (${change.type})`;
1265
+ // Carry one overlay to the post-alter schema, IN PLACE, so the overlay's layer
1266
+ // chain and savepoint snapshots stay intact (see the method doc for the per-type
1267
+ // routes). The `const` re-captures pin each case's narrowed change type for the
1268
+ // apply closure.
1269
+ const migrateOverlay = async (key, state, isIssuer) => {
1270
+ switch (change.type) {
1271
+ case 'addColumn':
1272
+ case 'dropColumn':
1273
+ case 'renameColumn':
1274
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardColumnShapeToOverlay(state, change, addColumnCtx));
1275
+ break;
1276
+ case 'alterColumn': {
1277
+ const alterColumnChange = change;
1278
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAlterColumnToOverlay(state, alterColumnChange, setNotNullCtx, pkRekeyCtx));
1279
+ break;
1280
+ }
1281
+ case 'addConstraint': {
1282
+ const addConstraintChange = change;
1283
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardAddConstraintToOverlay(state, addConstraintChange, updated));
1284
+ break;
1285
+ }
1286
+ case 'dropConstraint':
1287
+ case 'renameConstraint': {
1288
+ const nameChange = change;
1289
+ await this.applyInPlaceOverlayChange(isIssuer, state, schemaName, tableName, ddlDescription, () => this.forwardConstraintNameChangeToOverlay(state, nameChange));
1290
+ break;
1291
+ }
1292
+ case 'alterPrimaryKey':
1293
+ await this.replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updated, change);
1294
+ break;
1295
+ default: {
1296
+ const _exhaustive = change;
1297
+ }
1298
+ }
1299
+ };
1147
1300
  // Migrate the issuer's own overlay (already validated above). Its NOT NULL /
1148
1301
  // tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
1149
- // rejection, which `rowSource` already judged — {@link adoptRebuiltOverlay} raises
1302
+ // rejection, which `rowSource` already judged — the CONSTRAINT routing raises
1150
1303
  // INTERNAL if one fires anyway rather than dropping the row.
1151
1304
  if (ownEntry) {
1152
- await this.adoptRebuiltOverlay(ownEntry[0], ownEntry[1], true, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, ownEntry[1], updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
1305
+ await migrateOverlay(ownEntry[0], ownEntry[1], true);
1153
1306
  }
1154
1307
  // Tier 3: per FOREIGN overlay, validate then migrate — but a per-row NOT NULL
1155
1308
  // (CONSTRAINT) failure poisons that one overlay instead of aborting the issuer's
1156
1309
  // ALTER, as does a UNIQUE the migration itself raises (its staged rows may violate a
1157
- // constraint the issuer just declared). An INTERNAL failure (e.g. missing tombstone
1158
- // column) is a layer-invariant violation, not a data condition, so it rethrows loud
1159
- // for everyone. Both phases run per overlay, so one bad foreign overlay poisons only
1160
- // itself; healthy peers still migrate.
1310
+ // constraint the issuer just declared) and an unconvertible staged value under a retype
1311
+ // (MISMATCH). Those three are data conditions of ONE connection's uncommitted rows, and
1312
+ // the underlying has already been mutated by this point, so rethrowing any of them would
1313
+ // abort the issuer's ALTER after the fact — exactly the divergence this tiering exists to
1314
+ // prevent. An INTERNAL failure (e.g. missing tombstone column) is a layer-invariant
1315
+ // violation, not a data condition, so it rethrows loud for everyone. Both phases run per
1316
+ // overlay, so one bad foreign overlay poisons only itself; healthy peers still migrate.
1161
1317
  for (const [key, oldState] of foreign) {
1162
1318
  try {
1163
- await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx);
1319
+ await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx);
1164
1320
  }
1165
1321
  catch (e) {
1166
- if (e instanceof QuereusError && e.code === StatusCode.CONSTRAINT) {
1322
+ if (e instanceof QuereusError && (e.code === StatusCode.CONSTRAINT || e.code === StatusCode.MISMATCH)) {
1167
1323
  oldState.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
1168
1324
  continue; // poisoned — do NOT migrate; leave pre-alter rows in place
1169
1325
  }
1170
1326
  throw e;
1171
1327
  }
1172
- await this.adoptRebuiltOverlay(key, oldState, false, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
1328
+ await migrateOverlay(key, oldState, false);
1173
1329
  }
1174
1330
  return updated;
1175
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
+ }
1176
1358
  /**
1177
1359
  * Builds the poison message stamped onto a foreign overlay whose backfill could not
1178
1360
  * satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
1179
1361
  * schema.table and the offending column so the owning connection's eventual
1180
1362
  * 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) and the `set not null` tightening
1182
- * path (a staged NULL with no usable default); other change types never reach here but
1183
- * are handled defensively.
1363
+ * path (a new NOT-NULL column with no usable default), the `set not null` tightening
1364
+ * path (a staged NULL with no usable default), the `set data type` path (a staged value
1365
+ * that cannot be converted to the new type), and the primary-key `set collate` path (two
1366
+ * staged live rows colliding under the new collation); other change types never reach
1367
+ * here but are handled defensively.
1184
1368
  */
1185
1369
  buildAlterPoisonMessage(schemaName, tableName, change) {
1186
1370
  if (change.type === 'addColumn') {
1187
1371
  return `ALTER on '${schemaName}.${tableName}' added column '${change.columnDef.name}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
1188
1372
  }
1189
1373
  if (change.type === 'alterColumn') {
1374
+ if (change.setDataType !== undefined) {
1375
+ return `ALTER on '${schemaName}.${tableName}' changed the data type of column '${change.columnName}' to ${change.setDataType}, which this connection's uncommitted row cannot be converted to; roll back this transaction.`;
1376
+ }
1377
+ if (change.setCollation !== undefined) {
1378
+ return `ALTER on '${schemaName}.${tableName}' changed the collation of primary-key column '${change.columnName}', under which this connection's uncommitted rows collide; roll back this transaction.`;
1379
+ }
1190
1380
  return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
1191
1381
  }
1382
+ if (change.type === 'alterPrimaryKey') {
1383
+ return `ALTER on '${schemaName}.${tableName}' changed the table's primary key, which this connection's uncommitted rows cannot follow; roll back this transaction.`;
1384
+ }
1192
1385
  return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
1193
1386
  }
1194
1387
  /**
@@ -1307,48 +1500,11 @@ export class IsolationModule {
1307
1500
  }
1308
1501
  return oldKeys.length;
1309
1502
  }
1310
- /**
1311
- * Rebuilds an overlay table under the post-alter schema, translating each
1312
- * staged row to the new column layout.
1313
- *
1314
- * A row the post-alter schema's constraints reject throws CONSTRAINT out of
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
- }
1503
+ // NOTE: the ALTER overlay pre-validation machinery (the three derive* helpers,
1504
+ // validateOverlayMigration, buildAlterPoisonMessage, and the per-type forward* helpers below)
1505
+ // threads one context parameter per value-rewriting attribute. If a fourth attribute ever
1506
+ // needs a context, extract the cluster into its own module and pass a single context object
1507
+ // instead of a widening parameter list.
1352
1508
  /**
1353
1509
  * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
1354
1510
  * the folded literal DEFAULT (the `tryFoldLiteral` of the DEFAULT expr, or `null`
@@ -1393,12 +1549,12 @@ export class IsolationModule {
1393
1549
  * needs no staged-row work, and with no overlays there is nothing to backfill or reject.
1394
1550
  *
1395
1551
  * `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 source `dropColumnIdx`
1397
- * uses, and the same layout every migrated overlay shares). Folded exactly as
1552
+ * column's PRE-alter schema (via a to-be-migrated overlay — the same layout every migrated
1553
+ * overlay shares). Folded exactly as
1398
1554
  * {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
1399
1555
  * cannot drift from what the underlying enforces over its committed rows.
1400
1556
  */
1401
- deriveSetNotNullBackfill(change, toMigrate, tableName) {
1557
+ deriveSetNotNullBackfill(change, toMigrate) {
1402
1558
  if (change.type !== 'alterColumn' || change.setNotNull !== true)
1403
1559
  return undefined;
1404
1560
  if (toMigrate.length === 0)
@@ -1419,9 +1575,126 @@ export class IsolationModule {
1419
1575
  foldedDefault: hasDefault ? folded : null,
1420
1576
  hasDefault,
1421
1577
  colName: change.columnName,
1578
+ };
1579
+ }
1580
+ /**
1581
+ * Precomputes the per-ALTER constants an `alter column … set data type` overlay conversion
1582
+ * needs: the retyped column's index and a per-value `convert`. Returns undefined unless this
1583
+ * is a retype with at least one overlay to convert, and undefined for an ALIAS retype (the
1584
+ * new logical type IS the old type object — `inferType` flattens `varchar(50)` to
1585
+ * `TEXT_TYPE`) — both underlyings gate their value rewrite on that exact identity
1586
+ * comparison, so mirroring it here keeps overlay and committed rows moving together should
1587
+ * "what counts as a value-rewriting retype" ever change. A same-storage-class retype between
1588
+ * different types (text → date) DOES convert: staged values are validated and normalized to
1589
+ * the new type's canonical spelling, exactly as the underlyings treat their committed rows.
1590
+ *
1591
+ * `inferType` never throws (an unknown type name falls through SQLite-style affinity rules),
1592
+ * so deriving this BEFORE the underlying mutation is safe. `convert` is the literal mirror of
1593
+ * `MemoryTableManager.alterColumn` / `StoreModule.alterColumnSetDataType`: `validateAndParse`,
1594
+ * rethrowing failure as the same MISMATCH message, so the two legs cannot drift.
1595
+ */
1596
+ deriveSetDataTypeConvert(change, toMigrate) {
1597
+ if (change.type !== 'alterColumn' || change.setDataType === undefined)
1598
+ return undefined;
1599
+ if (toMigrate.length === 0)
1600
+ return undefined;
1601
+ const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1602
+ if (!overlaySchema)
1603
+ return undefined;
1604
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1605
+ if (colIndex === undefined)
1606
+ return undefined;
1607
+ const oldCol = overlaySchema.columns[colIndex];
1608
+ if (!oldCol)
1609
+ return undefined;
1610
+ const newLogicalType = inferType(change.setDataType);
1611
+ // Alias retype (same logical type object): the underlying rewrites nothing, so neither do we.
1612
+ if (newLogicalType === oldCol.logicalType)
1613
+ return undefined;
1614
+ const setDataType = change.setDataType;
1615
+ const columnName = change.columnName;
1616
+ return {
1617
+ colIndex,
1618
+ convert: (v) => {
1619
+ try {
1620
+ return validateAndParse(v, newLogicalType, columnName);
1621
+ }
1622
+ catch {
1623
+ throw new QuereusError(`Cannot convert value in '${columnName}' to ${setDataType}`, StatusCode.MISMATCH);
1624
+ }
1625
+ },
1626
+ };
1627
+ }
1628
+ /**
1629
+ * Precomputes the per-ALTER constants a primary-key re-keying `alter column … set collate`
1630
+ * overlay migration needs: the PK column positions and a key serializer built over the
1631
+ * POST-change collation — the PRE-alter overlay schema with the named column's collation
1632
+ * replaced. The overlay's PK definition mirrors the base's (the tombstone flag is appended
1633
+ * after the data columns), so the synthesized schema keys exactly as the re-keyed base will.
1634
+ *
1635
+ * Returns undefined unless the named column is a PRIMARY KEY member and the normalized
1636
+ * collation actually differs from the column's current one — mirroring the underlying's
1637
+ * metadata-only gate (`MemoryTableManager.alterColumn`'s `nameMatches`), so overlay and
1638
+ * committed rows re-key together or not at all. `validateCollationForType` can throw for an
1639
+ * unknown collation, but the engine already validated the name before dispatching the ALTER,
1640
+ * and a direct module caller gets the same pre-mutation rejection the underlying would give.
1641
+ */
1642
+ derivePkRekey(change, db, tableName, toMigrate) {
1643
+ if (change.type !== 'alterColumn' || change.setCollation === undefined)
1644
+ return undefined;
1645
+ if (toMigrate.length === 0)
1646
+ return undefined;
1647
+ const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1648
+ if (!overlaySchema)
1649
+ return undefined;
1650
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1651
+ if (colIndex === undefined)
1652
+ return undefined;
1653
+ if (!overlaySchema.primaryKeyDefinition.some(def => def.index === colIndex))
1654
+ return undefined;
1655
+ const oldCol = overlaySchema.columns[colIndex];
1656
+ if (!oldCol)
1657
+ return undefined;
1658
+ const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, (n) => db.isCollationRegistered(n));
1659
+ if (normalized === (oldCol.collation || 'BINARY'))
1660
+ return undefined; // metadata-only — the underlying re-keys nothing
1661
+ const postChangeSchema = {
1662
+ ...overlaySchema,
1663
+ columns: overlaySchema.columns.map((c, i) => (i === colIndex ? { ...c, collation: normalized } : c)),
1664
+ };
1665
+ return {
1666
+ pkIndices: overlaySchema.primaryKeyDefinition.map(def => def.index),
1667
+ keyOf: makePkKeySerializer(db, postChangeSchema),
1422
1668
  tableName,
1423
1669
  };
1424
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
+ }
1425
1698
  /**
1426
1699
  * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
1427
1700
  * so the caller can run it for every affected overlay BEFORE the irreversible
@@ -1439,24 +1712,38 @@ export class IsolationModule {
1439
1712
  * For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
1440
1713
  * non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
1441
1714
  * 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 {@link translateOverlayRow},
1443
- * so nothing is rejected here.
1444
- *
1445
- * Non-addColumn / non-tightening changes (`addColumnCtx === undefined` and no reject-mode
1446
- * `setNotNullCtx`) only run the tombstone guard; their row translation appends/removes nothing
1447
- * fallible on data grounds.
1715
+ * poison. With a usable DEFAULT the staged NULLs are backfilled by
1716
+ * {@link backfillStagedNotNull}, so nothing is rejected here.
1717
+ *
1718
+ * For `set data type` (`setDataTypeCtx`), every staged non-NULL value is run through the
1719
+ * conversion and the result discarded, so an unconvertible one throws MISMATCH here. For the
1720
+ * ISSUER this is belt-and-braces — the underlying's own pre-mutation pass walks the same rows
1721
+ * via {@link issuerEffectiveRows} — EXCEPT when an outer wrapper supplied `rows`, in which case
1722
+ * this is the only check. For a FOREIGN overlay it is the only pass that ever sees those rows,
1723
+ * and it must run before the migration so a failure becomes poison rather than a half-migrated
1724
+ * overlay.
1725
+ *
1726
+ * For a primary-key re-keying `set collate` (`pkRekeyCtx`), the staged rows are grouped by
1727
+ * their POST-change key: a deletion marker sharing a key with one live row is that row's
1728
+ * before-image (the migrate step discards it — {@link dropCollapsedPkRekeyMarkers}), and
1729
+ * markers alone on one key are one deletion — neither is a duplicate. Two LIVE rows on one
1730
+ * key ARE, so that throws CONSTRAINT here, mirroring the underlying's own collision message.
1731
+ * For the ISSUER this is belt-and-braces (the underlying's pre-mutation pass judges the same
1732
+ * staged rows via {@link issuerEffectiveRows}, though this check fires first); for a FOREIGN
1733
+ * overlay it is the only pass that sees those rows, and its throw becomes poison.
1734
+ *
1735
+ * Non-addColumn / non-tightening / non-retype / non-re-key changes (`addColumnCtx ===
1736
+ * undefined`, no reject-mode `setNotNullCtx`, no `setDataTypeCtx`, no `pkRekeyCtx`) only run
1737
+ * the tombstone guard; their row translation appends/removes nothing fallible on data grounds.
1448
1738
  */
1449
- async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx) {
1739
+ async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx, setDataTypeCtx, pkRekeyCtx) {
1450
1740
  const oldOverlay = oldState.overlayTable;
1451
1741
  const oldOverlaySchema = oldOverlay.tableSchema;
1452
1742
  // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
1453
1743
  // schema stages nothing and runs none of the fallible checks.
1454
1744
  if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
1455
1745
  return;
1456
- const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1457
- if (oldTombstoneIdx === undefined) {
1458
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1459
- }
1746
+ const oldTombstoneIdx = this.requireTombstoneIndex(oldOverlaySchema);
1460
1747
  if (addColumnCtx) {
1461
1748
  for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1462
1749
  // Discard the result — this is validation only. A NOT NULL violation throws here.
@@ -1464,16 +1751,48 @@ export class IsolationModule {
1464
1751
  }
1465
1752
  return;
1466
1753
  }
1467
- // SET NOT NULL with no usable DEFAULT: reject a staged NULL the migration could not fill.
1468
- // (With a DEFAULT there is nothing to reject — translateOverlayRow backfills instead.)
1754
+ // SET NOT NULL with no usable DEFAULT: reject a staged NULL the forward could not fill.
1755
+ // (With a DEFAULT there is nothing to reject — backfillStagedNotNull fills instead.)
1469
1756
  if (setNotNullCtx && !setNotNullCtx.hasDefault) {
1470
- for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1471
- if (oldRow[oldTombstoneIdx] === 1)
1472
- continue; // tombstone: placeholder NULLs, not a row
1757
+ for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1473
1758
  if (oldRow[setNotNullCtx.colIndex] === null) {
1474
1759
  throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
1475
1760
  }
1476
1761
  }
1762
+ return;
1763
+ }
1764
+ // SET DATA TYPE: prove every staged value convertible before anything is rewritten. NULLs
1765
+ // are left untouched by the conversion (the underlying's `convertNulls` is false for a retype).
1766
+ if (setDataTypeCtx) {
1767
+ for await (const oldRow of this.stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
1768
+ const value = oldRow[setDataTypeCtx.colIndex];
1769
+ if (value !== null)
1770
+ setDataTypeCtx.convert(value); // discard — validation only
1771
+ }
1772
+ return;
1773
+ }
1774
+ // SET COLLATE re-keying the PRIMARY KEY: two staged LIVE rows landing on one post-change
1775
+ // key are a real duplicate — refuse before the underlying re-keys (atomic for the issuer,
1776
+ // poison for a foreign overlay via the caller). Marker/live and marker/marker groups
1777
+ // collapse cleanly and are handled by the migrate step, not rejected here.
1778
+ if (pkRekeyCtx) {
1779
+ for (const group of (await this.collectPkRekeyGroups(oldOverlay, oldTombstoneIdx, pkRekeyCtx)).values()) {
1780
+ if (group.livePks.length < 2)
1781
+ continue;
1782
+ const keyDesc = group.livePks[1].map(formatKeyValue).join(', ');
1783
+ throw new QuereusError(`UNIQUE constraint failed: ${pkRekeyCtx.tableName} primary key collides under new collation (key: ${keyDesc})`, StatusCode.CONSTRAINT);
1784
+ }
1785
+ }
1786
+ }
1787
+ /**
1788
+ * The staged rows an overlay holds that represent LIVE data, i.e. every row except the
1789
+ * tombstones — those carry placeholder NULLs at every data column and must never be fed to a
1790
+ * per-value check, which would read a NULL that is not the row's real value.
1791
+ */
1792
+ async *stagedLiveRows(overlay, tombstoneIdx) {
1793
+ for await (const row of overlay.query(makeFullScanFilterInfo())) {
1794
+ if (row[tombstoneIdx] !== 1)
1795
+ yield row;
1477
1796
  }
1478
1797
  }
1479
1798
  /**
@@ -1503,63 +1822,427 @@ export class IsolationModule {
1503
1822
  return ctx.foldedDefault;
1504
1823
  }
1505
1824
  /**
1506
- * Translates a single overlay row from the pre-alter to the post-alter column layout.
1507
- * The tombstone value is preserved in the last position.
1508
- *
1509
- * `addColumnValue` is the per-row value the caller computed for an `addColumn`
1510
- * (via {@link computeAddColumnValue}); it is `undefined` for every other change
1511
- * type. Keeping the (async) backfill in the caller's loop lets this stay synchronous.
1512
- *
1513
- * `setNotNullCtx` (present only for a `set not null` tightening WITH a usable DEFAULT) maps a
1514
- * staged NULL at the now-NOT-NULL column to that DEFAULT — filling the issuer's own pending
1515
- * rows, which the underlying's committed-row backfill never touches. Reject-mode contexts (no
1516
- * DEFAULT) never reach here: {@link validateOverlayMigration} aborts/poisons first.
1517
- */
1518
- translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue, setNotNullCtx) {
1519
- const tombstoneValue = oldRow[oldTombstoneIdx];
1520
- const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
1521
- let newData;
1522
- switch (change.type) {
1523
- case 'addColumn':
1524
- // New column is always appended after existing data columns, backfilled per
1525
- // row by the caller (literal default, per-row evaluator, or NULL).
1526
- newData = [...data, addColumnValue ?? null];
1527
- break;
1528
- case 'dropColumn':
1529
- newData = dropColumnIdx !== undefined
1530
- ? [...data.slice(0, dropColumnIdx), ...data.slice(dropColumnIdx + 1)]
1531
- : data;
1532
- break;
1533
- case 'alterColumn':
1534
- // SET NOT NULL backfill (WITH a usable DEFAULT): fill a staged NULL at the
1535
- // now-NOT-NULL column. Tombstones carry placeholder NULLs never read, so leave them.
1536
- // Every other alterColumn attribute (and the no-DEFAULT reject case) leaves data as-is.
1537
- // NOTE: SET DATA TYPE also alters existing values but is NOT converted here — its
1538
- // overlay gap is deferred (see SetNotNullBackfillContext doc).
1539
- newData = (setNotNullCtx && tombstoneValue !== 1)
1540
- ? data.map((v, i) => (i === setNotNullCtx.colIndex && v === null ? setNotNullCtx.foldedDefault : v))
1541
- : data;
1542
- break;
1543
- case 'renameColumn':
1544
- case 'alterPrimaryKey':
1545
- case 'addConstraint':
1546
- case 'dropConstraint':
1547
- case 'renameConstraint':
1548
- newData = data;
1549
- break;
1550
- default: {
1551
- const _exhaustive = change;
1552
- newData = data;
1825
+ * Forwards one column-shape change (ADD / DROP / RENAME COLUMN) to an already-open
1826
+ * overlay IN PLACE via the overlay's optional `alterSchema`. The overlay module (the
1827
+ * memory module by default) reshapes its open transaction layers with their savepoint
1828
+ * snapshots intact, so `rollback to savepoint` keeps distinguishing rows staged before
1829
+ * the savepoint from rows staged after it — the rebuild path destroyed that
1830
+ * distinction in both directions (see {@link applyIndexChangeToOverlays}).
1831
+ *
1832
+ * A missing `alterSchema` is treated as a no-op, the way the index paths treat a
1833
+ * missing `createIndex` / `dropIndex`.
1834
+ *
1835
+ * `dropColumn` / `renameColumn` forward the caller's change unchanged. `addColumn`
1836
+ * forwards an overlay-flavoured copy — see {@link buildOverlayAddColumnChange}.
1837
+ */
1838
+ async forwardColumnShapeToOverlay(overlayState, change, addColumnCtx) {
1839
+ const overlayTable = overlayState.overlayTable;
1840
+ if (!overlayTable.alterSchema)
1841
+ return;
1842
+ // `deriveAddColumnBackfill` returns a context for every addColumn, so the assertion
1843
+ // cannot fire when the change type matches.
1844
+ const overlayChange = change.type === 'addColumn'
1845
+ ? this.buildOverlayAddColumnChange(change, addColumnCtx, overlayTable)
1846
+ : change;
1847
+ await overlayTable.alterSchema(overlayChange);
1848
+ }
1849
+ /**
1850
+ * The overlay-flavoured form of one `addColumn` change:
1851
+ *
1852
+ * - `insertAtIndex` = the overlay's current tombstone column index, so the new data
1853
+ * column lands ahead of the tombstone flag and the flag stays LAST — the layout
1854
+ * every read/write path of this package assumes. A bare forward would append after
1855
+ * the flag and every value written to the new column would be dropped on read.
1856
+ * A caller that named its own position keeps it: the overlay's data columns mirror
1857
+ * the base's one-for-one below the flag, so the base's index is the overlay's too.
1858
+ * (SQL always appends, so today the caller never names one — but silently ignoring
1859
+ * it would diverge the two layouts.)
1860
+ * - The column definition is made NULLABLE. The overlay's ADD re-runs the overlay
1861
+ * module's own NOT NULL validation against a different row population than the
1862
+ * base's: tombstone rows carry placeholder NULL in every non-PK column, so a
1863
+ * NOT NULL column the base already accepted would be rejected here. The base's
1864
+ * NOT NULL is still enforced where it belongs — by the engine's pre-mutation
1865
+ * `validateNotNullBackfill` and the underlying's own ADD (both judged the issuer's
1866
+ * effective rows), and per staged row by {@link computeAddColumnValue}'s
1867
+ * evaluator-path check.
1868
+ * - `backfillEvaluator` receives an OVERLAY row (data columns plus the flag) and
1869
+ * routes through {@link computeAddColumnValue} — the same helper
1870
+ * {@link validateOverlayMigration} dry-runs, so validation and forward cannot
1871
+ * drift: NULL for a tombstone row, else the engine's per-row `new.<col>` evaluator
1872
+ * over the flag-stripped row, or the folded literal default.
1873
+ */
1874
+ buildOverlayAddColumnChange(change, addColumnCtx, overlayTable) {
1875
+ const tombstoneIdx = overlayTable.tableSchema?.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
1876
+ if (tombstoneIdx === undefined) {
1877
+ throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
1878
+ }
1879
+ const constraints = (change.columnDef.constraints ?? []).filter(c => c.type !== 'notNull');
1880
+ if (!constraints.some(c => c.type === 'null'))
1881
+ constraints.push({ type: 'null' });
1882
+ return {
1883
+ ...change,
1884
+ columnDef: { ...change.columnDef, constraints },
1885
+ insertAtIndex: change.insertAtIndex ?? tombstoneIdx,
1886
+ backfillEvaluator: (overlayRow) => this.computeAddColumnValue(addColumnCtx, overlayRow, tombstoneIdx),
1887
+ };
1888
+ }
1889
+ /**
1890
+ * Forwards one ALTER COLUMN attribute change to an already-open overlay IN PLACE, with the
1891
+ * NOT NULL attribute deliberately withheld.
1892
+ *
1893
+ * `set data type` / `set collate` / `set default` forward through the overlay's own
1894
+ * `alterSchema`: the overlay module converts / re-keys its open transaction layers with
1895
+ * their savepoint snapshots intact, and NULLs pass a retype untouched, so tombstone rows —
1896
+ * placeholder NULLs at every non-key data column — ride through unharmed. One preparation
1897
+ * step precedes a PRIMARY-KEY re-keying `set collate` (`pkRekeyCtx`): deletion markers the
1898
+ * re-key collapses onto another staged row are discarded first
1899
+ * ({@link dropCollapsedPkRekeyMarkers}) — the overlay's PK deliberately covers markers, so
1900
+ * without the drop its own `alterSchema` would reject the collapsed pair as a PK collision.
1901
+ *
1902
+ * Neither NOT NULL direction forwards, and the overlay's `notNull` flag is left where it
1903
+ * stood when the overlay was created:
1904
+ *
1905
+ * - **Tightening (`set not null`).** Forwarding would have the overlay module tighten a
1906
+ * column whose tombstone rows carry NULL at every non-PK data column, so it would either
1907
+ * backfill a deletion marker's placeholder NULL from the DEFAULT (corrupting a row that
1908
+ * is not a row) or reject it outright when no usable DEFAULT exists. The base's NOT NULL
1909
+ * is enforced where it belongs — by the underlying over the issuer's effective rows, and
1910
+ * per overlay by {@link validateOverlayMigration} — and the staged LIVE rows' NULLs are
1911
+ * filled here via {@link backfillStagedNotNull}.
1912
+ * - **Relaxing (`drop not null`).** Nothing to do: the overlay never enforced the flag, and
1913
+ * the rows it stages are written with `preCoerced: true` through the overlay module's own
1914
+ * write path, which does not consult column nullability at all.
1915
+ *
1916
+ * NOTE: both directions therefore leave the overlay's `notNull` flag drifted from the base
1917
+ * for the rest of the transaction. Inert today — nothing on the overlay path reads it. The
1918
+ * one consumer that would, `MemoryTableModule.getBestAccessPlan` (which prunes `IS NULL` on
1919
+ * a NOT NULL column to an empty result), is unreachable because the isolation layer builds
1920
+ * the overlay's `FilterInfo` itself (see `filter-info.ts`) instead of planning against the
1921
+ * overlay module. If overlay scans ever route through the overlay module's planner hook, or
1922
+ * its write path starts enforcing nullability, this has to become a real forward that
1923
+ * excludes tombstones.
1924
+ *
1925
+ * The runtime admits exactly one attribute per ALTER COLUMN, so a `setNotNull` change
1926
+ * carries nothing else to forward. A missing overlay `alterSchema` is a no-op, as in
1927
+ * {@link forwardColumnShapeToOverlay}.
1928
+ */
1929
+ async forwardAlterColumnToOverlay(overlayState, change, setNotNullCtx, pkRekeyCtx) {
1930
+ if (change.setNotNull !== undefined) {
1931
+ if (change.setNotNull === true && setNotNullCtx?.hasDefault) {
1932
+ await this.backfillStagedNotNull(overlayState, setNotNullCtx);
1553
1933
  }
1934
+ return;
1554
1935
  }
1555
- return [...newData, tombstoneValue];
1936
+ if (!overlayState.overlayTable.alterSchema)
1937
+ return;
1938
+ if (pkRekeyCtx)
1939
+ await this.dropCollapsedPkRekeyMarkers(overlayState, pkRekeyCtx);
1940
+ await overlayState.overlayTable.alterSchema(change);
1941
+ }
1942
+ /**
1943
+ * Discards the deletion markers a pending PRIMARY-KEY re-key collapses onto another staged
1944
+ * row, through the overlay's ordinary write path so its layer chain and savepoint snapshots
1945
+ * stay intact. Two shapes collapse (see {@link PkRekeyContext}): a marker sharing its
1946
+ * post-change key with a live row is that row's before-image — the live row's flush upsert
1947
+ * is the complete net effect, so the marker is dropped; markers alone together on one key
1948
+ * are one deletion, so all but the first are dropped. A group holding two live rows was
1949
+ * already refused by {@link validateOverlayMigration} before the underlying mutated.
1950
+ *
1951
+ * Runs BEFORE the re-key is forwarded to the overlay, and deletes by each marker's OLD
1952
+ * primary key — the overlay is still keyed under the old collation at that moment.
1953
+ *
1954
+ * NOTE: like {@link backfillStagedNotNull}'s rewrites, the drops land in the overlay's
1955
+ * CURRENT savepoint frame. A later `rollback to savepoint` taken BEFORE this ALTER restores
1956
+ * the marker while the table stays re-keyed — the same divergence class that method's NOTE
1957
+ * documents (`bug-rolled-back-rows-violate-surviving-ddl`), not a new hole opened here.
1958
+ */
1959
+ async dropCollapsedPkRekeyMarkers(overlayState, ctx) {
1960
+ const plan = await this.planPkRekeyMarkerDrops(overlayState, ctx);
1961
+ await this.applyPkRekeyMarkerDrops(overlayState, ctx, plan);
1962
+ }
1963
+ /**
1964
+ * The whole overlay rows of every deletion marker {@link dropCollapsedPkRekeyMarkers} will
1965
+ * discard, computed without mutating anything. Re-planning after the drops finds nothing
1966
+ * (each group then holds at most one marker and no marker beside a live row), so the
1967
+ * issuer path — which drops in tier 2 for the pre-flight — can safely run the shared
1968
+ * migrate-step drop again as a no-op.
1969
+ */
1970
+ async planPkRekeyMarkerDrops(overlayState, ctx) {
1971
+ const overlay = overlayState.overlayTable;
1972
+ const overlaySchema = overlay.tableSchema;
1973
+ if (!overlaySchema || !overlay.query)
1974
+ return [];
1975
+ const tombstoneIdx = this.requireTombstoneIndex(overlaySchema);
1976
+ const toDrop = [];
1977
+ for (const group of (await this.collectPkRekeyGroups(overlay, tombstoneIdx, ctx)).values()) {
1978
+ // NOTE: the marker-only arm (`slice(1)`) is defensive — no marker-only collapse can
1979
+ // currently SURVIVE the ALTER. Two markers share a post-change key only if the rows
1980
+ // they shadow did too, and such a pair is refused one level down: by the underlying's
1981
+ // re-key (a committed pair) or by the overlay's own (a staged-then-deleted pair). It
1982
+ // still runs, and is still undone, on the way to those refusals.
1983
+ toDrop.push(...(group.livePks.length > 0 ? group.markerRows : group.markerRows.slice(1)));
1984
+ }
1985
+ return toDrop;
1986
+ }
1987
+ /** Deletes each planned marker by its OLD primary key through the overlay's ordinary write path. */
1988
+ async applyPkRekeyMarkerDrops(overlayState, ctx, toDrop) {
1989
+ const overlay = overlayState.overlayTable;
1990
+ const overlaySchema = overlay.tableSchema;
1991
+ if (!overlaySchema || toDrop.length === 0)
1992
+ return;
1993
+ for (const markerRow of toDrop) {
1994
+ const pk = ctx.pkIndices.map(i => markerRow[i]);
1995
+ const result = await overlay.update({
1996
+ operation: 'delete',
1997
+ values: undefined,
1998
+ oldKeyValues: pk,
1999
+ });
2000
+ // A bare delete of a staged marker has no data condition to hit, so a non-ok result
2001
+ // is a layer-invariant violation — raise INTERNAL (which applyInPlaceOverlayChange
2002
+ // rethrows loud for issuer and foreign alike), never poison.
2003
+ if (isConstraintViolation(result)) {
2004
+ throw new QuereusError(`Dropping a collapsed deletion marker (pk=[${pk.join(', ')}]) from overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.INTERNAL);
2005
+ }
2006
+ }
2007
+ }
2008
+ /**
2009
+ * Undoes {@link applyPkRekeyMarkerDrops} after a refusal that aborts the whole ALTER —
2010
+ * the overlay pre-flight's or the underlying's (see the tier-2 re-key block in
2011
+ * {@link alterTable}). Reinserts each marker row verbatim: a marker minted for a committed
2012
+ * row carries NULL at every non-key data column, but one made by deleting a row this
2013
+ * transaction had already staged keeps that row's values (the convert-to-tombstone branch
2014
+ * of `IsolatedTable.update`), so a reconstructed-from-PK row would not restore what was
2015
+ * dropped. The reinserts land in the same savepoint frame the drops did, so the pair nets
2016
+ * to nothing.
2017
+ */
2018
+ async reinsertPkRekeyMarkers(overlayState, droppedRows) {
2019
+ if (droppedRows.length === 0)
2020
+ return;
2021
+ const overlay = overlayState.overlayTable;
2022
+ const overlaySchema = overlay.tableSchema;
2023
+ if (!overlaySchema)
2024
+ return;
2025
+ for (const markerRow of droppedRows) {
2026
+ const result = await overlay.update({ operation: 'insert', values: [...markerRow] });
2027
+ if (isConstraintViolation(result)) {
2028
+ throw new QuereusError(`Restoring a deletion marker (row=[${markerRow.join(', ')}]) to overlay '${overlaySchema.name}' after a refused ALTER hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.INTERNAL);
2029
+ }
2030
+ }
2031
+ }
2032
+ /** The overlay's tombstone-flag column index, or INTERNAL if the schema lost it. */
2033
+ requireTombstoneIndex(overlaySchema) {
2034
+ const tombstoneIdx = overlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
2035
+ if (tombstoneIdx === undefined) {
2036
+ throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
2037
+ }
2038
+ return tombstoneIdx;
2039
+ }
2040
+ /**
2041
+ * Backfills a `set not null` tightening into an overlay's staged LIVE rows: every
2042
+ * non-tombstone row holding NULL at the tightened column is rewritten with the column's
2043
+ * folded literal DEFAULT through the overlay's ordinary write path, so the overlay's layer
2044
+ * chain and savepoint snapshots stay intact — the overlay-side mirror of the value rewrite
2045
+ * the underlying applies to its committed rows. Tombstone rows are never touched
2046
+ * ({@link stagedLiveRows}).
2047
+ *
2048
+ * Only called with a usable DEFAULT ({@link SetNotNullBackfillContext.hasDefault}); the
2049
+ * no-DEFAULT case rejects (issuer) or poisons (foreign) in {@link validateOverlayMigration}
2050
+ * before any overlay is touched.
2051
+ *
2052
+ * NOTE: these rewrites land in the overlay's CURRENT savepoint frame — each is a normal
2053
+ * staged write. A later `rollback to savepoint` taken BEFORE this ALTER therefore restores
2054
+ * the pre-backfill NULL while the column stays NOT NULL (DDL is not transactional here).
2055
+ * That is the same class of divergence as any rolled-back row violating surviving DDL — a
2056
+ * row un-inserted past an ADD CONSTRAINT, say — tracked by the backlog ticket
2057
+ * `bug-rolled-back-rows-violate-surviving-ddl`, not a new hole opened by this forward.
2058
+ */
2059
+ async backfillStagedNotNull(overlayState, ctx) {
2060
+ const overlay = overlayState.overlayTable;
2061
+ const overlaySchema = overlay.tableSchema;
2062
+ if (!overlaySchema || !overlay.query)
2063
+ return;
2064
+ const tombstoneIdx = overlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
2065
+ if (tombstoneIdx === undefined) {
2066
+ throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
2067
+ }
2068
+ const pkIndices = overlaySchema.primaryKeyDefinition.map(def => def.index);
2069
+ // Materialize before writing: the rewrites mutate the overlay's pending layer the
2070
+ // scan is reading through.
2071
+ const toFill = [];
2072
+ for await (const row of this.stagedLiveRows(overlay, tombstoneIdx)) {
2073
+ if (row[ctx.colIndex] === null)
2074
+ toFill.push(row);
2075
+ }
2076
+ for (const row of toFill) {
2077
+ const values = row.map((v, i) => (i === ctx.colIndex ? ctx.foldedDefault : v));
2078
+ // No `onConflict`, so `UpdateResult` is exactly `ok | constraint`. A constraint here
2079
+ // (the filled value colliding under a UNIQUE) is a data condition of this overlay's
2080
+ // staged rows — thrown as CONSTRAINT for applyInPlaceOverlayChange to route to
2081
+ // INTERNAL (issuer, whose merged rows the underlying's own converted-row UNIQUE
2082
+ // re-validation already judged) or poison (foreign).
2083
+ const result = await overlay.update({
2084
+ operation: 'update',
2085
+ values,
2086
+ oldKeyValues: pkIndices.map(i => row[i]),
2087
+ preCoerced: true,
2088
+ });
2089
+ if (isConstraintViolation(result)) {
2090
+ throw new QuereusError(`Backfilling column '${ctx.colName}' in overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
2091
+ }
2092
+ }
2093
+ }
2094
+ /**
2095
+ * Forwards one ADD CONSTRAINT to an already-open overlay, per constraint class:
2096
+ *
2097
+ * - **UNIQUE** is NOT forwarded as a constraint. The AST `TableConstraint` carries no
2098
+ * partial-predicate field, so a bare forward would enforce uniqueness over tombstone rows
2099
+ * too — a UNIQUE whose columns sit inside the primary key would see two deleted rows
2100
+ * (each carrying its PK and placeholder NULLs elsewhere) as duplicates of each other.
2101
+ * Instead it lands as a tombstone-narrowed **unique index** through the same route CREATE
2102
+ * INDEX takes ({@link installOverlayUniqueConstraint}).
2103
+ * - **CHECK** forwards verbatim: the overlay module appends it schema-only (no row scan, no
2104
+ * physical structure). Enforcement is engine-side at DML plan time against the user
2105
+ * table; the overlay's copy exists so a later DROP / RENAME CONSTRAINT resolves it.
2106
+ * - **FOREIGN KEY** is deliberately NOT forwarded. FK enforcement is engine-side
2107
+ * (planner-synthesized EXISTS checks) — the overlay never enforces it — and the overlay
2108
+ * module's ADD arm validates existing child rows through a catalog query by table name,
2109
+ * which the unregistered `_overlay_*` staging table cannot serve. The presence guard in
2110
+ * {@link forwardConstraintNameChangeToOverlay} keeps a later DROP / RENAME of the
2111
+ * unforwarded FK a clean no-op on the overlay.
2112
+ * - **PRIMARY KEY** cannot reach here — both bundled underlyings reject
2113
+ * `add constraint … primary key` with UNSUPPORTED before any overlay work — so it asserts
2114
+ * INTERNAL rather than leaving a silent gap for a third-party underlying that accepts it
2115
+ * (the overlay could not follow; see the alterPrimaryKey handling in {@link alterTable}).
2116
+ */
2117
+ async forwardAddConstraintToOverlay(overlayState, change, updatedSchema) {
2118
+ switch (change.constraint.type) {
2119
+ case 'unique':
2120
+ await this.installOverlayUniqueConstraint(overlayState, change.constraint, updatedSchema);
2121
+ return;
2122
+ case 'check':
2123
+ if (overlayState.overlayTable.alterSchema)
2124
+ await overlayState.overlayTable.alterSchema(change);
2125
+ return;
2126
+ case 'foreignKey':
2127
+ return;
2128
+ case 'primaryKey':
2129
+ throw new QuereusError(`Isolation layer: the underlying accepted 'add constraint … primary key' on '${updatedSchema.schemaName}.${updatedSchema.name}', which the per-connection overlays cannot follow.`, StatusCode.INTERNAL);
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Installs one runtime-added UNIQUE constraint on an overlay as a tombstone-narrowed unique
2134
+ * index (see {@link forwardAddConstraintToOverlay} for why a bare constraint forward is
2135
+ * wrong). Column indices and per-column collations are read from the post-alter underlying
2136
+ * schema — the canonical form, positionally identical to the overlay's data columns (the
2137
+ * tombstone flag is appended last). The index is named `constraint name ?? '_uc_<cols>'`,
2138
+ * the memory module's own covering-index naming rule (`implicitIndexNameFor`), so the
2139
+ * derived UNIQUE the overlay module synthesizes from it resolves under the SAME name a
2140
+ * later DROP / RENAME CONSTRAINT forward carries.
2141
+ *
2142
+ * `MemoryTableManager.createIndex` pre-validates the overlay's effective rows against the
2143
+ * narrowed predicate before any mutation, so a staged duplicate raises CONSTRAINT with the
2144
+ * overlay untouched — routed by the caller to INTERNAL (the issuer, whose merged rows the
2145
+ * underlying already judged via {@link issuerEffectiveRows}) or poison (a foreign overlay).
2146
+ * A missing overlay `createIndex` is a no-op, as in the index paths.
2147
+ */
2148
+ async installOverlayUniqueConstraint(overlayState, constraint, updatedSchema) {
2149
+ const overlayTable = overlayState.overlayTable;
2150
+ const overlaySchema = overlayTable.tableSchema;
2151
+ if (!overlayTable.createIndex || !overlaySchema)
2152
+ return;
2153
+ const columns = (constraint.columns ?? []).map(col => {
2154
+ const idx = updatedSchema.columnIndexMap.get(col.name.toLowerCase());
2155
+ if (idx === undefined) {
2156
+ throw new QuereusError(`Isolation layer: UNIQUE constraint column '${col.name}' not found on '${updatedSchema.schemaName}.${updatedSchema.name}' after the underlying accepted the constraint.`, StatusCode.INTERNAL);
2157
+ }
2158
+ return idx;
2159
+ });
2160
+ const indexName = constraint.name ?? `_uc_${columns.map(i => updatedSchema.columns[i].name).join('_')}`;
2161
+ if (this.schemaHasIndex(overlaySchema, indexName))
2162
+ return;
2163
+ // A runtime-added UNIQUE carries no partial predicate of its own (the AST has no field
2164
+ // for one), so the overlay predicate is exactly the live-rows narrowing.
2165
+ await overlayTable.createIndex({
2166
+ name: indexName,
2167
+ columns: columns.map(i => ({ index: i, collation: updatedSchema.columns[i]?.collation })),
2168
+ unique: true,
2169
+ predicate: this.overlayPredicate(undefined, updatedSchema.name, overlaySchema.name),
2170
+ });
2171
+ }
2172
+ /**
2173
+ * Forwards DROP / RENAME CONSTRAINT to an already-open overlay, presence-guarded: a
2174
+ * constraint the overlay never carried is skipped silently rather than letting the overlay
2175
+ * module's NOTFOUND abort the issuer's already-applied ALTER. The guard is what makes the
2176
+ * unforwarded classes safe ({@link forwardAddConstraintToOverlay}: FOREIGN KEY always, and
2177
+ * UNIQUE under an overlay module without `createIndex`) — their DROP / RENAME is simply a
2178
+ * no-op on the overlay.
2179
+ *
2180
+ * For a UNIQUE the overlay module drops/renames the constraint AND its covering index in
2181
+ * lock-step — both live under the constraint's name (or the `_uc_<cols>` implicit-name
2182
+ * rule), whether copied at overlay creation ({@link createOverlaySchema}) or installed by
2183
+ * {@link installOverlayUniqueConstraint} — so the base and overlay representations stay
2184
+ * resolvable by one name across the whole constraint lifecycle.
2185
+ */
2186
+ async forwardConstraintNameChangeToOverlay(overlayState, change) {
2187
+ const overlayTable = overlayState.overlayTable;
2188
+ const overlaySchema = overlayTable.tableSchema;
2189
+ if (!overlayTable.alterSchema || !overlaySchema)
2190
+ return;
2191
+ const name = change.type === 'dropConstraint' ? change.constraintName : change.oldName;
2192
+ if (!this.schemaHasNamedConstraint(overlaySchema, name))
2193
+ return;
2194
+ await overlayTable.alterSchema(change);
2195
+ }
2196
+ /** Case-insensitive "does `schema` declare a named constraint of ANY class (CHECK / UNIQUE / FOREIGN KEY)". */
2197
+ schemaHasNamedConstraint(schema, constraintName) {
2198
+ const lower = constraintName.toLowerCase();
2199
+ return (schema.checkConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
2200
+ || (schema.uniqueConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
2201
+ || (schema.foreignKeys ?? []).some(c => c.name?.toLowerCase() === lower);
2202
+ }
2203
+ /**
2204
+ * Carries one overlay across an ALTER PRIMARY KEY, which no overlay can follow in place:
2205
+ * its layer BTrees are keyed by the OLD primary key, and a staged tombstone identifies the
2206
+ * row it deletes BY that key — under a new key a tombstone's identity columns may be
2207
+ * placeholder NULLs, i.e. garbage. So:
2208
+ *
2209
+ * - An overlay WITH staged rows is either the ISSUER's — unreachable, {@link alterTable}
2210
+ * rejected it before the underlying mutated, so assert INTERNAL — or FOREIGN, which is
2211
+ * poisoned exactly like a foreign overlay whose staged values a retype cannot convert.
2212
+ * - A CLEAN overlay stages nothing; it is swapped for a fresh empty staging table built
2213
+ * from the post-alter schema, so later writes in this transaction key by the new primary
2214
+ * key. The fresh table's connection registers lazily at its first write, replaying the
2215
+ * active savepoint stack — correct for a table with no pre-existing staged rows (the
2216
+ * replay hazard is only ever about rows copied beneath it, and there are none).
2217
+ */
2218
+ async replaceOverlayForPrimaryKeyChange(key, state, isIssuer, schemaName, tableName, updatedSchema, change) {
2219
+ if (state.hasChanges) {
2220
+ if (isIssuer) {
2221
+ throw new QuereusError(`Isolation layer: the issuer's overlay for '${schemaName}.${tableName}' reached alterPrimaryKey migration with staged rows; the pre-mutation guard in alterTable must reject this first.`, StatusCode.INTERNAL);
2222
+ }
2223
+ state.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
2224
+ return;
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.
2234
+ const fresh = await this.overlayModule.create(state.db, this.createOverlaySchema(updatedSchema));
2235
+ this.connectionOverlays.set(key, { overlayTable: fresh, hasChanges: false, db: state.db });
2236
+ await this.releaseOverlayTable(state);
1556
2237
  }
1557
2238
  /**
1558
2239
  * Creates overlay schema from underlying schema.
1559
2240
  * Adds tombstone column and uses unique name to avoid conflicts.
1560
2241
  *
1561
- * Called by IsolatedTable when lazily creating its overlay, and by the two overlay-rebuild
1562
- * paths (`rebuildOverlayForIndexChange`, `migrateOverlayForAlter`).
2242
+ * Called by IsolatedTable when lazily creating its overlay, and by the ALTER PRIMARY KEY
2243
+ * clean-overlay swap (`replaceOverlayForPrimaryKeyChange`). Index DDL and every other ALTER
2244
+ * adopt in place — a new UNIQUE arrives through {@link createOverlayIndexSchema} /
2245
+ * {@link overlayPredicate}, which this method shares.
1563
2246
  *
1564
2247
  * Every copied secondary index — and every copied UNIQUE constraint, including the ones
1565
2248
  * a UNIQUE index derives — is narrowed to a PARTIAL structure over live rows only
@@ -1573,6 +2256,16 @@ export class IsolationModule {
1573
2256
  *
1574
2257
  * `IsolatedTable.mergedSecondaryIndexQuery` wants exactly the live overlay rows out of
1575
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.
1576
2269
  */
1577
2270
  createOverlaySchema(baseSchema) {
1578
2271
  const tombstoneColumn = {
@@ -1594,25 +2287,48 @@ export class IsolationModule {
1594
2287
  // Use unique ID to avoid conflicts when multiple overlays exist
1595
2288
  const overlayId = generateOverlayId();
1596
2289
  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
2290
  return {
1608
2291
  ...baseSchema,
1609
2292
  name: overlayName,
1610
2293
  columns: newColumns,
1611
2294
  columnIndexMap: newColumnIndexMap,
1612
- indexes: baseSchema.indexes?.map(idx => ({ ...idx, predicate: andPredicate(rescope(idx.predicate), liveOnly) })),
1613
- uniqueConstraints: baseSchema.uniqueConstraints?.map(uc => ({ ...uc, predicate: andPredicate(rescope(uc.predicate), liveOnly) })),
2295
+ indexes: baseSchema.indexes?.map(idx => this.createOverlayIndexSchema(idx, baseSchema.name, overlayName)),
2296
+ uniqueConstraints: baseSchema.uniqueConstraints?.map(uc => ({
2297
+ ...uc,
2298
+ predicate: this.overlayPredicate(uc.predicate, baseSchema.name, overlayName),
2299
+ })),
1614
2300
  };
1615
2301
  }
2302
+ /**
2303
+ * The overlay-flavored form of one base `IndexSchema`: same name and columns, predicate
2304
+ * narrowed to live rows and rescoped onto the overlay's table name (see
2305
+ * {@link overlayPredicate}).
2306
+ *
2307
+ * Shared by {@link createOverlaySchema}, which maps the whole index set when an overlay is
2308
+ * first created, and {@link createIndex}, which hands a single index to an overlay that is
2309
+ * already open — so both produce structurally identical entries, and the UNIQUE constraint
2310
+ * `MemoryTableManager.createIndex` synthesizes from a unique index inherits the same
2311
+ * predicate either way.
2312
+ */
2313
+ createOverlayIndexSchema(idx, baseName, overlayName) {
2314
+ return { ...idx, predicate: this.overlayPredicate(idx.predicate, baseName, overlayName) };
2315
+ }
2316
+ /**
2317
+ * `<base predicate, rescoped to the overlay> AND <tombstone> = 0`.
2318
+ *
2319
+ * A partial-index / UNIQUE predicate copied from the base carries a self-qualifier bound to
2320
+ * the base table's name (e.g. `where t.v > 0`). The overlay renames the table to
2321
+ * `overlayName`, so that qualifier now names a DIFFERENT table than the overlay's
2322
+ * MemoryIndex is scoped to — and `compilePredicate` rejects a foreign qualifier at
2323
+ * index-build time (see partial-index-predicate table-qualifier rejection). Rescope the
2324
+ * self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
2325
+ * cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
2326
+ * was created, so every qualifier present is the base name.
2327
+ */
2328
+ overlayPredicate(base, baseName, overlayName) {
2329
+ const rescoped = base ? rescopePredicateQualifier(base, baseName, overlayName) : undefined;
2330
+ return andPredicate(rescoped, this.liveRowPredicate());
2331
+ }
1616
2332
  /**
1617
2333
  * `<tombstoneColumn> = 0` — the partial-structure predicate that scopes an overlay index
1618
2334
  * or UNIQUE constraint to live rows. Built as an AST rather than parsed from text because