@quereus/isolation 4.3.1 → 4.4.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,5 +1,8 @@
1
- import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema } from '@quereus/quereus';
1
+ import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation } from '@quereus/quereus';
2
2
  import { IsolatedTable } from './isolated-table.js';
3
+ import { applyOverlayToUnderlying } from './flush.js';
4
+ import { makeFullScanFilterInfo } from './filter-info.js';
5
+ import { iterateEffectiveRows, makePkKeySerializer } from './overlay-rows.js';
3
6
  let overlayIdCounter = 0;
4
7
  /**
5
8
  * Generates a unique overlay ID for each overlay table instance.
@@ -54,7 +57,10 @@ export function clampToReentrantReads(mode) {
54
57
  *
55
58
  * This provides ACID semantics including:
56
59
  * - Read-your-own-writes within a transaction
57
- * - Snapshot isolation (reads see consistent state)
60
+ * - Read-committed reads of shared state (the underlying table is live and shared
61
+ * across connections — this is NOT snapshot isolation; another connection's commit
62
+ * can become visible mid-transaction, and there is no write-write conflict
63
+ * detection). A stable snapshot, if needed, is the underlying module's job.
58
64
  * - Savepoint support via overlay module's transaction support
59
65
  */
60
66
  export class IsolationModule {
@@ -155,7 +161,7 @@ export class IsolationModule {
155
161
  const underlyingTable = await underlyingCreateBacking.call(this.underlying, db, tableSchema);
156
162
  const state = { underlyingTable };
157
163
  this.setUnderlyingState(tableSchema.schemaName, tableSchema.name, state);
158
- return new IsolatedTable(db, this, underlyingTable);
164
+ return new IsolatedTable(db, this, tableSchema.schemaName, tableSchema.name, underlyingTable);
159
165
  };
160
166
  }
161
167
  // The attach seams swap the underlying storage flavor in place (ordinary ⇄
