@quereus/isolation 4.5.0 → 4.7.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.
@@ -0,0 +1,827 @@
1
+ import { QuereusError, StatusCode, foldDefaultToType, columnDefToSchema, isConstraintViolation, planRetypeConversion, validateCollationForType, formatKeyValue } from '@quereus/quereus';
2
+ import { makeFullScanFilterInfo } from './filter-info.js';
3
+ import { makePkKeySerializer } from './overlay-rows.js';
4
+ /**
5
+ * Rejects an ADD / RENAME COLUMN that would give a base column the same name as the
6
+ * overlay's private tombstone flag, BEFORE `underlying.alterTable` runs.
7
+ *
8
+ * Every overlay carries one extra column named `AlterMigrationHost.tombstoneColumn`, so a base
9
+ * column of that name collides with it. Forwarding the change to an open overlay makes the
10
+ * overlay module raise a duplicate-name `ERROR`, which is not a data condition and so
11
+ * rethrows out of `IsolationModule.applyInPlaceOverlayChange` — after the underlying has already
12
+ * (irreversibly) applied. The catalog then keeps the pre-alter column set while the base
13
+ * holds the new one, and the next write to the table lands values against the wrong
14
+ * columns. Rejecting up front keeps the atomic-abort guarantee `IsolationModule.alterTable`
15
+ * documents.
16
+ *
17
+ * NOTE: the check is unconditional — not gated on an overlay existing — so the answer
18
+ * does not depend on whether some connection happens to have a transaction open. That
19
+ * also stops a table from acquiring the colliding name while idle and hitting
20
+ * `IsolationModule.createOverlaySchema`'s (silent) duplicate at the next BEGIN. A CREATE TABLE
21
+ * declaring the name still gets that duplicate — tracked separately.
22
+ */
23
+ export function assertColumnNameNotTombstone(host, schemaName, tableName, change) {
24
+ const newName = change.type === 'addColumn' ? change.columnDef.name
25
+ : change.type === 'renameColumn' ? change.newName
26
+ : undefined;
27
+ if (newName?.toLowerCase() !== host.tombstoneColumn.toLowerCase())
28
+ return;
29
+ 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);
30
+ }
31
+ /**
32
+ * Builds the poison message stamped onto a foreign overlay whose backfill could not
33
+ * satisfy a cross-connection ALTER (see `IsolationModule.alterTable` tier 3). Names the
34
+ * schema.table and the offending column so the owning connection's eventual
35
+ * read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
36
+ * path (a new NOT-NULL column with no usable default), the `set not null` tightening
37
+ * path (a staged NULL with no usable default), the `set data type` path (a staged value
38
+ * that cannot be converted to the new type), and the primary-key `set collate` path (two
39
+ * staged live rows colliding under the new collation); other change types never reach
40
+ * here but are handled defensively.
41
+ */
42
+ export function buildAlterPoisonMessage(schemaName, tableName, change) {
43
+ if (change.type === 'addColumn') {
44
+ return `ALTER on '${schemaName}.${tableName}' added column '${change.columnDef.name}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
45
+ }
46
+ if (change.type === 'alterColumn') {
47
+ if (change.setDataType !== undefined) {
48
+ 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.`;
49
+ }
50
+ if (change.setCollation !== undefined) {
51
+ 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.`;
52
+ }
53
+ return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
54
+ }
55
+ if (change.type === 'alterPrimaryKey') {
56
+ return `ALTER on '${schemaName}.${tableName}' changed the table's primary key, which this connection's uncommitted rows cannot follow; roll back this transaction.`;
57
+ }
58
+ return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
59
+ }
60
+ /**
61
+ * Precomputes every per-change-type constant this ALTER's overlay migration needs, from the
62
+ * PRE-alter schema — so the whole plan is valid BEFORE the irreversible `underlying.alterTable`
63
+ * and the same plan drives the migration after it.
64
+ *
65
+ * `toMigrate` is the overlays that will actually be carried forward (issuer-own first). The
66
+ * attribute contexts are probed from `toMigrate[0]`'s schema, never from a skipped poisoned
67
+ * overlay whose schema may be a stale pre-alter layout; with no overlays to migrate there is
68
+ * nothing to backfill, convert or re-key, so those arms stay undefined.
69
+ */
70
+ export function deriveAlterMigrationPlan(change, db, tableName, toMigrate) {
71
+ return {
72
+ addColumn: deriveAddColumnBackfill(change, db, tableName),
73
+ setNotNull: deriveSetNotNullBackfill(change, toMigrate),
74
+ setDataType: deriveSetDataTypeConvert(change, toMigrate),
75
+ pkRekey: derivePkRekey(change, db, tableName, toMigrate),
76
+ };
77
+ }
78
+ /**
79
+ * The PRE-alter overlay schema every to-be-migrated overlay shares, or undefined when there is
80
+ * nothing to migrate (or the probe overlay has no queryable schema) and so no context to derive.
81
+ */
82
+ function probeOverlaySchema(toMigrate) {
83
+ if (toMigrate.length === 0)
84
+ return undefined;
85
+ return toMigrate[0][1].overlayTable.tableSchema;
86
+ }
87
+ /**
88
+ * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
89
+ * the folded literal DEFAULT (the `foldDefaultToType` of the DEFAULT expr — folded and
90
+ * converted to the new column's declared type — or `null` when there is no DEFAULT or it
91
+ * folds to NULL), the engine-supplied per-row
92
+ * evaluator (present only for a non-foldable `new.<col>` default), and whether
93
+ * the new column is NOT NULL. Returns undefined for every non-`addColumn` change
94
+ * so the row loop appends nothing.
95
+ *
96
+ * The new column's nullability is resolved exactly as both underlyings resolve it
97
+ * (`columnDefToSchema(columnDef, default_column_nullability === 'not_null')`) so the
98
+ * pre-validation cannot drift from what the underlying will enforce. Because it is
99
+ * derived purely from `change` + the session option — not the post-alter schema,
100
+ * which does not exist until `underlying.alterTable` runs — this can be built
101
+ * BEFORE the irreversible underlying mutation and reused by the migration after.
102
+ */
103
+ function deriveAddColumnBackfill(change, db, tableName) {
104
+ if (change.type !== 'addColumn')
105
+ return undefined;
106
+ const defaultExpr = change.columnDef.constraints?.find(c => c.type === 'default')?.expr;
107
+ const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
108
+ // Thread the session `default_collation` for symmetry with the underlying memory/store
109
+ // ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
110
+ // underlying materializes the real column), so it does not affect collation here — but
111
+ // keeping the call signature identical avoids drift and is correct for any future reader.
112
+ const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
113
+ // foldDefaultToType returns undefined for a non-foldable expr and null for one that
114
+ // folds to NULL; collapse both to null (the no-usable-literal default). It also converts
115
+ // the literal to the new column's declared type — the staged overlay row writes with
116
+ // `preCoerced: true`, so it can never pick that conversion up implicitly, and without it
117
+ // an overlay row would hold the raw literal where the committed store holds the converted
118
+ // one. An unconvertible literal throws MISMATCH here, before the underlying is mutated.
119
+ const foldedDefault = foldDefaultToType(defaultExpr, newColumn.logicalType, newColumn.name) ?? null;
120
+ return {
121
+ foldedDefault,
122
+ evaluator: change.backfillEvaluator,
123
+ newColNotNull: newColumn.notNull,
124
+ newColName: newColumn.name,
125
+ tableName,
126
+ };
127
+ }
128
+ /**
129
+ * Precomputes the per-ALTER constants an `alter column … set not null` overlay migration needs:
130
+ * the now-NOT-NULL column's index and the folded literal DEFAULT (with `hasDefault` gating
131
+ * backfill vs reject). Returns undefined unless this is a NOT NULL *tightening*
132
+ * (`setNotNull: true`) with at least one overlay to migrate — a DROP NOT NULL loosens and
133
+ * needs no staged-row work, and with no overlays there is nothing to backfill or reject.
134
+ *
135
+ * `change` for `set not null` carries no default expression, so the DEFAULT is read from the
136
+ * column's PRE-alter schema (via a to-be-migrated overlay — the same layout every migrated overlay
137
+ * shares). Folded exactly as
138
+ * {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
139
+ * cannot drift from what the underlying enforces over its committed rows.
140
+ */
141
+ function deriveSetNotNullBackfill(change, toMigrate) {
142
+ if (change.type !== 'alterColumn' || change.setNotNull !== true)
143
+ return undefined;
144
+ const overlaySchema = probeOverlaySchema(toMigrate);
145
+ if (!overlaySchema)
146
+ return undefined;
147
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
148
+ if (colIndex === undefined)
149
+ return undefined;
150
+ const oldCol = overlaySchema.columns[colIndex];
151
+ if (!oldCol)
152
+ return undefined;
153
+ // foldDefaultToType returns undefined for a non-foldable expr and null for one that folds
154
+ // to NULL; both mean "no usable literal default" — the staged NULL must reject, not
155
+ // backfill. It also converts the literal to the column's declared type, so a backfilled
156
+ // staged row and a backfilled committed row hold the same value (the overlay writes with
157
+ // `preCoerced: true` and cannot pick the conversion up implicitly). An unconvertible
158
+ // literal throws MISMATCH here, before the underlying is mutated — the same point both
159
+ // underlyings fold at.
160
+ const folded = foldDefaultToType(oldCol.defaultValue, oldCol.logicalType, change.columnName);
161
+ const hasDefault = folded !== undefined && folded !== null;
162
+ return {
163
+ colIndex,
164
+ foldedDefault: hasDefault ? folded : null,
165
+ hasDefault,
166
+ colName: change.columnName,
167
+ };
168
+ }
169
+ /**
170
+ * Precomputes the per-ALTER constants an `alter column … set data type` overlay conversion
171
+ * needs: the retyped column's index and a per-value `convert`. Returns undefined unless this
172
+ * is a retype with at least one overlay to convert, and undefined for an ALIAS retype — the
173
+ * same decision the underlyings gate their own value rewrite on, taken from the same shared
174
+ * helper, so overlay and committed rows keep moving together should "what counts as a
175
+ * value-rewriting retype" ever change. A same-storage-class retype between types (text → date) DOES
176
+ * convert: staged values are validated and normalized to the new type's canonical spelling,
177
+ * exactly as the underlyings treat their committed rows.
178
+ *
179
+ * `convert` comes from {@link planRetypeConversion}, the same decide-and-convert helper
180
+ * `MemoryTableManager.alterColumn` and `StoreModule.alterColumnSetDataType` call for their own
181
+ * committed rows — so the three legs (rethrowing failure as the same MISMATCH message) cannot
182
+ * drift apart.
183
+ */
184
+ function deriveSetDataTypeConvert(change, toMigrate) {
185
+ if (change.type !== 'alterColumn' || change.setDataType === undefined)
186
+ return undefined;
187
+ const overlaySchema = probeOverlaySchema(toMigrate);
188
+ if (!overlaySchema)
189
+ return undefined;
190
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
191
+ if (colIndex === undefined)
192
+ return undefined;
193
+ const oldCol = overlaySchema.columns[colIndex];
194
+ if (!oldCol)
195
+ return undefined;
196
+ const { convert } = planRetypeConversion(change.setDataType, oldCol.logicalType, change.columnName);
197
+ if (!convert)
198
+ return undefined;
199
+ return { colIndex, convert };
200
+ }
201
+ /**
202
+ * Precomputes the per-ALTER constants a primary-key re-keying `alter column … set collate`
203
+ * overlay migration needs: the PK column positions and a key serializer built over the
204
+ * POST-change collation — the PRE-alter overlay schema with the named column's collation
205
+ * replaced. The overlay's PK definition mirrors the base's (the tombstone flag is appended
206
+ * after the data columns), so the synthesized schema keys exactly as the re-keyed base will.
207
+ *
208
+ * Returns undefined unless the named column is a PRIMARY KEY member and the normalized
209
+ * collation actually differs from the column's current one — mirroring the underlying's
210
+ * metadata-only gate (`MemoryTableManager.alterColumn`'s `nameMatches`), so overlay and
211
+ * committed rows re-key together or not at all. `validateCollationForType` can throw for an
212
+ * unknown collation, but the engine already validated the name before dispatching the ALTER,
213
+ * and a direct module caller gets the same pre-mutation rejection the underlying would give.
214
+ */
215
+ function derivePkRekey(change, db, tableName, toMigrate) {
216
+ if (change.type !== 'alterColumn' || change.setCollation === undefined)
217
+ return undefined;
218
+ const overlaySchema = probeOverlaySchema(toMigrate);
219
+ if (!overlaySchema)
220
+ return undefined;
221
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
222
+ if (colIndex === undefined)
223
+ return undefined;
224
+ if (!overlaySchema.primaryKeyDefinition.some(def => def.index === colIndex))
225
+ return undefined;
226
+ const oldCol = overlaySchema.columns[colIndex];
227
+ if (!oldCol)
228
+ return undefined;
229
+ const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, (n) => db.isCollationRegistered(n));
230
+ if (normalized === (oldCol.collation || 'BINARY'))
231
+ return undefined; // metadata-only — the underlying re-keys nothing
232
+ const postChangeSchema = {
233
+ ...overlaySchema,
234
+ columns: overlaySchema.columns.map((c, i) => (i === colIndex ? { ...c, collation: normalized } : c)),
235
+ };
236
+ return {
237
+ pkIndices: overlaySchema.primaryKeyDefinition.map(def => def.index),
238
+ keyOf: makePkKeySerializer(db, postChangeSchema),
239
+ tableName,
240
+ };
241
+ }
242
+ /**
243
+ * Buckets an overlay's staged rows by their PRIMARY KEY as the pending `set collate` will
244
+ * re-key it: one {@link PkRekeyGroup} per post-change key. Shared by the validation dry run
245
+ * (which refuses a group holding two live rows) and the migrate-step marker drop (which
246
+ * discards collapsed markers), so the two passes cannot disagree about what collapses.
247
+ *
248
+ * NOTE: materializes one serialized key + one PK tuple per staged row (plus the marker rows
249
+ * themselves), once per pass of one ALTER. If an overlay with very many staged rows ever
250
+ * makes this ALTER slow, group lazily or reuse the merge `IsolationModule.effectiveRowsFor`
251
+ * already builds.
252
+ */
253
+ async function collectPkRekeyGroups(overlay, tombstoneIdx, ctx) {
254
+ const groups = new Map();
255
+ for await (const row of overlay.query(makeFullScanFilterInfo())) {
256
+ const pk = ctx.pkIndices.map(i => row[i]);
257
+ const key = ctx.keyOf(pk);
258
+ let group = groups.get(key);
259
+ if (!group) {
260
+ group = { livePks: [], markerRows: [] };
261
+ groups.set(key, group);
262
+ }
263
+ if (row[tombstoneIdx] === 1)
264
+ group.markerRows.push(row);
265
+ else
266
+ group.livePks.push(pk);
267
+ }
268
+ return groups;
269
+ }
270
+ /**
271
+ * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
272
+ * so the caller can run it for every affected overlay BEFORE the irreversible
273
+ * `underlying.alterTable` (see `IsolationModule.alterTable`). It exercises the EXACT code
274
+ * paths the real migration uses — the tombstone-present guard and, for addColumn,
275
+ * `computeAddColumnValue` per staged row — so a dry-run pass and the subsequent
276
+ * migrate pass cannot diverge:
277
+ *
278
+ * - A clean overlay (`!hasChanges`) stages no rows, so there is nothing to validate.
279
+ * - A missing tombstone column throws INTERNAL here, before the underlying is touched.
280
+ * - For addColumn, each staged row runs through {@link computeAddColumnValue}, which owns
281
+ * both NOT NULL reject sources and short-circuits tombstone rows; a violation throws
282
+ * CONSTRAINT here, atomically. Computed values are discarded.
283
+ *
284
+ * For `set not null` with NO usable DEFAULT (`plan.setNotNull.hasDefault === false`), a staged
285
+ * non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
286
+ * aborts atomically before the underlying mutates; for a foreign overlay the caller maps it to
287
+ * poison. With a usable DEFAULT the staged NULLs are backfilled by
288
+ * {@link backfillStagedNotNull}, so nothing is rejected here.
289
+ *
290
+ * For `set data type` (`plan.setDataType`), every staged non-NULL value is run through the
291
+ * conversion and the result discarded, so an unconvertible one throws MISMATCH here. For the
292
+ * ISSUER this is belt-and-braces — the underlying's own pre-mutation pass walks the same rows
293
+ * via `IsolationModule.issuerEffectiveRows` — EXCEPT when an outer wrapper supplied `rows`, in
294
+ * which case this is the only check. For a FOREIGN overlay it is the only pass that ever sees
295
+ * those rows, and it must run before the migration so a failure becomes poison rather than a
296
+ * half-migrated overlay.
297
+ *
298
+ * For a primary-key re-keying `set collate` (`plan.pkRekey`), the staged rows are grouped by
299
+ * their POST-change key: a deletion marker sharing a key with one live row is that row's
300
+ * before-image (the migrate step discards it — {@link dropCollapsedPkRekeyMarkers}), and
301
+ * markers alone on one key are one deletion — neither is a duplicate. Two LIVE rows on one
302
+ * key ARE, so that throws CONSTRAINT here, mirroring the underlying's own collision message.
303
+ * For the ISSUER this is belt-and-braces (the underlying's pre-mutation pass judges the same
304
+ * staged rows via `IsolationModule.issuerEffectiveRows`, though this check fires first); for a
305
+ * FOREIGN overlay it is the only pass that sees those rows, and its throw becomes poison.
306
+ *
307
+ * Non-addColumn / non-tightening / non-retype / non-re-key changes (an empty `plan`) only run
308
+ * the tombstone guard; their row translation appends/removes nothing fallible on data grounds.
309
+ */
310
+ export async function validateOverlayMigration(host, oldState, plan) {
311
+ const oldOverlay = oldState.overlayTable;
312
+ const oldOverlaySchema = oldOverlay.tableSchema;
313
+ // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
314
+ // schema stages nothing and runs none of the fallible checks.
315
+ if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
316
+ return;
317
+ const oldTombstoneIdx = requireTombstoneIndex(host, oldOverlaySchema);
318
+ if (plan.addColumn) {
319
+ // NOTE: full-scans every staged row even when nothing can fail (a nullable column with no
320
+ // evaluator rejects nothing) and even when the reject is row-independent (a mandatory
321
+ // column with no DEFAULT rejects on the first live row). If overlays ever get large, gate
322
+ // the scan on `newColNotNull` and stop at the first live row on the no-evaluator path.
323
+ for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
324
+ // Discard the result — this is validation only. A NOT NULL violation throws here.
325
+ await computeAddColumnValue(plan.addColumn, oldRow, oldTombstoneIdx);
326
+ }
327
+ return;
328
+ }
329
+ // SET NOT NULL with no usable DEFAULT: reject a staged NULL the forward could not fill.
330
+ // (With a DEFAULT there is nothing to reject — backfillStagedNotNull fills instead.)
331
+ if (plan.setNotNull && !plan.setNotNull.hasDefault) {
332
+ const setNotNullCtx = plan.setNotNull;
333
+ for await (const oldRow of stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
334
+ if (oldRow[setNotNullCtx.colIndex] === null) {
335
+ throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
336
+ }
337
+ }
338
+ return;
339
+ }
340
+ // SET DATA TYPE: prove every staged value convertible before anything is rewritten. NULLs
341
+ // are left untouched by the conversion (the underlying's `convertNulls` is false for a retype).
342
+ if (plan.setDataType) {
343
+ const setDataTypeCtx = plan.setDataType;
344
+ for await (const oldRow of stagedLiveRows(oldOverlay, oldTombstoneIdx)) {
345
+ const value = oldRow[setDataTypeCtx.colIndex];
346
+ if (value !== null)
347
+ setDataTypeCtx.convert(value); // discard — validation only
348
+ }
349
+ return;
350
+ }
351
+ // SET COLLATE re-keying the PRIMARY KEY: two staged LIVE rows landing on one post-change
352
+ // key are a real duplicate — refuse before the underlying re-keys (atomic for the issuer,
353
+ // poison for a foreign overlay via the caller). Marker/live and marker/marker groups
354
+ // collapse cleanly and are handled by the migrate step, not rejected here.
355
+ if (plan.pkRekey) {
356
+ const pkRekeyCtx = plan.pkRekey;
357
+ for (const group of (await collectPkRekeyGroups(oldOverlay, oldTombstoneIdx, pkRekeyCtx)).values()) {
358
+ if (group.livePks.length < 2)
359
+ continue;
360
+ const keyDesc = group.livePks[1].map(formatKeyValue).join(', ');
361
+ throw new QuereusError(`UNIQUE constraint failed: ${pkRekeyCtx.tableName} primary key collides under new collation (key: ${keyDesc})`, StatusCode.CONSTRAINT);
362
+ }
363
+ }
364
+ }
365
+ /**
366
+ * The staged rows an overlay holds that represent LIVE data, i.e. every row except the
367
+ * tombstones — those carry placeholder NULLs at every data column and must never be fed to a
368
+ * per-value check, which would read a NULL that is not the row's real value.
369
+ */
370
+ async function* stagedLiveRows(overlay, tombstoneIdx) {
371
+ for await (const row of overlay.query(makeFullScanFilterInfo())) {
372
+ if (row[tombstoneIdx] !== 1)
373
+ yield row;
374
+ }
375
+ }
376
+ /**
377
+ * Computes one staged row's value for a freshly added column, mirroring the
378
+ * committed-row backfill (see `base.ts` `recreatePrimaryTreeWithNewColumn` and
379
+ * `store-table.ts` `migrateRows`):
380
+ *
381
+ * - Tombstone rows carry NULL placeholders and their appended value is never read,
382
+ * so append `null` and never run the evaluator against them (it could reference
383
+ * NULL siblings or spuriously trip the NOT NULL check).
384
+ * - With a per-row evaluator, derive the value from the existing-columns slice and
385
+ * enforce NOT NULL on that path.
386
+ * - Otherwise use the folded literal default, enforcing NOT NULL on it too: the engine's own
387
+ * pre-mutation probe (`validateNotNullBackfill`) only sees the ISSUING connection's rows
388
+ * (committed + its own staged rows), never a foreign connection's, so a mandatory column
389
+ * with no usable DEFAULT must be re-checked here for every staged row this pass reaches.
390
+ */
391
+ async function computeAddColumnValue(ctx, oldRow, oldTombstoneIdx) {
392
+ if (oldRow[oldTombstoneIdx] === 1)
393
+ return null;
394
+ if (ctx.evaluator) {
395
+ const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
396
+ const value = await ctx.evaluator(data);
397
+ if (ctx.newColNotNull && value === null) {
398
+ throw new QuereusError(`NOT NULL constraint failed: backfilling column '${ctx.tableName}.${ctx.newColName}' produced NULL for a staged row`, StatusCode.CONSTRAINT);
399
+ }
400
+ return value;
401
+ }
402
+ if (ctx.newColNotNull && ctx.foldedDefault === null) {
403
+ throw new QuereusError(`NOT NULL constraint failed: column '${ctx.tableName}.${ctx.newColName}' has no usable DEFAULT for a staged row`, StatusCode.CONSTRAINT);
404
+ }
405
+ return ctx.foldedDefault;
406
+ }
407
+ /**
408
+ * Carries ONE already-open overlay to the post-alter schema, IN PLACE — through the overlay's
409
+ * own `alterSchema` / `createIndex` / ordinary writes — so the overlay's layer chain and
410
+ * savepoint snapshots survive the ALTER and `rollback to savepoint` keeps distinguishing rows
411
+ * staged before the savepoint from rows staged after it (see
412
+ * `IsolationModule.applyIndexChangeToOverlays` for why the old rebuild — copying staged rows
413
+ * into a fresh staging table — destroyed that distinction). Per change type:
414
+ *
415
+ * - ADD / DROP / RENAME COLUMN: {@link forwardColumnShapeToOverlay}.
416
+ * - ALTER COLUMN: {@link forwardAlterColumnToOverlay}.
417
+ * - ADD CONSTRAINT: {@link forwardAddConstraintToOverlay}.
418
+ * - DROP / RENAME CONSTRAINT: {@link forwardConstraintNameChangeToOverlay}.
419
+ *
420
+ * ALTER PRIMARY KEY is excluded from the parameter type: no overlay can follow it in place, so
421
+ * `IsolationModule.replaceOverlayForPrimaryKeyChange` handles that arm instead.
422
+ *
423
+ * The caller wraps this in `IsolationModule.applyInPlaceOverlayChange`, which routes a
424
+ * CONSTRAINT/BUSY failure by who owns the overlay (issuer → INTERNAL drift error; foreign →
425
+ * poison) and rethrows everything else.
426
+ */
427
+ export async function migrateOverlayForward(host, overlayState, change, plan, updatedSchema) {
428
+ switch (change.type) {
429
+ case 'addColumn':
430
+ case 'dropColumn':
431
+ case 'renameColumn':
432
+ await forwardColumnShapeToOverlay(host, overlayState, change, plan.addColumn);
433
+ return;
434
+ case 'alterColumn':
435
+ await forwardAlterColumnToOverlay(host, overlayState, change, plan.setNotNull, plan.pkRekey);
436
+ return;
437
+ case 'addConstraint':
438
+ await forwardAddConstraintToOverlay(host, overlayState, change, updatedSchema);
439
+ return;
440
+ case 'dropConstraint':
441
+ case 'renameConstraint':
442
+ await forwardConstraintNameChangeToOverlay(overlayState, change);
443
+ return;
444
+ default: {
445
+ const _exhaustive = change;
446
+ return _exhaustive;
447
+ }
448
+ }
449
+ }
450
+ /**
451
+ * Forwards one column-shape change (ADD / DROP / RENAME COLUMN) to an already-open
452
+ * overlay IN PLACE via the overlay's optional `alterSchema`. The overlay module (the
453
+ * memory module by default) reshapes its open transaction layers with their savepoint
454
+ * snapshots intact, so `rollback to savepoint` keeps distinguishing rows staged before
455
+ * the savepoint from rows staged after it — the rebuild path destroyed that
456
+ * distinction in both directions (see `IsolationModule.applyIndexChangeToOverlays`).
457
+ *
458
+ * A missing `alterSchema` is treated as a no-op, the way the index paths treat a
459
+ * missing `createIndex` / `dropIndex`.
460
+ *
461
+ * `dropColumn` / `renameColumn` forward the caller's change unchanged. `addColumn`
462
+ * forwards an overlay-flavoured copy — see {@link buildOverlayAddColumnChange}.
463
+ */
464
+ async function forwardColumnShapeToOverlay(host, overlayState, change, addColumnCtx) {
465
+ const overlayTable = overlayState.overlayTable;
466
+ if (!overlayTable.alterSchema)
467
+ return;
468
+ // `deriveAddColumnBackfill` returns a context for every addColumn, so the assertion
469
+ // cannot fire when the change type matches.
470
+ const overlayChange = change.type === 'addColumn'
471
+ ? buildOverlayAddColumnChange(host, change, addColumnCtx, overlayTable)
472
+ : change;
473
+ await overlayTable.alterSchema(overlayChange);
474
+ }
475
+ /**
476
+ * The overlay-flavoured form of one `addColumn` change:
477
+ *
478
+ * - `insertAtIndex` = the overlay's current tombstone column index, so the new data
479
+ * column lands ahead of the tombstone flag and the flag stays LAST — the layout
480
+ * every read/write path of this package assumes. A bare forward would append after
481
+ * the flag and every value written to the new column would be dropped on read.
482
+ * A caller that named its own position keeps it: the overlay's data columns mirror
483
+ * the base's one-for-one below the flag, so the base's index is the overlay's too.
484
+ * (SQL always appends, so today the caller never names one — but silently ignoring
485
+ * it would diverge the two layouts.)
486
+ * - The column definition is made NULLABLE. The overlay's ADD re-runs the overlay
487
+ * module's own NOT NULL validation against a different row population than the
488
+ * base's: tombstone rows carry placeholder NULL in every non-PK column, so a
489
+ * NOT NULL column the base already accepted would be rejected here. The base's
490
+ * NOT NULL is still enforced where it belongs — by the engine's pre-mutation
491
+ * `validateNotNullBackfill` and the underlying's own ADD (both judged the issuer's
492
+ * effective rows), and per staged row by {@link computeAddColumnValue}'s
493
+ * evaluator-path check.
494
+ * - `backfillEvaluator` receives an OVERLAY row (data columns plus the flag) and
495
+ * routes through {@link computeAddColumnValue} — the same helper
496
+ * {@link validateOverlayMigration} dry-runs, so validation and forward cannot
497
+ * drift: NULL for a tombstone row, else the engine's per-row `new.<col>` evaluator
498
+ * over the flag-stripped row, or the folded literal default.
499
+ */
500
+ function buildOverlayAddColumnChange(host, change, addColumnCtx, overlayTable) {
501
+ const tombstoneIdx = requireTombstoneIndex(host, overlayTable.tableSchema);
502
+ const constraints = (change.columnDef.constraints ?? []).filter(c => c.type !== 'notNull');
503
+ if (!constraints.some(c => c.type === 'null'))
504
+ constraints.push({ type: 'null' });
505
+ return {
506
+ ...change,
507
+ columnDef: { ...change.columnDef, constraints },
508
+ insertAtIndex: change.insertAtIndex ?? tombstoneIdx,
509
+ backfillEvaluator: (overlayRow) => computeAddColumnValue(addColumnCtx, overlayRow, tombstoneIdx),
510
+ };
511
+ }
512
+ /**
513
+ * Forwards one ALTER COLUMN attribute change to an already-open overlay IN PLACE, with the
514
+ * NOT NULL attribute deliberately withheld.
515
+ *
516
+ * `set data type` / `set collate` / `set default` forward through the overlay's own
517
+ * `alterSchema`: the overlay module converts / re-keys its open transaction layers with
518
+ * their savepoint snapshots intact, and NULLs pass a retype untouched, so tombstone rows —
519
+ * placeholder NULLs at every non-key data column — ride through unharmed. One preparation
520
+ * step precedes a PRIMARY-KEY re-keying `set collate` (`pkRekeyCtx`): deletion markers the
521
+ * re-key collapses onto another staged row are discarded first
522
+ * ({@link dropCollapsedPkRekeyMarkers}) — the overlay's PK deliberately covers markers, so
523
+ * without the drop its own `alterSchema` would reject the collapsed pair as a PK collision.
524
+ *
525
+ * Neither NOT NULL direction forwards, and the overlay's `notNull` flag is left where it
526
+ * stood when the overlay was created:
527
+ *
528
+ * - **Tightening (`set not null`).** Forwarding would have the overlay module tighten a
529
+ * column whose tombstone rows carry NULL at every non-PK data column, so it would either
530
+ * backfill a deletion marker's placeholder NULL from the DEFAULT (corrupting a row that
531
+ * is not a row) or reject it outright when no usable DEFAULT exists. The base's NOT NULL
532
+ * is enforced where it belongs — by the underlying over the issuer's effective rows, and
533
+ * per overlay by {@link validateOverlayMigration} — and the staged LIVE rows' NULLs are
534
+ * filled here via {@link backfillStagedNotNull}.
535
+ * - **Relaxing (`drop not null`).** Nothing to do: the overlay never enforced the flag, and
536
+ * the rows it stages are written with `preCoerced: true` through the overlay module's own
537
+ * write path, which does not consult column nullability at all.
538
+ *
539
+ * NOTE: both directions therefore leave the overlay's `notNull` flag drifted from the base
540
+ * for the rest of the transaction. Inert today — nothing on the overlay path reads it. The
541
+ * one consumer that would, `MemoryTableModule.getBestAccessPlan` (which prunes `IS NULL` on
542
+ * a NOT NULL column to an empty result), is unreachable because the isolation layer builds
543
+ * the overlay's `FilterInfo` itself (see `filter-info.ts`) instead of planning against the
544
+ * overlay module. If overlay scans ever route through the overlay module's planner hook, or
545
+ * its write path starts enforcing nullability, this has to become a real forward that
546
+ * excludes tombstones.
547
+ *
548
+ * The runtime admits exactly one attribute per ALTER COLUMN, so a `setNotNull` change
549
+ * carries nothing else to forward. A missing overlay `alterSchema` is a no-op, as in
550
+ * {@link forwardColumnShapeToOverlay}.
551
+ */
552
+ async function forwardAlterColumnToOverlay(host, overlayState, change, setNotNullCtx, pkRekeyCtx) {
553
+ if (change.setNotNull !== undefined) {
554
+ if (change.setNotNull === true && setNotNullCtx?.hasDefault) {
555
+ await backfillStagedNotNull(host, overlayState, setNotNullCtx);
556
+ }
557
+ return;
558
+ }
559
+ if (!overlayState.overlayTable.alterSchema)
560
+ return;
561
+ if (pkRekeyCtx)
562
+ await dropCollapsedPkRekeyMarkers(host, overlayState, pkRekeyCtx);
563
+ await overlayState.overlayTable.alterSchema(change);
564
+ }
565
+ /**
566
+ * Discards the deletion markers a pending PRIMARY-KEY re-key collapses onto another staged
567
+ * row, through the overlay's ordinary write path so its layer chain and savepoint snapshots
568
+ * stay intact. Two shapes collapse (see {@link PkRekeyContext}): a marker sharing its
569
+ * post-change key with a live row is that row's before-image — the live row's flush upsert
570
+ * is the complete net effect, so the marker is dropped; markers alone together on one key
571
+ * are one deletion, so all but the first are dropped. A group holding two live rows was
572
+ * already refused by {@link validateOverlayMigration} before the underlying mutated.
573
+ *
574
+ * Runs BEFORE the re-key is forwarded to the overlay, and deletes by each marker's OLD
575
+ * primary key — the overlay is still keyed under the old collation at that moment.
576
+ *
577
+ * NOTE: like {@link backfillStagedNotNull}'s rewrites, the drops land in the overlay's
578
+ * CURRENT savepoint frame. A later `rollback to savepoint` taken BEFORE this ALTER restores
579
+ * the marker while the table stays re-keyed — the same divergence class that method's NOTE
580
+ * documents (`bug-rolled-back-rows-violate-surviving-ddl`), not a new hole opened here.
581
+ */
582
+ async function dropCollapsedPkRekeyMarkers(host, overlayState, ctx) {
583
+ const plan = await planPkRekeyMarkerDrops(host, overlayState, ctx);
584
+ await applyPkRekeyMarkerDrops(overlayState, ctx, plan);
585
+ }
586
+ /**
587
+ * The whole overlay rows of every deletion marker {@link dropCollapsedPkRekeyMarkers} will
588
+ * discard, computed without mutating anything. Re-planning after the drops finds nothing
589
+ * (each group then holds at most one marker and no marker beside a live row), so the
590
+ * issuer path — which drops in tier 2 for the pre-flight — can safely run the shared
591
+ * migrate-step drop again as a no-op.
592
+ */
593
+ export async function planPkRekeyMarkerDrops(host, overlayState, ctx) {
594
+ const overlay = overlayState.overlayTable;
595
+ const overlaySchema = overlay.tableSchema;
596
+ if (!overlaySchema || !overlay.query)
597
+ return [];
598
+ const tombstoneIdx = requireTombstoneIndex(host, overlaySchema);
599
+ const toDrop = [];
600
+ for (const group of (await collectPkRekeyGroups(overlay, tombstoneIdx, ctx)).values()) {
601
+ // NOTE: the marker-only arm (`slice(1)`) is defensive — no marker-only collapse can
602
+ // currently SURVIVE the ALTER. Two markers share a post-change key only if the rows
603
+ // they shadow did too, and such a pair is refused one level down: by the underlying's
604
+ // re-key (a committed pair) or by the overlay's own (a staged-then-deleted pair). It
605
+ // still runs, and is still undone, on the way to those refusals.
606
+ toDrop.push(...(group.livePks.length > 0 ? group.markerRows : group.markerRows.slice(1)));
607
+ }
608
+ return toDrop;
609
+ }
610
+ /** Deletes each planned marker by its OLD primary key through the overlay's ordinary write path. */
611
+ export async function applyPkRekeyMarkerDrops(overlayState, ctx, toDrop) {
612
+ const overlay = overlayState.overlayTable;
613
+ const overlaySchema = overlay.tableSchema;
614
+ if (!overlaySchema || toDrop.length === 0)
615
+ return;
616
+ for (const markerRow of toDrop) {
617
+ const pk = ctx.pkIndices.map(i => markerRow[i]);
618
+ const result = await overlay.update({
619
+ operation: 'delete',
620
+ values: undefined,
621
+ oldKeyValues: pk,
622
+ });
623
+ // A bare delete of a staged marker has no data condition to hit, so a non-ok result
624
+ // is a layer-invariant violation — raise INTERNAL (which applyInPlaceOverlayChange
625
+ // rethrows loud for issuer and foreign alike), never poison.
626
+ if (isConstraintViolation(result)) {
627
+ 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);
628
+ }
629
+ }
630
+ }
631
+ /**
632
+ * Undoes {@link applyPkRekeyMarkerDrops} after a refusal that aborts the whole ALTER —
633
+ * the overlay pre-flight's or the underlying's (see the tier-2 re-key block in
634
+ * `IsolationModule.alterTable`). Reinserts each marker row verbatim: a marker minted for a
635
+ * committed row carries NULL at every non-key data column, but one made by deleting a row this
636
+ * transaction had already staged keeps that row's values (the convert-to-tombstone branch
637
+ * of `IsolatedTable.update`), so a reconstructed-from-PK row would not restore what was
638
+ * dropped. The reinserts land in the same savepoint frame the drops did, so the pair nets
639
+ * to nothing.
640
+ */
641
+ export async function reinsertPkRekeyMarkers(overlayState, droppedRows) {
642
+ if (droppedRows.length === 0)
643
+ return;
644
+ const overlay = overlayState.overlayTable;
645
+ const overlaySchema = overlay.tableSchema;
646
+ if (!overlaySchema)
647
+ return;
648
+ for (const markerRow of droppedRows) {
649
+ const result = await overlay.update({ operation: 'insert', values: [...markerRow] });
650
+ if (isConstraintViolation(result)) {
651
+ 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);
652
+ }
653
+ }
654
+ }
655
+ /**
656
+ * The overlay's tombstone-flag column index, or INTERNAL if the schema lost it — or has no
657
+ * queryable schema at all, which is the same defect from the caller's point of view.
658
+ */
659
+ function requireTombstoneIndex(host, overlaySchema) {
660
+ const tombstoneIdx = overlaySchema?.columnIndexMap.get(host.tombstoneColumn.toLowerCase());
661
+ if (tombstoneIdx === undefined) {
662
+ throw new QuereusError(`Tombstone column '${host.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
663
+ }
664
+ return tombstoneIdx;
665
+ }
666
+ /**
667
+ * Backfills a `set not null` tightening into an overlay's staged LIVE rows: every
668
+ * non-tombstone row holding NULL at the tightened column is rewritten with the column's
669
+ * folded literal DEFAULT through the overlay's ordinary write path, so the overlay's layer
670
+ * chain and savepoint snapshots stay intact — the overlay-side mirror of the value rewrite
671
+ * the underlying applies to its committed rows. Tombstone rows are never touched
672
+ * ({@link stagedLiveRows}).
673
+ *
674
+ * Only called with a usable DEFAULT ({@link SetNotNullBackfillContext.hasDefault}); the
675
+ * no-DEFAULT case rejects (issuer) or poisons (foreign) in {@link validateOverlayMigration}
676
+ * before any overlay is touched.
677
+ *
678
+ * NOTE: these rewrites land in the overlay's CURRENT savepoint frame — each is a normal
679
+ * staged write. A later `rollback to savepoint` taken BEFORE this ALTER therefore restores
680
+ * the pre-backfill NULL while the column stays NOT NULL (DDL is not transactional here).
681
+ * That is the same class of divergence as any rolled-back row violating surviving DDL — a
682
+ * row un-inserted past an ADD CONSTRAINT, say — tracked by the backlog ticket
683
+ * `bug-rolled-back-rows-violate-surviving-ddl`, not a new hole opened by this forward.
684
+ */
685
+ async function backfillStagedNotNull(host, overlayState, ctx) {
686
+ const overlay = overlayState.overlayTable;
687
+ const overlaySchema = overlay.tableSchema;
688
+ if (!overlaySchema || !overlay.query)
689
+ return;
690
+ const tombstoneIdx = requireTombstoneIndex(host, overlaySchema);
691
+ const pkIndices = overlaySchema.primaryKeyDefinition.map(def => def.index);
692
+ // Materialize before writing: the rewrites mutate the overlay's pending layer the
693
+ // scan is reading through.
694
+ const toFill = [];
695
+ for await (const row of stagedLiveRows(overlay, tombstoneIdx)) {
696
+ if (row[ctx.colIndex] === null)
697
+ toFill.push(row);
698
+ }
699
+ for (const row of toFill) {
700
+ const values = row.map((v, i) => (i === ctx.colIndex ? ctx.foldedDefault : v));
701
+ // No `onConflict`, so `UpdateResult` is exactly `ok | constraint`. A constraint here
702
+ // (the filled value colliding under a UNIQUE) is a data condition of this overlay's
703
+ // staged rows — thrown as CONSTRAINT for applyInPlaceOverlayChange to route to
704
+ // INTERNAL (issuer, whose merged rows the underlying's own converted-row UNIQUE
705
+ // re-validation already judged) or poison (foreign).
706
+ const result = await overlay.update({
707
+ operation: 'update',
708
+ values,
709
+ oldKeyValues: pkIndices.map(i => row[i]),
710
+ preCoerced: true,
711
+ });
712
+ if (isConstraintViolation(result)) {
713
+ throw new QuereusError(`Backfilling column '${ctx.colName}' in overlay '${overlaySchema.name}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
714
+ }
715
+ }
716
+ }
717
+ /**
718
+ * Forwards one ADD CONSTRAINT to an already-open overlay, per constraint class:
719
+ *
720
+ * - **UNIQUE** is NOT forwarded as a constraint. The AST `TableConstraint` carries no
721
+ * partial-predicate field, so a bare forward would enforce uniqueness over tombstone rows
722
+ * too — a UNIQUE whose columns sit inside the primary key would see two deleted rows
723
+ * (each carrying its PK and placeholder NULLs elsewhere) as duplicates of each other.
724
+ * Instead it lands as a tombstone-narrowed **unique index** through the same route CREATE
725
+ * INDEX takes ({@link installOverlayUniqueConstraint}).
726
+ * - **CHECK** forwards verbatim: the overlay module appends it schema-only (no row scan, no
727
+ * physical structure). Enforcement is engine-side at DML plan time against the user
728
+ * table; the overlay's copy exists so a later DROP / RENAME CONSTRAINT resolves it.
729
+ * - **FOREIGN KEY** is deliberately NOT forwarded. FK enforcement is engine-side
730
+ * (planner-synthesized EXISTS checks) — the overlay never enforces it — and the overlay
731
+ * module's ADD arm validates existing child rows through a catalog query by table name,
732
+ * which the unregistered `_overlay_*` staging table cannot serve. The presence guard in
733
+ * {@link forwardConstraintNameChangeToOverlay} keeps a later DROP / RENAME of the
734
+ * unforwarded FK a clean no-op on the overlay.
735
+ * - **PRIMARY KEY** cannot reach here — both bundled underlyings reject
736
+ * `add constraint … primary key` with UNSUPPORTED before any overlay work — so it asserts
737
+ * INTERNAL rather than leaving a silent gap for a third-party underlying that accepts it
738
+ * (the overlay could not follow; see the alterPrimaryKey handling in
739
+ * `IsolationModule.alterTable`).
740
+ */
741
+ async function forwardAddConstraintToOverlay(host, overlayState, change, updatedSchema) {
742
+ switch (change.constraint.type) {
743
+ case 'unique':
744
+ await installOverlayUniqueConstraint(host, overlayState, change.constraint, updatedSchema);
745
+ return;
746
+ case 'check':
747
+ if (overlayState.overlayTable.alterSchema)
748
+ await overlayState.overlayTable.alterSchema(change);
749
+ return;
750
+ case 'foreignKey':
751
+ return;
752
+ case 'primaryKey':
753
+ 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);
754
+ }
755
+ }
756
+ /**
757
+ * Installs one runtime-added UNIQUE constraint on an overlay as a tombstone-narrowed unique
758
+ * index (see {@link forwardAddConstraintToOverlay} for why a bare constraint forward is
759
+ * wrong). Column indices and per-column collations are read from the post-alter underlying
760
+ * schema — the canonical form, positionally identical to the overlay's data columns (the
761
+ * tombstone flag is appended last). The index is named `constraint name ?? '_uc_<cols>'`,
762
+ * the memory module's own covering-index naming rule (`implicitIndexNameFor`), so the
763
+ * derived UNIQUE the overlay module synthesizes from it resolves under the SAME name a
764
+ * later DROP / RENAME CONSTRAINT forward carries.
765
+ *
766
+ * `MemoryTableManager.createIndex` pre-validates the overlay's effective rows against the
767
+ * narrowed predicate before any mutation, so a staged duplicate raises CONSTRAINT with the
768
+ * overlay untouched — routed by the caller to INTERNAL (the issuer, whose merged rows the
769
+ * underlying already judged via `IsolationModule.issuerEffectiveRows`) or poison (a foreign
770
+ * overlay). A missing overlay `createIndex` is a no-op, as in the index paths.
771
+ */
772
+ async function installOverlayUniqueConstraint(host, overlayState, constraint, updatedSchema) {
773
+ const overlayTable = overlayState.overlayTable;
774
+ const overlaySchema = overlayTable.tableSchema;
775
+ if (!overlayTable.createIndex || !overlaySchema)
776
+ return;
777
+ const columns = (constraint.columns ?? []).map(col => {
778
+ const idx = updatedSchema.columnIndexMap.get(col.name.toLowerCase());
779
+ if (idx === undefined) {
780
+ 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);
781
+ }
782
+ return idx;
783
+ });
784
+ const indexName = constraint.name ?? `_uc_${columns.map(i => updatedSchema.columns[i].name).join('_')}`;
785
+ if (host.schemaHasIndex(overlaySchema, indexName))
786
+ return;
787
+ // A runtime-added UNIQUE carries no partial predicate of its own (the AST has no field
788
+ // for one), so the overlay predicate is exactly the live-rows narrowing.
789
+ await overlayTable.createIndex({
790
+ name: indexName,
791
+ columns: columns.map(i => ({ index: i, collation: updatedSchema.columns[i]?.collation })),
792
+ unique: true,
793
+ predicate: host.overlayPredicate(undefined, updatedSchema.name, overlaySchema.name),
794
+ });
795
+ }
796
+ /**
797
+ * Forwards DROP / RENAME CONSTRAINT to an already-open overlay, presence-guarded: a
798
+ * constraint the overlay never carried is skipped silently rather than letting the overlay
799
+ * module's NOTFOUND abort the issuer's already-applied ALTER. The guard is what makes the
800
+ * unforwarded classes safe ({@link forwardAddConstraintToOverlay}: FOREIGN KEY always, and
801
+ * UNIQUE under an overlay module without `createIndex`) — their DROP / RENAME is simply a
802
+ * no-op on the overlay.
803
+ *
804
+ * For a UNIQUE the overlay module drops/renames the constraint AND its covering index in
805
+ * lock-step — both live under the constraint's name (or the `_uc_<cols>` implicit-name
806
+ * rule), whether copied at overlay creation (`IsolationModule.createOverlaySchema`) or
807
+ * installed by {@link installOverlayUniqueConstraint} — so the base and overlay
808
+ * representations stay resolvable by one name across the whole constraint lifecycle.
809
+ */
810
+ async function forwardConstraintNameChangeToOverlay(overlayState, change) {
811
+ const overlayTable = overlayState.overlayTable;
812
+ const overlaySchema = overlayTable.tableSchema;
813
+ if (!overlayTable.alterSchema || !overlaySchema)
814
+ return;
815
+ const name = change.type === 'dropConstraint' ? change.constraintName : change.oldName;
816
+ if (!schemaHasNamedConstraint(overlaySchema, name))
817
+ return;
818
+ await overlayTable.alterSchema(change);
819
+ }
820
+ /** Case-insensitive "does `schema` declare a named constraint of ANY class (CHECK / UNIQUE / FOREIGN KEY)". */
821
+ function schemaHasNamedConstraint(schema, constraintName) {
822
+ const lower = constraintName.toLowerCase();
823
+ return (schema.checkConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
824
+ || (schema.uniqueConstraints ?? []).some(c => c.name?.toLowerCase() === lower)
825
+ || (schema.foreignKeys ?? []).some(c => c.name?.toLowerCase() === lower);
826
+ }
827
+ //# sourceMappingURL=alter-migration.js.map