@quereus/isolation 3.2.1 → 4.0.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 } from '@quereus/quereus';
1
+ import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema } from '@quereus/quereus';
2
2
  import { IsolatedTable } from './isolated-table.js';
3
3
  let overlayIdCounter = 0;
4
4
  /**
@@ -8,6 +8,36 @@ let overlayIdCounter = 0;
8
8
  export function generateOverlayId() {
9
9
  return ++overlayIdCounter;
10
10
  }
11
+ /**
12
+ * Concurrency-mode strength ranking: weakest → strongest.
13
+ * `'serial'` (0) tolerates the least; `'fully-reentrant'` (2) the most.
14
+ * Used by {@link weakerMode} / {@link clampToReentrantReads} to compute the
15
+ * mode `IsolationModule` forwards (see `IsolationModule.concurrencyMode`).
16
+ */
17
+ const MODE_RANK = {
18
+ serial: 0,
19
+ 'reentrant-reads': 1,
20
+ 'fully-reentrant': 2,
21
+ };
22
+ /**
23
+ * Returns the weaker (lower-rank) of two concurrency modes. A merged read
24
+ * through `IsolationModule` touches BOTH the underlying and the overlay table,
25
+ * so it is only as concurrency-safe as the weaker of the two.
26
+ */
27
+ export function weakerMode(a, b) {
28
+ return MODE_RANK[a] <= MODE_RANK[b] ? a : b;
29
+ }
30
+ /**
31
+ * Caps a mode at `'reentrant-reads'`. `IsolationModule`'s own write path
32
+ * (`IsolatedTable.update` → `ensureOverlay`, `setHasChanges`, the multi-step
33
+ * merged-conflict checks, the savepoint sets) mutates shared per-connection
34
+ * state non-atomically, so the wrapper is never `'fully-reentrant'` no matter
35
+ * how reentrant the underlying/overlay are. This is the single place that
36
+ * invariant is enforced.
37
+ */
38
+ export function clampToReentrantReads(mode) {
39
+ return MODE_RANK[mode] > MODE_RANK['reentrant-reads'] ? 'reentrant-reads' : mode;
40
+ }
11
41
  /**
12
42
  * A module wrapper that adds transaction isolation to any underlying module.
13
43
  *
@@ -46,10 +76,122 @@ export class IsolationModule {
46
76
  * rollbackToSavepoint(depth) looks up the correct stack index.
47
77
  */
48
78
  preOverlaySavepoints = new Map();