@@ -168,6 +174,14 @@ export class IsolationModule {
168
174
  // from the underlying. Evict only on success: a thrown attach leaves the
169
175
  // prior flavor (and its still-valid cache) intact, and the failure-cleanup
170
176
  // path is `discardBackingForAttach`, which evicts in its own right.
177
+ //
178
+ // NOTE: these three seams evict `underlyingTables` without touching
179
+ // `connectionOverlays`, unlike `destroy()`. That is safe only because writes to a
180
+ // materialized-view backing table are privileged and bypass the overlay, so no
181
+ // overlay is ever staged against a table that crosses a seam. If a seam ever runs
182
+ // on a table an open transaction has staged writes for, `commitConnectionOverlays`
183
+ // will raise its INTERNAL invariant error — give the seams the same overlay sweep
184
+ // `destroy()` performs.
171
185
  const underlyingEnsure = this.underlying.ensureBackingForAttach;
172
186
  if (underlyingEnsure) {
173
187
  this.ensureBackingForAttach = async (db, schemaName, tableName, backingSchema) => {
@@ -267,11 +281,40 @@ export class IsolationModule {
267
281
  this.connectionOverlays.set(key, state);
268
282
  }
269
283
  /**
270
- * Removes the overlay state for a specific connection and table.
271
- * Called after commit/rollback to clean up.
284
+ * Frees the in-memory staging (overlay) table backing `state` by calling the overlay
285
+ * module's `destroy`, so its manager entry (and the rows it holds) is removed from the
286
+ * overlay module's table registry rather than leaking there for the life of the
287
+ * `Database`. This is the single sink every overlay-discard path funnels through —
288
+ * without it, `MemoryTableModule.tables` accumulates one dead `_overlay_<table>_<id>`
289
+ * entry per writing transaction (and one more per rebuild), unbounded.
290
+ *
291
+ * `MemoryTableManager.destroy` rolls back the overlay's own pending layer and clears its
292
+ * connections; a later db-side teardown of the (now-detached) `MemoryVirtualTableConnection`
293
+ * is tolerated by `MemoryTableManager.disconnect` (`!connection` → no-op), so destroying
294
+ * here mid-commit/rollback does not throw when the connection is torn down afterwards.
295
+ *
296
+ * Defensive on a missing schema: real overlays always carry one (`createOverlaySchema`),
297
+ * so a schemaless state can only be a malformed/test-fabricated one — skip rather than throw.
298
+ */
299
+ async releaseOverlayTable(state) {
300
+ const overlaySchema = state.overlayTable.tableSchema;
301
+ if (!overlaySchema)
302
+ return;
303
+ await this.overlayModule.destroy(state.db, undefined, overlaySchema.vtabModuleName, overlaySchema.schemaName, overlaySchema.name);
304
+ }
305
+ /**
306
+ * Removes the overlay state for a specific connection and table, first releasing its
307
+ * staging table so it does not leak (see {@link releaseOverlayTable}). Async because the
308
+ * release drives the overlay module's `destroy`; all callers (`clearOverlay`, `alterSchema`)
309
+ * are already async and `await` it. Called on the rollback / alter-schema / rollback-to-
310
+ * pre-overlay-savepoint discard paths.
272
311
  */
273
- clearConnectionOverlay(db, schemaName, tableName) {
312
+ async clearConnectionOverlay(db, schemaName, tableName) {
274
313
  const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
314
+ const state = this.connectionOverlays.get(key);
315
+ if (!state)
316
+ return;
317
+ await this.releaseOverlayTable(state);
275
318
  this.connectionOverlays.delete(key);
276
319
  }
277
320
  /**
@@ -293,6 +336,155 @@ export class IsolationModule {
293
336
  const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
294
337
  this.preOverlaySavepoints.delete(key);
295
338
  }
339
+ /**
340
+ * Returns every key of a connection-scoped map (`<dbId>:<schema>.<table>`, the
341
+ * shape of {@link connectionOverlays} / {@link preOverlaySavepoints} /
342
+ * {@link connectionInFlight}) that belongs to `schemaName.tableName`, across ALL
343
+ * db ids. Those maps embed the db id as a prefix, so a per-table sweep is a suffix
344
+ * match on `:<schema>.<table>`. Keys are stored lowercased, so the suffix is too.
345
+ *
346
+ * The keys are materialized into an array rather than yielded, so callers may
347
+ * delete or re-key entries while walking the result.
348
+ */
349
+ connectionScopedKeys(map, schemaName, tableName) {
350
+ const suffix = `:${schemaName}.${tableName}`.toLowerCase();
351
+ const keys = [];
352
+ for (const key of map.keys()) {
353
+ if (key.endsWith(suffix))
354
+ keys.push(key);
355
+ }
356
+ return keys;
357
+ }
358
+ /**
359
+ * Commits every overlay this db-transaction staged as ONE coordinated two-phase
360
+ * flush, instead of each table flushing+committing its own underlying
361
+ * independently. The per-table approach tears a multi-table commit: table A's
362
+ * underlying `commit()` durably lands (and, for a shared-coordinator
363
+ * `quereus-store`, flushes *every* pending table) before table B has even
364
+ * applied, so a failure in B leaves A committed. See the fix ticket and
365
+ * `quereus-store/README` § "Atomic multi-store commit".
366
+ *
367
+ * Phase 1 (apply): for every staged overlay, begin its underlying table and
368
+ * apply the overlay's rows WITHOUT committing (see {@link applyOverlayToUnderlying}).
369
+ * For a `quereus-store` underlying, every table's writes accumulate in the
370
+ * module's single shared coordinator (the first `begin()` opens it; the rest are
371
+ * idempotent no-ops).
372
+ *
373
+ * Phase 2 (commit): once ALL overlays have applied, commit the affected
374
+ * underlying tables. For `quereus-store` the first `commit()` flushes every
375
+ * table's ops in one atomic coordinator commit — a single `AtomicBatch.write()`
376
+ * on a provider that exposes `beginAtomicBatch` — and the rest no-op. For an
377
+ * underlying with per-table transaction domains (the memory vtab), each table
378
+ * commits independently.
379
+ *
380
+ * On any Phase-1 error, roll back every underlying begun so far and rethrow;
381
+ * nothing was committed, so the transaction aborts atomically. Because all the
382
+ * fallible data work (constraint re-checks, injected/IO write errors) happens in
383
+ * Phase 1 before any commit, a data-driven abort is always clean. Full
384
+ * crash-atomicity across the commit phase itself is contingent on the underlying
385
+ * exposing a shared atomic commit domain (see docs/design-isolation-layer.md
386
+ * § "Commit Failure Recovery").
387
+ *
388
+ * A poisoned overlay aborts the whole commit before any apply — mirroring the
389
+ * per-connection `assertOverlayUsable` check, now with the added benefit that no earlier
390
+ * table is left committed. The overlay is left intact so the ensuing rollback discards
391
+ * it. Two DDLs poison: a cross-connection ALTER (rows left in the pre-alter layout) and
392
+ * a cross-connection DROP TABLE (the table is gone; see {@link destroy}).
393
+ *
394
+ * Driven once per db-transaction: the first `IsolatedConnection.commit()` in the
395
+ * database's commit loop runs this whole flush and clears every overlay, so the
396
+ * remaining connections find no overlay for their table and this is a no-op — no
397
+ * explicit "already flushed" latch is needed, the cleared-overlay state guards
398
+ * itself.
399
+ *
400
+ * **Invariant: every staged overlay resolves to an underlying table here, or is
401
+ * poisoned.** The table-lifecycle hooks are what keep that true — {@link destroy}
402
+ * discards or poisons the overlays of a dropped table across every connection, and
403
+ * {@link renameTable} re-connects the underlying under the new name whenever it re-keys
404
+ * an overlay onto it. The poison check above is the enforcement point: it runs BEFORE
405
+ * the `underlyingTables` lookup, so a dropped table's surviving foreign overlay raises
406
+ * its poison message rather than the orphan error below. A miss that is neither resolved
407
+ * nor poisoned is therefore a layer-invariant violation, not a routine condition, and is
408
+ * raised as `StatusCode.INTERNAL`: the alternative — dropping the staged rows and letting
409
+ * the commit report success — is silent data loss. Only a CLEAN overlay
410
+ * (`hasChanges === false`) may miss harmlessly; it staged nothing, so it is discarded.
411
+ */
412
+ async commitConnectionOverlays(db) {
413
+ const prefix = `${this.getDbId(db)}:`;
414
+ const entries = [];
415
+ /** Clean overlays with no underlying — never applied, but must still be cleared. */
416
+ const orphanedCleanKeys = [];
417
+ for (const [key, state] of this.connectionOverlays.entries()) {
418
+ if (!key.startsWith(prefix))
419
+ continue;
420
+ // A poisoned overlay can neither be flushed nor merged (its rows are in the
421
+ // pre-alter column layout). Abort the whole commit before applying anything;
422
+ // the overlay is left intact so the ensuing rollback discards it (and its
423
+ // poison). A poisoned overlay always has hasChanges === true.
424
+ if (state.poison) {
425
+ throw new QuereusError(state.poison.message, StatusCode.CONSTRAINT);
426
+ }
427
+ // The overlay key is `<dbId>:<schema>.<table>`; the suffix after the dbId is
428
+ // exactly the `underlyingTables` key (both lowercased).
429
+ const underlyingKey = key.slice(prefix.length);
430
+ const underlyingState = this.underlyingTables.get(underlyingKey);
431
+ if (!underlyingState) {
432
+ if (state.hasChanges) {
433
+ throw new QuereusError(`Isolation layer: staged overlay '${key}' has no underlying table '${underlyingKey}' to flush. `
434
+ + `A table-lifecycle hook (destroy / renameTable) failed to keep the overlay and underlying maps in step.`, StatusCode.INTERNAL);
435
+ }
436
+ // Staged nothing, so nothing is lost. It never reaches `entries`, so the
437
+ // clear-loop below would not see it — collect it explicitly or it leaks.
438
+ orphanedCleanKeys.push(key);
439
+ continue;
440
+ }
441
+ entries.push({ key, state, underlyingTable: underlyingState.underlyingTable });
442
+ }
443
+ // Phase 1: apply every staged overlay to its underlying WITHOUT committing.
444
+ const applied = [];
445
+ try {
446
+ for (const { state, underlyingTable } of entries) {
447
+ if (!state.hasChanges)
448
+ continue;
449
+ // Track BEFORE applying: applyOverlayToUnderlying begins the underlying up
450
+ // front, so a mid-apply throw still needs this table in the rollback set.
451
+ applied.push(underlyingTable);
452
+ await applyOverlayToUnderlying(underlyingTable, state.overlayTable, this.tombstoneColumn);
453
+ }
454
+ }
455
+ catch (error) {
456
+ // Nothing committed yet — roll back every underlying we began so no table is
457
+ // left half-applied, then propagate (the transaction aborts atomically). For a
458
+ // shared-coordinator store the first rollback discards all pending ops and the
459
+ // rest no-op. allSettled mirrors the engine's own rollback-during-abort posture
460
+ // in database-transaction.ts (rollback failures must not mask the original error).
461
+ await Promise.allSettled(applied.map(underlyingTable => underlyingTable.rollback?.()));
462
+ throw error;
463
+ }
464
+ // Phase 2: commit the affected underlyings. For a shared-coordinator store the
465
+ // first commit flushes all tables in one atomic batch and the rest no-op; for
466
+ // per-table domains (memory) each commits independently.
467
+ for (const underlyingTable of applied) {
468
+ await underlyingTable.commit?.();
469
+ }
470
+ // Clear every overlay for this db — the transaction's staged state is now
471
+ // durable (or was empty). Every key cleared here was either applied above
472
+ // (`hasChanges`) or staged nothing; a staged overlay that could not be applied
473
+ // threw INTERNAL before Phase 1 and never reaches this point. Subsequent
474
+ // IsolatedConnection.commit()s in the loop find no overlay and no-op. Pre-overlay
475
+ // savepoint sets are cleared per table by each connection's onConnectionCommit
476
+ // (which also covers a table that has savepoints but never got an overlay).
477
+ for (const { key, state } of entries) {
478
+ await this.releaseOverlayTable(state);
479
+ this.connectionOverlays.delete(key);
480
+ }
481
+ for (const key of orphanedCleanKeys) {
482
+ const state = this.connectionOverlays.get(key);
483
+ if (state)
484
+ await this.releaseOverlayTable(state);
485
+ this.connectionOverlays.delete(key);
486
+ }
487
+ }
296
488
  /**
297
489
  * Coalesces concurrent covering-connection builds for one (db, table) onto a
298
490
  * single in-flight promise, keyed identically to {@link connectionOverlays}
@@ -350,6 +542,13 @@ export class IsolationModule {
350
542
  }
351
543
  /**
352
544
  * Returns capabilities combining underlying module with isolation guarantees.
545
+ *
546
+ * `ddlTransactionality` is forwarded verbatim through the spread — the wrapper
547
+ * NEVER upgrades it. The overlay stages DML outside the underlying module, so an
548
+ * underlying DDL-commit flushes only module-side ops, leaving overlay writes
549
+ * behind (the `bug-store-savepoint-ddl-drop-lost-insert` asymmetry). Forwarding
550
+ * the underlying's (pessimistic) value is the honest choice; only `isolation` /
551
+ * `savepoints` are augmented, since the wrapper genuinely adds those.
353
552
  */
354
553
  getCapabilities() {
355
554
  const underlyingCaps = this.underlying.getCapabilities?.() ?? {};
@@ -446,8 +645,10 @@ export class IsolationModule {
446
645
  // 2. Store underlying state (overlay is per-connection, created lazily)
447
646
  const state = { underlyingTable };
448
647
  this.setUnderlyingState(tableSchema.schemaName, tableSchema.name, state);
449
- // 3. Return wrapped table (overlay will be created lazily on first write)
450
- return new IsolatedTable(db, this, underlyingTable);
648
+ // 3. Return wrapped table (overlay will be created lazily on first write).
649
+ // Keyed off the schema's own (schemaName, name) — the pair `underlyingTables` uses —
650
+ // never off the underlying table's self-reported names (see IsolatedTable's ctor doc).
651
+ return new IsolatedTable(db, this, tableSchema.schemaName, tableSchema.name, underlyingTable);
451
652
  }
452
653
  /**
453
654
  * Connects to an existing isolated table.
@@ -471,21 +672,180 @@ export class IsolationModule {
471
672
  // the per-connection overlay so reads reflect only persisted underlying state.
472
673
  const readCommitted = options?._readCommitted === true;
473
674
  // Return a fresh IsolatedTable instance that will look up its overlay
474
- // from connection-scoped storage (shared with other instances in same transaction)
475
- return new IsolatedTable(db, this, state.underlyingTable, readCommitted);
675
+ // from connection-scoped storage (shared with other instances in same transaction).
676
+ // Pass the connect-time (schemaName, tableName) — the pair `underlyingTables` is keyed
677
+ // by — never the underlying's self-reported names (see IsolatedTable's ctor doc).
678
+ return new IsolatedTable(db, this, schemaName, tableName, state.underlyingTable, readCommitted);
476
679
  }
477
680
  /**
478
- * Destroys the underlying table.
681
+ * Destroys the underlying table, then resolves every connection's staged state for it.
682
+ *
683
+ * DROP TABLE is not transaction-scoped: the table is gone for *every* connection the
684
+ * moment this returns, so no overlay staging writes against it can ever be flushed.
685
+ * What differs is who gets told. Per overlay key matching the dropped table (both maps
686
+ * are keyed `<dbId>:<schema>.<table>`, so the sweep spans all db ids):
687
+ *
688
+ * - **The dropping connection's own overlay** is discarded silently. It issued the DROP;
689
+ * there is nobody to notify.
690
+ * - **A foreign overlay with staged rows** (`hasChanges`) is **poisoned**, not swept.
691
+ * Sweeping it let that connection commit against an empty overlay set and report
692
+ * success after its rows were thrown away — silent cross-connection data loss. Poison
693
+ * makes its next read/write/commit throw `CONSTRAINT` (see
694
+ * {@link IsolatedTable.assertOverlayUsable} and the poison check at the head of
695
+ * {@link commitConnectionOverlays}, which precedes the `underlyingTables` lookup and so
696
+ * raises the poison message rather than the orphan INTERNAL error). An already-poisoned
697
+ * overlay keeps its original message — the first cause is the one worth reporting.
698
+ * - **A foreign overlay with no staged rows** is discarded: it staged nothing, so nothing
699
+ * is lost.
700
+ *
701
+ * `preOverlaySavepoints` is swept for every matching key whose overlay did NOT survive.
702
+ * A surviving poisoned overlay keeps its set: `ensureOverlay` padding still consults it,
703
+ * and the owning connection's `onConnectionRollback` reaps it when its failed commit
704
+ * rolls back. Without the sweep, an abandoned set outlived the table for the lifetime of
705
+ * the `Database` (nothing else is keyed to reap it once the table is gone).
706
+ *
707
+ * Nothing is discarded or poisoned until the underlying destroy SUCCEEDS. A throwing
708
+ * `underlying.destroy` means the table still exists, so every connection's staged
709
+ * writes are still flushable and every map entry must survive untouched — the same
710
+ * reason {@link renameTable} delegates before mutating its maps.
711
+ *
712
+ * NOTE: poison rides on the `ConnectionOverlayState`, not on its rows, so a foreign
713
+ * connection that later unwinds every staged row past the drop (rollback to a savepoint
714
+ * taken after the overlay existed) still fails its commit. Deliberately over-strict —
715
+ * the table is gone either way. If a caller ever needs the clean-unwind case to commit,
716
+ * re-evaluate the poison on `onConnectionRollbackToSavepoint` rather than special-casing
717
+ * here.
718
+ *
719
+ * NOTE: a connection whose own overlay was already poisoned (by another connection's
720
+ * ALTER) escapes that poison for this table by dropping it — the own-overlay branch
721
+ * deletes the state, poison and all. Correct as written: the rows it discards belong to
722
+ * a table this connection just asked to remove. If poison ever carries a cause that
723
+ * outlives the table, gate the own-overlay delete on it.
724
+ *
725
+ * NOTE: this mutates `connectionOverlays` while a foreign connection may be mid-scan in
726
+ * `IsolatedTable.query`'s merged branch — that scan will keep merging against an overlay
727
+ * whose underlying is now destroyed. The module clamps to `'reentrant-reads'`, so no
728
+ * in-tree host reaches it. If a host ever runs a DROP concurrently with a foreign scan,
729
+ * the merged iterator needs a per-scan snapshot of the overlay + underlying pair.
479
730
  */
480
731
  async destroy(db, pAux, moduleName, schemaName, tableName) {
481
- this.removeUnderlyingState(schemaName, tableName);
482
732
  await this.underlying.destroy(db, pAux, moduleName, schemaName, tableName);
733
+ this.removeUnderlyingState(schemaName, tableName);
734
+ const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
735
+ const survivingKeys = new Set();
736
+ for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
737
+ const state = this.connectionOverlays.get(key);
738
+ if (key !== ownKey && state.hasChanges) {
739
+ if (!state.poison) {
740
+ state.poison = { message: this.buildDropPoisonMessage(schemaName, tableName) };
741
+ }
742
+ survivingKeys.add(key);
743
+ continue;
744
+ }
745
+ // Own overlay, or a foreign CLEAN one — abandoned here, so free its staging table.
746
+ // A surviving poisoned foreign overlay is intentionally NOT released: it stays
747
+ // installed and is freed later when its owning connection rolls back (which routes
748
+ // through clearConnectionOverlay → releaseOverlayTable).
749
+ await this.releaseOverlayTable(state);
750
+ this.connectionOverlays.delete(key);
751
+ }
752
+ for (const key of this.connectionScopedKeys(this.preOverlaySavepoints, schemaName, tableName)) {
753
+ if (!survivingKeys.has(key))
754
+ this.preOverlaySavepoints.delete(key);
755
+ }
756
+ }
757
+ /**
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.
791
+ */
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);
815
+ }
816
+ /**
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.
822
+ */
823
+ buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, cause) {
824
+ return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
825
+ + `uncommitted rows violate (${cause}); roll back this transaction.`;
826
+ }
827
+ /**
828
+ * Builds the poison message stamped onto a foreign overlay whose table was dropped out
829
+ * from under it (see {@link destroy}). Names the schema.table so the owning connection's
830
+ * eventual read/write/commit error is self-explanatory. Companion to
831
+ * {@link buildAlterPoisonMessage}: both poison sources raise the same
832
+ * `StatusCode.CONSTRAINT` and are told apart by their message, not their code.
833
+ */
834
+ buildDropPoisonMessage(schemaName, tableName) {
835
+ return `Table '${schemaName}.${tableName}' was dropped by another connection while this connection had uncommitted changes staged for it; roll back this transaction.`;
483
836
  }
484
837
  /**
485
838
  * Closes all resources held by the underlying module (if it supports closeAll).
486
839
  * Also clears connection overlay state.
487
840
  */
488
841
  async closeAll() {
842
+ // Free every overlay's staging table before dropping the map. The default
843
+ // MemoryTableModule overlay is discarded with this wrapper, but a host-injected
844
+ // SHARED config.overlay would otherwise retain one dead entry per open overlay —
845
+ // each state carries its own db so the release targets the right one.
846
+ for (const state of this.connectionOverlays.values()) {
847
+ await this.releaseOverlayTable(state);
848
+ }
489
849
  this.connectionOverlays.clear();
490
850
  this.preOverlaySavepoints.clear();
491
851
  this.underlyingTables.clear();
@@ -495,24 +855,107 @@ export class IsolationModule {
495
855
  }
496
856
  }
497
857
  /**
498
- * Creates an index on the underlying table.
858
+ * The rows the connection owning `overlayState` can SEE — the underlying's committed rows
859
+ * merged with that overlay's staged writes. Re-callable, as `EffectiveRowSource` requires.
499
860
  *
500
- * Note: Indexes on per-connection overlays are created lazily when the
501
- * overlay is created, by copying from the underlying table's schema.
861
+ * This is the seam the whole fix turns on: the underlying module validates row-content DDL
862
+ * (UNIQUE duplicate detection, collation-rekey collisions) against its OWN rows, which under
863
+ * isolation are the committed rows only. The transaction's pending rows live here, in the
864
+ * overlay, where the underlying cannot reach them — so we hand them down.
502
865
  *
503
- * We use the stored table instance's createIndex() rather than the module-level
504
- * method so that the MemoryTable's local tableSchema property stays in sync.
505
- * That property is what ensureOverlay() reads when building the overlay schema.
866
+ * NOTE: each call re-materializes the overlay and re-scans the underlying. `alter column …
867
+ * set collate` calls once per UNIQUE constraint covering the altered column, so a table with
868
+ * many such constraints pays that many scans. If it ever shows up as slow, materialize the
869
+ * overlay's PK map once per DDL and share it across the calls.
506
870
  */
507
- async createIndex(db, schemaName, tableName, indexSchema) {
871
+ effectiveRowsFor(db, underlyingTable, overlayState) {
872
+ const schema = underlyingTable.tableSchema;
873
+ if (!schema) {
874
+ throw new QuereusError('Isolation layer: underlying table has no schema', StatusCode.INTERNAL);
875
+ }
876
+ const pkIndices = schema.primaryKeyDefinition.map(pkDef => pkDef.index);
877
+ const pkKeyOf = makePkKeySerializer(db, schema);
878
+ const overlayTable = overlayState.overlayTable;
879
+ return () => iterateEffectiveRows(underlyingTable, overlayTable, this.tombstoneColumn, pkIndices, pkKeyOf);
880
+ }
881
+ /**
882
+ * The row source to hand a row-validating DDL on behalf of the connection issuing it, or
883
+ * undefined when that connection has nothing staged and the underlying's own rows already
884
+ * ARE its effective rows.
885
+ *
886
+ * **Only the issuing connection's overlay feeds validation.** A foreign connection's
887
+ * overlay may hold rows that collide with the new constraint; that is its problem when it
888
+ * commits, exactly as an ordinary concurrent duplicate insert would be. A poisoned issuer
889
+ * overlay is likewise skipped — its rows are structurally stale, and the connection can
890
+ * only recover by rolling back.
891
+ */
892
+ issuerEffectiveRows(db, schemaName, tableName, underlyingTable) {
893
+ const overlayState = this.getConnectionOverlay(db, schemaName, tableName);
894
+ if (!overlayState || overlayState.poison || !overlayState.hasChanges)
895
+ return undefined;
896
+ return this.effectiveRowsFor(db, underlyingTable, overlayState);
897
+ }
898
+ /**
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.
902
+ *
903
+ * Two things the underlying cannot do for itself:
904
+ *
905
+ * 1. **Judge the right rows.** The issuing connection's pending rows are in its overlay,
906
+ * invisible to the underlying, so a duplicate it staged would slip past the build and a
907
+ * duplicate it deleted would spuriously reject it. {@link issuerEffectiveRows} supplies
908
+ * the merged view; the underlying builds its physical structure from its own committed
909
+ * rows, which is sound because every reader resolves an index entry back to its live row.
910
+ * 2. **Enforce the new constraint.** An overlay built before the index knows nothing of it,
911
+ * and `IsolatedTable.findMergedUniqueConflict` only scans the underlying — so a pending
912
+ * 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.
915
+ *
916
+ * We use the stored table instance's createIndex() rather than the module-level method so
917
+ * that the MemoryTable's local tableSchema property stays in sync. That property is what
918
+ * ensureOverlay() reads when building the overlay schema.
919
+ */
920
+ async createIndex(db, schemaName, tableName, indexSchema, rows) {
508
921
  const state = this.getUnderlyingState(schemaName, tableName);
922
+ // An outer wrapper's row source, if any, already names the effective rows; otherwise
923
+ // build our own from the issuing connection's overlay.
924
+ const rowSource = rows ?? (state ? this.issuerEffectiveRows(db, schemaName, tableName, state.underlyingTable) : undefined);
509
925
  if (state?.underlyingTable.createIndex) {
510
926
  // Instance-level createIndex keeps MemoryTable.tableSchema fresh
511
- await state.underlyingTable.createIndex(indexSchema);
927
+ await state.underlyingTable.createIndex(indexSchema, rowSource);
512
928
  }
513
929
  else if (this.underlying.createIndex) {
514
- await this.underlying.createIndex(db, schemaName, tableName, indexSchema);
930
+ await this.underlying.createIndex(db, schemaName, tableName, indexSchema, rowSource);
931
+ }
932
+ else {
933
+ return; // underlying does not support indexes; nothing was created, nothing to rebuild
515
934
  }
935
+ if (!state)
936
+ return;
937
+ const updatedSchema = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
938
+ await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `create index '${indexSchema.name}'`);
939
+ }
940
+ /**
941
+ * Reads back the underlying table instance's post-`createIndex` schema, asserting it now
942
+ * carries the new index.
943
+ *
944
+ * 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.
949
+ */
950
+ 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) {
954
+ throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
955
+ + `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
956
+ + `report; the underlying module must refresh VirtualTable.tableSchema in createIndex.`, StatusCode.INTERNAL);
957
+ }
958
+ return updatedSchema;
516
959
  }
517
960
  /**
518
961
  * Drops an index on the underlying table.
@@ -549,35 +992,54 @@ export class IsolationModule {
549
992
  const updatedSchema = state?.underlyingTable.tableSchema;
550
993
  if (!updatedSchema)
551
994
  return;
552
- const suffix = `:${schemaName}.${tableName}`.toLowerCase();
553
- for (const [key, overlayState] of this.connectionOverlays.entries()) {
554
- if (key.endsWith(suffix)) {
555
- // A poisoned overlay (cross-connection ALTER) holds rows in the pre-alter column
556
- // layout, narrower/wider than `updatedSchema`. Rebuilding it here would copy
557
- // layout-mismatched rows AND drop the poison flag (the new state carries none),
558
- // silently un-poisoning a connection that must still roll back. Leave it as-is.
559
- if (overlayState.poison)
560
- continue;
561
- const newState = await this.migrateOverlayForDropIndex(db, overlayState, updatedSchema);
562
- this.connectionOverlays.set(key, newState);
563
- }
995
+ await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `drop index '${indexName}'`);
996
+ }
997
+ /**
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.
1007
+ */
1008
+ async rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, ddlDescription) {
1009
+ const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
1010
+ for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
1011
+ const overlayState = this.connectionOverlays.get(key);
1012
+ if (overlayState.poison)
1013
+ continue;
1014
+ await this.adoptRebuiltOverlay(key, overlayState, key === ownKey, schemaName, tableName, ddlDescription, () => this.rebuildOverlayForIndexChange(db, overlayState, updatedSchema));
564
1015
  }
565
1016
  }
566
1017
  /**
567
- * Rebuilds an overlay table under the post-drop-index schema, preserving
568
- * staged rows (including tombstones). Column layout is unchanged by
569
- * DROP INDEX, so rows can be copied verbatim.
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).
570
1022
  */
571
- async migrateOverlayForDropIndex(db, oldState, updatedSchema) {
1023
+ async rebuildOverlayForIndexChange(db, oldState, updatedSchema) {
572
1024
  const oldOverlay = oldState.overlayTable;
573
1025
  const newOverlaySchema = this.createOverlaySchema(updatedSchema);
574
1026
  const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
575
- if (oldState.hasChanges && oldOverlay.query) {
576
- for await (const oldRow of oldOverlay.query(this.makeFullScanFilterInfo())) {
577
- await newOverlayTable.update({ operation: 'insert', values: oldRow, preCoerced: true });
1027
+ const newState = { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges, db };
1028
+ 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
+ }
578
1033
  }
579
1034
  }
580
- return { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges };
1035
+ 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;
1041
+ }
1042
+ return newState;
581
1043
  }
582
1044
  /**
583
1045
  * Delegates ALTER TABLE to the underlying module and migrates any per-connection
@@ -600,8 +1062,13 @@ export class IsolationModule {
600
1062
  * ALTER either fails clean or fully applies — base/catalog can no longer diverge.
601
1063
  * This mirrors the engine's pre-mutation `validateNotNullBackfill` in
602
1064
  * `runtime/emit/alter-table.ts`.
1065
+ *
1066
+ * **Row-content validation.** The row-validating arms (`add constraint … unique`,
1067
+ * `alter column … set collate`) judge the ISSUING connection's effective rows, not the
1068
+ * underlying's committed ones — see {@link issuerEffectiveRows}. The underlying runs that
1069
+ * check before it mutates anything, so the atomic-abort guarantee above still holds.
603
1070
  */
604
- async alterTable(db, schemaName, tableName, change) {
1071
+ async alterTable(db, schemaName, tableName, change, rows) {
605
1072
  if (!this.underlying.alterTable) {
606
1073
  throw new QuereusError(`Underlying module does not support ALTER TABLE for '${schemaName}.${tableName}'`, StatusCode.UNSUPPORTED);
607
1074
  }
@@ -612,13 +1079,11 @@ export class IsolationModule {
612
1079
  // left for its owning connection to error on, while the issuer's ALTER proceeds.
613
1080
  // Already-poisoned overlays (own or foreign) are skipped entirely: they hold rows
614
1081
  // from before an earlier ALTER, stay poisoned, and must not be re-read/migrated.
615
- const suffix = `:${schemaName}.${tableName}`.toLowerCase();
616
1082
  const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
617
1083
  let ownEntry;
618
1084
  const foreign = [];
619
- for (const [key, state] of this.connectionOverlays.entries()) {
620
- if (!key.endsWith(suffix))
621
- continue;
1085
+ for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
1086
+ const state = this.connectionOverlays.get(key);
622
1087
  // An already-poisoned overlay (from an earlier ALTER) holds pre-alter rows and must
623
1088
  // never be re-read or migrated — checked BEFORE the ownKey split so the poisoned
624
1089
  // connection's OWN later ALTER cannot route its overlay through migration, which
@@ -649,38 +1114,53 @@ export class IsolationModule {
649
1114
  // schema needed — so it is valid here, before the underlying is mutated, and the
650
1115
  // same context drives the post-mutation migration.
651
1116
  const addColumnCtx = this.deriveAddColumnBackfill(change, db, tableName);
1117
+ // Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
1118
+ // with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
1119
+ // 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);
652
1122
  // Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
653
1123
  // Any throw here (CONSTRAINT backfill or INTERNAL tombstone guard) propagates while
654
1124
  // underlying + catalog + every overlay are still untouched — the companion ticket's
655
1125
  // atomic-abort guarantee, preserved unchanged for the issuer.
656
1126
  if (ownEntry) {
657
- await this.validateOverlayMigration(ownEntry[1], addColumnCtx);
1127
+ await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx);
658
1128
  }
659
- const updated = await this.underlying.alterTable(db, schemaName, tableName, change);
1129
+ const underlyingState = this.getUnderlyingState(schemaName, tableName);
1130
+ // Hand the underlying the issuer's effective rows so its own row-content checks
1131
+ // (`add constraint … unique`, `alter column … set collate`) see the transaction's
1132
+ // pending rows and skip the ones it has deleted. An outer wrapper's source wins if
1133
+ // one was supplied.
1134
+ const rowSource = rows ?? (underlyingState
1135
+ ? this.issuerEffectiveRows(db, schemaName, tableName, underlyingState.underlyingTable)
1136
+ : undefined);
1137
+ const updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
660
1138
  // The cached underlying VirtualTable's `tableSchema` is a construction-time
661
1139
  // snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
662
1140
  // underlying manager's schema but not this instance's field. Refresh it so a
663
1141
  // freshly-connected IsolatedTable's merged-view UNIQUE check (which reads
664
1142
  // this.tableSchema.uniqueConstraints / per-column collation) sees the post-alter
665
1143
  // constraint set. Mirrors the implicit instance refresh dropIndex already gets.
666
- const underlyingState = this.getUnderlyingState(schemaName, tableName);
667
1144
  if (underlyingState)
668
1145
  underlyingState.underlyingTable.tableSchema = updated;
1146
+ const ddlDescription = `alter table (${change.type})`;
669
1147
  // Migrate the issuer's own overlay (already validated above). Its NOT NULL /
670
- // tombstone throw sites are unreachable after pre-validation.
1148
+ // tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
1149
+ // rejection, which `rowSource` already judged — {@link adoptRebuiltOverlay} raises
1150
+ // INTERNAL if one fires anyway rather than dropping the row.
671
1151
  if (ownEntry) {
672
- const newState = await this.migrateOverlayForAlter(db, ownEntry[1], updated, change, dropColumnIdx, addColumnCtx);
673
- this.connectionOverlays.set(ownEntry[0], newState);
1152
+ await this.adoptRebuiltOverlay(ownEntry[0], ownEntry[1], true, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, ownEntry[1], updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
674
1153
  }
675
1154
  // Tier 3: per FOREIGN overlay, validate then migrate — but a per-row NOT NULL
676
1155
  // (CONSTRAINT) failure poisons that one overlay instead of aborting the issuer's
677
- // ALTER. An INTERNAL failure (e.g. missing tombstone column) is a layer-invariant
678
- // violation, not a data condition, so it rethrows loud for everyone. Validation
679
- // runs per overlay, so one bad foreign overlay poisons only itself; healthy peers
680
- // still migrate.
1156
+ // 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.
681
1161
  for (const [key, oldState] of foreign) {
682
1162
  try {
683
- await this.validateOverlayMigration(oldState, addColumnCtx);
1163
+ await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx);
684
1164
  }
685
1165
  catch (e) {
686
1166
  if (e instanceof QuereusError && e.code === StatusCode.CONSTRAINT) {
@@ -689,8 +1169,7 @@ export class IsolationModule {
689
1169
  }
690
1170
  throw e;
691
1171
  }
692
- const newState = await this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx, addColumnCtx);
693
- this.connectionOverlays.set(key, newState);
1172
+ await this.adoptRebuiltOverlay(key, oldState, false, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
694
1173
  }
695
1174
  return updated;
696
1175
  }
@@ -698,13 +1177,19 @@ export class IsolationModule {
698
1177
  * Builds the poison message stamped onto a foreign overlay whose backfill could not
699
1178
  * satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
700
1179
  * schema.table and the offending column so the owning connection's eventual
701
- * read/write/commit error is self-explanatory. Poison only arises on the addColumn
702
- * NOT NULL path, so the column name is taken from the addColumn change; other change
703
- * types never reach here but are handled defensively.
1180
+ * 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.
704
1184
  */
705
1185
  buildAlterPoisonMessage(schemaName, tableName, change) {
706
- const col = change.type === 'addColumn' ? change.columnDef.name : '<column>';
707
- return `ALTER on '${schemaName}.${tableName}' added column '${col}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
1186
+ if (change.type === 'addColumn') {
1187
+ 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
+ }
1189
+ if (change.type === 'alterColumn') {
1190
+ return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
1191
+ }
1192
+ return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
708
1193
  }
