@quereus/store 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +512 -486
  2. package/dist/src/common/cached-kv-store.js +4 -0
  3. package/dist/src/common/cached-kv-store.js.map +1 -1
  4. package/dist/src/common/encoding.d.ts +32 -1
  5. package/dist/src/common/encoding.d.ts.map +1 -1
  6. package/dist/src/common/encoding.js +42 -10
  7. package/dist/src/common/encoding.js.map +1 -1
  8. package/dist/src/common/index.d.ts +3 -2
  9. package/dist/src/common/index.d.ts.map +1 -1
  10. package/dist/src/common/index.js +3 -1
  11. package/dist/src/common/index.js.map +1 -1
  12. package/dist/src/common/json-key.d.ts +96 -0
  13. package/dist/src/common/json-key.d.ts.map +1 -0
  14. package/dist/src/common/json-key.js +204 -0
  15. package/dist/src/common/json-key.js.map +1 -0
  16. package/dist/src/common/key-builder.d.ts +41 -5
  17. package/dist/src/common/key-builder.d.ts.map +1 -1
  18. package/dist/src/common/key-builder.js +60 -21
  19. package/dist/src/common/key-builder.js.map +1 -1
  20. package/dist/src/common/kv-store.d.ts +12 -2
  21. package/dist/src/common/kv-store.d.ts.map +1 -1
  22. package/dist/src/common/store-module.d.ts +64 -15
  23. package/dist/src/common/store-module.d.ts.map +1 -1
  24. package/dist/src/common/store-module.js +439 -133
  25. package/dist/src/common/store-module.js.map +1 -1
  26. package/dist/src/common/store-table.d.ts +226 -12
  27. package/dist/src/common/store-table.d.ts.map +1 -1
  28. package/dist/src/common/store-table.js +454 -37
  29. package/dist/src/common/store-table.js.map +1 -1
  30. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  31. package/dist/src/testing/kv-conformance.js +32 -0
  32. package/dist/src/testing/kv-conformance.js.map +1 -1
  33. package/package.json +6 -6
@@ -9,11 +9,12 @@
9
9
  * - Index stores: {schema}.{table}_idx_{name} - one per secondary index
10
10
  * - Stats store: __stats__ - unified store for all table statistics, keyed by {schema}.{table}
11
11
  */
12
- import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, compareSqlValuesFast, resolveUniqueEnforcementCollations, BINARY_COLLATION, rowsValueIdentical, validateAndParse, compilePredicate, decodeIdxStr, maintainedTableUniqueViolationError, uniqueEnforcementCollations, logicalTypeCanHoldText, pkKeyCollationName, } from '@quereus/quereus';
12
+ import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, compareSqlValuesFast, createTypedComparator, hasSemanticOrdering, semanticKeyTransform, resolveUniqueEnforcementCollations, uniqueEnforcementComparators, BINARY_COLLATION, rowsValueIdentical, coerceRowToSchema, compilePredicate, decodeIdxStr, planKindFromCode, maintainedTableUniqueViolationError, uniqueEnforcementCollations, logicalTypeCanHoldText, pkKeyCollationName, JSON_TYPE, } from '@quereus/quereus';
13
13
  import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
14
14
  import { StoreConnection } from './store-connection.js';