79
+ /**
80
+ * In-flight covering-connection builds, keyed identically to
81
+ * {@link connectionOverlays} (`<dbId>:<schema>.<table>` via
82
+ * {@link makeConnectionOverlayKey}). Connection registration is a
83
+ * per-connection (per-db+table) invariant, not a per-wrapper one, so the memo
84
+ * lives here — at the layer that spans every `IsolatedTable` wrapper for one
85
+ * (db, table) — rather than on the wrapper instance.
86
+ *
87
+ * `IsolatedTable.ensureConnection()` `await`s the overlay `createConnection()`
88
+ * / the database `registerConnection()` between its covering-reuse lookup and
89
+ * the `registeredConnection` set. This module forwards `'reentrant-reads'` (see
90
+ * {@link concurrencyMode}), so the runtime may drive two concurrent
91
+ * merged-overlay scans of one table — and it connects a FRESH `IsolatedTable`
92
+ * per scan (see {@link connect}), so the two scans land on DISTINCT wrapper
93
+ * instances. A per-wrapper memo cannot coalesce them: both see
94
+ * `registeredConnection === null`, both miss the existing-covering lookup, both
95
+ * `registerConnection` — double-registering, which makes
96
+ * `DeferredConstraintQueue.findConnection()` throw on multiple covering
97
+ * candidates. Keying the memo per (db, table) coalesces across wrappers: the
98
+ * first scan to enter creates the build promise; concurrent peers `await` it
99
+ * and resolve to the SAME covering connection. Typed in
100
+ * `VirtualTableConnection` terms (not `IsolatedConnection`) to keep this module
101
+ * free of an `isolated-connection` import; the resolved value is an
102
+ * `IsolatedConnection`. Mirrors `LaminaTable.connectionInFlight`.
103
+ */
104
+ connectionInFlight = new Map();
105
+ /**
106
+ * Backing-host capability forward (engine `vtab/backing-host.ts`) — assigned in
107
+ * the constructor ONLY when the underlying module implements it, so method
108
+ * PRESENCE mirrors the underlying (presence IS the capability; a wrapper around
109
+ * a capability-less module must not advertise it). A straight delegate is
110
+ * correct: every backing write is privileged (`applyMaintenance` /
111
+ * `replaceContents` bypass user DML entirely), so the per-connection overlay
112
+ * never holds backing rows and the underlying host's pending state is the only
113
+ * state there is. Mid-transaction `select`s of the MV reach that pending state
114
+ * through the merged read (empty overlay → underlying reads-own-writes), and at
115
+ * commit/rollback the backing's IsolatedConnection flushes a no-op empty overlay
116
+ * while the host's own connection commits/rolls back the underlying pending —
117
+ * disjoint state, so ordering between the two is immaterial.
118
+ */
119
+ getBackingHost;
120
+ /**
121
+ * Materialized-view backing-create capability forward
122
+ * (`SchemaManager.createBackingTable` prefers `createBacking?() ?? create()`)
123
+ * — assigned in the constructor ONLY when the underlying module implements it,
124
+ * so method PRESENCE mirrors the underlying, exactly like {@link getBackingHost}.
125
+ * The two MUST be forwarded together: this forward routes the MV backing into
126
+ * the underlying's durable store via its `createBacking`, so the subsequent
127
+ * (forwarded) {@link getBackingHost} resolves a real host. Without it, the
128
+ * wrapper would have no `createBacking`, `createBackingTable` would fall back to
129
+ * the wrapper's generic {@link create} (an ordinary underlying table), and the
130
+ * forwarded `getBackingHost` would find no durable host for it. The body mirrors
131
+ * {@link create} — wrap the underlying table in an `IsolatedTable` and record
132
+ * underlying state — but builds the underlying via `createBacking`. Backing
133
+ * writes are privileged and bypass the per-connection overlay (see
134
+ * {@link getBackingHost}), so the empty-overlay wrapper is correct here too.
135
+ */
136
+ createBacking;
49
137
  constructor(config) {
50
138
  this.underlying = config.underlying;
51
139
  this.overlayModule = config.overlay ?? new MemoryTableModule();
52
140
  this.tombstoneColumn = config.tombstoneColumn ?? '_tombstone';
141
+ const underlyingGetBackingHost = this.underlying.getBackingHost;
142
+ if (underlyingGetBackingHost) {
143
+ this.getBackingHost = (db, schemaName, tableName) => underlyingGetBackingHost.call(this.underlying, db, schemaName, tableName);
144
+ }
145
+ const underlyingCreateBacking = this.underlying.createBacking;
146
+ if (underlyingCreateBacking) {
147
+ this.createBacking = async (db, tableSchema) => {
148
+ const underlyingTable = await underlyingCreateBacking.call(this.underlying, db, tableSchema);
149
+ const state = { underlyingTable };
150
+ this.setUnderlyingState(tableSchema.schemaName, tableSchema.name, state);
151
+ return new IsolatedTable(db, this, underlyingTable);
152
+ };
153
+ }
154
+ }
155
+ /**
156
+ * Forwards a concurrency-mode hint so a host that wraps a reentrant module
157
+ * in `IsolationModule` keeps the plan-level `concurrencySafe` it would get
158
+ * registering the underlying directly (read by
159
+ * `TableReferenceNode.computePhysical` via `getModuleConcurrencyMode`).
160
+ *
161
+ * Merged reads touch BOTH the underlying table and the overlay table (a
162
+ * `MemoryTable` by default, or a host-injected `config.overlay`), so the
163
+ * forwarded mode is the {@link weakerMode weaker} of the two — a serial
164
+ * underlying OR a serial custom overlay degrades the whole wrapper to
165
+ * `'serial'`. The result is then {@link clampToReentrantReads capped} at
166
+ * `'reentrant-reads'`: `IsolationModule`'s write path is never reentrant.
167
+ *
168
+ * A live getter (not a construction-time snapshot): the underlying's mode is
169
+ * a static module property today, but mirroring `expectedLatencyMs` — whose
170
+ * value is learned lazily at connect time — keeps both forwards reading live
171
+ * each plan. Always returns a concrete value (never `undefined`), satisfying
172
+ * the optional `concurrencyMode?` under `exactOptionalPropertyTypes`.
173
+ */
174
+ get concurrencyMode() {
175
+ const underlying = this.underlying.concurrencyMode ?? 'serial';
176
+ const overlay = this.overlayModule.concurrencyMode ?? 'serial';
177
+ return clampToReentrantReads(weakerMode(underlying, overlay));
178
+ }
179
+ /**
180
+ * Forwards the underlying module's first-row-latency planner hint so a cold
181
+ * `NodeFsProvider` / OPFS install's scan node carries the latency estimate
182
+ * through the wrapper (read by `TableReferenceNode.computePhysical`, which
183
+ * only lifts the value when `> 0`). The overlay is an in-memory staging table
184
+ * with no meaningful latency, so only the underlying contributes.
185
+ *
186
+ * Returns `0` (never `undefined`) when the underlying declares none — `0` is
187
+ * observably identical to omitting the hint, and a concrete value satisfies
188
+ * the optional `expectedLatencyMs?` under `exactOptionalPropertyTypes`. A
189
+ * getter, not a stored field: `LaminaModule.expectedLatencyMs` is itself a
190
+ * getter whose value is learned lazily at connect time, so a construction-time
191
+ * snapshot would capture a stale `0`.
192
+ */
193
+ get expectedLatencyMs() {
194
+ return this.underlying.expectedLatencyMs ?? 0;
53
195
  }