709
1194
  /**
710
1195
  * Renames a table through the isolation layer.
@@ -717,69 +1202,152 @@ export class IsolationModule {
717
1202
  * Done in this order so a failure in the underlying rename leaves our
718
1203
  * internal maps untouched (the engine will not update the schema catalog
719
1204
  * if this method throws).
1205
+ *
1206
+ * **Why the underlying is re-connected, not re-keyed.** A rename mid-transaction
1207
+ * moves any staged overlay onto the new name, and `commitConnectionOverlays`
1208
+ * resolves an overlay's underlying by that name. Simply re-keying
1209
+ * `underlyingTables` old→new would be cheaper, but the cached `VirtualTable` may
1210
+ * be dead: `StoreModule.renameTable` closes and re-opens the store, so the stale
1211
+ * handle yields "store is closed". So we evict it and, when an overlay was
1212
+ * carried onto the new name, immediately connect a fresh underlying under that
1213
+ * name — otherwise the transaction commits against a table that is in neither
1214
+ * map, and the overlay's rows vanish (see {@link commitConnectionOverlays}'s
1215
+ * invariant). With no overlay carried over there is nothing to flush, so the
1216
+ * eviction alone is enough and the next `connect()` re-resolves lazily.
720
1217
  */
721
1218
  async renameTable(db, schemaName, oldName, newName) {
1219
+ // Read the catalog entry BEFORE anything mutates: `runtime/emit/alter-table.ts`
1220
+ // calls this hook ahead of the catalog swap, so the table is still registered
1221
+ // under `oldName` here. It carries the vtab module name / args that
1222
+ // `reconnectUnderlyingAfterRename` needs — the hook's own signature has neither.
1223
+ const preRenameSchema = db.schemaManager.getTable(schemaName, oldName);
722
1224
  if (this.underlying.renameTable) {
723
1225
  await this.underlying.renameTable(db, schemaName, oldName, newName);
724
1226
  }
725
1227
  // Drop our cached underlying VirtualTable for the old name. It may have
726
1228
  // been disconnected by the underlying module (e.g. StoreModule closes
727
1229
  // and re-opens stores during rename), so reusing it would yield "store
728
- // is closed" errors. The next connect() under the new name will fetch a
729
- // fresh underlying table from the underlying module.
1230
+ // is closed" errors.
730
1231
  this.removeUnderlyingState(schemaName, oldName);
731
- // Re-key per-connection overlay and savepoint state, preserving the
732
- // connection-id prefix so overlays created earlier in an open
733
- // transaction remain visible under the new name.
734
- this.rekeyConnectionScopedMap(this.connectionOverlays, schemaName, oldName, newName);
735
- this.rekeyConnectionScopedMap(this.preOverlaySavepoints, schemaName, oldName, newName);
1232
+ // Re-key per-connection overlay state, preserving the connection-id prefix so
1233
+ // overlays created earlier in an open transaction remain visible under the new
1234
+ // name — the commit flush resolves an overlay's underlying by current name.
1235
+ const movedOverlays = this.rekeyConnectionScopedMap(this.connectionOverlays, schemaName, oldName, newName);
1236
+ // `preOverlaySavepoints` is deliberately NOT re-keyed. The set's own maintainers —
1237
+ // the savepoint/commit/rollback callbacks on the already-registered
1238
+ // IsolatedConnection — resolve the name the IsolatedTable was constructed with,
1239
+ // which stays `oldName` for the life of the transaction. Moving the set to
1240
+ // `newName` would strand it: the old-name instance clears a key that no longer
1241
+ // exists and the moved set survives into the next transaction, where a matching
1242
+ // `rollback to savepoint` depth would wrongly discard the whole overlay.
1243
+ // Nothing needs carrying over: the first statement after the rename connects a
1244
+ // fresh IsolatedTable under `newName`, whose ensureConnection() registers a new
1245
+ // IsolatedConnection, and `Database.registerConnection` replays the active
1246
+ // savepoint stack onto it. If no overlay was carried across, that replay rebuilds
1247
+ // the depth set under the new name from scratch; if one was, the replay adds
1248
+ // nothing (the depths no longer pre-date an overlay) and the overlay's own
1249
+ // registered connection already holds a snapshot per active depth, taken when
1250
+ // `ensureOverlay` pre-registered it.
1251
+ if (movedOverlays > 0) {
1252
+ await this.reconnectUnderlyingAfterRename(db, schemaName, newName, preRenameSchema);
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Forward the engine's post-propagation rename finalize to the underlying (see
1257
+ * {@link renameTable} for the two-phase split). The underlying (e.g. `StoreModule`)
1258
+ * uses it to drop the old name's catalog entry only after the cross-table rewrites
1259
+ * `propagateTableRename` enqueued are durable. `IsolationModule` owns no persistent
1260
+ * catalog of its own and already evicted every old-name state in `renameTable`, so
1261
+ * it simply delegates.
1262
+ */
1263
+ async finalizeRename(db, schemaName, oldName, newName) {
1264
+ await this.underlying.finalizeRename?.(db, schemaName, oldName, newName);
1265
+ }
1266
+ /**
1267
+ * Connects a fresh underlying table under the post-rename name and records it in
1268
+ * `underlyingTables`, restoring the "every staged overlay resolves to an underlying"
1269
+ * invariant that {@link renameTable}'s eviction would otherwise break.
1270
+ *
1271
+ * `preRenameSchema` is the catalog's pre-rename `TableSchema`; it is cloned under the
1272
+ * new name so the underlying module sees the same column layout, PK, and vtab args it
1273
+ * was created with.
1274
+ *
1275
+ * NOTE: `pAux` is passed as `undefined`: the aux data the engine hands
1276
+ * `IsolationModule.connect()` belongs to *this* wrapper's registration, not the
1277
+ * underlying's, and both bundled underlyings (`MemoryTableModule`, `StoreModule`)
1278
+ * ignore the parameter — the same assumption `connect()` already relies on when it
1279
+ * forwards its own caller's `pAux` straight through. If a third-party underlying ever
1280
+ * reads `pAux` in `connect()`, `IsolationModule` must capture the underlying's own aux
1281
+ * data at registration and hand it back here.
1282
+ */
1283
+ async reconnectUnderlyingAfterRename(db, schemaName, newName, preRenameSchema) {
1284
+ if (!preRenameSchema) {
1285
+ throw new QuereusError(`Isolation layer: cannot re-resolve underlying table for renamed '${schemaName}.${newName}' — `
1286
+ + `no catalog entry for the pre-rename name, and a staged overlay depends on it.`, StatusCode.INTERNAL);
1287
+ }
1288
+ const renamedSchema = { ...preRenameSchema, name: newName };
1289
+ const underlyingTable = await this.underlying.connect(db, undefined, preRenameSchema.vtabModuleName, schemaName, newName, preRenameSchema.vtabArgs ?? {}, renamedSchema);
1290
+ this.setUnderlyingState(schemaName, newName, { underlyingTable });
736
1291
  }
737
1292
  /**
738
1293
  * Re-keys all entries of a connection-scoped map (`<dbId>:<schema>.<table>`)
739
1294
  * from oldName to newName, leaving entries for other tables untouched.
1295
+ * Returns how many entries moved.
740
1296
  */
741
1297
  rekeyConnectionScopedMap(map, schemaName, oldName, newName) {
742
- const oldSuffix = `:${schemaName}.${oldName}`.toLowerCase();
1298
+ // Length of the LOWERCASED suffix: keys are stored lowercased, and case folding is
1299
+ // not always length-preserving (`'İ'.toLowerCase()` is two code units).
1300
+ const oldSuffixLength = `:${schemaName}.${oldName}`.toLowerCase().length;
743
1301
  const newSuffix = `:${schemaName}.${newName}`.toLowerCase();
744
- const moved = [];
745
- for (const [key, value] of map.entries()) {
746
- if (key.endsWith(oldSuffix)) {
747
- const prefix = key.substring(0, key.length - oldSuffix.length);
748
- moved.push([`${prefix}${newSuffix}`, value]);
749
- map.delete(key);
750
- }
751
- }
752
- for (const [newKey, value] of moved) {
753
- map.set(newKey, value);
1302
+ const oldKeys = this.connectionScopedKeys(map, schemaName, oldName);
1303
+ for (const oldKey of oldKeys) {
1304
+ const value = map.get(oldKey);
1305
+ map.delete(oldKey);
1306
+ map.set(`${oldKey.substring(0, oldKey.length - oldSuffixLength)}${newSuffix}`, value);
754
1307
  }
1308
+ return oldKeys.length;
755
1309
  }
756
1310
  /**
757
1311
  * Rebuilds an overlay table under the post-alter schema, translating each
758
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.
759
1317
  */
760
- async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx) {
1318
+ async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx, setNotNullCtx) {
761
1319
  const oldOverlay = oldState.overlayTable;
762
1320
  const oldOverlaySchema = oldOverlay.tableSchema;
763
1321
  const newOverlaySchema = this.createOverlaySchema(updatedSchema);
764
1322
  const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
765
- if (oldState.hasChanges && oldOverlaySchema && oldOverlay.query) {
766
- const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
767
- if (oldTombstoneIdx === undefined) {
768
- throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
769
- }
770
- // `addColumnCtx` (folded literal default, the per-row evaluator, and the new
771
- // column's NOT NULL flag) was precomputed once per ALTER by the caller and already
772
- // dry-run validated against these same staged rows; undefined for non-addColumn
773
- // change types, which append nothing to staged rows.
774
- for await (const oldRow of oldOverlay.query(this.makeFullScanFilterInfo())) {
775
- const addColumnValue = addColumnCtx
776
- ? await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx)
777
- : undefined;
778
- const newRow = this.translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue);
779
- await newOverlayTable.update({ operation: 'insert', values: newRow, preCoerced: true });
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
+ }
780
1341
  }