15
15
  import { buildDataKey, buildIndexKey, buildIndexPrefixBounds, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
16
16
  import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
17
+ import { jsonStructuralKey } from './json-key.js';
17
18
  /** Number of mutations before persisting statistics. */
18
19
  const STATS_FLUSH_INTERVAL = 100;
19
20
  /** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
@@ -87,6 +88,76 @@ export function resolvePkKeyCollations(pkDef, columns, fallback) {
87
88
  return name === undefined ? undefined : (name || fallback).toUpperCase();
88
89
  });
89
90
  }
91
+ /**
92
+ * The store's per-type key VALUE TRANSFORM for a semantic-ordering logical type, or
93
+ * `undefined` when raw values already key faithfully.
94
+ *
95
+ * The contract is TWO-sided — identity AND order. `StoreTable` implements no
96
+ * `comparePrimaryKey`, so the isolation layer merges overlay against underlying with
97
+ * its own fallback comparator (the PK type's `compare` via `createTypedComparator`)
98
+ * and needs this table's key BYTE order to agree with it; and byte equality must be
99
+ * exactly the type's equality, or two spellings of one value land on two keys.
100
+ *
101
+ * - JSON → the store-local structural encoder ({@link jsonStructuralKey}): a
102
+ * `Uint8Array` whose memcmp order reproduces the type's structural `compare`
103
+ * (canonical-text bytes are identity-faithful but sort `[10]` before `[2]`,
104
+ * which broke overlay shadowing — see json-key.ts).
105
+ * - Everything else → the engine's {@link semanticKeyTransform} (the type's
106
+ * `groupKey`). Today that is TIMESPAN: total seconds against the same reference
107
+ * date as `TIMESPAN.compare`, so 'PT1H' and 'PT60M' collide on one key AND byte
108
+ * order is elapsed-time order.
109
+ *
110
+ * JSON deliberately gets NO engine `groupKey` — `groupKey` also drives GROUP BY /
111
+ * `IN` / hash-join identity, where canonical-text identity is already correct and a
112
+ * `Uint8Array` key would be a behaviour change for no benefit. Hence this
113
+ * store-local seam. A semantic-ordering type with NEITHER a `groupKey` nor an entry
114
+ * here cannot key a persisted structure soundly; `StoreTable` rejects it at DDL time
115
+ * (see `validateSemanticKeyTransforms`).
116
+ */
117
+ export function storeSemanticKeyTransform(type) {
118
+ // Matched by NAME, not object identity (`type === JSON_TYPE`): the engine and this
119
+ // package can be loaded as two module instances (e.g. the logic suite runs the
120
+ // engine from src via ts-node while the store resolves `@quereus/quereus` to dist),
121
+ // and each instance carries its own JSON_TYPE singleton. The `hasSemanticOrdering`
122
+ // gate keeps a hypothetical plain custom type named 'JSON' out of this branch.
123
+ if (hasSemanticOrdering(type) && type.name === JSON_TYPE.name)
124
+ return jsonStructuralKey;
125
+ return semanticKeyTransform(type);
126
+ }
127
+ /**
128
+ * Resolve the per-column key value transform for each primary-key column —
129
+ * {@link storeSemanticKeyTransform} of the member's logical type. One entry per PK
130
+ * member in `pkDef` order; `undefined` for every type whose raw value already keys
131
+ * faithfully. The companion of {@link resolvePkKeyCollations}: every call site that
132
+ * threads the collations MUST thread these too, or key identity/order drifts
133
+ * between the write path and the rebuild/lookup path.
134
+ */
135
+ export function resolvePkKeyTransforms(pkDef, columns) {
136
+ return pkDef.map(def => storeSemanticKeyTransform(columns[def.index]?.logicalType));
137
+ }
138
+ /**
139
+ * Per-column key value transforms for a secondary index's OWN columns (the
140
+ * index half of `buildIndexKey`), positionally aligned with `index.columns`.
141
+ * Same rule as {@link resolvePkKeyTransforms}: a semantic-ordering member
142
+ * canonicalizes through {@link storeSemanticKeyTransform}, everything else
143
+ * encodes raw.
144
+ */
145
+ export function resolveIndexKeyTransforms(index, columns) {
146
+ return index.columns.map(col => storeSemanticKeyTransform(columns[col.index]?.logicalType));
147
+ }
148
+ /**
149
+ * Per-PK-column typed equality comparator for semantic-ordering members, `undefined`
150
+ * elsewhere. Backs {@link StoreTable.keysEqual}: two PK arrays naming the same
151
+ * physical key under {@link resolvePkKeyTransforms} ('PT1H' / 'PT60M') must also
152
+ * answer "same row" here, or self-PK exclusion in the UNIQUE checks false-conflicts
153
+ * a row against its own differently-spelled image.
154
+ */
155
+ function resolvePkSemanticEquality(pkDef, columns) {
156
+ return pkDef.map(def => {
157
+ const logicalType = columns[def.index]?.logicalType;
158
+ return hasSemanticOrdering(logicalType) ? createTypedComparator(logicalType) : undefined;
159
+ });
160
+ }
90
161
  /**
91
162
  * True when `col` can produce a TEXT value at runtime, and so when its physical
92
163
  * key bytes are produced by a collation normalizer rather than a type-native
@@ -128,6 +199,17 @@ export function columnCanHoldText(col) {
128
199
  * "mark handled" and "build a window" decisions can never disagree.
129
200
  */
130
201
  export function keyOrderMatchesCollation(db, column, keyCollation, compareCollation) {
202
+ // A semantic-ordering logical type (TIMESPAN, JSON — see docs/types.md "Semantic
203
+ // ordering") is ordered by its `compare` (elapsed time / structural), which the
204
+ // engine's Sort, comparison operators, and the memory backend all use. Both types'
205
+ // key bytes now encode through an order-preserving transform (TIMESPAN's `groupKey`
206
+ // total seconds; JSON's structural encoding — see `storeSemanticKeyTransform`), so
207
+ // byte order DOES match the type's `compare` for every well-formed value and this
208
+ // blanket decline is conservative, costing the seek/sort-elision, never a row.
209
+ // Re-opening the window/advertisement (and the byte-EQ arms in `analyzePKAccess` /
210
+ // `analyzeIndexAccess`) is tracked in backlog `feat-reopen-timespan-store-seeks`.
211
+ if (hasSemanticOrdering(column?.logicalType))
212
+ return false;
131
213
  if (!columnCanHoldText(column))
132
214
  return true;
133
215
  if (compareCollation.toUpperCase() !== keyCollation.toUpperCase())
@@ -145,13 +227,12 @@ export function keyOrderMatchesCollation(db, column, keyCollation, compareCollat
145
227
  * already aligned with the key collation for every *textual* PK member.
146
228
  *
147
229
  * What the advertisement is measured against is the order the planner's `Sort` would have
148
- * produced, and `Sort` (like every scalar comparison) orders under the operand's COLLATION via
149
- * `compareSqlValuesFast` — never through `logicalType.compare`. So a collation match is the
150
- * whole question, even for a member whose type declares a `compare` that orders differently
151
- * (`TIMESPAN.compare` ranks by `Temporal.Duration` total, `JSON_TYPE.compare` structurally).
152
- * `MemoryTable`, whose primary-key BTree *is* keyed by `createTypedComparator`, advertises the
153
- * type's order instead and so disagrees with its own `Sort` — tracked as
154
- * `bug-memory-pk-btree-orders-by-logical-type-compare`, not something the store should copy.
230
+ * produced. For most members Sort orders under the operand's COLLATION, so a collation match
231
+ * is the question; for a member whose logical type carries SEMANTIC ordering (TIMESPAN, JSON
232
+ * — see docs/types.md "Semantic ordering") Sort now ranks by `logicalType.compare`, which no
233
+ * text-byte encoding reproduces, and {@link keyOrderMatchesCollation} closes the gate for
234
+ * such members outright (the memory backend's typed BTree order is the canonical order under
235
+ * that ruling; the store declines rather than advertising a divergent byte order).
155
236
  *
156
237
  * `0` ⇒ even the leading member is unsafe: no range seek, no PK-order advertisement.
157
238
  * A prefix shorter than the PK truncates the ordering advertisement rather than voiding it.
@@ -279,6 +360,21 @@ export class StoreTable extends VirtualTable {
279
360
  * {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
280
361
  */
281
362
  pkKeyCollations;
363
+ /**
364
+ * Per-PK-column key-identity value transform (see {@link resolvePkKeyTransforms}).
365
+ * Threaded alongside {@link pkKeyCollations} into every data-key / PK-suffix /
366
+ * PK-prefix-bounds encode, so a semantic-ordering PK member (TIMESPAN) keys
367
+ * semantically-equal spellings to one physical key. Recomputed on every
368
+ * {@link updateSchema}.
369
+ */
370
+ pkKeyTransforms;
371
+ /**
372
+ * Per-PK-column typed EQUALITY comparator for the semantic-ordering members only —
373
+ * `undefined` for every other member, which {@link keysEqual} still compares with
374
+ * strict `!==` as before. Keeps self-PK exclusion and PK-identity questions in
375
+ * agreement with the physical key identity above ('PT1H' row IS the 'PT60M' row).
376
+ */
377
+ pkSemanticEquality;
282
378
  /**
283
379
  * `db.getCollationResolver()`, bound once at construction. Every collation-aware
284
380
  * VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
@@ -345,9 +441,12 @@ export class StoreTable extends VirtualTable {
345
441
  };
346
442
  this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
347
443
  this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
444
+ this.pkKeyTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
445
+ this.pkSemanticEquality = resolvePkSemanticEquality(tableSchema.primaryKeyDefinition, tableSchema.columns);
348
446
  // Validate the MATERIALIZED schema, so a `_uc_*` over a text column that needs the
349
447
  // table key collation K is rejected up front just like an explicit index would be.
350
448
  this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
449
+ this.validateSemanticKeyTransforms(this.materializedSchema);
351
450
  this.ddlSaved = isConnected;
352
451
  }
353
452
  /**
@@ -397,6 +496,47 @@ export class StoreTable extends VirtualTable {
397
496
  + `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
398
497
  }
399
498
  }
499
+ /**
500
+ * Reject, at DDL time, a key member whose semantic-ordering logical type has no
501
+ * store key transform — before a single row is keyed under bytes whose order the
502
+ * type's `compare` (and so the isolation merge, the memory oracle, and Sort)
503
+ * disagrees with.
504
+ *
505
+ * A semantic-ordering type keys soundly only through an order- and
506
+ * identity-preserving transform ({@link storeSemanticKeyTransform}: the type's
507
+ * `groupKey`, or a store-local encoder like JSON's structural one). This gap used
508
+ * to be undetectable — JSON sat here transform-less for a release, keying in
509
+ * canonical-text order while every comparator ordered structurally, and the only
510
+ * symptom was a misaligned overlay merge deep inside a transaction. Unreachable
511
+ * today (TIMESPAN and JSON are both covered); it exists so the NEXT
512
+ * semantic-ordering type fails loudly at CREATE TABLE instead.
513
+ *
514
+ * Checked over the same materialized schema as {@link validateKeyCollations}:
515
+ * every PK member and every (explicit or hidden `_uc_*`) index column.
516
+ *
517
+ * CAVEAT: this can only check that a transform EXISTS — a `groupKey` that is
518
+ * identity-faithful but not order-preserving passes undetected, so a new type's
519
+ * transform still needs its byte order argued against its `compare` (see
520
+ * json-key.ts for the shape of that argument).
521
+ */
522
+ validateSemanticKeyTransforms(schema) {
523
+ const check = (colIndex, member) => {
524
+ const col = schema.columns[colIndex];
525
+ const type = col?.logicalType;
526
+ if (!hasSemanticOrdering(type) || storeSemanticKeyTransform(type) !== undefined)
527
+ return;
528
+ throw new QuereusError(`column ${col?.name ?? String(colIndex)} (${member}): semantic-ordering type `
529
+ + `${type.name} cannot key a persisted structure: its raw encoding would not `
530
+ + `reproduce the type's compare order — the type needs an order-preserving `
531
+ + `groupKey or a store key encoder (see storeSemanticKeyTransform)`, StatusCode.ERROR);
532
+ };
533
+ for (const def of schema.primaryKeyDefinition)
534
+ check(def.index, 'primary key');
535
+ for (const index of schema.indexes ?? []) {
536
+ for (const col of index.columns)
537
+ check(col.index, `index ${index.name}`);
538
+ }
539
+ }
400
540
  /** Get the table configuration. */
401
541
  getConfig() {
402
542
  return this.config;
@@ -427,10 +567,13 @@ export class StoreTable extends VirtualTable {
427
567
  const materialized = withImplicitUniqueIndexes(newSchema);
428
568
  const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
429
569
  this.validateKeyCollations(materialized, pkKeyCollations);
570
+ this.validateSemanticKeyTransforms(materialized);
430
571
  this.tableSchema = newSchema;
431
572
  this.materializedSchema = materialized;
432
573
  this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
433
574
  this.pkKeyCollations = pkKeyCollations;
575
+ this.pkKeyTransforms = resolvePkKeyTransforms(newSchema.primaryKeyDefinition, newSchema.columns);
576
+ this.pkSemanticEquality = resolvePkSemanticEquality(newSchema.primaryKeyDefinition, newSchema.columns);
434
577
  }
435
578
  /**
436
579
  * Mark the table's DDL as already persisted to the catalog, so the lazy
@@ -566,9 +709,13 @@ export class StoreTable extends VirtualTable {
566
709
  const bounds = buildFullScanBounds();
567
710
  const newPkDirections = newPkDef.map(pk => !!pk.desc);
568
711
  const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
712
+ // Post-ALTER key-identity transforms too: an ALTER PRIMARY KEY onto (or a SET
713
+ // DATA TYPE creating) a semantic-ordering member must collapse equal spellings
714
+ // — pass 1 then rejects 'PT1H'/'PT60M' as a duplicate PK, all-or-nothing.
715
+ const newPkTransforms = resolvePkKeyTransforms(newPkDef, newColumns);
569
716
  // Both passes key rows through this one helper, so a collision judged in pass 1
570
717
  // is byte-identical to the key pass 2 writes.
571
- const computeNewKey = (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations);
718
+ const computeNewKey = (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations, newPkTransforms);
572
719
  // Pass 1 — collision detection only. Hold one hex signature per new key, never
573
720
  // the row or old key. On a repeat, reject before any write; the store is
574
721
  // untouched on rejection.
@@ -783,11 +930,7 @@ export class StoreTable extends VirtualTable {
783
930
  * objects before PK extraction, serialization, and index-key construction.
784
931
  */
785
932
  coerceRow(row) {
786
- const cols = this.tableSchema.columns;
787
- if (row.length > cols.length) {
788
- throw new QuereusError(`Too many values for ${this.schemaName}.${this.tableName}: expected ${cols.length}, got ${row.length}`, StatusCode.ERROR);
789
- }
790
- return row.map((v, i) => validateAndParse(v, cols[i].logicalType, cols[i].name));
933
+ return coerceRowToSchema(row, this.tableSchema.columns, `${this.schemaName}.${this.tableName}`);
791
934
  }
792
935
  /**
793
936
  * Query the table with optional filters.
@@ -800,6 +943,16 @@ export class StoreTable extends VirtualTable {
800
943
  */
801
944
  async *query(filterInfo) {
802
945
  const store = await this.ensureStore();
946
+ // Multi-seek (`plan=5`, an IN-list served as N point seeks) dispatches FIRST —
947
+ // before analyzePKAccess: the FilterInfo carries N*W EQ constraints, so on a
948
+ // table whose PK column is also the leading column of the chosen index,
949
+ // analyzePKAccess would match the FIRST equality and answer a single-value
950
+ // point lookup — one list value's rows, a silent wrong result.
951
+ const idxSpec = decodeIdxStr(filterInfo.idxStr);
952
+ if (idxSpec && planKindFromCode(idxSpec.plan) === 'multiSeek') {
953
+ yield* this.scanMultiSeek(idxSpec, filterInfo);
954
+ return;
955
+ }
803
956
  const pkAccess = this.analyzePKAccess(filterInfo);
804
957
  if (pkAccess.type === 'point') {
805
958
  const row = await this.readLiveRowByPk(pkAccess.values);
@@ -891,6 +1044,22 @@ export class StoreTable extends VirtualTable {
891
1044
  leadingPkRangeIsOrderSafe() {
892
1045
  return pkOrderPreservingPrefixLength(this.db, this.tableSchema, this.pkKeyCollations, this.encodeOptions.collation ?? 'NOCASE') >= 1;
893
1046
  }
1047
+ /**
1048
+ * True when some PK member's logical type carries semantic ordering (TIMESPAN,
1049
+ * JSON). Such PKs decline the point arm and full-scan, where
1050
+ * {@link matchesFilters} applies the type's compare. Historically required —
1051
+ * byte-key equality was a strict subset of the type's equality ('PT1H' ≡
1052
+ * 'PT60M' elapsed-time-equal, byte-distinct) and a point window under-fetched
1053
+ * with no residual able to resurrect a skipped row. Both types' key transforms
1054
+ * (see {@link storeSemanticKeyTransform}) now make byte equality the type's
1055
+ * equality, so the decline is conservative; re-opening is tracked in backlog
1056
+ * `feat-reopen-timespan-store-seeks`. (Range windows and ordering
1057
+ * advertisements are already declined by {@link keyOrderMatchesCollation}.)
1058
+ */
1059
+ pkHasSemanticOrderingMember() {
1060
+ const schema = this.tableSchema;
1061
+ return schema.primaryKeyDefinition.some(def => hasSemanticOrdering(schema.columns[def.index]?.logicalType));
1062
+ }
894
1063
  /** Analyze filter info to determine PK access pattern. */
895
1064
  analyzePKAccess(filterInfo) {
896
1065
  const schema = this.tableSchema;
@@ -912,7 +1081,7 @@ export class StoreTable extends VirtualTable {
912
1081
  break;
913
1082
  }
914
1083
  }
915
- if (allEq) {
1084
+ if (allEq && !this.pkHasSemanticOrderingMember()) {
916
1085
  return { type: 'point', values: eqValues };
917
1086
  }
918
1087
  // Check for range constraints on first PK column
@@ -1068,9 +1237,14 @@ export class StoreTable extends VirtualTable {
1068
1237
  return null;
1069
1238
  const indexCols = index.columns.map(c => c.index);
1070
1239
  const indexDirections = index.columns.map(c => !!c.desc);
1071
- // Contiguous leading-prefix EQ → point/prefix window.
1240
+ // Contiguous leading-prefix EQ → point/prefix window. A prefix member whose
1241
+ // logical type carries semantic ordering stops the prefix: its byte-equality
1242
+ // window under-fetches the type's equality (see pkHasSemanticOrderingMember),
1243
+ // and a skipped row cannot be resurrected by the residual.
1072
1244
  const eqValues = [];
1073
1245
  for (let i = 0; i < indexCols.length; i++) {
1246
+ if (hasSemanticOrdering(this.tableSchema.columns[indexCols[i]]?.logicalType))
1247
+ break;
1074
1248
  const eq = filterInfo.constraints?.find(c => c.constraint.iColumn === indexCols[i]
1075
1249
  && c.constraint.op === IndexConstraintOp.EQ
1076
1250
  && c.argvIndex > 0);
@@ -1170,12 +1344,13 @@ export class StoreTable extends VirtualTable {
1170
1344
  * a superset, and a stale entry whose indexed column no longer matches is
1171
1345
  * dropped).
1172
1346
  */
1173
- async *scanIndex(indexStore, access, filterInfo) {
1347
+ async *scanIndex(indexStore, access, filterInfo, multi) {
1174
1348
  // Re-check each resolved row under the INDEX's per-column collation (see
1175
1349
  // matchesFilters): the planner dropped the residual based on the index
1176
1350
  // column's collation, which an explicit index `COLLATE` can make differ from
1177
1351
  // the table column's declared collation.
1178
- const indexCollations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
1352
+ const indexCollations = multi?.collations
1353
+ ?? this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
1179
1354
  for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
1180
1355
  // NOTE: a legacy index store (written before index values carried the data
1181
1356
  // key) holds EMPTY values; a zero-length data key is not a row key, so skip
@@ -1190,6 +1365,14 @@ export class StoreTable extends VirtualTable {
1190
1365
  // fall back to a full scan the first time an empty value is seen.
1191
1366
  if (entry.value.length === 0)
1192
1367
  continue;
1368
+ // Cross-window dedup for a multi-seek: a data key an earlier window already
1369
+ // YIELDED is skipped before the data-store read, so a duplicate costs no
1370
+ // extra `get`. The seen-set is only ever ADDED to on a yield (below) — adding
1371
+ // at visit time would let a stale index entry that fails its residual poison
1372
+ // the set and suppress the row's live entry in a later window.
1373
+ const dataKeyHex = multi ? bytesToHex(entry.value) : undefined;
1374
+ if (dataKeyHex !== undefined && multi.seen.has(dataKeyHex))
1375
+ continue;
1193
1376
  // NOTE: one extra data-store `get` per matched index entry — the row lives
1194
1377
  // in the data store, not the index (the index value carries only the data
1195
1378
  // key, no covering payload). Fine now; if index-covered scans ever dominate
@@ -1197,7 +1380,204 @@ export class StoreTable extends VirtualTable {
1197
1380
  // at the cost of an index rewrite on EVERY column change (not just indexed
1198
1381
  // columns) — deliberately not done here.
1199
1382
  const row = await this.readEffectiveRowByKey(entry.value);
1200
- if (row && this.matchesFilters(row, filterInfo, indexCollations)) {
1383
+ if (!row)
1384
+ continue;
1385
+ if (this.matchesFilters(row, filterInfo, indexCollations)
1386
+ || (multi !== undefined && multi.extraTuples.some(fi => this.matchesFilters(row, fi, indexCollations)))) {
1387
+ if (dataKeyHex !== undefined)
1388
+ multi.seen.add(dataKeyHex);
1389
+ yield row;
1390
+ }
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Fail loudly on a multi-seek FilterInfo this table cannot serve. The plan already
1395
+ * dropped the residual Filter, and {@link matchesFilters} ANDs every pushed
1396
+ * constraint — so falling through to the scan arm would AND N mutually-exclusive
1397
+ * equalities and return zero rows: a silent wrong answer, not a slow one.
1398
+ */
1399
+ multiSeekMalformed(filterInfo, why) {
1400
+ throw new QuereusError(`Malformed multi-seek FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
1401
+ }
1402
+ /**
1403
+ * Decode a multi-seek FilterInfo into its N seek tuples of width W. Tuple i pairs
1404
+ * `args[i*W … i*W+W-1]` with the same slice of `constraints` (argvIndex runs
1405
+ * 1…N*W in order — see rule-select-access-path's multiSeek emission). A tuple with
1406
+ * a NULL/undefined component is dropped: IN is set membership and a NULL component
1407
+ * matches nothing. The planner pre-reduces literal lists, but a parameter-bound /
1408
+ * mixed-binding list reaches here unreduced, so this skip is the only line of
1409
+ * defense for those.
1410
+ */
1411
+ decodeMultiSeekTuples(spec, filterInfo) {
1412
+ const seekWidth = Number.parseInt(spec.params.get('seekWidth') ?? '1', 10);
1413
+ const inCount = Number.parseInt(spec.params.get('inCount') ?? '0', 10);
1414
+ if (!Number.isInteger(seekWidth) || seekWidth < 1) {
1415
+ this.multiSeekMalformed(filterInfo, `seekWidth=${spec.params.get('seekWidth')}`);
1416
+ }
1417
+ if (!Number.isInteger(inCount) || inCount < 1) {
1418
+ this.multiSeekMalformed(filterInfo, `inCount=${spec.params.get('inCount')}`);
1419
+ }
1420
+ const constraints = filterInfo.constraints ?? [];
1421
+ if (constraints.length < inCount * seekWidth) {
1422
+ this.multiSeekMalformed(filterInfo, `${constraints.length} constraints for ${inCount}×${seekWidth} seek keys`);
1423
+ }
1424
+ if (filterInfo.args.length < inCount * seekWidth) {
1425
+ this.multiSeekMalformed(filterInfo, `${filterInfo.args.length} args for ${inCount}×${seekWidth} seek keys`);
1426
+ }
1427
+ const tuples = [];
1428
+ for (let t = 0; t < inCount; t++) {
1429
+ const entries = constraints.slice(t * seekWidth, (t + 1) * seekWidth);
1430
+ if (entries.some(e => e.constraint.op !== IndexConstraintOp.EQ || e.argvIndex <= 0)) {
1431
+ this.multiSeekMalformed(filterInfo, `seek tuple ${t} carries a non-EQ or unbound constraint`);
1432
+ }
1433
+ const values = entries.map(e => filterInfo.args[e.argvIndex - 1]);
1434
+ if (values.some(v => v === null || v === undefined))
1435
+ continue;
1436
+ tuples.push({ entries, values });
1437
+ }
1438
+ return { tuples, seekWidth };
1439
+ }
1440
+ /**
1441
+ * Order one tuple's values to match `keyCols` (the leading index columns, or the
1442
+ * PK definition) using each constraint entry's column. The plan's seek columns and
1443
+ * the constraint list must agree; a tuple that does not cover exactly those
1444
+ * columns is malformed.
1445
+ */
1446
+ orderTupleValues(tuple, keyCols, filterInfo) {
1447
+ const byCol = new Map();
1448
+ tuple.entries.forEach((e, i) => byCol.set(e.constraint.iColumn, tuple.values[i]));
1449
+ if (byCol.size !== keyCols.length) {
1450
+ this.multiSeekMalformed(filterInfo, `seek tuple covers ${byCol.size} columns, expected ${keyCols.length}`);
1451
+ }
1452
+ return keyCols.map(colIdx => {
1453
+ if (!byCol.has(colIdx))
1454
+ this.multiSeekMalformed(filterInfo, `seek tuple missing column ${colIdx}`);
1455
+ return byCol.get(colIdx);
1456
+ });
1457
+ }
1458
+ /**
1459
+ * Serve a multi-seek (`plan=5`) FilterInfo: one point window per distinct
1460
+ * IN-list tuple, scanned in ascending encoded-key order.
1461
+ *
1462
+ * Window order is not cosmetic. Encoded-byte order IS index-key order (per-column
1463
+ * DESC inversion is baked into the bytes), and the isolation overlay merges an
1464
+ * index scan with its pending rows by (indexKey, PK) — an out-of-order underlying
1465
+ * stream misplaces overlay rows in the output. Two overlap hazards are folded
1466
+ * away before scanning:
1467
+ * - Tuples whose K-encoded prefix byte-matches (duplicate bound parameters, or
1468
+ * case variants under a NOCASE table key collation) share ONE window, each
1469
+ * kept as a residual alternative — see {@link MultiSeekWindowContext} for why
1470
+ * a single tuple's residual would drop rows when the comparison collation is
1471
+ * finer than K.
1472
+ * - A window with no finite upper bound (all-0xff prefix — see
1473
+ * buildIndexPrefixBounds) contains every later-sorting window outright (any
1474
+ * key ≥ an all-0xff prefix necessarily starts with it), so those windows fold
1475
+ * into it instead of re-scanning an overlapping range out of order.
1476
+ *
1477
+ * Emission is lazy per window — `… in (…) limit 1` stops after the first yielded
1478
+ * row without materializing the remaining windows. Each window goes through
1479
+ * {@link scanIndex} → {@link iterateEffective}, so read-your-own-writes and the
1480
+ * stale-entry / deleted-row defenses hold per seek key.
1481
+ */
1482
+ async *scanMultiSeek(spec, filterInfo) {
1483
+ const { tuples, seekWidth } = this.decodeMultiSeekTuples(spec, filterInfo);
1484
+ if (spec.indexName === '_primary_') {
1485
+ yield* this.scanMultiSeekPrimary(tuples, seekWidth, filterInfo);
1486
+ return;
1487
+ }
1488
+ const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
1489
+ if (!index)
1490
+ this.multiSeekMalformed(filterInfo, `no index named '${spec.indexName}'`);
1491
+ const indexCols = index.columns.map(c => c.index);
1492
+ const indexDirections = index.columns.map(c => !!c.desc);
1493
+ if (seekWidth > indexCols.length) {
1494
+ this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} exceeds ${index.name}'s ${indexCols.length} columns`);
1495
+ }
1496
+ const seekCols = indexCols.slice(0, seekWidth);
1497
+ // The K-encoded byte windows below carry no residual able to resurrect a
1498
+ // skipped row, so a seek column whose byte equality under-fetches the type's
1499
+ // equality (TIMESPAN, JSON — see pkHasSemanticOrderingMember) cannot be
1500
+ // multi-seeked. StoreModule.tryIndexAccessPlan declines such plans; one
1501
+ // arriving anyway is malformed.
1502
+ if (seekCols.some(colIdx => hasSemanticOrdering(this.tableSchema.columns[colIdx]?.logicalType))) {
1503
+ this.multiSeekMalformed(filterInfo, 'semantic-ordering seek column');
1504
+ }
1505
+ // One window per distinct K-encoded tuple prefix; K-equal tuples merge into
1506
+ // one window, each kept as a residual alternative.
1507
+ const windows = new Map();
1508
+ for (const tuple of tuples) {
1509
+ const ordered = this.orderTupleValues(tuple, seekCols, filterInfo);
1510
+ const bounds = buildIndexPrefixBounds(ordered, this.encodeOptions, indexDirections.slice(0, seekWidth));
1511
+ const hex = bytesToHex(bounds.gte);
1512
+ const info = { ...filterInfo, constraints: tuple.entries };
1513
+ const existing = windows.get(hex);
1514
+ if (existing)
1515
+ existing.infos.push(info);
1516
+ else
1517
+ windows.set(hex, { bounds, infos: [info] });
1518
+ }
1519
+ if (windows.size === 0)
1520
+ return; // every tuple had a NULL component — nothing matches
1521
+ const sorted = [...windows.values()].sort((a, b) => compareBytes(a.bounds.gte, b.bounds.gte));
1522
+ const disjoint = [];
1523
+ for (const w of sorted) {
1524
+ const last = disjoint[disjoint.length - 1];
1525
+ if (last && last.bounds.lt === undefined)
1526
+ last.infos.push(...w.infos);
1527
+ else
1528
+ disjoint.push(w);
1529
+ }
1530
+ const indexStore = await this.ensureIndexStore(index.name);
1531
+ // Identical for every window (resolveFilterCollations dedups by column), so
1532
+ // resolve once and thread it through.
1533
+ const collations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(index));
1534
+ const seen = new Set();
1535
+ for (const w of disjoint) {
1536
+ yield* this.scanIndex(indexStore, { index, type: 'point', bounds: w.bounds }, w.infos[0], { seen, collations, extraTuples: w.infos.slice(1) });
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Multi-seek over the PRIMARY key: each tuple is a full PK, resolved by a point
1541
+ * read in ascending encoded-data-key order (the table's native emission order).
1542
+ *
1543
+ * NOT reachable from this module's own plans today: `StoreModule.computeBestAccessPlan`
1544
+ * claims IN-list filters only for secondary indexes (see its EQ_OPS vs EQ_OR_IN_OPS
1545
+ * split and tickets/backlog/feat-store-pk-in-list-multiseek). The branch exists so
1546
+ * a `_primary_` multi-seek arriving from a future plan gets a correct answer rather
1547
+ * than the scan arm's silent zero rows, and it is what PK-IN enablement will build on.
1548
+ */
1549
+ async *scanMultiSeekPrimary(tuples, seekWidth, filterInfo) {
1550
+ const pkColumns = this.tableSchema.primaryKeyDefinition.map(pk => pk.index);
1551
+ if (pkColumns.length === 0) {
1552
+ this.multiSeekMalformed(filterInfo, 'primary-key multi-seek on a table with no primary key');
1553
+ }
1554
+ if (seekWidth !== pkColumns.length) {
1555
+ this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} does not cover the ${pkColumns.length}-column primary key`);
1556
+ }
1557
+ // Mirrors analyzePKAccess's conservative decline of the point arm for such PKs
1558
+ // (see pkHasSemanticOrderingMember); with the residual gone there is no scan to
1559
+ // degrade to, so fail loudly rather than risk an under-fetch.
1560
+ if (this.pkHasSemanticOrderingMember()) {
1561
+ this.multiSeekMalformed(filterInfo, 'semantic-ordering primary-key member');
1562
+ }
1563
+ // Dedup by encoded data key (collapses K-equal tuples), keeping every merged
1564
+ // tuple's residual as an alternative — same rationale as the index branch.
1565
+ const points = new Map();
1566
+ for (const tuple of tuples) {
1567
+ const ordered = this.orderTupleValues(tuple, pkColumns, filterInfo);
1568
+ const key = this.encodeDataKey(ordered);
1569
+ const hex = bytesToHex(key);
1570
+ const info = { ...filterInfo, constraints: tuple.entries };
1571
+ const existing = points.get(hex);
1572
+ if (existing)
1573
+ existing.infos.push(info);
1574
+ else
1575
+ points.set(hex, { key, infos: [info] });
1576
+ }
1577
+ const collations = this.resolveFilterCollations(filterInfo);
1578
+ for (const p of [...points.values()].sort((a, b) => compareBytes(a.key, b.key))) {
1579
+ const row = await this.readEffectiveRowByKey(p.key);
1580
+ if (row && p.infos.some(fi => this.matchesFilters(row, fi, collations))) {
1201
1581
  yield row;
1202
1582
  }
1203
1583
  }
@@ -1225,7 +1605,16 @@ export class StoreTable extends VirtualTable {
1225
1605
  const rowValue = row[constraint.iColumn];
1226
1606
  const filterValue = filterInfo.args[argvIndex - 1];
1227
1607
  const collation = collations.get(constraint.iColumn) ?? BINARY_COLLATION;
1228
- if (!this.compareValues(rowValue, constraint.op, filterValue, collation)) {
1608
+ // A semantic-ordering column (TIMESPAN, JSON) must filter under the type's
1609
+ // compare — the same order the engine's operators and Sort use — or a pushed
1610
+ // `d > 'PT90M'` would text-compare and drop rows the predicate admits. The
1611
+ // comparator is built per row only on this rare column kind; if a profile
1612
+ // ever shows it, resolve it once per scan alongside the collations.
1613
+ const logicalType = this.tableSchema.columns[constraint.iColumn]?.logicalType;
1614
+ const compare = hasSemanticOrdering(logicalType)
1615
+ ? createTypedComparator(logicalType, collation)
1616
+ : undefined;
1617
+ if (!this.compareValues(rowValue, constraint.op, filterValue, collation, compare)) {
1229
1618
  return false;
1230
1619
  }
1231
1620
  }
@@ -1296,11 +1685,11 @@ export class StoreTable extends VirtualTable {
1296
1685
  * NULL on either side fails every operator except EQ-with-both-NULL (the
1297
1686
  * internal point-lookup convention; the planner never pushes `= NULL`).
1298
1687
  */
1299
- compareValues(a, op, b, collationFunc) {
1688
+ compareValues(a, op, b, collationFunc, semanticCompare) {
1300
1689
  if (a === null || b === null) {
1301
1690
  return op === IndexConstraintOp.EQ ? a === b : false;
1302
1691
  }
1303
- const cmp = compareSqlValuesFast(a, b, collationFunc);
1692
+ const cmp = semanticCompare ? semanticCompare(a, b) : compareSqlValuesFast(a, b, collationFunc);
1304
1693
  switch (op) {
1305
1694
  case IndexConstraintOp.EQ: return cmp === 0;
1306
1695
  case IndexConstraintOp.NE: return cmp !== 0;
@@ -1324,7 +1713,7 @@ export class StoreTable extends VirtualTable {
1324
1713
  throw new QuereusError('INSERT requires values', StatusCode.MISUSE);
1325
1714
  const coerced = args.preCoerced ? values : this.coerceRow(values);
1326
1715
  const pk = this.extractPK(coerced);
1327
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
1716
+ const key = this.encodeDataKey(pk);
1328
1717
  // Check for existing row (for conflict handling).
1329
1718
  // Resolve PK-conflict action: statement OR > per-constraint default > ABORT.
1330
1719
  const pkEffective = args.onConflict ?? resolvePkDefaultConflict(schema) ?? ConflictResolution.ABORT;
@@ -1442,8 +1831,8 @@ export class StoreTable extends VirtualTable {
1442
1831
  const coerced = args.preCoerced ? values : this.coerceRow(values);
1443
1832
  const oldPk = this.extractPK(oldKeyValues);
1444
1833
  const newPk = this.extractPK(coerced);
1445
- const oldKey = buildDataKey(oldPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
1446
- const newKey = buildDataKey(newPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
1834
+ const oldKey = this.encodeDataKey(oldPk);
1835
+ const newKey = this.encodeDataKey(newPk);
1447
1836
  // Get old row for index updates. Read the effective
1448
1837
  // (pending-over-committed) image UNCONDITIONALLY — including the trusted
1449
1838
  // flush path — so an old image written earlier in the same transaction is
@@ -1558,7 +1947,7 @@ export class StoreTable extends VirtualTable {
1558
1947
  if (!oldKeyValues)
1559
1948
  throw new QuereusError('DELETE requires oldKeyValues', StatusCode.MISUSE);
1560
1949
  const pk = this.extractPK(oldKeyValues);
1561
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
1950
+ const key = this.encodeDataKey(pk);
1562
1951
  // Get old row for index cleanup. Read the effective
1563
1952
  // (pending-over-committed) image so a row inserted earlier in the same
1564
1953
  // transaction is seen: this fixes index cleanup, the `-1` stats delta
@@ -1614,6 +2003,9 @@ export class StoreTable extends VirtualTable {
1614
2003
  const indexStore = await this.ensureIndexStore(index.name);
1615
2004
  const indexCols = index.columns.map(c => c.index);
1616
2005
  const indexDirections = index.columns.map(c => !!c.desc);
2006
+ // Canonicalize semantic-ordering index members ('PT1H'/'PT60M' → one key) so a
2007
+ // UNIQUE enforcement seek and delete-then-insert maintenance address one entry.
2008
+ const indexTransforms = resolveIndexKeyTransforms(index, this.tableSchema.columns);
1617
2009
  // Partial index: only rows the predicate unambiguously accepts are
1618
2010
  // indexed (mirrors buildIndexEntries' build-time filtering). Guarding both
1619
2011
  // halves keeps a row that transitions across the predicate scope on UPDATE
@@ -1624,7 +2016,7 @@ export class StoreTable extends VirtualTable {
1624
2016
  // Remove old index entry (only if the old row was within scope).
1625
2017
  if (oldRow && (!predicate || predicate.evaluate(oldRow) === true)) {
1626
2018
  const oldIndexValues = indexCols.map(i => oldRow[i]);
1627
- const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
2019
+ const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations, indexTransforms, this.pkKeyTransforms);
1628
2020
  if (inTransaction && this.coordinator) {
1629
2021
  this.coordinator.delete(oldIndexKey, indexStore);
1630
2022
  }
@@ -1635,7 +2027,7 @@ export class StoreTable extends VirtualTable {
1635
2027
  // Add new index entry (only if the new row is within scope).
1636
2028
  if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
1637
2029
  const newIndexValues = indexCols.map(i => newRow[i]);
1638
- const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
2030
+ const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations, indexTransforms, this.pkKeyTransforms);
1639
2031
  // Index value = the row's encoded DATA key. The index-entry key can
1640
2032
  // locate a row's byte window, but its PK suffix is not losslessly
1641
2033
  // recoverable to SqlValues (a NOCASE/RTRIM PK column encodes lossily)
@@ -1684,16 +2076,36 @@ export class StoreTable extends VirtualTable {
1684
2076
  }
1685
2077
  return compiled;
1686
2078
  }
1687
- /** Check if two PK arrays are equal. */
2079
+ /**
2080
+ * Check if two PK arrays name the same row. A semantic-ordering member compares
2081
+ * through its type's `compare` (see {@link resolvePkSemanticEquality}) so
2082
+ * differently-spelled equal values ('PT1H' / 'PT60M') answer "same row",
2083
+ * agreeing with the physical key identity {@link encodeDataKey} produces; every
2084
+ * other member keeps the original strict `!==`.
2085
+ */
1688
2086
  keysEqual(a, b) {
1689
2087
  if (a.length !== b.length)
1690
2088
  return false;
1691
2089
  for (let i = 0; i < a.length; i++) {
1692
- if (a[i] !== b[i])
2090
+ const semantic = this.pkSemanticEquality[i];
2091
+ if (semantic ? semantic(a[i], b[i]) !== 0 : a[i] !== b[i])
1693
2092
  return false;
1694
2093
  }
1695
2094
  return true;
1696
2095
  }
2096
+ /**
2097
+ * Per-constrained-column comparison functions for one UNIQUE constraint: the
2098
+ * type's `compare` for a semantic-ordering column (so 'PT1H' conflicts with
2099
+ * 'PT60M', matching the memory backend's typed BTree), else the enforcement
2100
+ * collation the caller resolved (`resolveUniqueEnforcementCollations`) through
2101
+ * `compareSqlValuesFast` — the exact comparison every finder used before.
2102
+ *
2103
+ * Thin wrapper over the engine's `uniqueEnforcementComparators`, which memory
2104
+ * and the isolation overlay share so the three backends cannot drift.
2105
+ */
2106
+ uniqueColumnComparators(uc, collations) {
2107
+ return uniqueEnforcementComparators(this.tableSchema.columns, uc.columns, collations);
2108
+ }
1697
2109
  /**
1698
2110
  * Returns true if any column covered by a UNIQUE constraint differs between
1699
2111
  * oldRow and newRow, or — for partial UNIQUE — any column referenced by the
@@ -1927,7 +2339,10 @@ export class StoreTable extends VirtualTable {
1927
2339
  // unregistered name and cannot be inlined, so a per-candidate call would be
1928
2340
  // pure overhead.
1929
2341
  const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
1930
- const bounds = buildIndexPrefixBounds(uc.columns.map(c => newRow[c]), this.encodeOptions, index.columns.map(c => !!c.desc));
2342
+ const compares = this.uniqueColumnComparators(uc, collations);
2343
+ // Same per-column transforms `updateSecondaryIndexes` wrote the entries under, so
2344
+ // the probe for 'PT60M' lands on the window holding the 'PT1H' entry.
2345
+ const bounds = buildIndexPrefixBounds(uc.columns.map(c => newRow[c]), this.encodeOptions, index.columns.map(c => !!c.desc), resolveIndexKeyTransforms(index, this.tableSchema.columns));
1931
2346
  for await (const entry of this.iterateEffective(indexStore, bounds)) {
1932
2347
  // A legacy index store (written before index values carried the data key)
1933
2348
  // holds EMPTY values. `scanIndex` may skip such an entry — a read that
@@ -1944,7 +2359,7 @@ export class StoreTable extends VirtualTable {
1944
2359
  const pk = this.extractPK(candidate);
1945
2360
  if (selfPks.some(skip => this.keysEqual(pk, skip)))
1946
2361
  continue;
1947
- if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], candidate[c], collations[i]) !== 0))
2362
+ if (uc.columns.some((c, i) => compares[i](newRow[c], candidate[c]) !== 0))
1948
2363
  continue;
1949
2364
  if (predicate && predicate.evaluate(candidate) !== true)
1950
2365
  continue;
@@ -1971,6 +2386,7 @@ export class StoreTable extends VirtualTable {
1971
2386
  // COLLATE for an index-derived UNIQUE, else the declared column collation.
1972
2387
  // Resolved once here, not per candidate row.
1973
2388
  const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
2389
+ const compares = this.uniqueColumnComparators(uc, collations);
1974
2390
  const matches = (candidate) => {
1975
2391
  const pk = this.extractPK(candidate);
1976
2392
  for (const skip of selfPks) {
@@ -1979,7 +2395,7 @@ export class StoreTable extends VirtualTable {
1979
2395
  }
1980
2396
  for (let i = 0; i < constrainedCols.length; i++) {
1981
2397
  const idx = constrainedCols[i];
1982
- if (compareSqlValuesFast(newRow[idx], candidate[idx], collations[i]) !== 0)
2398
+ if (compares[i](newRow[idx], candidate[idx]) !== 0)
1983
2399
  return null;
1984
2400
  }
1985
2401
  // Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
@@ -2024,6 +2440,7 @@ export class StoreTable extends VirtualTable {
2024
2440
  const newSourcePk = this.extractPK(newRow);
2025
2441
  // Resolved once, above the candidate loop.
2026
2442
  const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
2443
+ const compares = this.uniqueColumnComparators(uc, collations);
2027
2444
  const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
2028
2445
  for (const cand of candidates) {
2029
2446
  const liveRow = await this.readLiveRowByPk(cand.pk);
@@ -2041,7 +2458,7 @@ export class StoreTable extends VirtualTable {
2041
2458
  // index over a BINARY column) is declined upstream by the collation gate in
2042
2459
  // findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
2043
2460
  // reach here — the superset this re-validation can soundly filter.
2044
- if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], liveRow[c], collations[i]) !== 0))
2461
+ if (uc.columns.some((c, i) => compares[i](newRow[c], liveRow[c]) !== 0))
2045
2462
  continue;
2046
2463
  if (predicate && predicate.evaluate(liveRow) !== true)
2047
2464
  continue;
@@ -2065,7 +2482,7 @@ export class StoreTable extends VirtualTable {
2065
2482
  // the table's own read/write paths.
2066
2483
  /** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
2067
2484
  encodeDataKey(pkValues) {
2068
- return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
2485
+ return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations, this.pkKeyTransforms);
2069
2486
  }
2070
2487
  /**
2071
2488
  * Byte bounds covering every data key whose leading PK columns equal
@@ -2074,7 +2491,7 @@ export class StoreTable extends VirtualTable {
2074
2491
  * exact slice. An empty prefix yields full-scan bounds.
2075
2492
  */
2076
2493
  encodePkPrefixBounds(prefixValues) {
2077
- return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length));
2494
+ return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length), this.pkKeyTransforms.slice(0, prefixValues.length));
2078
2495
  }
2079
2496
  /**
2080
2497
  * Effective (pending-over-committed) point read by encoded data key: a pending
@@ -2284,7 +2701,7 @@ export class StoreTable extends VirtualTable {
2284
2701
  */
2285
2702
  async deleteRowAt(inTransaction, pk, oldRow) {
2286
2703
  const store = await this.ensureStore();
2287
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
2704
+ const key = this.encodeDataKey(pk);
2288
2705
  if (inTransaction && this.coordinator) {
2289
2706
  this.coordinator.delete(key, store);
2290
2707
  }