@quereus/isolation 3.2.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { 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 (!this.registeredConnection) {
144
- // Reuse an existing covering (IsolatedConnection) if one is already registered
145
- // for this table — avoids accumulating one IsolatedConnection per statement.
146
- const qualifiedName = `${this.schemaName}.${this.tableName}`;
147
- const existing = this.db.getConnectionsForTable(qualifiedName);
148
- const existingCovering = existing.find((c) => c.isCovering);
149
- if (existingCovering) {
150
- this.registeredConnection = existingCovering;
151
- return this.registeredConnection;
152
- }
153
- // Create connection - overlay connection created lazily if needed
154
- const overlayConn = this.overlayTable
155
- ? await Promise.resolve(this.overlayTable.createConnection?.())
156
- : undefined;
157
- this.registeredConnection = new IsolatedConnection(`${this.schemaName}.${this.tableName}`, undefined, overlayConn, this);
158
- // Register connection with the database for transaction management
159
- await this.db.registerConnection(this.registeredConnection);
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
- return this.registeredConnection;
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 cmp = compareSqlValues(a[i], b[i]);
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
- const result = await overlay.update({
612
- operation: 'insert',
613
- values: overlayRow,
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
- const result = await overlay.update({
647
- operation: 'insert',
648
- values: overlayRow,
649
- onConflict: effectiveOR,
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 carries the trailing
716
- // tombstone column (overlay schema appends it). Slice it off so consumers
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
- return { status: 'ok', row: result.row.slice(0, tombstoneIndex), replacedRow };
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 { status: 'ok', row: result.row, replacedRow: replacedUnderlyingRow };
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
- if (compareSqlValues(a[i], b[i]) !== 0)
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
- const matches = constrainedCols.every(idx => {
906
- if (newRow[idx] === null || underlyingRow[idx] === null)
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
- return compareSqlValues(newRow[idx], underlyingRow[idx]) === 0;
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(underlyingRow) !== true)
1064
+ if (predicate && predicate.evaluate(mergedRow) !== true)
914
1065
  continue;
915
- return { pk, row: underlyingRow };
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 overlayEntries) {
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.