781
1342
  }
782
- return { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges };
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;
783
1351
  }
784
1352
  /**
785
1353
  * Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
@@ -808,7 +1376,7 @@ export class IsolationModule {
808
1376
  // ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
809
1377
  // underlying materializes the real column), so it does not affect collation here — but
810
1378
  // keeping the call signature identical avoids drift and is correct for any future reader.
811
- const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'));
1379
+ const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
812
1380
  return {
813
1381
  foldedDefault,
814
1382
  evaluator: change.backfillEvaluator,
@@ -817,6 +1385,43 @@ export class IsolationModule {
817
1385
  tableName,
818
1386
  };
819
1387
  }
1388
+ /**
1389
+ * Precomputes the per-ALTER constants an `alter column … set not null` overlay migration needs:
1390
+ * the now-NOT-NULL column's index and the folded literal DEFAULT (with `hasDefault` gating
1391
+ * backfill vs reject). Returns undefined unless this is a NOT NULL *tightening*
1392
+ * (`setNotNull: true`) with at least one overlay to migrate — a DROP NOT NULL loosens and
1393
+ * needs no staged-row work, and with no overlays there is nothing to backfill or reject.
1394
+ *
1395
+ * `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
1398
+ * {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
1399
+ * cannot drift from what the underlying enforces over its committed rows.
1400
+ */
1401
+ deriveSetNotNullBackfill(change, toMigrate, tableName) {
1402
+ if (change.type !== 'alterColumn' || change.setNotNull !== true)
1403
+ return undefined;
1404
+ if (toMigrate.length === 0)
1405
+ return undefined;
1406
+ const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
1407
+ if (!overlaySchema)
1408
+ return undefined;
1409
+ const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
1410
+ if (colIndex === undefined)
1411
+ return undefined;
1412
+ const defaultExpr = overlaySchema.columns[colIndex]?.defaultValue;
1413
+ // tryFoldLiteral returns undefined for a non-foldable expr and null for one that folds to
1414
+ // NULL; both mean "no usable literal default" — the staged NULL must reject, not backfill.
1415
+ const folded = defaultExpr ? tryFoldLiteral(defaultExpr) : undefined;
1416
+ const hasDefault = folded !== undefined && folded !== null;
1417
+ return {
1418
+ colIndex,
1419
+ foldedDefault: hasDefault ? folded : null,
1420
+ hasDefault,
1421
+ colName: change.columnName,
1422
+ tableName,
1423
+ };
1424
+ }
820
1425
  /**
821
1426
  * Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
822
1427
  * so the caller can run it for every affected overlay BEFORE the irreversible
@@ -831,10 +1436,17 @@ export class IsolationModule {
831
1436
  * rows short-circuit to `null` (the evaluator never runs), and a NOT-NULL-violating
832
1437
  * evaluated row throws CONSTRAINT here, atomically. Computed values are discarded.
833
1438
  *
834
- * Non-addColumn changes (`addColumnCtx === undefined`) only run the tombstone guard;
835
- * their row translation appends/removes nothing fallible on data grounds.
1439
+ * For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
1440
+ * non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
1441
+ * 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.
836
1448
  */
