@quereus/isolation 3.3.0 → 4.1.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 +6 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VirtualTable, compareSqlValues, isUpdateOk, IndexConstraintOp, ConflictResolution, compilePredicate } from '@quereus/quereus';
|
|
1
|
+
import { VirtualTable, compareSqlValues, isUpdateOk, isConstraintViolation, IndexConstraintOp, ConflictResolution, compilePredicate, QuereusError, StatusCode, uniqueEnforcementCollations } from '@quereus/quereus';
|
|
2
2
|
import { IsolatedConnection } from './isolated-connection.js';
|
|
3
3
|
import { mergeStreams, createMergeEntry, createTombstone } from './merge-iterator.js';
|
|
4
4
|
/**
|
|
@@ -59,6 +59,21 @@ export class IsolatedTable extends VirtualTable {
|
|
|
59
59
|
getOverlayState() {
|
|
60
60
|
return this.isolationModule.getConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Throws if this connection's overlay was poisoned by a cross-connection ALTER
|
|
64
|
+
* (see `IsolationModule.alterTable`). A poisoned overlay still holds rows in the
|
|
65
|
+
* PRE-alter column layout, so it can neither be merged into a read nor flushed to
|
|
66
|
+
* the now-altered underlying. Called at the data-op chokepoints (write, the merged
|
|
67
|
+
* read branch, and the commit flush) — never on the committed-snapshot read path,
|
|
68
|
+
* which bypasses the overlay entirely and stays safe. The connection recovers by
|
|
69
|
+
* rolling back, which discards the overlay (and its poison).
|
|
70
|
+
*/
|
|
71
|
+
assertOverlayUsable() {
|
|
72
|
+
const state = this.getOverlayState();
|
|
73
|
+
if (state?.poison) {
|
|
74
|
+
throw new QuereusError(state.poison.message, StatusCode.CONSTRAINT);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
62
77
|
/**
|
|
63
78
|
* Gets the overlay table, or undefined if no overlay exists yet.
|
|
64
79
|
*/
|
|
@@ -138,27 +153,57 @@ export class IsolatedTable extends VirtualTable {
|
|
|
138
153
|
* IsolatedConnection, causing DeferredConstraintQueue.findConnection() to find multiple
|
|
139
154
|
* covering candidates and throw. We therefore reuse the first covering connection
|
|
140
155
|
* already registered for this table.
|
|
156
|
+
*
|
|
157
|
+
* Concurrent first-reads coalesce through the module-level in-flight memo
|
|
158
|
+
* (`IsolationModule.coalesceConnectionBuild`, keyed per db+table) rather than a
|
|
159
|
+
* per-instance one: the runtime connects a FRESH `IsolatedTable` per scan, so
|
|
160
|
+
* two concurrent scans of one table land on distinct instances and only a memo
|
|
161
|
+
* that spans all wrappers for the (db, table) can collapse them onto one
|
|
162
|
+
* registered covering connection. The resolved connection is cached in
|
|
163
|
+
* `registeredConnection` so subsequent reads on THIS instance fast-path — this
|
|
164
|
+
* is the only place that field is set when the build was coalesced onto another
|
|
165
|
+
* instance's in-flight promise (that instance's `buildConnection` ran, ours did
|
|
166
|
+
* not).
|
|
141
167
|
*/
|
|
142
168
|
async ensureConnection() {
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
if (this.registeredConnection)
|
|
170
|
+
return this.registeredConnection;
|
|
171
|
+
const conn = await this.isolationModule.coalesceConnectionBuild(this.db, this.schemaName, this.tableName, () => this.buildConnection());
|
|
172
|
+
this.registeredConnection = conn;
|
|
173
|
+
return conn;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Builds (or reuses) the registered connection for this table. Always called
|
|
177
|
+
* through `IsolationModule.coalesceConnectionBuild` so concurrent callers —
|
|
178
|
+
* across all `IsolatedTable` instances for this (db, table) — share one build.
|
|
179
|
+
*
|
|
180
|
+
* The covering-reuse check stays INSIDE this coalesced body so a connection
|
|
181
|
+
* registered by another instance between calls (e.g. after the memo cleared on
|
|
182
|
+
* settle) is still picked up by the next read.
|
|
183
|
+
* `registeredConnection` is assigned only on the success paths (covering reuse
|
|
184
|
+
* or a completed `registerConnection`); a thrown `registerConnection` /
|
|
185
|
+
* overlay `createConnection` leaves it null and rejects the in-flight promise,
|
|
186
|
+
* which `coalesceConnectionBuild` clears so a later read rebuilds.
|
|
187
|
+
*/
|
|
188
|
+
async buildConnection() {
|
|
189
|
+
// Reuse an existing covering (IsolatedConnection) if one is already registered
|
|
190
|
+
// for this table — avoids accumulating one IsolatedConnection per statement.
|
|
191
|
+
const qualifiedName = `${this.schemaName}.${this.tableName}`;
|
|
192
|
+
const existing = this.db.getConnectionsForTable(qualifiedName);
|
|
193
|
+
const existingCovering = existing.find((c) => c.isCovering);
|
|
194
|
+
if (existingCovering) {
|
|
195
|
+
this.registeredConnection = existingCovering;
|
|
196
|
+
return existingCovering;
|
|
160
197
|
}
|
|
161
|
-
|
|
198
|
+
// Create connection - overlay connection created lazily if needed
|
|
199
|
+
const overlayConn = this.overlayTable
|
|
200
|
+
? await Promise.resolve(this.overlayTable.createConnection?.())
|
|
201
|
+
: undefined;
|
|
202
|
+
const connection = new IsolatedConnection(`${this.schemaName}.${this.tableName}`, undefined, overlayConn, this);
|
|
203
|
+
// Register connection with the database for transaction management
|
|
204
|
+
await this.db.registerConnection(connection);
|
|
205
|
+
this.registeredConnection = connection;
|
|
206
|
+
return connection;
|
|
162
207
|
}
|
|
163
208
|
// ==================== Connection Management ====================
|
|
164
209
|
/**
|
|
@@ -198,10 +243,16 @@ export class IsolatedTable extends VirtualTable {
|
|
|
198
243
|
if (!this.underlyingTable.query) {
|
|
199
244
|
throw new Error('Underlying table does not support query');
|
|
200
245
|
}
|
|
201
|
-
// Fast path: no overlay or no changes, or a committed-snapshot read — skip overlay
|
|
246
|
+
// Fast path: no overlay or no changes, or a committed-snapshot read — skip overlay.
|
|
247
|
+
// A poisoned overlay always has hasChanges === true, so this path never serves it;
|
|
248
|
+
// a committed.<table> (readCommitted) read reaches here and stays safe — it reads
|
|
249
|
+
// only the underlying and never merges the poisoned overlay.
|
|
202
250
|
if (this.readCommitted || !this.overlayTable || !this.hasChanges) {
|
|
203
251
|
return this.underlyingTable.query(filterInfo);
|
|
204
252
|
}
|
|
253
|
+
// Merged branch: a poisoned overlay cannot be merged (its rows are in the
|
|
254
|
+
// pre-alter layout) — error before touching it.
|
|
255
|
+
this.assertOverlayUsable();
|
|
205
256
|
// Merge overlay with underlying (with connection ensured)
|
|
206
257
|
return this.mergedQueryWithConnection(filterInfo);
|
|
207
258
|
}
|
|
@@ -441,10 +492,17 @@ export class IsolatedTable extends VirtualTable {
|
|
|
441
492
|
if (this.underlyingTable.comparePrimaryKey) {
|
|
442
493
|
return this.underlyingTable.comparePrimaryKey.bind(this.underlyingTable);
|
|
443
494
|
}
|
|
444
|
-
// Fallback to default comparator
|
|
495
|
+
// Fallback to default comparator. Compare under each PK column's declared
|
|
496
|
+
// collation (e.g. NOCASE), not BINARY: the merge aligns overlay and underlying
|
|
497
|
+
// entries by this comparator to decide shadowing, and the underlying store keys
|
|
498
|
+
// rows collation-aware. A binary comparator would treat a case-only-updated
|
|
499
|
+
// overlay row ('APPLE') and the underlying row it shadows ('apple') as distinct
|
|
500
|
+
// keys, surfacing BOTH in a scan instead of the overlay shadowing the underlying.
|
|
501
|
+
const pkDef = this.tableSchema?.primaryKeyDefinition;
|
|
445
502
|
return (a, b) => {
|
|
446
503
|
for (let i = 0; i < a.length; i++) {
|
|
447
|
-
const
|
|
504
|
+
const collation = pkDef ? this.tableSchema.columns[pkDef[i].index].collation : undefined;
|
|
505
|
+
const cmp = compareSqlValues(a[i], b[i], collation);
|
|
448
506
|
if (cmp !== 0)
|
|
449
507
|
return cmp;
|
|
450
508
|
}
|
|
@@ -504,6 +562,9 @@ export class IsolatedTable extends VirtualTable {
|
|
|
504
562
|
* The overlay is created lazily on first write, using schema from the underlying table.
|
|
505
563
|
*/
|
|
506
564
|
async update(args) {
|
|
565
|
+
// A poisoned overlay (cross-connection ALTER) cannot accept further writes — its
|
|
566
|
+
// staged rows are in the pre-alter layout. Error before staging anything.
|
|
567
|
+
this.assertOverlayUsable();
|
|
507
568
|
// Ensure connection is registered for transaction coordination
|
|
508
569
|
await this.ensureConnection();
|
|
509
570
|
// Lazily create overlay on first write
|
|
@@ -523,6 +584,8 @@ export class IsolatedTable extends VirtualTable {
|
|
|
523
584
|
case 'insert': {
|
|
524
585
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
525
586
|
const pk = values ? pkIndices.map(i => values[i]) : undefined;
|
|
587
|
+
// Secondary-UNIQUE REPLACE evictions surfaced via `evictedRows`.
|
|
588
|
+
const evicted = [];
|
|
526
589
|
// Captured when OR REPLACE displaces a row that lives only in the
|
|
527
590
|
// underlying store — surfaced as `replacedRow` so the DML executor
|
|
528
591
|
// fires ON DELETE cascades for the displaced parent.
|
|
@@ -563,7 +626,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
563
626
|
return pkOutcome.terminating;
|
|
564
627
|
replacedUnderlyingRow = pkOutcome.replacedUnderlyingRow;
|
|
565
628
|
// Check non-PK UNIQUE constraints against merged view
|
|
566
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, [pk], tombstoneIndex, args.onConflict);
|
|
629
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, [pk], tombstoneIndex, args.onConflict, evicted);
|
|
567
630
|
if (ucResult !== null)
|
|
568
631
|
return ucResult;
|
|
569
632
|
}
|
|
@@ -575,7 +638,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
575
638
|
values: overlayRow,
|
|
576
639
|
});
|
|
577
640
|
const stripped = this.stripTombstoneFromResult(result, tombstoneIndex);
|
|
578
|
-
return this.attachReplacedUnderlying(stripped, replacedUnderlyingRow);
|
|
641
|
+
return this.attachEvicted(this.attachReplacedUnderlying(stripped, replacedUnderlyingRow), evicted, tombstoneIndex);
|
|
579
642
|
}
|
|
580
643
|
case 'update': {
|
|
581
644
|
// For updates, we need to handle the case where the row exists in:
|
|
@@ -589,6 +652,8 @@ export class IsolatedTable extends VirtualTable {
|
|
|
589
652
|
}
|
|
590
653
|
const existingOverlayRow = await this.getOverlayRow(overlay, targetPK);
|
|
591
654
|
const overlayRow = [...values, 0]; // tombstone = 0
|
|
655
|
+
// Secondary-UNIQUE REPLACE evictions surfaced via `evictedRows`.
|
|
656
|
+
const evicted = [];
|
|
592
657
|
if (existingOverlayRow) {
|
|
593
658
|
const newPK = pkIndices.map(i => values[i]);
|
|
594
659
|
const pkChanged = !this.keysEqual(targetPK, newPK);
|
|
@@ -599,7 +664,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
599
664
|
const pkOutcome = await this.checkMergedPKConflict(overlay, newPK, tombstoneIndex, args.onConflict);
|
|
600
665
|
if (pkOutcome.terminating)
|
|
601
666
|
return pkOutcome.terminating;
|
|
602
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, [targetPK, newPK], tombstoneIndex, args.onConflict);
|
|
667
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, [targetPK, newPK], tombstoneIndex, args.onConflict, evicted);
|
|
603
668
|
if (ucResult !== null)
|
|
604
669
|
return ucResult;
|
|
605
670
|
// Remove existing overlay row then insert a tombstone so the underlying
|
|
@@ -608,13 +673,11 @@ export class IsolatedTable extends VirtualTable {
|
|
|
608
673
|
// correctly when updating a value inserted in the same transaction.
|
|
609
674
|
await overlay.update({ operation: 'delete', values: undefined, oldKeyValues: targetPK });
|
|
610
675
|
await this.insertTombstoneForPK(overlay, targetPK, tombstoneIndex);
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
onConflict: effectiveOR,
|
|
615
|
-
});
|
|
676
|
+
// Reuse a tombstone already at newPK (a PK freed earlier in this txn)
|
|
677
|
+
// instead of colliding with it — see writeRelocatedRow.
|
|
678
|
+
const result = await this.writeRelocatedRow(overlay, newPK, overlayRow, tombstoneIndex, effectiveOR);
|
|
616
679
|
const stripped = this.stripTombstoneFromResult(result, tombstoneIndex);
|
|
617
|
-
return this.attachReplacedUnderlying(stripped, pkOutcome.replacedUnderlyingRow);
|
|
680
|
+
return this.attachEvicted(this.attachReplacedUnderlying(stripped, pkOutcome.replacedUnderlyingRow), evicted, tombstoneIndex);
|
|
618
681
|
}
|
|
619
682
|
// Same PK — update the overlay row in place
|
|
620
683
|
const result = await overlay.update({
|
|
@@ -636,20 +699,22 @@ export class IsolatedTable extends VirtualTable {
|
|
|
636
699
|
replacedUnderlyingRow = pkOutcome.replacedUnderlyingRow;
|
|
637
700
|
}
|
|
638
701
|
const selfPks = pkChanged ? [targetPK, newPK] : [targetPK];
|
|
639
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, selfPks, tombstoneIndex, args.onConflict);
|
|
702
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, values, selfPks, tombstoneIndex, args.onConflict, evicted);
|
|
640
703
|
if (ucResult !== null)
|
|
641
704
|
return ucResult;
|
|
642
705
|
// For PK-change updates, tombstone the old PK so the underlying row is deleted at flush
|
|
643
706
|
if (pkChanged) {
|
|
644
707
|
await this.insertTombstoneForPK(overlay, targetPK, tombstoneIndex);
|
|
645
708
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
709
|
+
// On a PK-change, reuse a tombstone already at newPK (a PK freed earlier
|
|
710
|
+
// in this txn) instead of colliding with it — see writeRelocatedRow.
|
|
711
|
+
// A same-PK update has no overlay row at newPK (=== targetPK), so the
|
|
712
|
+
// helper's insert path matches the prior behavior.
|
|
713
|
+
const result = pkChanged
|
|
714
|
+
? await this.writeRelocatedRow(overlay, newPK, overlayRow, tombstoneIndex, effectiveOR)
|
|
715
|
+
: await overlay.update({ operation: 'insert', values: overlayRow, onConflict: effectiveOR });
|
|
651
716
|
const stripped = this.stripTombstoneFromResult(result, tombstoneIndex);
|
|
652
|
-
return this.attachReplacedUnderlying(stripped, replacedUnderlyingRow);
|
|
717
|
+
return this.attachEvicted(this.attachReplacedUnderlying(stripped, replacedUnderlyingRow), evicted, tombstoneIndex);
|
|
653
718
|
}
|
|
654
719
|
}
|
|
655
720
|
case 'delete': {
|
|
@@ -712,13 +777,22 @@ export class IsolatedTable extends VirtualTable {
|
|
|
712
777
|
*/
|
|
713
778
|
stripTombstoneFromResult(result, tombstoneIndex) {
|
|
714
779
|
if (isUpdateOk(result) && result.row) {
|
|
715
|
-
// `replacedRow` from the overlay's memory module also
|
|
716
|
-
// tombstone column (overlay schema appends it).
|
|
717
|
-
// see rows that match the user-facing schema.
|
|
780
|
+
// `replacedRow` / `evictedRows` from the overlay's memory module also carry
|
|
781
|
+
// the trailing tombstone column (the overlay schema appends it). Slice it off
|
|
782
|
+
// so consumers see rows that match the user-facing schema.
|
|
783
|
+
//
|
|
784
|
+
// `evictedRows` here are evictions the overlay's OWN memory module performed —
|
|
785
|
+
// an intra-statement secondary-UNIQUE REPLACE against a row written earlier in
|
|
786
|
+
// the same statement (it lives in the overlay, not the underlying, so the
|
|
787
|
+
// isolation layer's own `findMergedUniqueConflict` does not see it). They must
|
|
788
|
+
// flow through to the DML executor's eviction pipeline like any other.
|
|
718
789
|
const replacedRow = result.replacedRow
|
|
719
790
|
? result.replacedRow.slice(0, tombstoneIndex)
|
|
720
791
|
: undefined;
|
|
721
|
-
|
|
792
|
+
const evictedRows = result.evictedRows
|
|
793
|
+
? result.evictedRows.map(r => r.slice(0, tombstoneIndex))
|
|
794
|
+
: undefined;
|
|
795
|
+
return { status: 'ok', row: result.row.slice(0, tombstoneIndex), replacedRow, evictedRows };
|
|
722
796
|
}
|
|
723
797
|
return result;
|
|
724
798
|
}
|
|
@@ -733,7 +807,28 @@ export class IsolatedTable extends VirtualTable {
|
|
|
733
807
|
return result;
|
|
734
808
|
if (!isUpdateOk(result) || !result.row)
|
|
735
809
|
return result;
|
|
736
|
-
return {
|
|
810
|
+
return { ...result, row: result.row, replacedRow: replacedUnderlyingRow };
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Surface internal REPLACE evictions of **underlying** rows (rows at OTHER PKs the
|
|
814
|
+
* isolation layer's own merged-view detection displaced via a tombstone) as
|
|
815
|
+
* `evictedRows`, so the DML executor runs the full delete pipeline for each. The
|
|
816
|
+
* passed rows come from `findMergedUniqueConflict`, which yields live underlying
|
|
817
|
+
* rows already in user-facing schema, so the tombstone-column slice is a defensive
|
|
818
|
+
* no-op. Preserves any `replacedRow` already attached and **merges** with any
|
|
819
|
+
* `evictedRows` already present (overlay-internal evictions propagated by
|
|
820
|
+
* {@link stripTombstoneFromResult}) — the two eviction sources are disjoint per
|
|
821
|
+
* write today (the conflicting row lives in the overlay XOR the underlying) but
|
|
822
|
+
* merging keeps both correct if that ever changes.
|
|
823
|
+
*/
|
|
824
|
+
attachEvicted(result, evicted, tombstoneIndex) {
|
|
825
|
+
if (evicted.length === 0)
|
|
826
|
+
return result;
|
|
827
|
+
if (!isUpdateOk(result) || !result.row)
|
|
828
|
+
return result;
|
|
829
|
+
const stripped = evicted.map(r => r.slice(0, tombstoneIndex));
|
|
830
|
+
const evictedRows = result.evictedRows ? [...result.evictedRows, ...stripped] : stripped;
|
|
831
|
+
return { ...result, evictedRows };
|
|
737
832
|
}
|
|
738
833
|
/**
|
|
739
834
|
* Gets a row from the overlay by primary key using O(log n) point lookup.
|
|
@@ -821,8 +916,15 @@ export class IsolatedTable extends VirtualTable {
|
|
|
821
916
|
keysEqual(a, b) {
|
|
822
917
|
if (a.length !== b.length)
|
|
823
918
|
return false;
|
|
919
|
+
// Compare under each PK column's declared collation, not BINARY. The underlying
|
|
920
|
+
// store keys rows collation-aware (e.g. a NOCASE text PK), so a case-only PK
|
|
921
|
+
// rewrite ('apple' → 'APPLE') is the SAME logical key. A binary comparison here
|
|
922
|
+
// would mis-classify it as a PK relocation, then resolve the "new" key back to the
|
|
923
|
+
// same physical underlying row and raise a false UNIQUE PK conflict.
|
|
924
|
+
const pkDef = this.tableSchema?.primaryKeyDefinition;
|
|
824
925
|
for (let i = 0; i < a.length; i++) {
|
|
825
|
-
|
|
926
|
+
const collation = pkDef ? this.tableSchema.columns[pkDef[i].index].collation : undefined;
|
|
927
|
+
if (compareSqlValues(a[i], b[i], collation) !== 0)
|
|
826
928
|
return false;
|
|
827
929
|
}
|
|
828
930
|
return true;
|
|
@@ -835,6 +937,39 @@ export class IsolatedTable extends VirtualTable {
|
|
|
835
937
|
}
|
|
836
938
|
return undefined;
|
|
837
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Writes a relocated row at `newPK` in the overlay for a PK-changing UPDATE.
|
|
942
|
+
*
|
|
943
|
+
* If the overlay already holds a **tombstone** at newPK (a PK that was freed
|
|
944
|
+
* earlier in this same transaction), overwrite it via `operation: 'update'`
|
|
945
|
+
* rather than `operation: 'insert'` — the overlay is itself a StoreTable whose
|
|
946
|
+
* insert path treats a tombstone row at the target key as a live PK conflict
|
|
947
|
+
* and would throw `_overlay_<table> PK`. Overwriting the tombstone is the
|
|
948
|
+
* logical reuse of the freed PK. This mirrors the plain-INSERT tombstone
|
|
949
|
+
* conversion (~the `existingRow[tombstoneIndex] === 1` branch in `update`).
|
|
950
|
+
*
|
|
951
|
+
* A **live** overlay row at newPK is already rejected upstream by
|
|
952
|
+
* {@link checkMergedPKConflict} (which returns its terminating constraint
|
|
953
|
+
* result) and by the existing-overlay-row PK-conflict branch, so reaching here
|
|
954
|
+
* with a non-tombstone overlay row should not happen; if it ever does, fall
|
|
955
|
+
* through to insert and let the overlay enforce the genuine conflict.
|
|
956
|
+
*/
|
|
957
|
+
async writeRelocatedRow(overlay, newPK, overlayRow, tombstoneIndex, effectiveOR) {
|
|
958
|
+
const existingAtNewPK = await this.getOverlayRow(overlay, newPK);
|
|
959
|
+
if (existingAtNewPK && existingAtNewPK[tombstoneIndex] === 1) {
|
|
960
|
+
return overlay.update({
|
|
961
|
+
operation: 'update',
|
|
962
|
+
values: overlayRow,
|
|
963
|
+
oldKeyValues: newPK,
|
|
964
|
+
onConflict: effectiveOR,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return overlay.update({
|
|
968
|
+
operation: 'insert',
|
|
969
|
+
values: overlayRow,
|
|
970
|
+
onConflict: effectiveOR,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
838
973
|
async insertTombstoneForPK(overlay, pk, tombstoneIndex) {
|
|
839
974
|
const schema = this.tableSchema;
|
|
840
975
|
if (!schema)
|
|
@@ -895,6 +1030,9 @@ export class IsolatedTable extends VirtualTable {
|
|
|
895
1030
|
return null;
|
|
896
1031
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
897
1032
|
const constrainedCols = uc.columns;
|
|
1033
|
+
// One comparison collation per constrained column — the index's per-column
|
|
1034
|
+
// COLLATE for an index-derived UNIQUE, else the declared column collation.
|
|
1035
|
+
const collations = uniqueEnforcementCollations(this.tableSchema, uc);
|
|
898
1036
|
for await (const underlyingRow of this.underlyingTable.query(this.createFullScanFilterInfo())) {
|
|
899
1037
|
const pk = pkIndices.map(i => underlyingRow[i]);
|
|
900
1038
|
if (selfPks.some(self => this.keysEqual(pk, self)))
|
|
@@ -902,25 +1040,44 @@ export class IsolatedTable extends VirtualTable {
|
|
|
902
1040
|
const overlayRow = await this.getOverlayRow(overlay, pk);
|
|
903
1041
|
if (overlayRow && overlayRow[tombstoneIndex] === 1)
|
|
904
1042
|
continue;
|
|
905
|
-
|
|
906
|
-
|
|
1043
|
+
// When a non-tombstone overlay entry supersedes this committed row, the
|
|
1044
|
+
// row's current merged-view value is the overlay's — not the stale
|
|
1045
|
+
// underlying value. Evaluate the UNIQUE columns (and any partial
|
|
1046
|
+
// predicate) against the merged row so a candidate that was moved off
|
|
1047
|
+
// the value earlier in this txn no longer counts as a conflict
|
|
1048
|
+
// (isolation-merged-unique-stale-underlying-false-positive). The overlay
|
|
1049
|
+
// row carries the appended tombstone column; strip it back to schema shape.
|
|
1050
|
+
const mergedRow = overlayRow ? overlayRow.slice(0, tombstoneIndex) : underlyingRow;
|
|
1051
|
+
const matches = constrainedCols.every((idx, i) => {
|
|
1052
|
+
if (newRow[idx] === null || mergedRow[idx] === null)
|
|
907
1053
|
return false;
|
|
908
|
-
|
|
1054
|
+
// Compare under each column's enforcement collation (the index's
|
|
1055
|
+
// per-column COLLATE for an index-derived UNIQUE, else the declared
|
|
1056
|
+
// column collation), so a UNIQUE over a collated column is enforced
|
|
1057
|
+
// against committed rows through the isolation merge path
|
|
1058
|
+
// (unique-constraint-honors-column-collation / store-index-derived-unique).
|
|
1059
|
+
return compareSqlValues(newRow[idx], mergedRow[idx], collations[i]) === 0;
|
|
909
1060
|
});
|
|
910
1061
|
if (!matches)
|
|
911
1062
|
continue;
|
|
912
1063
|
// Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
|
|
913
|
-
if (predicate && predicate.evaluate(
|
|
1064
|
+
if (predicate && predicate.evaluate(mergedRow) !== true)
|
|
914
1065
|
continue;
|
|
915
|
-
return { pk, row:
|
|
1066
|
+
return { pk, row: mergedRow };
|
|
916
1067
|
}
|
|
917
1068
|
return null;
|
|
918
1069
|
}
|
|
919
1070
|
/**
|
|
920
1071
|
* Checks all non-PK UNIQUE constraints against the merged view.
|
|
921
1072
|
* Returns null when all pass or REPLACE evictions succeed.
|
|
1073
|
+
*
|
|
1074
|
+
* A REPLACE eviction tombstones the conflicting live merged row in the overlay
|
|
1075
|
+
* (so the underlying row is deleted at flush) and pushes that row onto `evicted`
|
|
1076
|
+
* — surfaced to the DML executor via `evictedRows` so it runs the full delete
|
|
1077
|
+
* pipeline (change-tracking, FK cascade, auto-events, and the row-time
|
|
1078
|
+
* covering-MV backing maintenance the isolation layer otherwise never drives).
|
|
922
1079
|
*/
|
|
923
|
-
async checkMergedUniqueConstraints(overlay, newRow, selfPks, tombstoneIndex, onConflict) {
|
|
1080
|
+
async checkMergedUniqueConstraints(overlay, newRow, selfPks, tombstoneIndex, onConflict, evicted) {
|
|
924
1081
|
const schema = this.tableSchema;
|
|
925
1082
|
const uniqueConstraints = schema?.uniqueConstraints;
|
|
926
1083
|
if (!uniqueConstraints || uniqueConstraints.length === 0)
|
|
@@ -942,6 +1099,9 @@ export class IsolatedTable extends VirtualTable {
|
|
|
942
1099
|
return { status: 'ok', row: undefined };
|
|
943
1100
|
if (effective === ConflictResolution.REPLACE) {
|
|
944
1101
|
await this.insertTombstoneForPK(overlay, conflict.pk, tombstoneIndex);
|
|
1102
|
+
// Report the eviction (the conflicting row is the live underlying row,
|
|
1103
|
+
// already user-facing). The executor maintains the covering backing.
|
|
1104
|
+
evicted.push(conflict.row);
|
|
945
1105
|
continue;
|
|
946
1106
|
}
|
|
947
1107
|
const colNames = uc.columns.map(i => schema.columns[i].name).join(', ');
|
|
@@ -972,6 +1132,12 @@ export class IsolatedTable extends VirtualTable {
|
|
|
972
1132
|
* Shared by commit() and onConnectionCommit().
|
|
973
1133
|
*/
|
|
974
1134
|
async flushAndClearOverlay() {
|
|
1135
|
+
// A poisoned overlay must never be flushed to the (already-altered) underlying:
|
|
1136
|
+
// its rows are in the pre-alter layout. Throwing here is how a connection that
|
|
1137
|
+
// never touches the poisoned table again still errors at commit, failing the
|
|
1138
|
+
// whole transaction. The overlay is left intact so a subsequent rollback clears
|
|
1139
|
+
// it (and the poison).
|
|
1140
|
+
this.assertOverlayUsable();
|
|
975
1141
|
const overlay = this.overlayTable;
|
|
976
1142
|
if (this.hasChanges && overlay) {
|
|
977
1143
|
await this.flushOverlayToUnderlying(overlay);
|
|
@@ -1001,36 +1167,49 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1001
1167
|
}
|
|
1002
1168
|
if (overlayEntries.length === 0)
|
|
1003
1169
|
return;
|
|
1170
|
+
// Apply deletes (tombstones) before inserts/updates so a write that collides
|
|
1171
|
+
// on a secondary UNIQUE with a row being deleted in this same flush sees the
|
|
1172
|
+
// slot freed first (e.g. INSERT OR REPLACE that both replaces a PK-colliding
|
|
1173
|
+
// row AND evicts a different row on a secondary UNIQUE). Each PK appears at
|
|
1174
|
+
// most once in the overlay, so there is no same-PK delete-then-insert pair
|
|
1175
|
+
// this reordering could invert; sort() is stable in V8/Node, preserving the
|
|
1176
|
+
// original PK order within each group.
|
|
1177
|
+
const ordered = [...overlayEntries].sort((a, b) => (a.isTombstone === b.isTombstone ? 0 : a.isTombstone ? -1 : 1));
|
|
1004
1178
|
// Begin a transaction on the underlying table for the flush
|
|
1005
1179
|
await this.underlyingTable.begin?.();
|
|
1006
1180
|
try {
|
|
1007
1181
|
// Apply all overlay entries to underlying
|
|
1008
|
-
for (const entry of
|
|
1182
|
+
for (const entry of ordered) {
|
|
1009
1183
|
if (entry.isTombstone) {
|
|
1010
1184
|
// Delete from underlying
|
|
1011
|
-
await this.underlyingTable.update({
|
|
1185
|
+
const result = await this.underlyingTable.update({
|
|
1012
1186
|
operation: 'delete',
|
|
1013
1187
|
values: undefined,
|
|
1014
1188
|
oldKeyValues: entry.pk,
|
|
1015
1189
|
});
|
|
1190
|
+
this.assertFlushWriteOk(result, 'delete', entry.pk);
|
|
1016
1191
|
}
|
|
1017
1192
|
else {
|
|
1018
1193
|
// Check if row exists in underlying to decide insert vs update
|
|
1019
1194
|
const existsInUnderlying = await this.rowExistsInUnderlying(entry.pk);
|
|
1020
1195
|
if (existsInUnderlying) {
|
|
1021
|
-
await this.underlyingTable.update({
|
|
1196
|
+
const result = await this.underlyingTable.update({
|
|
1022
1197
|
operation: 'update',
|
|
1023
1198
|
values: entry.dataRow,
|
|
1024
1199
|
oldKeyValues: entry.pk,
|
|
1025
1200
|
preCoerced: true,
|
|
1201
|
+
trustedWrite: true,
|
|
1026
1202
|
});
|
|
1203
|
+
this.assertFlushWriteOk(result, 'update', entry.pk);
|
|
1027
1204
|
}
|
|
1028
1205
|
else {
|
|
1029
|
-
await this.underlyingTable.update({
|
|
1206
|
+
const result = await this.underlyingTable.update({
|
|
1030
1207
|
operation: 'insert',
|
|
1031
1208
|
values: entry.dataRow,
|
|
1032
1209
|
preCoerced: true,
|
|
1210
|
+
trustedWrite: true,
|
|
1033
1211
|
});
|
|
1212
|
+
this.assertFlushWriteOk(result, 'insert', entry.pk);
|
|
1034
1213
|
}
|
|
1035
1214
|
}
|
|
1036
1215
|
}
|
|
@@ -1043,6 +1222,21 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1043
1222
|
throw error;
|
|
1044
1223
|
}
|
|
1045
1224
|
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Asserts that an underlying write performed during the commit flush succeeded.
|
|
1227
|
+
*
|
|
1228
|
+
* The overlay's merged-view pre-checks ({@link checkMergedPKConflict} /
|
|
1229
|
+
* {@link checkMergedUniqueConstraints}) resolve every constraint before commit, so a
|
|
1230
|
+
* `constraint` result returned here means a real invariant was violated *after* those
|
|
1231
|
+
* checks. Historically this result was discarded, silently dropping the colliding
|
|
1232
|
+
* write and surfacing as data corruption. Convert it into a loud INTERNAL error; the
|
|
1233
|
+
* caller's try/catch rolls back the underlying flush transaction and rethrows.
|
|
1234
|
+
*/
|
|
1235
|
+
assertFlushWriteOk(result, operation, pk) {
|
|
1236
|
+
if (isConstraintViolation(result)) {
|
|
1237
|
+
throw new QuereusError(`Isolation flush ${operation} on '${this.tableName}' (pk=[${pk.join(', ')}]) hit a ${result.constraint} constraint: ${result.message ?? 'no message'}. The overlay merged-view pre-checks should have resolved this before commit; this indicates an isolation-layer invariant violation.`, StatusCode.INTERNAL);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1046
1240
|
/**
|
|
1047
1241
|
* Checks if a row with the given primary key exists in the underlying table.
|
|
1048
1242
|
* Uses O(log n) point lookup via the PK index.
|