54
196
  /**
55
197
  * Gets the underlying table state for a table.
@@ -113,6 +255,38 @@ export class IsolationModule {
113
255
  const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
114
256
  this.preOverlaySavepoints.delete(key);
115
257
  }
258
+ /**
259
+ * Coalesces concurrent covering-connection builds for one (db, table) onto a
260
+ * single in-flight promise, keyed identically to {@link connectionOverlays}
261
+ * (see {@link connectionInFlight}).
262
+ *
263
+ * On a cache hit, returns the existing in-flight build so a concurrent peer
264
+ * resolves to the SAME covering connection. On a miss, calls `build()` and
265
+ * stores the returned promise with **no `await` between the `get` and the
266
+ * `set`** — `build()` runs its synchronous prefix (including the
267
+ * covering-reuse lookup) and returns at its first `await`, so a second caller
268
+ * cannot interleave into the synchronous get→set region and always observes
269
+ * the populated memo. This holds regardless of where the build's internal
270
+ * `await`s fall or how microtasks order.
271
+ *
272
+ * The memo is cleared on settle (fulfil AND reject), identity-guarded so a
273
+ * later rebuild's promise is never clobbered by an earlier build's clear — a
274
+ * failed build must let the next read retry.
275
+ */
276
+ coalesceConnectionBuild(db, schemaName, tableName, build) {
277
+ const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
278
+ const existing = this.connectionInFlight.get(key);
279
+ if (existing)
280
+ return existing;
281
+ const inFlight = build();
282
+ this.connectionInFlight.set(key, inFlight);
283
+ const clear = () => {
284
+ if (this.connectionInFlight.get(key) === inFlight)
285
+ this.connectionInFlight.delete(key);
286
+ };
287
+ inFlight.then(clear, clear);
288
+ return inFlight;
289
+ }
116
290
  /**
117
291
  * Creates a unique key for connection-scoped overlay storage.
118
292
  * Uses the database instance's identity as the connection identifier.
@@ -147,6 +321,64 @@ export class IsolationModule {
147
321
  savepoints: true,
148
322
  };
149
323
  }
324
+ /**
325
+ * Forwards mapping-advertisement discovery to the underlying module.
326
+ *
327
+ * The lens compiler's advertisement resolver reaches a basis table's
328
+ * `vtabModule` — which is this wrapper when a memory/store basis is isolated —
329
+ * and calls the optional `getMappingAdvertisements` hook. A decomposition's
330
+ * storage/access shape is a property of the underlying basis relations and is
331
+ * isolation-transparent (the overlay does not change the decomposition shape),
332
+ * so a straight delegate is correct. Without this forward, `quereus.lens.decomp.*`
333
+ * tags on isolation-wrapped basis tables are silently dropped and a logical
334
+ * table over the decomposition fails body compilation with "no basis backing".
335
+ */
336
+ getMappingAdvertisements(db, basisSchema) {
337
+ return this.underlying.getMappingAdvertisements?.(db, basisSchema) ?? [];
338
+ }
339
+ /**
340
+ * Forwards APPLY SCHEMA's batch-begin signal to the underlying module.
341
+ *
342
+ * APPLY SCHEMA's migration loop fires `beginSchemaBatch`/`endSchemaBatch`
343
+ * on the *registered* module that owns each table — which is this wrapper
344
+ * when a basis is isolated. A batching-capable underlying module folds the
345
+ * whole APPLY SCHEMA into a single substrate commit by opening a batch here
346
+ * that its subsequent create/destroy/alter callbacks (which IsolationModule
347
+ * forwards to the underlying) join. Without this forward the underlying is
348
+ * never reached and silently falls back to per-DDL commits.
349
+ *
350
+ * This is a straight delegate to the underlying: APPLY SCHEMA migrations are
351
+ * DDL against the underlying substrate, not staged data writes, so the
352
+ * per-connection overlays do not participate. Overlays hold uncommitted
353
+ * *data* writes inside a user transaction; schema DDL does not route through
354
+ * them, so there is nothing for the overlay/commit lifecycle to flush as
355
+ * part of the batch.
356
+ */
357
+ async beginSchemaBatch(db, schemaName) {
358
+ await this.underlying.beginSchemaBatch?.(db, schemaName);
359
+ }
360
+ /**
361
+ * Forwards APPLY SCHEMA's batch-end signal to the underlying module.
362
+ * See `beginSchemaBatch` for why a straight delegate is correct.
363
+ */
364
+ async endSchemaBatch(db, schemaName, error) {
365
+ await this.underlying.endSchemaBatch?.(db, schemaName, error);
366
+ }
367
+ /**
368
+ * Forwards APPLY SCHEMA's lens deployment notification to the underlying module.
369
+ *
370
+ * A logical `apply schema X` fires `notifyLensDeployment` on the *registered*
371
+ * module (this wrapper when a basis is isolated), handing it the freshly
372
+ * deployed `LensDeploymentSnapshot` so a basis-backing module can reconcile its
373
+ * storage against the new lens. The deployed lens shape is a property of the
374
+ * declared logical/basis schemas and is isolation-transparent (the overlay does
375
+ * not change it), so a straight delegate is correct — mirroring the
376
+ * `getMappingAdvertisements` forward. Without this forward an isolation-wrapped
377
+ * basis module would silently never hear the deployment.
378
+ */
379
+ async notifyLensDeployment(db, logicalSchemaName, snapshot) {
380
+ await this.underlying.notifyLensDeployment?.(db, logicalSchemaName, snapshot);
381
+ }
150
382
  /**
151
383
  * Delegates access plan selection to the underlying module.
152
384
  * This ensures the query planner knows about indexes and can generate
@@ -282,6 +514,12 @@ export class IsolationModule {
282
514
  const suffix = `:${schemaName}.${tableName}`.toLowerCase();
283
515
  for (const [key, overlayState] of this.connectionOverlays.entries()) {
284
516
  if (key.endsWith(suffix)) {
517
+ // A poisoned overlay (cross-connection ALTER) holds rows in the pre-alter column
518
+ // layout, narrower/wider than `updatedSchema`. Rebuilding it here would copy
519
+ // layout-mismatched rows AND drop the poison flag (the new state carries none),
520
+ // silently un-poisoning a connection that must still roll back. Leave it as-is.
521
+ if (overlayState.poison)
522
+ continue;
285
523
  const newState = await this.migrateOverlayForDropIndex(db, overlayState, updatedSchema);
286
524
  this.connectionOverlays.set(key, newState);
287
525
  }
@@ -307,36 +545,129 @@ export class IsolationModule {
307
545
  * Delegates ALTER TABLE to the underlying module and migrates any per-connection
308
546
  * overlays to the post-alter schema without discarding staged rows.
309
547
  *
310
- * ADD COLUMN — appends null to each overlay row's data columns.
548
+ * ADD COLUMN — appends the new column's value to each overlay row's data columns,
549
+ * backfilled per row exactly as the committed path does (literal default,
550
+ * per-row `new.<col>` evaluator, or NULL); tombstone rows get NULL.
311
551
  * DROP COLUMN — removes the dropped column from each overlay row.
312
552
  * RENAME / ALTER COLUMN — data column indices are unchanged; only schema metadata rotates.
553
+ *
554
+ * **Atomicity guarantee.** DDL through Quereus is not transaction-scoped and the
555
+ * underlying (shared, committed) base auto-commits its mutation immediately —
556
+ * there is no frame to unwind, and `dropColumn` / type-converting `alterColumn`
557
+ * are lossy and not invertible, so "revert the underlying on overlay-migration
558
+ * failure" is not viable. Instead this method **pre-validates** every affected
559
+ * overlay's backfill (the per-row NOT NULL check and the tombstone-present guard)
560
+ * BEFORE calling `underlying.alterTable`. A rejection therefore fires while the
561
+ * underlying, the schema catalog, and every overlay are still untouched, so the
562
+ * ALTER either fails clean or fully applies — base/catalog can no longer diverge.
563
+ * This mirrors the engine's pre-mutation `validateNotNullBackfill` in
564
+ * `runtime/emit/alter-table.ts`.
313
565
  */