837
- async validateOverlayMigration(oldState, addColumnCtx) {
1449
+ async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx) {
838
1450
  const oldOverlay = oldState.overlayTable;
839
1451
  const oldOverlaySchema = oldOverlay.tableSchema;
840
1452
  // Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
@@ -845,11 +1457,23 @@ export class IsolationModule {
845
1457
  if (oldTombstoneIdx === undefined) {
846
1458
  throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
847
1459
  }
848
- if (!addColumnCtx)
1460
+ if (addColumnCtx) {
1461
+ for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
1462
+ // Discard the result — this is validation only. A NOT NULL violation throws here.
1463
+ await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
1464
+ }
849
1465
  return;
850
- for await (const oldRow of oldOverlay.query(this.makeFullScanFilterInfo())) {
851
- // Discard the result — this is validation only. A NOT NULL violation throws here.
852
- await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
1466
+ }
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.)
1469
+ 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
1473
+ if (oldRow[setNotNullCtx.colIndex] === null) {
1474
+ throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
1475
+ }
1476
+ }
853
1477
  }
854
1478
  }
855
1479
  /**
@@ -885,8 +1509,13 @@ export class IsolationModule {
885
1509
  * `addColumnValue` is the per-row value the caller computed for an `addColumn`
886
1510
  * (via {@link computeAddColumnValue}); it is `undefined` for every other change
887
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.
888
1517
  */
