@quereus/isolation 4.3.1 → 4.3.2
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 +25 -5
- package/dist/src/filter-info.d.ts +34 -0
- package/dist/src/filter-info.d.ts.map +1 -0
- package/dist/src/filter-info.js +51 -0
- package/dist/src/filter-info.js.map +1 -0
- package/dist/src/flush.d.ts +36 -0
- package/dist/src/flush.d.ts.map +1 -0
- package/dist/src/flush.js +110 -0
- package/dist/src/flush.js.map +1 -0
- package/dist/src/isolated-table.d.ts +251 -56
- package/dist/src/isolated-table.d.ts.map +1 -1
- package/dist/src/isolated-table.js +658 -320
- package/dist/src/isolated-table.js.map +1 -1
- package/dist/src/isolation-module.d.ts +394 -32
- package/dist/src/isolation-module.d.ts.map +1 -1
- package/dist/src/isolation-module.js +821 -136
- package/dist/src/isolation-module.js.map +1 -1
- package/dist/src/overlay-rows.d.ts +49 -0
- package/dist/src/overlay-rows.d.ts.map +1 -0
- package/dist/src/overlay-rows.js +85 -0
- package/dist/src/overlay-rows.js.map +1 -0
- package/package.json +4 -3
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { VirtualTable, compareSqlValues,
|
|
1
|
+
import { VirtualTable, compareSqlValues, compareSqlValuesFast, resolveCollationFunctions, BINARY_COLLATION, isUpdateOk, ConflictResolution, compilePredicate, QuereusError, StatusCode, resolveUniqueEnforcementCollations, uniqueEnforcementCollations, normalizeCollationName, serializeRowKey, pkKeyCollationName, retargetFilterInfoIndex, PRIMARY_INDEX_NAME, validateAndParse, IndexConstraintOp, decodeIdxStr } from '@quereus/quereus';
|
|
2
2
|
import { IsolatedConnection } from './isolated-connection.js';
|
|
3
3
|
import { mergeStreams, createMergeEntry, createTombstone } from './merge-iterator.js';
|
|
4
|
+
import { makeFullScanFilterInfo, makePkPointLookupFilter, makeSecondaryIndexEqSeekFilter } from './filter-info.js';
|
|
5
|
+
/** Returned when the schema has not been populated yet; never mutated. */
|
|
6
|
+
const EMPTY_PK_KEY_SHAPE = { functions: [], directions: [] };
|
|
4
7
|
/**
|
|
5
8
|
* A table wrapper that provides transaction isolation via an overlay.
|
|
6
9
|
*
|
|
@@ -28,22 +31,68 @@ export class IsolatedTable extends VirtualTable {
|
|
|
28
31
|
* lets the GC reclaim entries for retired constraints.
|
|
29
32
|
*/
|
|
30
33
|
predicateCache = new WeakMap();
|
|
34
|
+
/**
|
|
35
|
+
* `db.getCollationResolver()`, bound once at connect. Both the overlay/underlying
|
|
36
|
+
* merge comparators and the UNIQUE conflict checks resolve collation names through
|
|
37
|
+
* it, so a collation registered with `db.registerCollation` participates instead of
|
|
38
|
+
* silently degrading to BINARY.
|
|
39
|
+
*
|
|
40
|
+
* The overlay's key comparator and the underlying table's must agree, or a staged
|
|
41
|
+
* row fails to shadow the base row it replaces; both now derive from this one
|
|
42
|
+
* database registry.
|
|
43
|
+
*/
|
|
44
|
+
collationResolver;
|
|
45
|
+
/**
|
|
46
|
+
* `db.getKeyNormalizerResolver()`, bound once at connect beside {@link collationResolver}
|
|
47
|
+
* for the same reason: the modified-PK set's key encoding and the comparators above must
|
|
48
|
+
* agree on which rows are equal, or a staged row fails to shadow the base row it replaces.
|
|
49
|
+
*/
|
|
50
|
+
keyNormalizerResolver;
|
|
51
|
+
/**
|
|
52
|
+
* Per-PK-column comparison functions and sort directions, resolved from the PK
|
|
53
|
+
* columns' declared collations and memoized against the `TableSchema` object
|
|
54
|
+
* identity — an `alter table` hands this instance a fresh (frozen) schema object,
|
|
55
|
+
* which invalidates the entry.
|
|
56
|
+
*/
|
|
57
|
+
pkCollationCache;
|
|
31
58
|
/**
|
|
32
59
|
* Returns the connection-scoped set of savepoint depths that pre-date the overlay.
|
|
33
60
|
* Stored in IsolationModule (keyed by db+schema+table) so all IsolatedTable instances
|
|
34
61
|
* for the same connection see the same set — important because each statement creates
|
|
35
62
|
* a fresh IsolatedTable instance via module.connect(), so instance-local state would
|
|
36
63
|
* be lost between the createSavepoint callback and the ensureOverlay() call.
|
|
64
|
+
*
|
|
65
|
+
* NOTE: keyed by the name this IsolatedTable was *constructed* with, which after a
|
|
66
|
+
* mid-transaction `alter table … rename to …` is the pre-rename name. Deliberate:
|
|
67
|
+
* this same instance stays the registered connection's callback object for the rest
|
|
68
|
+
* of the transaction and clears the set at commit/rollback, so the key must not move
|
|
69
|
+
* out from under it. A statement after the rename connects a fresh IsolatedTable under
|
|
70
|
+
* the new name, which builds its own set from `Database.registerConnection`'s savepoint
|
|
71
|
+
* replay. See `IsolationModule.renameTable`.
|
|
37
72
|
*/
|
|
38
73
|
get savepointsBeforeOverlay() {
|
|
39
74
|
return this.isolationModule.getPreOverlaySavepoints(this.db, this.schemaName, this.tableName);
|
|
40
75
|
}
|
|
41
|
-
|
|
76
|
+
/**
|
|
77
|
+
* @param schemaName Schema name as supplied to `IsolationModule.create()`/`.connect()` — the
|
|
78
|
+
* same identity `IsolationModule.underlyingTables` is keyed by.
|
|
79
|
+
* @param tableName Bare table name from the same source. These MUST NOT be read off
|
|
80
|
+
* `underlyingTable.schemaName` / `.tableName`: `VirtualTable.tableName` is contracted bare,
|
|
81
|
+
* but an underlying module that reports a schema-qualified name there (lamina-quereus does,
|
|
82
|
+
* using the field as a catalogue key) would key this table's overlay as
|
|
83
|
+
* `<dbId>:store.store.widget` while `underlyingTables` holds `store.widget`. The commit
|
|
84
|
+
* flush looks the overlay's key up in `underlyingTables`, misses, and silently discards
|
|
85
|
+
* every staged row. Keying off the connect-time pair keeps both maps on one identity by
|
|
86
|
+
* construction, whatever the underlying self-reports.
|
|
87
|
+
*/
|
|
88
|
+
constructor(db, module, schemaName, tableName, underlyingTable, readCommitted = false) {
|
|
42
89
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
-
super(db, module,
|
|
90
|
+
super(db, module, schemaName, tableName);
|
|
44
91
|
this.isolationModule = module;
|
|
45
92
|
this.underlyingTable = underlyingTable;
|
|
46
93
|
this.readCommitted = readCommitted;
|
|
94
|
+
this.collationResolver = db.getCollationResolver();
|
|
95
|
+
this.keyNormalizerResolver = db.getKeyNormalizerResolver();
|
|
47
96
|
// Schema comes from underlying - may be populated lazily by the underlying module
|
|
48
97
|
this.tableSchema = underlyingTable.tableSchema;
|
|
49
98
|
}
|
|
@@ -60,13 +109,14 @@ export class IsolatedTable extends VirtualTable {
|
|
|
60
109
|
return this.isolationModule.getConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
61
110
|
}
|
|
62
111
|
/**
|
|
63
|
-
* Throws if this connection's overlay was poisoned by a cross-connection ALTER
|
|
64
|
-
* (see `IsolationModule.alterTable`)
|
|
65
|
-
* PRE-alter column layout,
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* rolling back, which discards the
|
|
112
|
+
* Throws if this connection's overlay was poisoned by a cross-connection DDL: an ALTER
|
|
113
|
+
* that could not migrate it (see `IsolationModule.alterTable`), leaving its rows in the
|
|
114
|
+
* PRE-alter column layout, or a DROP TABLE that removed the table underneath it (see
|
|
115
|
+
* `IsolationModule.destroy`). Either way it can neither be merged into a read nor
|
|
116
|
+
* flushed. Called at the data-op chokepoints (write, the merged read branch, and the
|
|
117
|
+
* commit flush) — never on the committed-snapshot read path, which bypasses the overlay
|
|
118
|
+
* entirely and stays safe. The connection recovers by rolling back, which discards the
|
|
119
|
+
* overlay (and its poison).
|
|
70
120
|
*/
|
|
71
121
|
assertOverlayUsable() {
|
|
72
122
|
const state = this.getOverlayState();
|
|
@@ -136,10 +186,12 @@ export class IsolatedTable extends VirtualTable {
|
|
|
136
186
|
const preAlignedConn = await overlayTable.createConnection();
|
|
137
187
|
await this.db.registerConnection(preAlignedConn);
|
|
138
188
|
}
|
|
139
|
-
// Store in connection-scoped storage
|
|
189
|
+
// Store in connection-scoped storage. `db` rides along so the module can free this
|
|
190
|
+
// overlay's staging table on any discard path (see IsolationModule.releaseOverlayTable).
|
|
140
191
|
const state = {
|
|
141
192
|
overlayTable,
|
|
142
193
|
hasChanges: false,
|
|
194
|
+
db: this.db,
|
|
143
195
|
};
|
|
144
196
|
this.isolationModule.setConnectionOverlay(this.db, this.schemaName, this.tableName, state);
|
|
145
197
|
return overlayTable;
|
|
@@ -188,6 +240,13 @@ export class IsolatedTable extends VirtualTable {
|
|
|
188
240
|
async buildConnection() {
|
|
189
241
|
// Reuse an existing covering (IsolatedConnection) if one is already registered
|
|
190
242
|
// for this table — avoids accumulating one IsolatedConnection per statement.
|
|
243
|
+
//
|
|
244
|
+
// NOTE: a rename leaves the covering connection registered under the OLD name
|
|
245
|
+
// (StoreModule.renameTable evicts only its own StoreConnections), so a table later
|
|
246
|
+
// created under that freed name adopts that stale connection here. Sound today only
|
|
247
|
+
// because the overlay and underlying maps are keyed by table name rather than by
|
|
248
|
+
// connection, so each table still resolves its own state. If per-connection state
|
|
249
|
+
// ever moves onto IsolatedConnection, retarget the connection across the rename.
|
|
191
250
|
const qualifiedName = `${this.schemaName}.${this.tableName}`;
|
|
192
251
|
const existing = this.db.getConnectionsForTable(qualifiedName);
|
|
193
252
|
const existingCovering = existing.find((c) => c.isCovering);
|
|
@@ -250,8 +309,8 @@ export class IsolatedTable extends VirtualTable {
|
|
|
250
309
|
if (this.readCommitted || !this.overlayTable || !this.hasChanges) {
|
|
251
310
|
return this.underlyingTable.query(filterInfo);
|
|
252
311
|
}
|
|
253
|
-
// Merged branch: a poisoned overlay cannot be merged (
|
|
254
|
-
//
|
|
312
|
+
// Merged branch: a poisoned overlay cannot be merged (pre-alter row layout, or the
|
|
313
|
+
// table itself is gone) — error before touching it.
|
|
255
314
|
this.assertOverlayUsable();
|
|
256
315
|
// Merge overlay with underlying (with connection ensured)
|
|
257
316
|
return this.mergedQueryWithConnection(filterInfo);
|
|
@@ -279,7 +338,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
279
338
|
yield* this.underlyingTable.query(filterInfo);
|
|
280
339
|
return;
|
|
281
340
|
}
|
|
282
|
-
const indexInfo = this.
|
|
341
|
+
const indexInfo = this.resolveScanIndex(filterInfo);
|
|
283
342
|
if (indexInfo.type === 'secondary') {
|
|
284
343
|
yield* this.mergedSecondaryIndexQuery(overlay, filterInfo, indexInfo);
|
|
285
344
|
return;
|
|
@@ -296,10 +355,19 @@ export class IsolatedTable extends VirtualTable {
|
|
|
296
355
|
*
|
|
297
356
|
* Instead of position-based merging (which fails when overlay entries have
|
|
298
357
|
* different index key values than the underlying rows they shadow), this:
|
|
299
|
-
* 1.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
358
|
+
* 1. Full-scans the overlay ONCE, collecting all modified PKs and the non-tombstone
|
|
359
|
+
* data rows that fall inside the query's constraint window
|
|
360
|
+
* 2. Sorts the collected overlay rows by the scan's (indexKey, PK) sort key
|
|
361
|
+
* 3. Queries underlying via secondary index, excluding modified PKs, and merges the
|
|
362
|
+
* sorted overlay rows in by sort key
|
|
363
|
+
*
|
|
364
|
+
* The overlay is deliberately never asked to resolve the query's index name. The
|
|
365
|
+
* underlying module may drive this scan under an index it minted itself (lamina
|
|
366
|
+
* mints `_column_<id>_`, `_compound_<name>_`, `_nd_<name>_`, `_intersect_<ids>_`
|
|
367
|
+
* per plan) — a name no table schema, and therefore no overlay, declares — so
|
|
368
|
+
* re-issuing the index-named FilterInfo against the overlay would throw
|
|
369
|
+
* "Secondary index not found". The isolation layer filters and sorts the overlay
|
|
370
|
+
* delta itself instead.
|
|
303
371
|
*/
|
|
304
372
|
async *mergedSecondaryIndexQuery(overlay, filterInfo, indexInfo) {
|
|
305
373
|
if (!overlay.query) {
|
|
@@ -308,29 +376,65 @@ export class IsolatedTable extends VirtualTable {
|
|
|
308
376
|
}
|
|
309
377
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
310
378
|
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
311
|
-
//
|
|
379
|
+
// Key the modified-PK set with the engine's canonical, bigint-safe,
|
|
380
|
+
// collation-aware encoder — NOT JSON.stringify, which throws on a bigint PK
|
|
381
|
+
// value and ignores collation (a NOCASE PK rewritten 'abc' -> 'ABC' would fail
|
|
382
|
+
// to shadow the underlying 'abc', surfacing both rows). One normalizer per PK
|
|
383
|
+
// column, drawn from `pkKeyCollationName` (the same decision `resolvePkKeyCollations`
|
|
384
|
+
// makes for the store's on-disk PK encoding) via the connection's own resolver, so
|
|
385
|
+
// equal keys under the PK collation encode to identical strings — matching
|
|
386
|
+
// getComparePK/keysEqual and agreeing with `db.registerCollation`.
|
|
387
|
+
//
|
|
388
|
+
// A PK column whose declared type can never hold text takes the identity
|
|
389
|
+
// normalizer regardless of its collation: `serializeRowKey` normalizes only
|
|
390
|
+
// string values, so the collation cannot affect how such a key buckets. Asking
|
|
391
|
+
// the resolver for it would reject `n integer collate mycoll` under a
|
|
392
|
+
// comparator-only collation, which the engine's own hash sites accept (they gate
|
|
393
|
+
// through `hashKeyCollationName`, the same predicate). A text-capable-but-not-
|
|
394
|
+
// `isTextual` column (`any`, `json`, the temporal types) is keyed under `'BINARY'`
|
|
395
|
+
// regardless of its declared collation, since PK equality compares those types
|
|
396
|
+
// through `logicalType.compare`, which ignores collation entirely.
|
|
397
|
+
const pkNormalizers = pkIndices.map(i => {
|
|
398
|
+
const column = this.tableSchema.columns[i];
|
|
399
|
+
return this.keyNormalizerResolver(pkKeyCollationName(column));
|
|
400
|
+
});
|
|
401
|
+
// Step 1: one full overlay scan collects the modified PKs AND the in-window
|
|
402
|
+
// non-tombstone data rows. The window filter is applied here, unconditionally —
|
|
403
|
+
// whether the engine adds a residual Filter above the isolation scan depends on
|
|
404
|
+
// the underlying's handledFilters, which the isolation layer does not control.
|
|
405
|
+
// NOTE: full-scans the overlay on every merged secondary read. Fine while an
|
|
406
|
+
// overlay holds a single transaction's writes (small); if isolation-write volume
|
|
407
|
+
// ever makes overlays large and this shows up hot, retarget to an overlay index
|
|
408
|
+
// with the same key columns when one exists, keeping this full scan as the
|
|
409
|
+
// fallback for underlying-minted index names no overlay index matches.
|
|
410
|
+
const matchesWindow = this.buildConstraintMatcher(filterInfo);
|
|
312
411
|
const modifiedPKs = new Set();
|
|
313
|
-
for await (const row of overlay.query(this.createFullScanFilterInfo())) {
|
|
314
|
-
const pk = pkIndices.map(i => row[i]);
|
|
315
|
-
modifiedPKs.add(JSON.stringify(pk));
|
|
316
|
-
}
|
|
317
|
-
// Step 2: Query overlay via secondary index for non-tombstone data rows
|
|
318
|
-
const overlayFilterInfo = this.adaptFilterInfoForOverlay(filterInfo);
|
|
319
412
|
const overlayRows = [];
|
|
320
|
-
for await (const row of overlay.query(
|
|
413
|
+
for await (const row of overlay.query(this.createFullScanFilterInfo())) {
|
|
414
|
+
// `!` is safe: PK columns are NOT NULL, so serializeRowKey never returns
|
|
415
|
+
// null here; both sides use the same encoder so they stay consistent.
|
|
416
|
+
modifiedPKs.add(serializeRowKey(row, pkIndices, pkNormalizers));
|
|
321
417
|
if (row[tombstoneIndex] !== 1) {
|
|
322
|
-
|
|
418
|
+
const dataRow = row.slice(0, tombstoneIndex);
|
|
419
|
+
if (matchesWindow(dataRow)) {
|
|
420
|
+
overlayRows.push(dataRow);
|
|
421
|
+
}
|
|
323
422
|
}
|
|
324
423
|
}
|
|
325
|
-
// Step
|
|
424
|
+
// Step 2: sort the collected overlay rows by the merge's sort key. The full scan
|
|
425
|
+
// emits in PK order, and the step-3 merge walks overlayRows expecting
|
|
426
|
+
// (indexKey, PK) order — sorting here decouples merge correctness from whatever
|
|
427
|
+
// order the overlay happens to emit.
|
|
326
428
|
const mergeConfig = this.buildMergeConfig(indexInfo);
|
|
327
429
|
const compareSortKey = mergeConfig.compareSortKey ?? mergeConfig.comparePK;
|
|
328
430
|
const extractSortKey = mergeConfig.extractSortKey ?? mergeConfig.extractPK;
|
|
431
|
+
overlayRows.sort((a, b) => compareSortKey(extractSortKey(a), extractSortKey(b)));
|
|
432
|
+
// Step 3: query underlying via secondary index, filter out modified PKs
|
|
329
433
|
// Merge two sorted, disjoint streams
|
|
330
434
|
let oi = 0;
|
|
331
435
|
for await (const underlyingRow of this.underlyingTable.query(filterInfo)) {
|
|
332
|
-
|
|
333
|
-
if (modifiedPKs.has(
|
|
436
|
+
// `!` is safe for the same reason as the build loop above (PK NOT NULL).
|
|
437
|
+
if (modifiedPKs.has(serializeRowKey(underlyingRow, pkIndices, pkNormalizers))) {
|
|
334
438
|
continue; // Skip rows modified in overlay
|
|
335
439
|
}
|
|
336
440
|
// Yield any overlay rows that sort before this underlying row
|
|
@@ -352,57 +456,224 @@ export class IsolatedTable extends VirtualTable {
|
|
|
352
456
|
}
|
|
353
457
|
}
|
|
354
458
|
/**
|
|
355
|
-
*
|
|
356
|
-
*
|
|
459
|
+
* Determine the order the underlying scan emits, from the planner's typed access path.
|
|
460
|
+
*
|
|
461
|
+
* A `fullScan` (or a provably-empty plan) merges by primary key: every underlying module
|
|
462
|
+
* the isolation layer wraps emits an unbounded scan in primary-key order. That is a
|
|
463
|
+
* contract on the underlying, not an inference from any string — see
|
|
464
|
+
* docs/design-isolation-layer.md.
|
|
465
|
+
*
|
|
466
|
+
* `role` is authoritative over `name`: a descriptor with `role: 'primary'` IS the table's
|
|
467
|
+
* primary key however the module named it (an alias like `_primary_1`), so it merges by PK.
|
|
468
|
+
* An `unresolvedIndex` — a name the engine could not resolve to any index, with no
|
|
469
|
+
* module-supplied descriptor — cannot yield a comparator, so we fail loudly rather than
|
|
470
|
+
* silently merge by the wrong sort key.
|
|
357
471
|
*/
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
if (!
|
|
361
|
-
|
|
472
|
+
resolveScanIndex(filterInfo) {
|
|
473
|
+
const path = filterInfo.accessPath;
|
|
474
|
+
if (!path) {
|
|
475
|
+
throw new QuereusError(`IsolatedTable '${this.tableName}': FilterInfo carries no accessPath, so the ` +
|
|
476
|
+
`underlying scan's sort order is unknown and the overlay cannot be merged. ` +
|
|
477
|
+
`Build FilterInfo with the engine's makeFullScanFilterInfo/makeIndexEqSeekFilterInfo helpers.`, StatusCode.INTERNAL);
|
|
362
478
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
479
|
+
switch (path.kind) {
|
|
480
|
+
case 'fullScan':
|
|
481
|
+
case 'empty':
|
|
482
|
+
return { type: 'primary' };
|
|
483
|
+
case 'index':
|
|
484
|
+
return path.index.role === 'primary'
|
|
485
|
+
? { type: 'primary' }
|
|
486
|
+
: {
|
|
487
|
+
type: 'secondary',
|
|
488
|
+
indexName: path.index.name,
|
|
489
|
+
columnIndices: path.index.keyColumns.map(c => c.columnIndex),
|
|
490
|
+
keyColumns: path.index.keyColumns,
|
|
491
|
+
// A reversed scan (ORDER BY … DESC) emits the underlying stream in
|
|
492
|
+
// reverse of the descriptor's declared key order; the merge must walk
|
|
493
|
+
// its sort key reversed to interleave overlay rows correctly.
|
|
494
|
+
reverse: path.index.reverse === true,
|
|
495
|
+
};
|
|
496
|
+
case 'unresolvedIndex':
|
|
497
|
+
throw new QuereusError(`IsolatedTable '${this.tableName}': the underlying module chose index ` +
|
|
498
|
+
`'${path.indexName}', which the engine could not resolve against the table schema. ` +
|
|
499
|
+
`A module that names an index anything other than '_primary_' or a schema index ` +
|
|
500
|
+
`must return an 'indexDescriptor' from getBestAccessPlan (see docs/module-authoring.md).`, StatusCode.INTERNAL);
|
|
501
|
+
default: {
|
|
502
|
+
// A new AccessPath kind must extend this switch; never-assignment makes that a
|
|
503
|
+
// compile error rather than a silent `undefined` return that would crash at the
|
|
504
|
+
// `indexInfo.type` read in mergedQuery (noImplicitReturns is off in this package).
|
|
505
|
+
const _exhaustive = path;
|
|
506
|
+
throw new QuereusError(`IsolatedTable '${this.tableName}': unhandled accessPath kind '${path.kind}'`, StatusCode.INTERNAL);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Adapts FilterInfo for the overlay table schema (which has an extra tombstone column).
|
|
512
|
+
* The constraints and index references remain the same since the overlay has matching indexes.
|
|
513
|
+
*
|
|
514
|
+
* The one mismatch is the index NAME. The underlying may drive its PK plan under a per-plan
|
|
515
|
+
* alias (e.g. `_primary_1`) that the overlay MemoryTable does not know — the overlay always
|
|
516
|
+
* names its PK index `_primary_`. When the access path is the primary key under such an
|
|
517
|
+
* alias, retarget the FilterInfo's index name to `_primary_` so the overlay re-plans it as a
|
|
518
|
+
* primary-key scan instead of failing to resolve a non-existent secondary index of that name.
|
|
519
|
+
* A genuine secondary scan (or the bare `_primary_`) is returned unchanged.
|
|
520
|
+
*/
|
|
521
|
+
adaptFilterInfoForOverlay(filterInfo) {
|
|
522
|
+
const path = filterInfo.accessPath;
|
|
523
|
+
if (path?.kind === 'index' && path.index.role === 'primary' && path.index.name !== PRIMARY_INDEX_NAME) {
|
|
524
|
+
return retargetFilterInfoIndex(filterInfo, PRIMARY_INDEX_NAME);
|
|
373
525
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
526
|
+
return filterInfo;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Builds the window predicate for the overlay side of a merged secondary-index read.
|
|
530
|
+
*
|
|
531
|
+
* The overlay is full-scanned there (it cannot resolve an underlying-minted index
|
|
532
|
+
* name), so every staged row surfaces — including rows outside the query's window.
|
|
533
|
+
* This predicate re-applies the pushed constraints that the underlying's index seek
|
|
534
|
+
* enforces on its own stream.
|
|
535
|
+
*
|
|
536
|
+
* Interpretation follows the plan kind:
|
|
537
|
+
* - `scan` pushes no window — everything matches.
|
|
538
|
+
* - `eqSeek` / `rangeSeek` / `prefixRangeSeek` / `multiSeek` carry one genuine
|
|
539
|
+
* (column, op, value) triple per constraint entry. Per-column EQ values form an
|
|
540
|
+
* IN set — the planner encodes an IN multi-seek as one EQ constraint per seek
|
|
541
|
+
* value on the same column, and a composite IN as its full per-column
|
|
542
|
+
* cross-product — so EQ matches when the row equals ANY of the column's values,
|
|
543
|
+
* while range bounds all AND together.
|
|
544
|
+
* - `multiRangeSeek` constraint entries are positional placeholders (every arg is
|
|
545
|
+
* stamped GE); the real OR-of-ranges lives in the idxStr `rangeOps` parameters,
|
|
546
|
+
* decoded by {@link buildMultiRangeWindowMatcher}.
|
|
547
|
+
*
|
|
548
|
+
* Comparisons run under the index key column's collation (falling back to the table
|
|
549
|
+
* column's, then BINARY) with seek semantics for NULL: a NULL operand or a NULL row
|
|
550
|
+
* value never matches, mirroring what an index seek returns. Operators the matcher
|
|
551
|
+
* does not interpret (LIKE, MATCH, ...) are ignored — that can only let through
|
|
552
|
+
* rows a residual Filter above still removes, never drop rows.
|
|
553
|
+
*/
|
|
554
|
+
buildConstraintMatcher(filterInfo) {
|
|
555
|
+
const path = filterInfo.accessPath;
|
|
556
|
+
// Only 'index' paths reach the merged secondary read (resolveScanIndex gates on
|
|
557
|
+
// the access path first); an ordering-only walk pushes no window.
|
|
558
|
+
if (path?.kind !== 'index' || path.plan === 'scan') {
|
|
559
|
+
return () => true;
|
|
377
560
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
561
|
+
if (path.plan === 'multiRangeSeek') {
|
|
562
|
+
return this.buildMultiRangeWindowMatcher(filterInfo, path.index);
|
|
563
|
+
}
|
|
564
|
+
const windows = new Map();
|
|
565
|
+
const windowFor = (columnIndex) => {
|
|
566
|
+
let window = windows.get(columnIndex);
|
|
567
|
+
if (!window) {
|
|
568
|
+
window = { collation: this.constraintCollation(columnIndex, path.index), eqValues: [], ranges: [] };
|
|
569
|
+
windows.set(columnIndex, window);
|
|
570
|
+
}
|
|
571
|
+
return window;
|
|
572
|
+
};
|
|
573
|
+
for (const { constraint, argvIndex } of filterInfo.constraints) {
|
|
574
|
+
if (argvIndex <= 0)
|
|
575
|
+
continue;
|
|
576
|
+
const value = filterInfo.args[argvIndex - 1];
|
|
577
|
+
switch (constraint.op) {
|
|
578
|
+
case IndexConstraintOp.EQ:
|
|
579
|
+
windowFor(constraint.iColumn).eqValues.push(value);
|
|
580
|
+
break;
|
|
581
|
+
case IndexConstraintOp.GT:
|
|
582
|
+
case IndexConstraintOp.GE:
|
|
583
|
+
case IndexConstraintOp.LT:
|
|
584
|
+
case IndexConstraintOp.LE:
|
|
585
|
+
windowFor(constraint.iColumn).ranges.push({ op: constraint.op, value });
|
|
586
|
+
break;
|
|
587
|
+
default:
|
|
588
|
+
break; // uninterpreted op — see doc comment
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (windows.size === 0)
|
|
592
|
+
return () => true;
|
|
593
|
+
const entries = [...windows.entries()];
|
|
594
|
+
return (row) => {
|
|
595
|
+
for (const [columnIndex, window] of entries) {
|
|
596
|
+
const rowValue = row[columnIndex];
|
|
597
|
+
if (rowValue === null)
|
|
598
|
+
return false;
|
|
599
|
+
if (window.eqValues.length > 0
|
|
600
|
+
&& !window.eqValues.some(v => v !== null && compareSqlValuesFast(rowValue, v, window.collation) === 0)) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
for (const { op, value } of window.ranges) {
|
|
604
|
+
if (value === null || !satisfiesBound(compareSqlValuesFast(rowValue, value, window.collation), op)) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
383
610
|
};
|
|
384
611
|
}
|
|
385
612
|
/**
|
|
386
|
-
*
|
|
613
|
+
* Window matcher for a `multiRangeSeek` — an OR of ranges over the index's leading
|
|
614
|
+
* key column. The FilterInfo's constraint entries are positional placeholders for
|
|
615
|
+
* this plan kind, so the ranges are decoded from the idxStr `rangeCount`/`rangeOps`
|
|
616
|
+
* parameters exactly as the memory module's scan-plan builder does.
|
|
387
617
|
*/
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
618
|
+
buildMultiRangeWindowMatcher(filterInfo, index) {
|
|
619
|
+
const keyColumn = index.keyColumns[0];
|
|
620
|
+
const spec = decodeIdxStr(filterInfo.idxStr);
|
|
621
|
+
if (!keyColumn || !spec)
|
|
622
|
+
return () => true;
|
|
623
|
+
const rangeCount = parseInt(spec.params.get('rangeCount') ?? '0', 10);
|
|
624
|
+
const rangeOpsList = (spec.params.get('rangeOps') ?? '').split(',');
|
|
625
|
+
const { args } = filterInfo;
|
|
626
|
+
const ranges = [];
|
|
627
|
+
let argIdx = 0;
|
|
628
|
+
for (let i = 0; i < rangeCount; i++) {
|
|
629
|
+
const range = {};
|
|
630
|
+
for (const op of (rangeOpsList[i] ?? '').split(':')) {
|
|
631
|
+
if (op === 'gt' || op === 'ge') {
|
|
632
|
+
range.lower = { strict: op === 'gt', value: args[argIdx++] };
|
|
633
|
+
}
|
|
634
|
+
else if (op === 'lt' || op === 'le') {
|
|
635
|
+
range.upper = { strict: op === 'lt', value: args[argIdx++] };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
ranges.push(range);
|
|
639
|
+
}
|
|
640
|
+
if (ranges.length === 0)
|
|
641
|
+
return () => true;
|
|
642
|
+
const columnIndex = keyColumn.columnIndex;
|
|
643
|
+
const collation = this.constraintCollation(columnIndex, index);
|
|
644
|
+
return (row) => {
|
|
645
|
+
const rowValue = row[columnIndex];
|
|
646
|
+
if (rowValue === null)
|
|
647
|
+
return false;
|
|
648
|
+
return ranges.some(({ lower, upper }) => {
|
|
649
|
+
if (lower) {
|
|
650
|
+
if (lower.value === null)
|
|
651
|
+
return false;
|
|
652
|
+
const cmp = compareSqlValuesFast(rowValue, lower.value, collation);
|
|
653
|
+
if (lower.strict ? cmp <= 0 : cmp < 0)
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
if (upper) {
|
|
657
|
+
if (upper.value === null)
|
|
658
|
+
return false;
|
|
659
|
+
const cmp = compareSqlValuesFast(rowValue, upper.value, collation);
|
|
660
|
+
if (upper.strict ? cmp >= 0 : cmp > 0)
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
});
|
|
665
|
+
};
|
|
396
666
|
}
|
|
397
667
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
668
|
+
* Comparison collation for a window-matcher column: the index key column's declared
|
|
669
|
+
* collation when the column is part of the scanned index (the seek's own window
|
|
670
|
+
* follows the index collation), else the table column's declared collation, else
|
|
671
|
+
* BINARY.
|
|
400
672
|
*/
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return filterInfo;
|
|
673
|
+
constraintCollation(columnIndex, index) {
|
|
674
|
+
const name = index.keyColumns.find(kc => kc.columnIndex === columnIndex)?.collation
|
|
675
|
+
?? this.tableSchema?.columns[columnIndex]?.collation;
|
|
676
|
+
return name ? this.collationResolver(name) : BINARY_COLLATION;
|
|
406
677
|
}
|
|
407
678
|
/**
|
|
408
679
|
* Queries the overlay table and converts rows to MergeEntry format.
|
|
@@ -474,9 +745,22 @@ export class IsolatedTable extends VirtualTable {
|
|
|
474
745
|
const pk = pkIndices.map(i => row[i]);
|
|
475
746
|
return [...indexKey, ...pk];
|
|
476
747
|
};
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
748
|
+
// Prefer the underlying table's per-column index comparators; when it exposes
|
|
749
|
+
// none for this index (always the case for a module-minted synthetic name),
|
|
750
|
+
// derive them from the descriptor's key columns so a DESC or collated key
|
|
751
|
+
// column still merges in the underlying's emission order rather than a
|
|
752
|
+
// BINARY-ascending guess.
|
|
753
|
+
const indexComparators = this.underlyingTable.getIndexComparator?.(indexInfo.indexName)
|
|
754
|
+
?? this.buildDescriptorComparators(indexInfo.keyColumns);
|
|
755
|
+
const forwardCompareSortKey = this.buildCompareSortKey(indexColIndices.length, comparePK, indexComparators);
|
|
756
|
+
// A reversed scan emits the whole (indexKey, PK) sort key in reverse — including
|
|
757
|
+
// the PK tie-break within an equal index-key group — so negate the entire
|
|
758
|
+
// comparator. That makes the step-2 overlay sort descend and the step-3 merge's
|
|
759
|
+
// `compareSortKey(oKey, uKey) <= 0` walk yield overlay rows while they sort at or
|
|
760
|
+
// before the underlying row under the reversed order.
|
|
761
|
+
const compareSortKey = indexInfo.reverse
|
|
762
|
+
? (a, b) => -forwardCompareSortKey(a, b)
|
|
763
|
+
: forwardCompareSortKey;
|
|
480
764
|
return {
|
|
481
765
|
extractPK,
|
|
482
766
|
comparePK,
|
|
@@ -484,6 +768,49 @@ export class IsolatedTable extends VirtualTable {
|
|
|
484
768
|
compareSortKey,
|
|
485
769
|
};
|
|
486
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Per-PK-column comparison functions for this table's current schema, resolved
|
|
773
|
+
* against THIS database's collation registry (so `db.registerCollation` names
|
|
774
|
+
* participate) rather than the process-global built-in trio.
|
|
775
|
+
*
|
|
776
|
+
* A PK column with no declared `COLLATE` resolves to BINARY; a PK definition
|
|
777
|
+
* shorter than the key being compared (or absent entirely) yields BINARY for the
|
|
778
|
+
* trailing positions, matching the prior `collation === undefined` behaviour.
|
|
779
|
+
*
|
|
780
|
+
* Memoized on the schema object so comparator construction — and the per-call
|
|
781
|
+
* {@link keysEqual} — never re-resolves per row. Resolution is not retroactive: a
|
|
782
|
+
* collation registered *after* a comparator was built is not picked up, the same
|
|
783
|
+
* contract `Database.registerCollation` documents engine-wide.
|
|
784
|
+
*
|
|
785
|
+
* NOTE: this is the *comparison* collation only. The store's physical key bytes come
|
|
786
|
+
* from a separate encoder registry that does not consult the database — see
|
|
787
|
+
* `fix/bug-store-key-encoder-ignores-database-collations`.
|
|
788
|
+
*/
|
|
789
|
+
getPkCollations() {
|
|
790
|
+
return this.getPkKeyShape().functions;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Per-PK-column sort directions (`true` ⇒ DESC), positionally aligned with
|
|
794
|
+
* {@link getPkCollations}. Only the ordering comparator needs them; `keysEqual`
|
|
795
|
+
* asks about equality, which direction cannot change.
|
|
796
|
+
*/
|
|
797
|
+
getPkDirections() {
|
|
798
|
+
return this.getPkKeyShape().directions;
|
|
799
|
+
}
|
|
800
|
+
getPkKeyShape() {
|
|
801
|
+
const schema = this.tableSchema;
|
|
802
|
+
if (!schema)
|
|
803
|
+
return EMPTY_PK_KEY_SHAPE;
|
|
804
|
+
if (this.pkCollationCache?.schema !== schema) {
|
|
805
|
+
const pkDef = schema.primaryKeyDefinition ?? [];
|
|
806
|
+
this.pkCollationCache = {
|
|
807
|
+
schema,
|
|
808
|
+
functions: resolveCollationFunctions(this.collationResolver, pkDef.map(pk => schema.columns[pk.index]?.collation)),
|
|
809
|
+
directions: pkDef.map(pk => !!pk.desc),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return this.pkCollationCache;
|
|
813
|
+
}
|
|
487
814
|
/**
|
|
488
815
|
* Gets the primary key comparator, preferring the underlying table's comparator.
|
|
489
816
|
*/
|
|
@@ -493,22 +820,40 @@ export class IsolatedTable extends VirtualTable {
|
|
|
493
820
|
return this.underlyingTable.comparePrimaryKey.bind(this.underlyingTable);
|
|
494
821
|
}
|
|
495
822
|
// Fallback to default comparator. Compare under each PK column's declared
|
|
496
|
-
// collation (e.g. NOCASE), not BINARY
|
|
497
|
-
// entries by this comparator to decide shadowing,
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
823
|
+
// collation (e.g. NOCASE), not BINARY, and in its declared direction: the merge
|
|
824
|
+
// aligns overlay and underlying entries by this comparator to decide shadowing,
|
|
825
|
+
// and the underlying store keys rows both collation-aware and DESC-aware. A
|
|
826
|
+
// binary comparator would treat a case-only-updated overlay row ('APPLE') and
|
|
827
|
+
// the underlying row it shadows ('apple') as distinct keys; an ascending one
|
|
828
|
+
// walks a `primary key (k desc)` table against its scan order. Either way both
|
|
829
|
+
// rows surface in a scan instead of the overlay shadowing the underlying.
|
|
830
|
+
const collations = this.getPkCollations();
|
|
831
|
+
const directions = this.getPkDirections();
|
|
502
832
|
return (a, b) => {
|
|
503
833
|
for (let i = 0; i < a.length; i++) {
|
|
504
|
-
const
|
|
505
|
-
const cmp = compareSqlValues(a[i], b[i], collation);
|
|
834
|
+
const cmp = compareSqlValuesFast(a[i], b[i], collations[i] ?? BINARY_COLLATION);
|
|
506
835
|
if (cmp !== 0)
|
|
507
|
-
return cmp;
|
|
836
|
+
return directions[i] ? -cmp : cmp;
|
|
508
837
|
}
|
|
509
838
|
return 0;
|
|
510
839
|
};
|
|
511
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* Per-column comparators derived from an index descriptor's key columns — the
|
|
843
|
+
* fallback when the underlying exposes no `getIndexComparator` for the index
|
|
844
|
+
* (always the case for a module-minted synthetic name). Honors each key column's
|
|
845
|
+
* declared direction and collation so the merge walks the underlying's emission
|
|
846
|
+
* order.
|
|
847
|
+
*/
|
|
848
|
+
buildDescriptorComparators(keyColumns) {
|
|
849
|
+
return keyColumns.map(kc => {
|
|
850
|
+
const collation = kc.collation ? this.collationResolver(kc.collation) : BINARY_COLLATION;
|
|
851
|
+
return (a, b) => {
|
|
852
|
+
const cmp = compareSqlValuesFast(a, b, collation);
|
|
853
|
+
return kc.desc ? -cmp : cmp;
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
}
|
|
512
857
|
/**
|
|
513
858
|
* Builds a sort key comparator for secondary index scans.
|
|
514
859
|
*
|
|
@@ -554,6 +899,21 @@ export class IsolatedTable extends VirtualTable {
|
|
|
554
899
|
return [];
|
|
555
900
|
return schema.primaryKeyDefinition.map(pkDef => pkDef.index);
|
|
556
901
|
}
|
|
902
|
+
/**
|
|
903
|
+
* Coerce each cell to its declared column logical type before PK extraction and
|
|
904
|
+
* conflict detection — the same step StoreTable.coerceRow / MemoryTableManager.performInsert
|
|
905
|
+
* run. Without it, an ON CONFLICT insert whose proposed key is a different storage class than
|
|
906
|
+
* the stored key (TEXT '1' into an INTEGER key holding 1) probes the underlying with the
|
|
907
|
+
* un-coerced key, misses the committed row, and stages the proposed row instead of updating
|
|
908
|
+
* the existing one (bug-store-isolation-upsert-affinity-coerced-pk).
|
|
909
|
+
*/
|
|
910
|
+
coerceRow(row) {
|
|
911
|
+
const cols = this.tableSchema.columns;
|
|
912
|
+
if (row.length > cols.length) {
|
|
913
|
+
throw new QuereusError(`Too many values for ${this.schemaName}.${this.tableName}: expected ${cols.length}, got ${row.length}`, StatusCode.ERROR);
|
|
914
|
+
}
|
|
915
|
+
return row.map((v, i) => validateAndParse(v, cols[i].logicalType, cols[i].name));
|
|
916
|
+
}
|
|
557
917
|
// ==================== Write Operations ====================
|
|
558
918
|
/**
|
|
559
919
|
* Performs INSERT, UPDATE, or DELETE on the overlay.
|
|
@@ -562,8 +922,8 @@ export class IsolatedTable extends VirtualTable {
|
|
|
562
922
|
* The overlay is created lazily on first write, using schema from the underlying table.
|
|
563
923
|
*/
|
|
564
924
|
async update(args) {
|
|
565
|
-
// A poisoned overlay (cross-connection ALTER) cannot accept further
|
|
566
|
-
//
|
|
925
|
+
// A poisoned overlay (cross-connection ALTER or DROP TABLE) cannot accept further
|
|
926
|
+
// writes — it can never be flushed. Error before staging anything.
|
|
567
927
|
this.assertOverlayUsable();
|
|
568
928
|
// Ensure connection is registered for transaction coordination
|
|
569
929
|
await this.ensureConnection();
|
|
@@ -573,6 +933,21 @@ export class IsolatedTable extends VirtualTable {
|
|
|
573
933
|
this.setHasChanges(true);
|
|
574
934
|
const { operation, values, oldKeyValues } = args;
|
|
575
935
|
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
936
|
+
// Coerced only for PK extraction / merged-view conflict detection below — NOT
|
|
937
|
+
// for the overlay write. The overlay (a memory-module table) always re-coerces
|
|
938
|
+
// every cell on its own insert/update unconditionally, so writing the coerced
|
|
939
|
+
// row through would coerce twice. That is a no-op for most logical types, but
|
|
940
|
+
// JSON's `parse` is not idempotent for a JSON-string scalar (`'"hello"'` parses
|
|
941
|
+
// to the native JS string `"hello"`; re-parsing that bare string as JSON throws,
|
|
942
|
+
// since it lacks its own quotes) — double coercion would break any JSON column.
|
|
943
|
+
// Detection instead needs the coerced form: probing the overlay/underlying by an
|
|
944
|
+
// un-coerced PK (e.g. TEXT '1' against a stored INTEGER 1) misses the existing
|
|
945
|
+
// row entirely (bug-store-isolation-upsert-affinity-coerced-pk).
|
|
946
|
+
// NOTE: this coerces the full row on every isolation-layer write, and the overlay
|
|
947
|
+
// coerces it again on its own insert/update — two validateAndParse passes per write
|
|
948
|
+
// (two JSON.parse for JSON columns). Negligible now; if isolation-write throughput or
|
|
949
|
+
// large-JSON rows ever show as hot, thread the coerced row through as pre-coerced.
|
|
950
|
+
const coercedValues = values ? this.coerceRow(values) : values;
|
|
576
951
|
// Resolve the effective PK-level action once so the wrapped overlay vtab
|
|
577
952
|
// agrees with the overlay's decision. Per-UC defaults are applied inside
|
|
578
953
|
// checkMergedUniqueConstraints, since each UC may declare its own action.
|
|
@@ -583,7 +958,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
583
958
|
switch (operation) {
|
|
584
959
|
case 'insert': {
|
|
585
960
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
586
|
-
const pk =
|
|
961
|
+
const pk = coercedValues ? pkIndices.map(i => coercedValues[i]) : undefined;
|
|
587
962
|
// Secondary-UNIQUE REPLACE evictions surfaced via `evictedRows`.
|
|
588
963
|
const evicted = [];
|
|
589
964
|
// Captured when OR REPLACE displaces a row that lives only in the
|
|
@@ -593,7 +968,15 @@ export class IsolatedTable extends VirtualTable {
|
|
|
593
968
|
if (pk) {
|
|
594
969
|
const existingRow = await this.getOverlayRow(overlay, pk);
|
|
595
970
|
if (existingRow && existingRow[tombstoneIndex] === 1) {
|
|
596
|
-
// Convert tombstone to regular row (delete then re-insert same PK)
|
|
971
|
+
// Convert tombstone to regular row (delete then re-insert same PK).
|
|
972
|
+
// Run the same merged non-PK UNIQUE check the normal insert path runs
|
|
973
|
+
// (~below) before writing — otherwise a revived row that collides on a
|
|
974
|
+
// secondary UNIQUE is flushed with trustedWrite (store skips its re-check),
|
|
975
|
+
// yielding an opaque INTERNAL error at commit or silent corruption.
|
|
976
|
+
// selfPks = [pk] excludes this row's own PK from conflict detection.
|
|
977
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, coercedValues, [pk], tombstoneIndex, args.onConflict, evicted);
|
|
978
|
+
if (ucResult !== null)
|
|
979
|
+
return ucResult;
|
|
597
980
|
const overlayRow = [...(values ?? []), 0];
|
|
598
981
|
const result = await overlay.update({
|
|
599
982
|
operation: 'update',
|
|
@@ -601,7 +984,8 @@ export class IsolatedTable extends VirtualTable {
|
|
|
601
984
|
oldKeyValues: pk,
|
|
602
985
|
onConflict: effectiveOR,
|
|
603
986
|
});
|
|
604
|
-
|
|
987
|
+
const stripped = this.stripTombstoneFromResult(result, tombstoneIndex);
|
|
988
|
+
return this.attachEvicted(stripped, evicted, tombstoneIndex);
|
|
605
989
|
}
|
|
606
990
|
if (existingRow) {
|
|
607
991
|
// Live row already in overlay for this PK. Resolve against any column-level
|
|
@@ -626,7 +1010,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
626
1010
|
return pkOutcome.terminating;
|
|
627
1011
|
replacedUnderlyingRow = pkOutcome.replacedUnderlyingRow;
|
|
628
1012
|
// Check non-PK UNIQUE constraints against merged view
|
|
629
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay,
|
|
1013
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, coercedValues, [pk], tombstoneIndex, args.onConflict, evicted);
|
|
630
1014
|
if (ucResult !== null)
|
|
631
1015
|
return ucResult;
|
|
632
1016
|
}
|
|
@@ -646,7 +1030,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
646
1030
|
// 2. Underlying only - insert into overlay with new values
|
|
647
1031
|
// 3. Both (previous update) - update the overlay row
|
|
648
1032
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
649
|
-
const targetPK = oldKeyValues ?? (
|
|
1033
|
+
const targetPK = oldKeyValues ?? (coercedValues ? pkIndices.map(i => coercedValues[i]) : undefined);
|
|
650
1034
|
if (!targetPK || !values) {
|
|
651
1035
|
throw new Error('UPDATE requires oldKeyValues or values with primary key');
|
|
652
1036
|
}
|
|
@@ -655,7 +1039,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
655
1039
|
// Secondary-UNIQUE REPLACE evictions surfaced via `evictedRows`.
|
|
656
1040
|
const evicted = [];
|
|
657
1041
|
if (existingOverlayRow) {
|
|
658
|
-
const newPK = pkIndices.map(i =>
|
|
1042
|
+
const newPK = pkIndices.map(i => coercedValues[i]);
|
|
659
1043
|
const pkChanged = !this.keysEqual(targetPK, newPK);
|
|
660
1044
|
if (pkChanged) {
|
|
661
1045
|
// PK is changing: check for conflicts at the new PK, then tombstone the old
|
|
@@ -664,7 +1048,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
664
1048
|
const pkOutcome = await this.checkMergedPKConflict(overlay, newPK, tombstoneIndex, args.onConflict);
|
|
665
1049
|
if (pkOutcome.terminating)
|
|
666
1050
|
return pkOutcome.terminating;
|
|
667
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay,
|
|
1051
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, coercedValues, [targetPK, newPK], tombstoneIndex, args.onConflict, evicted);
|
|
668
1052
|
if (ucResult !== null)
|
|
669
1053
|
return ucResult;
|
|
670
1054
|
// Remove existing overlay row then insert a tombstone so the underlying
|
|
@@ -689,7 +1073,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
689
1073
|
}
|
|
690
1074
|
else {
|
|
691
1075
|
// Insert new overlay row (shadows underlying) — check underlying conflicts first
|
|
692
|
-
const newPK = pkIndices.map(i =>
|
|
1076
|
+
const newPK = pkIndices.map(i => coercedValues[i]);
|
|
693
1077
|
const pkChanged = !this.keysEqual(targetPK, newPK);
|
|
694
1078
|
let replacedUnderlyingRow;
|
|
695
1079
|
if (pkChanged) {
|
|
@@ -699,7 +1083,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
699
1083
|
replacedUnderlyingRow = pkOutcome.replacedUnderlyingRow;
|
|
700
1084
|
}
|
|
701
1085
|
const selfPks = pkChanged ? [targetPK, newPK] : [targetPK];
|
|
702
|
-
const ucResult = await this.checkMergedUniqueConstraints(overlay,
|
|
1086
|
+
const ucResult = await this.checkMergedUniqueConstraints(overlay, coercedValues, selfPks, tombstoneIndex, args.onConflict, evicted);
|
|
703
1087
|
if (ucResult !== null)
|
|
704
1088
|
return ucResult;
|
|
705
1089
|
// For PK-change updates, tombstone the old PK so the underlying row is deleted at flush
|
|
@@ -720,7 +1104,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
720
1104
|
case 'delete': {
|
|
721
1105
|
// For deletes, insert a tombstone into overlay
|
|
722
1106
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
723
|
-
const targetPK = oldKeyValues ?? (
|
|
1107
|
+
const targetPK = oldKeyValues ?? (coercedValues ? pkIndices.map(i => coercedValues[i]) : undefined);
|
|
724
1108
|
if (!targetPK) {
|
|
725
1109
|
throw new Error('DELETE requires oldKeyValues or values with primary key');
|
|
726
1110
|
}
|
|
@@ -845,57 +1229,14 @@ export class IsolatedTable extends VirtualTable {
|
|
|
845
1229
|
* Creates a FilterInfo for a full table scan (no constraints).
|
|
846
1230
|
*/
|
|
847
1231
|
createFullScanFilterInfo() {
|
|
848
|
-
return
|
|
849
|
-
idxNum: 0,
|
|
850
|
-
idxStr: null,
|
|
851
|
-
constraints: [],
|
|
852
|
-
args: [],
|
|
853
|
-
indexInfoOutput: {
|
|
854
|
-
nConstraint: 0,
|
|
855
|
-
aConstraint: [],
|
|
856
|
-
nOrderBy: 0,
|
|
857
|
-
aOrderBy: [],
|
|
858
|
-
colUsed: 0n,
|
|
859
|
-
aConstraintUsage: [],
|
|
860
|
-
idxNum: 0,
|
|
861
|
-
idxStr: null,
|
|
862
|
-
orderByConsumed: false,
|
|
863
|
-
estimatedCost: 1000000,
|
|
864
|
-
estimatedRows: 1000000n,
|
|
865
|
-
idxFlags: 0,
|
|
866
|
-
},
|
|
867
|
-
};
|
|
1232
|
+
return makeFullScanFilterInfo();
|
|
868
1233
|
}
|
|
869
1234
|
/**
|
|
870
1235
|
* Creates a FilterInfo for a primary key point lookup (equality on all PK columns).
|
|
871
1236
|
* This produces O(log n) lookups instead of O(n) full scans.
|
|
872
1237
|
*/
|
|
873
1238
|
buildPKPointLookupFilter(pk) {
|
|
874
|
-
|
|
875
|
-
const constraints = pkIndices.map((colIdx, i) => ({
|
|
876
|
-
constraint: { iColumn: colIdx, op: IndexConstraintOp.EQ, usable: true },
|
|
877
|
-
argvIndex: i + 1,
|
|
878
|
-
}));
|
|
879
|
-
return {
|
|
880
|
-
idxNum: 0,
|
|
881
|
-
idxStr: 'idx=_primary_(0);plan=2',
|
|
882
|
-
constraints,
|
|
883
|
-
args: pk,
|
|
884
|
-
indexInfoOutput: {
|
|
885
|
-
nConstraint: constraints.length,
|
|
886
|
-
aConstraint: constraints.map(c => c.constraint),
|
|
887
|
-
nOrderBy: 0,
|
|
888
|
-
aOrderBy: [],
|
|
889
|
-
colUsed: 0n,
|
|
890
|
-
aConstraintUsage: constraints.map(c => ({ argvIndex: c.argvIndex, omit: true })),
|
|
891
|
-
idxNum: 0,
|
|
892
|
-
idxStr: 'idx=_primary_(0);plan=2',
|
|
893
|
-
orderByConsumed: false,
|
|
894
|
-
estimatedCost: 1,
|
|
895
|
-
estimatedRows: 1n,
|
|
896
|
-
idxFlags: 0,
|
|
897
|
-
},
|
|
898
|
-
};
|
|
1239
|
+
return makePkPointLookupFilter(this.getPrimaryKeyIndices(), pk);
|
|
899
1240
|
}
|
|
900
1241
|
// ==================== Merged-View Conflict Detection ====================
|
|
901
1242
|
/**
|
|
@@ -908,7 +1249,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
908
1249
|
return undefined;
|
|
909
1250
|
let compiled = this.predicateCache.get(uc);
|
|
910
1251
|
if (!compiled) {
|
|
911
|
-
compiled = compilePredicate(uc.predicate, this.tableSchema.columns);
|
|
1252
|
+
compiled = compilePredicate(uc.predicate, this.tableSchema.columns, this.tableSchema.name);
|
|
912
1253
|
this.predicateCache.set(uc, compiled);
|
|
913
1254
|
}
|
|
914
1255
|
return compiled;
|
|
@@ -921,10 +1262,9 @@ export class IsolatedTable extends VirtualTable {
|
|
|
921
1262
|
// rewrite ('apple' → 'APPLE') is the SAME logical key. A binary comparison here
|
|
922
1263
|
// would mis-classify it as a PK relocation, then resolve the "new" key back to the
|
|
923
1264
|
// same physical underlying row and raise a false UNIQUE PK conflict.
|
|
924
|
-
const
|
|
1265
|
+
const collations = this.getPkCollations();
|
|
925
1266
|
for (let i = 0; i < a.length; i++) {
|
|
926
|
-
|
|
927
|
-
if (compareSqlValues(a[i], b[i], collation) !== 0)
|
|
1267
|
+
if (compareSqlValuesFast(a[i], b[i], collations[i] ?? BINARY_COLLATION) !== 0)
|
|
928
1268
|
return false;
|
|
929
1269
|
}
|
|
930
1270
|
return true;
|
|
@@ -1021,52 +1361,144 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1021
1361
|
};
|
|
1022
1362
|
}
|
|
1023
1363
|
/**
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1364
|
+
* Finds a row conflicting with `newRow` on `uc.columns` across the MERGED view —
|
|
1365
|
+
* this connection's overlay superimposed on the underlying committed rows —
|
|
1366
|
+
* excluding `selfPks`. For partial UNIQUE, candidates outside the predicate's scope
|
|
1367
|
+
* are skipped.
|
|
1368
|
+
*
|
|
1369
|
+
* The merged view splits cleanly into two disjoint halves, each searched the way it
|
|
1370
|
+
* is cheap to search:
|
|
1371
|
+
*
|
|
1372
|
+
* merged view = (overlay rows) ∪ (underlying rows with no overlay entry)
|
|
1373
|
+
*
|
|
1374
|
+
* Phase 1 scans the small in-memory overlay; Phase 2 seeks/scans the large
|
|
1375
|
+
* underlying, skipping any candidate whose PK the overlay already owns (Phase 1's
|
|
1376
|
+
* territory, whatever the underlying still says). Together they cover the merged
|
|
1377
|
+
* view exactly once with no row visited twice.
|
|
1378
|
+
*
|
|
1379
|
+
* Phase 1 runs first, so when the constraint was ALREADY violated an overlay-side
|
|
1380
|
+
* conflict is reported in preference to an underlying one. Under a satisfied
|
|
1381
|
+
* constraint at most one conflicting row can exist, so this tie-break only changes
|
|
1382
|
+
* WHICH row is named in that pre-violated case.
|
|
1027
1383
|
*/
|
|
1028
1384
|
async findMergedUniqueConflict(overlay, uc, predicate, newRow, selfPks, tombstoneIndex) {
|
|
1385
|
+
// One comparison collation per constrained column — the index's per-column
|
|
1386
|
+
// COLLATE for an index-derived UNIQUE, else the declared column collation.
|
|
1387
|
+
// Resolved once, above both candidate scans; both phases compare identically.
|
|
1388
|
+
const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
|
|
1389
|
+
const overlayConflict = await this.findOverlayUniqueConflict(overlay, predicate, newRow, selfPks, tombstoneIndex, uc.columns, collations);
|
|
1390
|
+
if (overlayConflict)
|
|
1391
|
+
return overlayConflict;
|
|
1392
|
+
return this.findUnderlyingUniqueConflict(overlay, uc, predicate, newRow, selfPks, tombstoneIndex, collations);
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Phase 1 — scan the overlay (this connection's uncommitted write set, small and
|
|
1396
|
+
* in-memory) for a live row conflicting with `newRow`. Tombstones and `selfPks` are
|
|
1397
|
+
* skipped; a matching live overlay row IS the merged row (stripped of the trailing
|
|
1398
|
+
* tombstone column), and its overlay value — not any stale underlying value — is
|
|
1399
|
+
* what the merged view holds for that PK
|
|
1400
|
+
* (isolation-merged-unique-stale-underlying-false-positive).
|
|
1401
|
+
*/
|
|
1402
|
+
async findOverlayUniqueConflict(overlay, predicate, newRow, selfPks, tombstoneIndex, constrainedCols, collations) {
|
|
1403
|
+
if (!overlay.query)
|
|
1404
|
+
return null;
|
|
1405
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
1406
|
+
for await (const overlayRow of overlay.query(this.createFullScanFilterInfo())) {
|
|
1407
|
+
if (overlayRow[tombstoneIndex] === 1)
|
|
1408
|
+
continue; // deletion marker — not a live row
|
|
1409
|
+
const pk = pkIndices.map(i => overlayRow[i]);
|
|
1410
|
+
if (selfPks.some(self => this.keysEqual(pk, self)))
|
|
1411
|
+
continue;
|
|
1412
|
+
const mergedRow = overlayRow.slice(0, tombstoneIndex);
|
|
1413
|
+
if (this.rowMatchesUniqueConstraint(mergedRow, newRow, constrainedCols, collations, predicate)) {
|
|
1414
|
+
return { pk, row: mergedRow };
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Phase 2 — find an underlying committed row conflicting with `newRow`, skipping
|
|
1421
|
+
* `selfPks` and any PK the overlay already owns (Phase 1's territory). The lookup is
|
|
1422
|
+
* an index seek when {@link canSeekForConstraint} allows it, else the full scan —
|
|
1423
|
+
* either way the per-column match runs, so a module that ignores the index hint and
|
|
1424
|
+
* returns extra rows stays correct. `getOverlayRow` fires only for the candidates the
|
|
1425
|
+
* seek actually returned, so it no longer costs one lookup per underlying row.
|
|
1426
|
+
*/
|
|
1427
|
+
async findUnderlyingUniqueConflict(overlay, uc, predicate, newRow, selfPks, tombstoneIndex, collations) {
|
|
1029
1428
|
if (!this.underlyingTable.query)
|
|
1030
1429
|
return null;
|
|
1031
1430
|
const pkIndices = this.getPrimaryKeyIndices();
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
for await (const underlyingRow of this.underlyingTable.query(
|
|
1431
|
+
const seekIndex = this.canSeekForConstraint(uc);
|
|
1432
|
+
const filterInfo = seekIndex
|
|
1433
|
+
? makeSecondaryIndexEqSeekFilter(seekIndex, newRow)
|
|
1434
|
+
: this.createFullScanFilterInfo();
|
|
1435
|
+
for await (const underlyingRow of this.underlyingTable.query(filterInfo)) {
|
|
1037
1436
|
const pk = pkIndices.map(i => underlyingRow[i]);
|
|
1038
1437
|
if (selfPks.some(self => this.keysEqual(pk, self)))
|
|
1039
1438
|
continue;
|
|
1439
|
+
// A PK with any overlay entry (live OR tombstone) belongs to Phase 1, which
|
|
1440
|
+
// evaluated the overlay's value for it. Skip here whatever the underlying holds.
|
|
1040
1441
|
const overlayRow = await this.getOverlayRow(overlay, pk);
|
|
1041
|
-
if (overlayRow
|
|
1042
|
-
continue;
|
|
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)
|
|
1053
|
-
return false;
|
|
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;
|
|
1060
|
-
});
|
|
1061
|
-
if (!matches)
|
|
1062
|
-
continue;
|
|
1063
|
-
// Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
|
|
1064
|
-
if (predicate && predicate.evaluate(mergedRow) !== true)
|
|
1442
|
+
if (overlayRow)
|
|
1065
1443
|
continue;
|
|
1066
|
-
|
|
1444
|
+
if (this.rowMatchesUniqueConstraint(underlyingRow, newRow, uc.columns, collations, predicate)) {
|
|
1445
|
+
return { pk, row: underlyingRow };
|
|
1446
|
+
}
|
|
1067
1447
|
}
|
|
1068
1448
|
return null;
|
|
1069
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* True-match test shared by both merged-check phases so they compare identically:
|
|
1452
|
+
* `candidate` matches `newRow` on every constrained column under that column's
|
|
1453
|
+
* enforcement collation (NULLs never match), and — for a partial UNIQUE — the
|
|
1454
|
+
* predicate holds for `candidate`.
|
|
1455
|
+
*
|
|
1456
|
+
* The per-column collation (the index's per-column COLLATE for an index-derived
|
|
1457
|
+
* UNIQUE, else the declared column collation) enforces a UNIQUE over a collated
|
|
1458
|
+
* column against merged rows through the isolation path
|
|
1459
|
+
* (unique-constraint-honors-column-collation / store-index-derived-unique).
|
|
1460
|
+
*/
|
|
1461
|
+
rowMatchesUniqueConstraint(candidate, newRow, constrainedCols, collations, predicate) {
|
|
1462
|
+
const matches = constrainedCols.every((idx, i) => {
|
|
1463
|
+
if (newRow[idx] === null || candidate[idx] === null)
|
|
1464
|
+
return false;
|
|
1465
|
+
return compareSqlValuesFast(newRow[idx], candidate[idx], collations[i]) === 0;
|
|
1466
|
+
});
|
|
1467
|
+
if (!matches)
|
|
1468
|
+
return false;
|
|
1469
|
+
// Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
|
|
1470
|
+
if (predicate && predicate.evaluate(candidate) !== true)
|
|
1471
|
+
return false;
|
|
1472
|
+
return true;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* The secondary index Phase 2 may seek for `uc`, or null to fall back to a full scan.
|
|
1476
|
+
*
|
|
1477
|
+
* Seek only when the constraint was synthesized from a `CREATE UNIQUE INDEX`
|
|
1478
|
+
* (`derivedFromIndex` names a live entry in `tableSchema.indexes`) AND every key
|
|
1479
|
+
* column's effective enforcement collation is BINARY.
|
|
1480
|
+
*
|
|
1481
|
+
* NOTE: the BINARY gate is load-bearing, not an optimisation choice. The store's
|
|
1482
|
+
* physical index key bytes come from a separate encoder registry that does NOT
|
|
1483
|
+
* consult the database's collation registry
|
|
1484
|
+
* (backlog/debt-store-index-keys-use-column-collation, see the comment at
|
|
1485
|
+
* getPkKeyShape above). Seeking a NOCASE index for 'B@X' would miss the committed
|
|
1486
|
+
* 'b@x' that the full scan catches, turning this perf fix into a LOST UNIQUE
|
|
1487
|
+
* violation. Widen this gate (to per-column enforcement collations generally) only
|
|
1488
|
+
* once that encoder defect is fixed.
|
|
1489
|
+
*/
|
|
1490
|
+
canSeekForConstraint(uc) {
|
|
1491
|
+
if (!uc.derivedFromIndex)
|
|
1492
|
+
return null;
|
|
1493
|
+
const index = this.tableSchema?.indexes?.find(i => i.name === uc.derivedFromIndex);
|
|
1494
|
+
if (!index)
|
|
1495
|
+
return null; // DROP INDEX may have retired it before a stale UC object is retired
|
|
1496
|
+
// Enforcement collation per constrained column, positionally aligned with the
|
|
1497
|
+
// index key columns (appendIndexToTableSchema guarantees the alignment).
|
|
1498
|
+
const enforcement = uniqueEnforcementCollations(this.tableSchema, uc);
|
|
1499
|
+
const allBinary = enforcement.every(c => normalizeCollationName(c ?? 'BINARY') === 'BINARY');
|
|
1500
|
+
return allBinary ? index : null;
|
|
1501
|
+
}
|
|
1070
1502
|
/**
|
|
1071
1503
|
* Checks all non-PK UNIQUE constraints against the merged view.
|
|
1072
1504
|
* Returns null when all pass or REPLACE evictions succeed.
|
|
@@ -1099,8 +1531,9 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1099
1531
|
return { status: 'ok', row: undefined };
|
|
1100
1532
|
if (effective === ConflictResolution.REPLACE) {
|
|
1101
1533
|
await this.insertTombstoneForPK(overlay, conflict.pk, tombstoneIndex);
|
|
1102
|
-
// Report the eviction
|
|
1103
|
-
//
|
|
1534
|
+
// Report the eviction. `conflict.row` is the merged row in user-facing
|
|
1535
|
+
// schema shape whether it was found overlay-side (Phase 1) or
|
|
1536
|
+
// underlying-side (Phase 2). The executor maintains the covering backing.
|
|
1104
1537
|
evicted.push(conflict.row);
|
|
1105
1538
|
continue;
|
|
1106
1539
|
}
|
|
@@ -1124,143 +1557,25 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1124
1557
|
await this.overlayTable?.sync?.();
|
|
1125
1558
|
}
|
|
1126
1559
|
async commit() {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
* Shared by commit() and onConnectionCommit().
|
|
1133
|
-
*/
|
|
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();
|
|
1141
|
-
const overlay = this.overlayTable;
|
|
1142
|
-
if (this.hasChanges && overlay) {
|
|
1143
|
-
await this.flushOverlayToUnderlying(overlay);
|
|
1144
|
-
}
|
|
1145
|
-
this.clearOverlay();
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Flushes all overlay changes to the underlying table.
|
|
1149
|
-
* Called during commit to persist changes.
|
|
1150
|
-
*
|
|
1151
|
-
* This method manages the underlying table's transaction lifecycle independently
|
|
1152
|
-
* to ensure that flushed data is committed and won't be rolled back by subsequent
|
|
1153
|
-
* transaction rollbacks.
|
|
1154
|
-
*/
|
|
1155
|
-
async flushOverlayToUnderlying(overlay) {
|
|
1156
|
-
if (!overlay.query)
|
|
1157
|
-
return;
|
|
1158
|
-
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
1159
|
-
const pkIndices = this.getPrimaryKeyIndices();
|
|
1160
|
-
// Collect all overlay entries first
|
|
1161
|
-
const overlayEntries = [];
|
|
1162
|
-
for await (const overlayRow of overlay.query(this.createFullScanFilterInfo())) {
|
|
1163
|
-
const isTombstone = overlayRow[tombstoneIndex] === 1;
|
|
1164
|
-
const pk = pkIndices.map(i => overlayRow[i]);
|
|
1165
|
-
const dataRow = overlayRow.slice(0, tombstoneIndex);
|
|
1166
|
-
overlayEntries.push({ row: overlayRow, isTombstone, pk, dataRow });
|
|
1167
|
-
}
|
|
1168
|
-
if (overlayEntries.length === 0)
|
|
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));
|
|
1178
|
-
// Begin a transaction on the underlying table for the flush
|
|
1179
|
-
await this.underlyingTable.begin?.();
|
|
1180
|
-
try {
|
|
1181
|
-
// Apply all overlay entries to underlying
|
|
1182
|
-
for (const entry of ordered) {
|
|
1183
|
-
if (entry.isTombstone) {
|
|
1184
|
-
// Delete from underlying
|
|
1185
|
-
const result = await this.underlyingTable.update({
|
|
1186
|
-
operation: 'delete',
|
|
1187
|
-
values: undefined,
|
|
1188
|
-
oldKeyValues: entry.pk,
|
|
1189
|
-
});
|
|
1190
|
-
this.assertFlushWriteOk(result, 'delete', entry.pk);
|
|
1191
|
-
}
|
|
1192
|
-
else {
|
|
1193
|
-
// Check if row exists in underlying to decide insert vs update
|
|
1194
|
-
const existsInUnderlying = await this.rowExistsInUnderlying(entry.pk);
|
|
1195
|
-
if (existsInUnderlying) {
|
|
1196
|
-
const result = await this.underlyingTable.update({
|
|
1197
|
-
operation: 'update',
|
|
1198
|
-
values: entry.dataRow,
|
|
1199
|
-
oldKeyValues: entry.pk,
|
|
1200
|
-
preCoerced: true,
|
|
1201
|
-
trustedWrite: true,
|
|
1202
|
-
});
|
|
1203
|
-
this.assertFlushWriteOk(result, 'update', entry.pk);
|
|
1204
|
-
}
|
|
1205
|
-
else {
|
|
1206
|
-
const result = await this.underlyingTable.update({
|
|
1207
|
-
operation: 'insert',
|
|
1208
|
-
values: entry.dataRow,
|
|
1209
|
-
preCoerced: true,
|
|
1210
|
-
trustedWrite: true,
|
|
1211
|
-
});
|
|
1212
|
-
this.assertFlushWriteOk(result, 'insert', entry.pk);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
// Commit the underlying table's transaction
|
|
1217
|
-
await this.underlyingTable.commit?.();
|
|
1218
|
-
}
|
|
1219
|
-
catch (error) {
|
|
1220
|
-
// Rollback underlying on error
|
|
1221
|
-
await this.underlyingTable.rollback?.();
|
|
1222
|
-
throw error;
|
|
1223
|
-
}
|
|
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
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* Checks if a row with the given primary key exists in the underlying table.
|
|
1242
|
-
* Uses O(log n) point lookup via the PK index.
|
|
1243
|
-
*/
|
|
1244
|
-
async rowExistsInUnderlying(pk) {
|
|
1245
|
-
if (!this.underlyingTable.query)
|
|
1246
|
-
return false;
|
|
1247
|
-
for await (const _row of this.underlyingTable.query(this.buildPKPointLookupFilter(pk))) {
|
|
1248
|
-
return true;
|
|
1249
|
-
}
|
|
1250
|
-
return false;
|
|
1560
|
+
// Route through the module coordinator (see onConnectionCommit) so even the
|
|
1561
|
+
// table-level commit path performs the atomic apply-all-then-commit-all flush
|
|
1562
|
+
// across the whole db-transaction's overlays, rather than committing this
|
|
1563
|
+
// table's underlying in isolation (which tears a multi-table commit).
|
|
1564
|
+
await this.isolationModule.commitConnectionOverlays(this.db);
|
|
1251
1565
|
}
|
|
1252
1566
|
async rollback() {
|
|
1253
1567
|
await this.underlyingTable.rollback?.();
|
|
1254
|
-
this.clearOverlay();
|
|
1568
|
+
await this.clearOverlay();
|
|
1255
1569
|
}
|
|
1256
1570
|
/**
|
|
1257
|
-
* Discards the connection-scoped overlay entirely.
|
|
1258
|
-
*
|
|
1259
|
-
* the reference
|
|
1571
|
+
* Discards the connection-scoped overlay entirely. The module frees the overlay's
|
|
1572
|
+
* staging table (see IsolationModule.clearConnectionOverlay → releaseOverlayTable) rather
|
|
1573
|
+
* than merely dropping the reference — the overlay module's registry pins the manager, so
|
|
1574
|
+
* a bare reference-drop leaked it for the life of the Database. A fresh overlay is created
|
|
1260
1575
|
* lazily via ensureOverlay() on the next write.
|
|
1261
1576
|
*/
|
|
1262
|
-
clearOverlay() {
|
|
1263
|
-
this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
1577
|
+
async clearOverlay() {
|
|
1578
|
+
await this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
1264
1579
|
}
|
|
1265
1580
|
// ==================== Savepoints ====================
|
|
1266
1581
|
// Overlay savepoints are managed by IsolatedConnection (which forwards
|
|
@@ -1288,20 +1603,24 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1288
1603
|
// Update our schema reference
|
|
1289
1604
|
this.tableSchema = this.underlyingTable.tableSchema;
|
|
1290
1605
|
// Clear any existing overlay - it will be recreated with new schema on next write
|
|
1291
|
-
this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
1606
|
+
await this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
1292
1607
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1608
|
+
/**
|
|
1609
|
+
* Index DDL delegates to the module rather than driving the underlying and the overlay
|
|
1610
|
+
* directly. The module owns the full protocol — validating against the issuing
|
|
1611
|
+
* connection's effective rows, then REBUILDING every affected overlay under the new
|
|
1612
|
+
* schema — and a bare `underlying.createIndex()` + `overlay.createIndex()` pair silently
|
|
1613
|
+
* skips both halves. The engine only ever reaches the module-level hook, so these
|
|
1614
|
+
* instance methods exist for a module that wraps `IsolationModule` in turn; routing them
|
|
1615
|
+
* through the module keeps that path on the same protocol.
|
|
1616
|
+
*/
|
|
1617
|
+
async createIndex(indexInfo, rows) {
|
|
1618
|
+
await this.isolationModule.createIndex(this.db, this.schemaName, this.tableName, indexInfo, rows);
|
|
1296
1619
|
this.tableSchema = this.underlyingTable.tableSchema;
|
|
1297
|
-
// If overlay exists, add index to it too
|
|
1298
|
-
await this.overlayTable?.createIndex?.(indexInfo);
|
|
1299
1620
|
}
|
|
1300
1621
|
async dropIndex(indexName) {
|
|
1301
|
-
await this.
|
|
1302
|
-
// Update schema reference
|
|
1622
|
+
await this.isolationModule.dropIndex(this.db, this.schemaName, this.tableName, indexName);
|
|
1303
1623
|
this.tableSchema = this.underlyingTable.tableSchema;
|
|
1304
|
-
await this.overlayTable?.dropIndex?.(indexName);
|
|
1305
1624
|
}
|
|
1306
1625
|
// ==================== Internal Helpers ====================
|
|
1307
1626
|
/**
|
|
@@ -1338,7 +1657,16 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1338
1657
|
* Flushes overlay to underlying and clears overlay.
|
|
1339
1658
|
*/
|
|
1340
1659
|
async onConnectionCommit() {
|
|
1341
|
-
|
|
1660
|
+
// Delegate to the module coordinator, which flushes EVERY overlay this
|
|
1661
|
+
// db-transaction staged (not just this table's) in one apply-all-then-commit-all
|
|
1662
|
+
// pass and clears them — so a multi-table commit is atomic. The first connection
|
|
1663
|
+
// in the database's commit loop performs the whole flush; later connections find
|
|
1664
|
+
// their overlay already cleared and this is a no-op. Poison is enforced inside
|
|
1665
|
+
// the coordinator (it aborts before any table commits).
|
|
1666
|
+
await this.isolationModule.commitConnectionOverlays(this.db);
|
|
1667
|
+
// Clear this table's pre-overlay savepoint set. Kept per-connection (rather than
|
|
1668
|
+
// inside the coordinator) so a table that took a savepoint before its first write
|
|
1669
|
+
// but never got an overlay still has its set cleared here.
|
|
1342
1670
|
this.isolationModule.clearPreOverlaySavepoints(this.db, this.schemaName, this.tableName);
|
|
1343
1671
|
}
|
|
1344
1672
|
/**
|
|
@@ -1346,7 +1674,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1346
1674
|
* Clears overlay without flushing.
|
|
1347
1675
|
*/
|
|
1348
1676
|
async onConnectionRollback() {
|
|
1349
|
-
this.clearOverlay();
|
|
1677
|
+
await this.clearOverlay();
|
|
1350
1678
|
this.isolationModule.clearPreOverlaySavepoints(this.db, this.schemaName, this.tableName);
|
|
1351
1679
|
}
|
|
1352
1680
|
/**
|
|
@@ -1384,7 +1712,7 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1384
1712
|
async onConnectionRollbackToSavepoint(index) {
|
|
1385
1713
|
if (this.savepointsBeforeOverlay.has(index)) {
|
|
1386
1714
|
// Rolling back to before the overlay existed — discard all overlay changes
|
|
1387
|
-
this.clearOverlay();
|
|
1715
|
+
await this.clearOverlay();
|
|
1388
1716
|
// Remove savepoints above the target (they're implicitly gone)
|
|
1389
1717
|
for (const depth of [...this.savepointsBeforeOverlay]) {
|
|
1390
1718
|
if (depth > index) {
|
|
@@ -1395,6 +1723,16 @@ export class IsolatedTable extends VirtualTable {
|
|
|
1395
1723
|
// If overlay's registered connection has this savepoint, it handles rollback
|
|
1396
1724
|
}
|
|
1397
1725
|
}
|
|
1726
|
+
/** True when a comparator result `cmp` (row value vs bound value) satisfies the range op. */
|
|
1727
|
+
function satisfiesBound(cmp, op) {
|
|
1728
|
+
switch (op) {
|
|
1729
|
+
case IndexConstraintOp.GT: return cmp > 0;
|
|
1730
|
+
case IndexConstraintOp.GE: return cmp >= 0;
|
|
1731
|
+
case IndexConstraintOp.LT: return cmp < 0;
|
|
1732
|
+
case IndexConstraintOp.LE: return cmp <= 0;
|
|
1733
|
+
default: return true;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1398
1736
|
/**
|
|
1399
1737
|
* Resolves the per-constraint default conflict action for PK conflicts.
|
|
1400
1738
|
* Prefers the table-level `PRIMARY KEY (...) ON CONFLICT <action>` clause
|