@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.
@@ -1,6 +1,9 @@
1
- import { VirtualTable, compareSqlValues, isUpdateOk, isConstraintViolation, IndexConstraintOp, ConflictResolution, compilePredicate, QuereusError, StatusCode, uniqueEnforcementCollations } from '@quereus/quereus';
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
- constructor(db, module, underlyingTable, readCommitted = false) {
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, underlyingTable.schemaName, underlyingTable.tableName);
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`). 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).
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 (its rows are in the
254
- // pre-alter layout) — error before touching it.
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.parseIndexFromFilterInfo(filterInfo);
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. Collects all PKs modified in the overlay (full scan)
300
- * 2. Queries underlying via secondary index, excluding modified PKs
301
- * 3. Queries overlay via secondary index for non-tombstone data rows
302
- * 4. Merges the two disjoint, sorted streams by sort key
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
- // Step 1: Collect all PKs modified in overlay (full scan)
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(overlayFilterInfo)) {
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
- overlayRows.push(row.slice(0, tombstoneIndex));
418
+ const dataRow = row.slice(0, tombstoneIndex);
419
+ if (matchesWindow(dataRow)) {
420
+ overlayRows.push(dataRow);
421
+ }
323
422
  }
324
423
  }
325
- // Step 3: Query underlying via secondary index, filter out modified PKs
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
- const pk = pkIndices.map(i => underlyingRow[i]);
333
- if (modifiedPKs.has(JSON.stringify(pk))) {
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
- * Parses FilterInfo to determine which index is being used.
356
- * Returns null for full table scan or primary key scan, index name for secondary indexes.
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
- parseIndexFromFilterInfo(filterInfo) {
359
- const { idxStr } = filterInfo;
360
- if (!idxStr) {
361
- return { type: 'primary' };
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
- // Parse idxStr format: "idx=indexName(n);plan=2;..."
364
- const params = new Map();
365
- idxStr.split(';').forEach(part => {
366
- const [key, value] = part.split('=', 2);
367
- if (key && value !== undefined)
368
- params.set(key, value);
369
- });
370
- const idxMatch = params.get('idx')?.match(/^(.*?)\((\d+)\)$/);
371
- if (!idxMatch) {
372
- return { type: 'primary' };
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
- const indexName = idxMatch[1];
375
- if (indexName === '_primary_') {
376
- return { type: 'primary' };
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
- // Secondary index scan
379
- return {
380
- type: 'secondary',
381
- indexName,
382
- columnIndices: this.getIndexColumnIndices(indexName),
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
- * Gets the column indices for a secondary index.
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
- getIndexColumnIndices(indexName) {
389
- const schema = this.tableSchema;
390
- if (!schema?.indexes)
391
- return [];
392
- const index = schema.indexes.find(idx => idx.name.toLowerCase() === indexName.toLowerCase());
393
- if (!index)
394
- return [];
395
- return index.columns.map(col => col.index);
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
- * Adapts FilterInfo for the overlay table schema (which has an extra tombstone column).
399
- * The constraints and index references remain the same since the overlay has matching indexes.
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
- adaptFilterInfoForOverlay(filterInfo) {
402
- // The overlay table has the same schema plus a tombstone column at the end.
403
- // Column indices for data columns are the same, so FilterInfo constraints work as-is.
404
- // The overlay module will interpret the constraints correctly.
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
- // Try to use the underlying table's per-column index comparators if available
478
- const indexComparators = this.underlyingTable.getIndexComparator?.(indexInfo.indexName);
479
- const compareSortKey = this.buildCompareSortKey(indexColIndices.length, comparePK, indexComparators);
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: 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;
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 collation = pkDef ? this.tableSchema.columns[pkDef[i].index].collation : undefined;
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 writes — its
566
- // staged rows are in the pre-alter layout. Error before staging anything.
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 = values ? pkIndices.map(i => values[i]) : undefined;
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
- return this.stripTombstoneFromResult(result, tombstoneIndex);
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, values, [pk], tombstoneIndex, args.onConflict, evicted);
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 ?? (values ? pkIndices.map(i => values[i]) : undefined);
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 => values[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, values, [targetPK, newPK], tombstoneIndex, args.onConflict, evicted);
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 => values[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, values, selfPks, tombstoneIndex, args.onConflict, evicted);
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 ?? (values ? pkIndices.map(i => values[i]) : undefined);
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
- const pkIndices = this.getPrimaryKeyIndices();
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 pkDef = this.tableSchema?.primaryKeyDefinition;
1265
+ const collations = this.getPkCollations();
925
1266
  for (let i = 0; i < a.length; i++) {
926
- const collation = pkDef ? this.tableSchema.columns[pkDef[i].index].collation : undefined;
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
- * Scans the underlying table for a row conflicting with newRow on `uc.columns`,
1025
- * excluding selfPks and rows tombstoned in the overlay. For partial UNIQUE,
1026
- * candidates whose row does not satisfy the predicate are skipped.
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 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);
1036
- for await (const underlyingRow of this.underlyingTable.query(this.createFullScanFilterInfo())) {
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 && overlayRow[tombstoneIndex] === 1)
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
- return { pk, row: mergedRow };
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 (the conflicting row is the live underlying row,
1103
- // already user-facing). The executor maintains the covering backing.
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
- await this.flushAndClearOverlay();
1128
- await this.underlyingTable.commit?.();
1129
- }
1130
- /**
1131
- * Flushes overlay changes to underlying (if any) and discards the overlay.
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
- * The overlay table is per-connection and ephemeral, so simply removing
1259
- * the reference allows GC to reclaim it. A fresh overlay will be created
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
- async createIndex(indexInfo) {
1294
- await this.underlyingTable.createIndex?.(indexInfo);
1295
- // Update schema reference
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.underlyingTable.dropIndex?.(indexName);
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
- await this.flushAndClearOverlay();
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