889
- translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue) {
1518
+ translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue, setNotNullCtx) {
890
1519
  const tombstoneValue = oldRow[oldTombstoneIdx];
891
1520
  const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
892
1521
  let newData;
@@ -901,8 +1530,17 @@ export class IsolationModule {
901
1530
  ? [...data.slice(0, dropColumnIdx), ...data.slice(dropColumnIdx + 1)]
902
1531
  : data;
903
1532
  break;
904
- case 'renameColumn':
905
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':
906
1544
  case 'alterPrimaryKey':
907
1545
  case 'addConstraint':
908
1546
  case 'dropConstraint':
@@ -916,34 +1554,25 @@ export class IsolationModule {
916
1554
  }
917
1555
  return [...newData, tombstoneValue];
918
1556
  }
919
- /** Creates a FilterInfo for a full table scan (no constraints). */
920
- makeFullScanFilterInfo() {
921
- return {
922
- idxNum: 0,
923
- idxStr: null,
924
- constraints: [],
925
- args: [],
926
- indexInfoOutput: {
927
- nConstraint: 0,
928
- aConstraint: [],
929
- nOrderBy: 0,
930
- aOrderBy: [],
931
- colUsed: 0n,
932
- aConstraintUsage: [],
933
- idxNum: 0,
934
- idxStr: null,
935
- orderByConsumed: false,
936
- estimatedCost: 1000000,
937
- estimatedRows: 1000000n,
938
- idxFlags: 0,
939
- },
940
- };
941
- }
942
1557
  /**
943
1558
  * Creates overlay schema from underlying schema.
944
1559
  * Adds tombstone column and uses unique name to avoid conflicts.
945
1560
  *
946
- * Called by IsolatedTable when lazily creating its overlay.
1561
+ * Called by IsolatedTable when lazily creating its overlay, and by the two overlay-rebuild
1562
+ * paths (`rebuildOverlayForIndexChange`, `migrateOverlayForAlter`).
1563
+ *
1564
+ * Every copied secondary index — and every copied UNIQUE constraint, including the ones
1565
+ * a UNIQUE index derives — is narrowed to a PARTIAL structure over live rows only
1566
+ * (`<tombstone> = 0`), AND-ed onto whatever partial predicate it already carried. A
1567
+ * tombstone is a deletion marker, not a row, so no uniqueness rule may be evaluated over
1568
+ * it: it carries its row's PK and NULL everywhere else, so a UNIQUE structure whose
1569
+ * columns all sit inside the PK would otherwise see two deleted rows as duplicates.
1570
+ * (Non-PK unique columns escaped only because their tombstone key is NULL and SQL treats
1571
+ * NULLs as distinct.) The overlay's PRIMARY KEY uniqueness is NOT narrowed — it must keep
1572
+ * covering tombstones so a re-insert at a tombstoned PK is detected and converted.
1573
+ *
1574
+ * `IsolatedTable.mergedSecondaryIndexQuery` wants exactly the live overlay rows out of
1575
+ * these indexes, so narrowing them is what it already expects.
947
1576
  */
948
1577
  createOverlaySchema(baseSchema) {
949
1578
  const tombstoneColumn = {
@@ -964,14 +1593,70 @@ export class IsolationModule {
964
1593
  newColumnIndexMap.set(this.tombstoneColumn.toLowerCase(), newColumns.length - 1);
965
1594
  // Use unique ID to avoid conflicts when multiple overlays exist
966
1595
  const overlayId = generateOverlayId();
1596
+ 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;
967
1607
  return {
968
1608
  ...baseSchema,
969
- name: `_overlay_${baseSchema.name}_${overlayId}`,
1609
+ name: overlayName,
970
1610
  columns: newColumns,
971
1611
  columnIndexMap: newColumnIndexMap,
972
- // Copy indexes - they'll be created on the overlay table
973
- indexes: baseSchema.indexes,
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) })),
974
1614
  };
