@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.
- package/README.md +7 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/isolated-table.d.ts +83 -0
- package/dist/src/isolated-table.d.ts.map +1 -1
- package/dist/src/isolated-table.js +248 -54
- package/dist/src/isolated-table.js.map +1 -1
- package/dist/src/isolation-module.d.ts +265 -2
- package/dist/src/isolation-module.d.ts.map +1 -1
- package/dist/src/isolation-module.js +459 -17
- package/dist/src/isolation-module.js.map +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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' &&
|
|
329
|
-
const overlaySchema =
|
|
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
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|