314
566
  async alterTable(db, schemaName, tableName, change) {
315
567
  if (!this.underlying.alterTable) {
316
568
  throw new QuereusError(`Underlying module does not support ALTER TABLE for '${schemaName}.${tableName}'`, StatusCode.UNSUPPORTED);
317
569
  }
318
- // Collect affected overlays before the underlying schema is mutated.
570
+ // Partition affected overlays into the ISSUER's own (the connection that issued
571
+ // the ALTER) and FOREIGN ones (other open connections). The issuer staged both
572
+ // the data and the DDL, so its own un-backfillable overlay aborts the ALTER up
573
+ // front (atomic); a foreign un-backfillable overlay must not — it is poisoned and
574
+ // left for its owning connection to error on, while the issuer's ALTER proceeds.
575
+ // Already-poisoned overlays (own or foreign) are skipped entirely: they hold rows
576
+ // from before an earlier ALTER, stay poisoned, and must not be re-read/migrated.
319
577
  const suffix = `:${schemaName}.${tableName}`.toLowerCase();
320
- const affected = [];
578
+ const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
579
+ let ownEntry;
580
+ const foreign = [];
321
581
  for (const [key, state] of this.connectionOverlays.entries()) {
322
- if (key.endsWith(suffix)) {
323
- affected.push([key, state]);
582
+ if (!key.endsWith(suffix))
583
+ continue;
584
+ // An already-poisoned overlay (from an earlier ALTER) holds pre-alter rows and must
585
+ // never be re-read or migrated — checked BEFORE the ownKey split so the poisoned
586
+ // connection's OWN later ALTER cannot route its overlay through migration, which
587
+ // would silently clear the poison and rebuild a layout-mismatched overlay. A
588
+ // poisoned connection recovers only by rolling back, regardless of who issues the ALTER.
589
+ if (state.poison)
590
+ continue;
591
+ if (key === ownKey) {
592
+ ownEntry = [key, state];
593
+ }
594
+ else {
595
+ foreign.push([key, state]);
324
596
  }
325
597
  }
326
- // For dropColumn we need the pre-alter column index, readable from any overlay schema.
598
+ // Overlays we will actually migrate forward (issuer-own first). The shared
599
+ // dropColumn index is probed from one of these, never from a skipped poisoned
600
+ // overlay whose schema may be a stale pre-alter layout.
601
+ const toMigrate = ownEntry ? [ownEntry, ...foreign] : foreign;
602
+ // For dropColumn we need the pre-alter column index, readable from any
603
+ // to-be-migrated overlay's schema.
327
604
  let dropColumnIdx;
328
- if (change.type === 'dropColumn' && affected.length > 0) {
329
- const overlaySchema = affected[0][1].overlayTable.tableSchema;
605
+ if (change.type === 'dropColumn' && toMigrate.length > 0) {
606
+ const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
330
607
  dropColumnIdx = overlaySchema?.columnIndexMap.get(change.columnName.toLowerCase());
331
608
  }
609
+ // Build the addColumn backfill context up front (undefined for other change types).
610
+ // Derived purely from `change` + the session nullability option — no post-alter
611
+ // schema needed — so it is valid here, before the underlying is mutated, and the
612
+ // same context drives the post-mutation migration.
613
+ const addColumnCtx = this.deriveAddColumnBackfill(change, db, tableName);
614
+ // Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
615
+ // Any throw here (CONSTRAINT backfill or INTERNAL tombstone guard) propagates while
616
+ // underlying + catalog + every overlay are still untouched — the companion ticket's
617
+ // atomic-abort guarantee, preserved unchanged for the issuer.
618
+ if (ownEntry) {
619
+ await this.validateOverlayMigration(ownEntry[1], addColumnCtx);
620
+ }
332
621
  const updated = await this.underlying.alterTable(db, schemaName, tableName, change);
333
- // Migrate each affected overlay to the new schema, preserving staged rows.
334
- for (const [key, oldState] of affected) {
335
- const newState = await this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx);
622
+ // The cached underlying VirtualTable's `tableSchema` is a construction-time
623
+ // snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
624
+ // underlying manager's schema but not this instance's field. Refresh it so a
625
+ // freshly-connected IsolatedTable's merged-view UNIQUE check (which reads
626
+ // this.tableSchema.uniqueConstraints / per-column collation) sees the post-alter
627
+ // constraint set. Mirrors the implicit instance refresh dropIndex already gets.
628
+ const underlyingState = this.getUnderlyingState(schemaName, tableName);
629
+ if (underlyingState)
630
+ underlyingState.underlyingTable.tableSchema = updated;
631
+ // Migrate the issuer's own overlay (already validated above). Its NOT NULL /
632
+ // tombstone throw sites are unreachable after pre-validation.
633
+ if (ownEntry) {
634
+ const newState = await this.migrateOverlayForAlter(db, ownEntry[1], updated, change, dropColumnIdx, addColumnCtx);
635
+ this.connectionOverlays.set(ownEntry[0], newState);
636
+ }
637
+ // Tier 3: per FOREIGN overlay, validate then migrate — but a per-row NOT NULL
638
+ // (CONSTRAINT) failure poisons that one overlay instead of aborting the issuer's
639
+ // ALTER. An INTERNAL failure (e.g. missing tombstone column) is a layer-invariant
640
+ // violation, not a data condition, so it rethrows loud for everyone. Validation
641
+ // runs per overlay, so one bad foreign overlay poisons only itself; healthy peers
642
+ // still migrate.
643
+ for (const [key, oldState] of foreign) {
644
+ try {
645
+ await this.validateOverlayMigration(oldState, addColumnCtx);
646
+ }
647
+ catch (e) {
648
+ if (e instanceof QuereusError && e.code === StatusCode.CONSTRAINT) {
649
+ oldState.poison = { message: this.buildAlterPoisonMessage(schemaName, tableName, change) };
650
+ continue; // poisoned — do NOT migrate; leave pre-alter rows in place
651
+ }
652
+ throw e;
653
+ }
654
+ const newState = await this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx, addColumnCtx);
336
655
  this.connectionOverlays.set(key, newState);
337
656
  }
338
657
  return updated;
339
658
  }
659
+ /**
660
+ * Builds the poison message stamped onto a foreign overlay whose backfill could not
661
+ * satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
662
+ * schema.table and the offending column so the owning connection's eventual
663
+ * read/write/commit error is self-explanatory. Poison only arises on the addColumn
664
+ * NOT NULL path, so the column name is taken from the addColumn change; other change
665
+ * types never reach here but are handled defensively.
666
+ */
667
+ buildAlterPoisonMessage(schemaName, tableName, change) {
668
+ const col = change.type === 'addColumn' ? change.columnDef.name : '<column>';
669
+ return `ALTER on '${schemaName}.${tableName}' added column '${col}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
670
+ }
340
671
  /**
341
672
  * Renames a table through the isolation layer.
342
673
  *
@@ -388,7 +719,7 @@ export class IsolationModule {
388
719
  * Rebuilds an overlay table under the post-alter schema, translating each
389
720
  * staged row to the new column layout.
390
721
  */
391
- async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx) {
722
+ async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx) {
392
723
  const oldOverlay = oldState.overlayTable;
393
724
  const oldOverlaySchema = oldOverlay.tableSchema;
394
725
  const newOverlaySchema = this.createOverlaySchema(updatedSchema);
@@ -398,25 +729,134 @@ export class IsolationModule {
398
729
  if (oldTombstoneIdx === undefined) {
399
730
  throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
400
731
  }
732
+ // `addColumnCtx` (folded literal default, the per-row evaluator, and the new
733
+ // column's NOT NULL flag) was precomputed once per ALTER by the caller and already
734
+ // dry-run validated against these same staged rows; undefined for non-addColumn
735
+ // change types, which append nothing to staged rows.
401
736
  for await (const oldRow of oldOverlay.query(this.makeFullScanFilterInfo())) {
402
- const newRow = this.translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx);
737
+ const addColumnValue = addColumnCtx
738
+ ? await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx)
739
+ : undefined;
740
+ const newRow = this.translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue);
403
741
  await newOverlayTable.update({ operation: 'insert', values: newRow, preCoerced: true });
404
742
  }
405
743
  }
406
744
  return { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges };
407
745
  }
746
+ /**
747
+ * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
748
+ * the folded literal DEFAULT (the `tryFoldLiteral` of the DEFAULT expr, or `null`
749
+ * when there is no DEFAULT or it folds to NULL), the engine-supplied per-row
750
+ * evaluator (present only for a non-foldable `new.<col>` default), and whether
751
+ * the new column is NOT NULL. Returns undefined for every non-`addColumn` change
752
+ * so the row loop appends nothing.
753
+ *
754
+ * The new column's nullability is resolved exactly as both underlyings resolve it
755
+ * (`columnDefToSchema(columnDef, default_column_nullability === 'not_null')`) so the
756
+ * pre-validation cannot drift from what the underlying will enforce. Because it is
757
+ * derived purely from `change` + the session option — not the post-alter schema,
758
+ * which does not exist until `underlying.alterTable` runs — this can be built
759
+ * BEFORE the irreversible underlying mutation and reused by the migration after.
760
+ */
761
+ deriveAddColumnBackfill(change, db, tableName) {
762
+ if (change.type !== 'addColumn')
763
+ return undefined;
764
+ const defaultExpr = change.columnDef.constraints?.find(c => c.type === 'default')?.expr;
765
+ // tryFoldLiteral returns undefined for a non-foldable expr and null for one that
766
+ // folds to NULL; collapse both to null (the no-usable-literal default).
767
+ const foldedDefault = defaultExpr ? (tryFoldLiteral(defaultExpr) ?? null) : null;
768
+ const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
769
+ // Thread the session `default_collation` for symmetry with the underlying memory/store
770
+ // ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
771
+ // underlying materializes the real column), so it does not affect collation here — but
772
+ // keeping the call signature identical avoids drift and is correct for any future reader.
773
+ const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'));
774
+ return {
775
+ foldedDefault,
776
+ evaluator: change.backfillEvaluator,
777
+ newColNotNull: newColumn.notNull,
778
+ newColName: newColumn.name,
779
+ tableName,
780
+ };
781
+ }
782
+ /**
783
+ * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
784
+ * so the caller can run it for every affected overlay BEFORE the irreversible
785
+ * `underlying.alterTable` (see {@link alterTable}). It exercises the EXACT code
786
+ * paths the real migration uses — the tombstone-present guard and, for addColumn,
787
+ * `computeAddColumnValue` per staged row — so a dry-run pass and the subsequent
788
+ * migrate pass cannot diverge:
789
+ *
790
+ * - A clean overlay (`!hasChanges`) stages no rows, so there is nothing to validate.
791
+ * - A missing tombstone column throws INTERNAL here, before the underlying is touched.
792
+ * - For addColumn, each staged row runs through `computeAddColumnValue`: tombstone
793
+ * rows short-circuit to `null` (the evaluator never runs), and a NOT-NULL-violating
794
+ * evaluated row throws CONSTRAINT here, atomically. Computed values are discarded.
795
+ *
796
+ * Non-addColumn changes (`addColumnCtx === undefined`) only run the tombstone guard;
797
+ * their row translation appends/removes nothing fallible on data grounds.
798
+ */
799
+ async validateOverlayMigration(oldState, addColumnCtx) {
800
+ const oldOverlay = oldState.overlayTable;
801
+ const oldOverlaySchema = oldOverlay.tableSchema;
802
+ // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
803
+ // schema stages nothing and runs none of the fallible checks.
804
+ if (!(oldState.hasChanges && oldOverlaySchema && oldOverlay.query))
805
+ return;
806
+ const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
807
+ if (oldTombstoneIdx === undefined) {
808
+ throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
809
+ }
810
+ if (!addColumnCtx)
811
+ return;
812
+ for await (const oldRow of oldOverlay.query(this.makeFullScanFilterInfo())) {
813
+ // Discard the result — this is validation only. A NOT NULL violation throws here.
814
+ await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
815
+ }
816
+ }
817
+ /**
818
+ * Computes one staged row's value for a freshly added column, mirroring the
819
+ * committed-row backfill (see `base.ts` `recreatePrimaryTreeWithNewColumn` and
820
+ * `store-module.ts` `migrateRows`):
821
+ *
822
+ * - Tombstone rows carry NULL placeholders and their appended value is never read,
823
+ * so append `null` and never run the evaluator against them (it could reference
824
+ * NULL siblings or spuriously trip the NOT NULL check).
825
+ * - With a per-row evaluator, derive the value from the existing-columns slice and
826
+ * enforce NOT NULL on that path only (a literal/NULL default's nullability is gated
827
+ * up-front by the engine, exactly as `base.ts` does).
828
+ * - Otherwise use the folded literal default.
829
+ */
830
+ async computeAddColumnValue(ctx, oldRow, oldTombstoneIdx) {
831
+ if (oldRow[oldTombstoneIdx] === 1)
832
+ return null;
833
+ if (ctx.evaluator) {
834
+ const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
835
+ const value = await ctx.evaluator(data);
836
+ if (ctx.newColNotNull && value === null) {
837
+ throw new QuereusError(`NOT NULL constraint failed: backfilling column '${ctx.tableName}.${ctx.newColName}' produced NULL for a staged row`, StatusCode.CONSTRAINT);
838
+ }
839
+ return value;
840
+ }
841
+ return ctx.foldedDefault;
842
+ }
408
843
  /**
409
844
  * Translates a single overlay row from the pre-alter to the post-alter column layout.
410
845
  * The tombstone value is preserved in the last position.
846
+ *
847
+ * `addColumnValue` is the per-row value the caller computed for an `addColumn`
848
+ * (via {@link computeAddColumnValue}); it is `undefined` for every other change
849
+ * type. Keeping the (async) backfill in the caller's loop lets this stay synchronous.
411
850
  */
412
- translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx) {
851
+ translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue) {
413
852
  const tombstoneValue = oldRow[oldTombstoneIdx];
414
853
  const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
415
854
  let newData;
416
855
  switch (change.type) {
417
856
  case 'addColumn':
418
- // New column is always appended after existing data columns.
419
- newData = [...data, null];
857
+ // New column is always appended after existing data columns, backfilled per
858
+ // row by the caller (literal default, per-row evaluator, or NULL).
859
+ newData = [...data, addColumnValue ?? null];
420
860
  break;
421
861
  case 'dropColumn':
422
862
  newData = dropColumnIdx !== undefined
@@ -427,6 +867,8 @@ export class IsolationModule {
427
867
  case 'alterColumn':
428
868
  case 'alterPrimaryKey':
429
869
  case 'addConstraint':
870
+ case 'dropConstraint':
871
+ case 'renameConstraint':
430
872
  newData = data;
431
873
  break;
432
874
  default: {