975
1615
  }
1616
+ /**
1617
+ * `<tombstoneColumn> = 0` — the partial-structure predicate that scopes an overlay index
1618
+ * or UNIQUE constraint to live rows. Built as an AST rather than parsed from text because
1619
+ * the tombstone column name is host-configurable.
1620
+ *
1621
+ * NOTE: the default overlay is a `MemoryTableModule`, which honors `IndexSchema.predicate`
1622
+ * and `UniqueConstraintSchema.predicate`. A host that injects its own `config.overlay`
1623
+ * module must honor them too, or its overlay will re-enforce uniqueness over tombstones.
1624
+ */
1625
+ liveRowPredicate() {
1626
+ return {
1627
+ type: 'binary',
1628
+ operator: '=',
1629
+ left: { type: 'column', name: this.tombstoneColumn },
1630
+ right: { type: 'literal', value: 0 },
1631
+ };
1632
+ }
1633
+ }
1634
+ function andPredicate(base, extra) {
1635
+ return base ? { type: 'binary', operator: 'AND', left: base, right: extra } : extra;
1636
+ }
1637
+ /**
1638
+ * Deep-clone `pred`, rewriting every column reference whose `table` qualifier names
1639
+ * `fromName` (case-insensitive) to `toName`. Depth-blind structural walk — a `column`
1640
+ * node is identified by `type === 'column'`; every other node is cloned verbatim. Used
1641
+ * to re-anchor a base table's partial-predicate self-qualifier onto the renamed overlay
1642
+ * table (see {@link IsolationModule.createOverlaySchema}).
1643
+ */
1644
+ function rescopePredicateQualifier(pred, fromName, toName) {
1645
+ const fromLower = fromName.toLowerCase();
1646
+ const clone = (v) => {
1647
+ if (v === null || typeof v !== 'object')
1648
+ return v;
1649
+ if (Array.isArray(v))
1650
+ return v.map(clone);
1651
+ const out = {};
1652
+ for (const [k, val] of Object.entries(v)) {
1653
+ out[k] = clone(val);
1654
+ }
1655
+ if (out.type === 'column' && typeof out.table === 'string' && out.table.toLowerCase() === fromLower) {
1656
+ out.table = toName;
1657
+ }
1658
+ return out;
1659
+ };
1660
+ return clone(pred);
976
1661
  }
977
1662
  //# sourceMappingURL=isolation-module.js.map