@quereus/store 4.3.1 → 4.4.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 (44) hide show
  1. package/README.md +486 -436
  2. package/dist/src/common/bytes.d.ts +4 -1
  3. package/dist/src/common/bytes.d.ts.map +1 -1
  4. package/dist/src/common/bytes.js +23 -2
  5. package/dist/src/common/bytes.js.map +1 -1
  6. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  7. package/dist/src/common/cached-kv-store.js +6 -13
  8. package/dist/src/common/cached-kv-store.js.map +1 -1
  9. package/dist/src/common/encoding.d.ts +69 -26
  10. package/dist/src/common/encoding.d.ts.map +1 -1
  11. package/dist/src/common/encoding.js +292 -230
  12. package/dist/src/common/encoding.js.map +1 -1
  13. package/dist/src/common/index.d.ts +1 -1
  14. package/dist/src/common/index.d.ts.map +1 -1
  15. package/dist/src/common/index.js +1 -1
  16. package/dist/src/common/index.js.map +1 -1
  17. package/dist/src/common/key-builder.d.ts +5 -1
  18. package/dist/src/common/key-builder.d.ts.map +1 -1
  19. package/dist/src/common/key-builder.js +30 -2
  20. package/dist/src/common/key-builder.js.map +1 -1
  21. package/dist/src/common/kv-store.d.ts +1 -1
  22. package/dist/src/common/memory-store.d.ts.map +1 -1
  23. package/dist/src/common/memory-store.js +21 -6
  24. package/dist/src/common/memory-store.js.map +1 -1
  25. package/dist/src/common/serialization.d.ts.map +1 -1
  26. package/dist/src/common/serialization.js +6 -3
  27. package/dist/src/common/serialization.js.map +1 -1
  28. package/dist/src/common/store-module.d.ts +293 -26
  29. package/dist/src/common/store-module.d.ts.map +1 -1
  30. package/dist/src/common/store-module.js +1543 -612
  31. package/dist/src/common/store-module.js.map +1 -1
  32. package/dist/src/common/store-table.d.ts +515 -36
  33. package/dist/src/common/store-table.d.ts.map +1 -1
  34. package/dist/src/common/store-table.js +873 -100
  35. package/dist/src/common/store-table.js.map +1 -1
  36. package/dist/src/common/transaction.d.ts +8 -5
  37. package/dist/src/common/transaction.d.ts.map +1 -1
  38. package/dist/src/common/transaction.js +32 -5
  39. package/dist/src/common/transaction.js.map +1 -1
  40. package/dist/src/testing/kv-conformance.d.ts +47 -0
  41. package/dist/src/testing/kv-conformance.d.ts.map +1 -0
  42. package/dist/src/testing/kv-conformance.js +418 -0
  43. package/dist/src/testing/kv-conformance.js.map +1 -0
  44. package/package.json +17 -5
@@ -9,12 +9,11 @@
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, rowsValueIdentical, validateAndParse, compilePredicate, maintainedTableUniqueViolationError, uniqueEnforcementCollations, } from '@quereus/quereus';
12
+ import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, compareSqlValuesFast, resolveUniqueEnforcementCollations, BINARY_COLLATION, rowsValueIdentical, validateAndParse, compilePredicate, decodeIdxStr, maintainedTableUniqueViolationError, uniqueEnforcementCollations, logicalTypeCanHoldText, pkKeyCollationName, } from '@quereus/quereus';
13
13
  import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
14
14
  import { StoreConnection } from './store-connection.js';
15
- import { buildDataKey, buildIndexKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
15
+ import { buildDataKey, buildIndexKey, buildIndexPrefixBounds, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
16
16
  import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
17
- import { getCollationEncoder } from './encoding.js';
18
17
  /** Number of mutations before persisting statistics. */
19
18
  const STATS_FLUSH_INTERVAL = 100;
20
19
  /** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
@@ -52,31 +51,200 @@ function resolvePkDefaultConflict(schema) {
52
51
  /**
53
52
  * Resolve the per-column KEY collation for each primary-key column.
54
53
  *
55
- * The store encodes PRIMARY KEY uniqueness/ordering PHYSICALLY in the key bytes,
56
- * so each text PK column's key must be encoded under that column's declared
57
- * collation (BINARY / NOCASE / RTRIM the registered encoders). Returns one
58
- * entry per PK member, in `pkDef` order:
59
- * - text member → its declared `collation` (normalized upper-case), or
54
+ * The store encodes PRIMARY KEY uniqueness/ordering PHYSICALLY in the key bytes, so each
55
+ * text-capable PK column's key must be encoded under the collation the engine actually
56
+ * COMPARES that column under. Returns one entry per PK member, in `pkDef` order:
57
+ * - `isTextual` member (`text`) its declared `collation` (normalized upper-case), or
60
58
  * `fallback` (the table key collation K) when the column carries none.
61
- * - non-text member `undefined`: collation is meaningless for
59
+ * - text-capable but not `isTextual` member `any`, `json`, and the temporal types
60
+ * (`date` / `time` / `datetime` / `timespan`) → hard-coded `'BINARY'`, see below.
61
+ * - never-text member → `undefined`: collation is meaningless for
62
62
  * integer/real/blob keys (they encode type-natively), so the encoder ignores
63
63
  * it and the data/index key bytes are identical regardless.
64
64
  *
65
- * A custom comparator-only collation with no registered byte encoder still maps
66
- * to NOCASE bytes inside `encodeText` (its `?? NOCASE_ENCODER` fallback); that
67
- * residual is the same one documented for store UNIQUE enforcement and is out of
68
- * scope here. Shared by {@link StoreTable} (data-key + index-maintenance) and
65
+ * Note that `undefined` does NOT mean "encode type-natively" to `encodeValue` — it means
66
+ * "fall back to `options.collation`", the table key collation K. Only a never-text column
67
+ * may be left `undefined`, because only it never reaches the collation branch.
68
+ *
69
+ * Every name returned here is encoded through the key-normalizer resolver carried
70
+ * in `EncodeOptions.normalizers` (`db.getKeyNormalizerResolver()`), so a collation
71
+ * registered or overridden with `db.registerCollation` produces key bytes that agree
72
+ * with the comparator the store's UNIQUE enforcement uses. A collation that cannot
73
+ * key — unregistered, or registered with a comparator but no normalizer — is rejected
74
+ * at DDL time by {@link StoreTable.validateKeyCollations}, so no encode call ever has
75
+ * to fall back. Shared by {@link StoreTable} (data-key + index-maintenance) and
69
76
  * `StoreModule.buildIndexEntries` (index rebuild) so the PK suffix encoding can
70
77
  * never drift between the two.
71
78
  */
72
79
  export function resolvePkKeyCollations(pkDef, columns, fallback) {
73
80
  return pkDef.map(def => {
74
81
  const col = columns[def.index];
75
- if (!col || !col.logicalType.isTextual)
76
- return undefined;
77
- return (col.collation || fallback).toUpperCase();
82
+ // Delegates the isTextual/text-capable/never-text branch to the engine's
83
+ // `pkKeyCollationName` — the same decision `quereus-isolation`'s modified-PK set
84
+ // makes for its own key normalizers, so the two can never drift apart again. Only
85
+ // the fallback-to-K and uppercase-normalization are store-specific.
86
+ const name = pkKeyCollationName(col);
87
+ return name === undefined ? undefined : (name || fallback).toUpperCase();
78
88
  });
79
89
  }
90
+ /**
91
+ * True when `col` can produce a TEXT value at runtime, and so when its physical
92
+ * key bytes are produced by a collation normalizer rather than a type-native
93
+ * encoding. The `ColumnSchema`-shaped wrapper over the engine's
94
+ * {@link logicalTypeCanHoldText}; both collation-safety guards over the store's
95
+ * secondary indexes — the write-side {@link StoreTable.indexSeekHonorsEnforcementCollation}
96
+ * and the read-side `StoreModule.tryIndexAccessPlan` — exempt a never-text column
97
+ * from their K-vs-C comparison, so a false "non-text" answer is a silent
98
+ * wrong-result (a seek under the wrong collation, with the residual dropped).
99
+ */
100
+ export function columnCanHoldText(col) {
101
+ return logicalTypeCanHoldText(col?.logicalType);
102
+ }
103
+ /**
104
+ * True when a byte window — or a byte-order advertisement — over `column` is SOUND.
105
+ *
106
+ * The store physically orders rows by memcmp of each column's key bytes, which for a
107
+ * text-capable column are produced by `keyCollation`'s normalizer. A range seek (and the
108
+ * `providesOrdering` / `monotonicOn` advertisement) equates that byte order with the order
109
+ * `compareCollation`'s comparator defines. Two things must hold for that equation:
110
+ *
111
+ * - **Same collation on both sides** (`compareCollation === keyCollation`). A `keyCollation`
112
+ * merely COARSER than `compareCollation` — the relaxation the EQUALITY seek is allowed to
113
+ * make, see `StoreModule.tryIndexAccessPlan` — is not enough here: it would need the key
114
+ * normalizer to be monotone with respect to the OTHER collation's order. It generally is
115
+ * not, even for built-ins: with `keyCollation = NOCASE` and `compareCollation = BINARY`,
116
+ * 'K' (U+212A KELVIN SIGN) is `> 'z'` under BINARY, yet its key bytes are
117
+ * `toLowerCase('K') = 'k'`, which sorts BEFORE 'z' — the row falls outside a `> 'z'`
118
+ * window and is silently dropped.
119
+ * - **The normalizer preserves order** (`db._isCollationOrderPreserving`). `registerCollation`
120
+ * promises only that a normalizer partitions strings the way the comparator calls them
121
+ * equal; a custom pair may agree on equality and disagree on order.
122
+ *
123
+ * A never-text column is exempt: its key bytes are type-native and collation-independent.
124
+ *
125
+ * Returning `false` costs an optimization (the caller full-scans and lets the
126
+ * collation-aware `matchesFilters` residual decide); returning `true` wrongly is a silent
127
+ * wrong-result. Shared by `StoreTable`'s read arms and `StoreModule`'s access planner so the
128
+ * "mark handled" and "build a window" decisions can never disagree.
129
+ */
130
+ export function keyOrderMatchesCollation(db, column, keyCollation, compareCollation) {
131
+ if (!columnCanHoldText(column))
132
+ return true;
133
+ if (compareCollation.toUpperCase() !== keyCollation.toUpperCase())
134
+ return false;
135
+ return db._isCollationOrderPreserving(keyCollation);
136
+ }
137
+ /**
138
+ * How many LEADING primary-key members have key bytes whose memcmp order matches their
139
+ * declared collation's comparator order, per {@link keyOrderMatchesCollation}.
140
+ *
141
+ * `pkKeyCollations` is {@link resolvePkKeyCollations}' output for this table; an `undefined`
142
+ * entry belongs to a never-text member, which {@link keyOrderMatchesCollation} exempts
143
+ * outright — `tableKeyCollation` is passed only so the exempt branch has a name to ignore.
144
+ * The comparison collation is the column's declared one, which `reconcilePkCollations` has
145
+ * already aligned with the key collation for every *textual* PK member.
146
+ *
147
+ * 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.
155
+ *
156
+ * `0` ⇒ even the leading member is unsafe: no range seek, no PK-order advertisement.
157
+ * A prefix shorter than the PK truncates the ordering advertisement rather than voiding it.
158
+ */
159
+ export function pkOrderPreservingPrefixLength(db, schema, pkKeyCollations, tableKeyCollation) {
160
+ const pk = schema.primaryKeyDefinition;
161
+ let n = 0;
162
+ while (n < pk.length) {
163
+ const col = schema.columns[pk[n].index];
164
+ const keyCollation = pkKeyCollations[n] ?? tableKeyCollation;
165
+ // NOTE: an explicit `k any collate nocase` PK declines its seek here — the key
166
+ // collation is BINARY (what ANY_TYPE.compare actually uses) while `col.collation` is
167
+ // the declared-but-ignored NOCASE, so the two sides differ and the gate closes. Both
168
+ // sides genuinely compare under BINARY, so the decline is conservative: it costs the
169
+ // seek, never a row. If that declaration ever shows up in practice, resolve the
170
+ // comparison collation the way `resolvePkKeyCollations` resolves the key collation
171
+ // rather than widening the gate.
172
+ if (!keyOrderMatchesCollation(db, col, keyCollation, col?.collation ?? 'BINARY'))
173
+ break;
174
+ n++;
175
+ }
176
+ return n;
177
+ }
178
+ /**
179
+ * Deterministic name of the implicit covering index materialized for a
180
+ * NON-DERIVED UNIQUE constraint `uc`: the constraint's own name when it has one,
181
+ * else the auto-name `_uc_<colNames>`. This MUST equal the engine's
182
+ * `catalog.ts` `implicitIndexName`, so a name that ever reaches the catalog
183
+ * bundle generator is recognized as hidden / exposed-implicit rather than a user
184
+ * index.
185
+ */
186
+ export function implicitUniqueIndexName(schema, uc) {
187
+ return uc.name ?? `_uc_${uc.columns.map(i => schema.columns[i]?.name ?? String(i)).join('_')}`;
188
+ }
189
+ /**
190
+ * Return `schema` with a synthetic secondary index materialized into
191
+ * `schema.indexes` for every NON-DERIVED UNIQUE constraint that lacks one — the
192
+ * store analogue of `MemoryTableManager.ensureUniqueConstraintIndexes`.
193
+ *
194
+ * This is what lets a plain column- or table-level `UNIQUE` be enforced by a
195
+ * bounded index point-seek ({@link StoreTable.findUniqueConflictViaIndex})
196
+ * instead of an O(rows) full scan: DML maintenance
197
+ * ({@link StoreTable.updateSecondaryIndexes}) and the UNIQUE check both iterate
198
+ * `schema.indexes`, so once the `_uc_*` entry is present the physical index is
199
+ * maintained and seeked with no new code path.
200
+ *
201
+ * The result is held ONLY in {@link StoreTable.materializedSchema} (the
202
+ * enforcement copy), never in the engine-facing `tableSchema` the store returns
203
+ * from `getSchema()` / registers with the engine. So the read-query planner and
204
+ * the catalog bundle never see `_uc_*` (it is fully derived on open from
205
+ * `uniqueConstraints`); like the memory backend, a plain UNIQUE gets no read-side
206
+ * plan from `_uc_*`, only enforcement / maintenance.
207
+ *
208
+ * Idempotent — a name already present is left untouched — and returns a frozen
209
+ * schema (or the input unchanged when there is nothing to add). A
210
+ * `derivedFromIndex` UC is skipped (its `CREATE UNIQUE INDEX` is already in
211
+ * `schema.indexes`). A partial UNIQUE's synthetic index carries the SAME
212
+ * `uc.predicate` object reference, which
213
+ * {@link StoreTable.findIndexForUniqueConstraint} matches on identity.
214
+ */
215
+ export function withImplicitUniqueIndexes(schema) {
216
+ const ucs = schema.uniqueConstraints;
217
+ if (!ucs || ucs.length === 0)
218
+ return schema;
219
+ const existing = schema.indexes ?? [];
220
+ const present = new Set(existing.map(idx => idx.name.toLowerCase()));
221
+ const additions = [];
222
+ for (const uc of ucs) {
223
+ if (uc.derivedFromIndex)
224
+ continue;
225
+ const name = implicitUniqueIndexName(schema, uc);
226
+ if (present.has(name.toLowerCase()))
227
+ continue;
228
+ present.add(name.toLowerCase());
229
+ additions.push({
230
+ name,
231
+ columns: uc.columns.map(idx => ({ index: idx, collation: schema.columns[idx]?.collation })),
232
+ // SAME reference as `uc.predicate` (undefined for a full UNIQUE) — load-bearing:
233
+ // findIndexForUniqueConstraint admits a partial `_uc_*` by `ix.predicate === uc.predicate`.
234
+ predicate: uc.predicate,
235
+ });
236
+ }
237
+ if (additions.length === 0)
238
+ return schema;
239
+ return Object.freeze({ ...schema, indexes: Object.freeze([...existing, ...additions]) });
240
+ }
241
+ /**
242
+ * Returned by {@link StoreTable.findUniqueConflictViaIndex} when the index
243
+ * cannot soundly answer the check and the caller must fall back to the full
244
+ * data-store scan. Distinct from `null`, which means "the index answered: no
245
+ * conflict".
246
+ */
247
+ const INDEX_UNUSABLE = Symbol('store.uniqueIndexUnusable');
80
248
  /**
81
249
  * Generic KVStore-backed virtual table.
82
250
  *
@@ -111,6 +279,22 @@ export class StoreTable extends VirtualTable {
111
279
  * {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
112
280
  */
113
281
  pkKeyCollations;
282
+ /**
283
+ * `db.getCollationResolver()`, bound once at construction. Every collation-aware
284
+ * VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
285
+ * detection — resolves names through it, so a collation registered with
286
+ * `db.registerCollation` on *this* connection is honored rather than silently
287
+ * degraded to BINARY by the process-global built-in registry.
288
+ *
289
+ * Only the resolver closure is cached: it reads the live registry, so a collation
290
+ * registered after connect is still visible. Resolved *functions* are hoisted no
291
+ * further than the comparator or constraint check that resolved them.
292
+ *
293
+ * Its key-bytes counterpart, `db.getKeyNormalizerResolver()`, is bound into
294
+ * {@link encodeOptions} so the names in {@link pkKeyCollations} resolve to the same
295
+ * per-connection registry.
296
+ */
297
+ collationResolver;
114
298
  ddlSaved = false;
115
299
  // Statistics tracking
116
300
  cachedStats = null;
@@ -127,31 +311,126 @@ export class StoreTable extends VirtualTable {
127
311
  // object, so the WeakMap reclaims retired entries). Mirrors predicateCache but
128
312
  // for secondary-index maintenance rather than UNIQUE enforcement.
129
313
  indexPredicateCache = new WeakMap();
314
+ /**
315
+ * {@link tableSchema} with a hidden `_uc_*` secondary index materialized per
316
+ * non-derived UNIQUE constraint (see {@link withImplicitUniqueIndexes}) — the
317
+ * schema that drives UNIQUE ENFORCEMENT ({@link findIndexForUniqueConstraint})
318
+ * and physical index MAINTENANCE ({@link updateSecondaryIndexes}).
319
+ *
320
+ * Deliberately SEPARATE from the engine-facing {@link tableSchema}: the engine
321
+ * reads `tableInstance.tableSchema` at CREATE (`SchemaManager.finalizeCreatedTableSchema`)
322
+ * and the read-query planner reads the registered schema, and NEITHER may see
323
+ * `_uc_*` (mirrors the memory backend keeping its materialized schema
324
+ * manager-local; a plain UNIQUE gets no read-side plan from `_uc_*`, only
325
+ * enforcement). Recomputed alongside `tableSchema` in the constructor and
326
+ * {@link updateSchema}.
327
+ */
328
+ materializedSchema;
130
329
  constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
131
330
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
331
  super(db, storeModule, tableSchema.schemaName, tableSchema.name);
133
332
  this.storeModule = storeModule;
333
+ // The engine-facing schema stays NON-materialized (the engine registers it and the
334
+ // read-query planner reads it — neither may see `_uc_*`); the hidden `_uc_*`
335
+ // per-UNIQUE index that lets plain UNIQUE enforce via a point-seek lives only in
336
+ // {@link materializedSchema}.
134
337
  this.tableSchema = tableSchema;
338
+ this.materializedSchema = withImplicitUniqueIndexes(tableSchema);
135
339
  this.config = config;
136
340
  this.eventEmitter = eventEmitter;
137
- this.encodeOptions = { collation: config.collation || 'NOCASE' };
341
+ this.collationResolver = db.getCollationResolver();
342
+ this.encodeOptions = {
343
+ collation: config.collation || 'NOCASE',
344
+ normalizers: db.getKeyNormalizerResolver(),
345
+ };
138
346
  this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
139
347
  this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
348
+ // Validate the MATERIALIZED schema, so a `_uc_*` over a text column that needs the
349
+ // table key collation K is rejected up front just like an explicit index would be.
350
+ this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
140
351
  this.ddlSaved = isConnected;
141
352
  }
353
+ /**
354
+ * Reject, at DDL time, any collation this table's key encoding would need but cannot
355
+ * use — before a single row is written under bytes the connection cannot reproduce.
356
+ *
357
+ * A collation keys a persisted structure only if it carries a KEY NORMALIZER (the
358
+ * `(s) => string` whose output equality partitions strings exactly as its comparator
359
+ * does). `db.registerCollation(name, cmp)` with no `{ normalizer }` gives a collation
360
+ * that can order rows but not key them; an unregistered name gives nothing at all.
361
+ * Both raise here rather than at the first insert.
362
+ *
363
+ * Checked over exactly the collations the key encoding actually uses:
364
+ * - every defined `pkKeyCollations` entry (text-capable PK columns);
365
+ * - the table key collation K, but only when it is reachable — a secondary index
366
+ * encodes a TEXT-CAPABLE index column's bytes under K (`buildIndexKey`).
367
+ *
368
+ * So neither a table with an integer PK and no secondary index, nor one whose every
369
+ * index column is type-natively keyed, is made unopenable by a K it never encodes with.
370
+ *
371
+ * Blast radius: this also fires on catalog rehydration. Reopening a persisted database
372
+ * from a connection that has not re-registered its custom collation now throws at
373
+ * CREATE-TABLE-from-catalog rather than silently reading rows under a key layout it
374
+ * cannot reproduce. See `docs/plugins.md`.
375
+ *
376
+ * Takes `pkKeyCollations` rather than reading the field so `updateSchema` can validate
377
+ * the incoming schema BEFORE it overwrites its own state with it.
378
+ */
379
+ validateKeyCollations(schema, pkKeyCollations) {
380
+ const names = new Set();
381
+ for (const collation of pkKeyCollations) {
382
+ if (collation !== undefined)
383
+ names.add(collation);
384
+ }
385
+ const indexKeysText = (schema.indexes ?? []).some(index => index.columns.some(col => columnCanHoldText(schema.columns[col.index])));
386
+ if (indexKeysText) {
387
+ names.add((this.encodeOptions.collation ?? 'NOCASE').toUpperCase());
388
+ }
389
+ const dbInternal = this.db;
390
+ for (const name of names) {
391
+ if (dbInternal._getCollationNormalizer(name))
392
+ continue;
393
+ // Unregistered names raise `no such collation sequence: X` from the resolver;
394
+ // reaching past it means the collation exists but is comparator-only.
395
+ this.collationResolver(name);
396
+ throw new QuereusError(`collation ${name} cannot key a persisted structure: no key normalizer registered `
397
+ + `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
398
+ }
399
+ }
142
400
  /** Get the table configuration. */
143
401
  getConfig() {
144
402
  return this.config;
145
403
  }
146
- /** Get the table schema. */
404
+ /** Get the table schema (engine-facing, WITHOUT the hidden `_uc_*` indexes). */
147
405
  getSchema() {
148
406
  return this.tableSchema;
149
407
  }
150
- /** Update the table schema after an ALTER TABLE operation. */
408
+ /**
409
+ * Get the enforcement schema — {@link getSchema} plus the hidden `_uc_*`
410
+ * index materialized per non-derived UNIQUE. Used by the module to resolve the
411
+ * physical index store to build/tear down during ALTER; never registered with
412
+ * the engine.
413
+ */
414
+ getMaterializedSchema() {
415
+ return this.materializedSchema;
416
+ }
417
+ /**
418
+ * Update the table schema after an ALTER TABLE / CREATE INDEX operation.
419
+ *
420
+ * Validates the incoming schema's key collations before adopting any of it, so a
421
+ * rejection leaves this table on its previous, consistent schema.
422
+ */
151
423
  updateSchema(newSchema) {
424
+ // `newSchema` is the engine-facing (non-materialized) schema; recompute the
425
+ // enforcement copy alongside it. Validate the MATERIALIZED copy so a `_uc_*` over a
426
+ // text column that needs the table key collation K is caught before adoption.
427
+ const materialized = withImplicitUniqueIndexes(newSchema);
428
+ const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
429
+ this.validateKeyCollations(materialized, pkKeyCollations);
152
430
  this.tableSchema = newSchema;
431
+ this.materializedSchema = materialized;
153
432
  this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
154
- this.pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
433
+ this.pkKeyCollations = pkKeyCollations;
155
434
  }
156
435
  /**
157
436
  * Mark the table's DDL as already persisted to the catalog, so the lazy
@@ -174,34 +453,59 @@ export class StoreTable extends VirtualTable {
174
453
  catch { /* close is best-effort */ }
175
454
  }
176
455
  /**
177
- * Returns true if the table has at least one stored row. Stops after the first hit.
456
+ * Returns true if the table has at least one LIVE row the open transaction's
457
+ * pending overlay over the committed store. Stops after the first hit.
458
+ *
459
+ * Effective (not committed-only) because its one caller, `ADD COLUMN`'s NOT
460
+ * NULL-without-DEFAULT rejection, must see rows the same transaction inserted:
461
+ * they are migrated too (the arm DDL-commits before `migrateRows`), and a
462
+ * committed-only probe would wave them through only to give them a NULL in a
463
+ * NOT NULL column. Conversely, a pending delete of the last row makes the table
464
+ * legitimately empty. Reading effectively keeps the check throw-only and
465
+ * BEFORE the DDL-commit, so a rejection leaves the transaction intact.
178
466
  */
179
467
  async hasAnyRows() {
180
- const store = await this.ensureStore();
181
- const bounds = buildFullScanBounds();
182
- for await (const _entry of store.iterate(bounds)) {
468
+ for await (const _entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
183
469
  return true;
184
470
  }
185
471
  return false;
186
472
  }
187
473
  /**
188
- * Scan every row, checking whether the column at `colIndex` ever holds NULL.
474
+ * Yield the LIVE value of `colIndex` for every row — the open transaction's
475
+ * pending overlay over the committed store. For throw-only ALTER COLUMN probes
476
+ * that must run BEFORE the arm's DDL-commit (see `StoreModule.ddlCommitPendingOps`):
477
+ * they have to see rows this transaction wrote, because those rows are rewritten
478
+ * too, and a rejection here must leave the transaction intact.
479
+ */
480
+ async *iterateEffectiveValuesAtIndex(colIndex) {
481
+ for await (const entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
482
+ yield deserializeRow(entry.value)[colIndex];
483
+ }
484
+ }
485
+ /**
486
+ * Count the LIVE rows whose column at `colIndex` holds NULL (see
487
+ * {@link iterateEffectiveValuesAtIndex} for why this reads effectively).
189
488
  * Used by ALTER COLUMN SET NOT NULL to decide whether the tightening is safe.
190
489
  */
191
490
  async rowsWithNullAtIndex(colIndex) {
192
- const store = await this.ensureStore();
193
- const bounds = buildFullScanBounds();
194
491
  let count = 0;
195
- for await (const entry of store.iterate(bounds)) {
196
- const row = deserializeRow(entry.value);
197
- if (row[colIndex] === null)
492
+ for await (const value of this.iterateEffectiveValuesAtIndex(colIndex)) {
493
+ if (value === null)
198
494
  count++;
199
495
  }
200
496
  return count;
201
497
  }
202
498
  /**
203
499
  * Apply a per-row mapping function to every stored row, in place (re-writing
204
- * the same key). The mapper may throw QuereusError — propagated to the caller.
500
+ * the same key). The mapper may throw QuereusError — propagated to the caller;
501
+ * the batch is written only after every row maps, so a throw leaves the store
502
+ * untouched.
503
+ *
504
+ * NOTE: reads and writes the COMMITTED store, outside the coordinator. Sound
505
+ * only because every caller calls `StoreModule.ddlCommitPendingOps` first, so
506
+ * "committed" is "everything live". A caller that skips that flush would leave
507
+ * its transaction's pending rows unmapped, and they would replay unconverted
508
+ * over the rewritten store at commit.
205
509
  */
206
510
  async mapRowsAtIndex(colIndex, mapper) {
207
511
  const store = await this.ensureStore();
@@ -222,10 +526,26 @@ export class StoreTable extends VirtualTable {
222
526
  /**
223
527
  * Re-key every stored row under a new primary-key definition.
224
528
  *
225
- * Two-pass: the first pass reads every row and computes the new data keys,
226
- * tracking duplicates. On collision we throw `CONSTRAINT` without touching
227
- * the store. The second pass batches deletes of displaced old keys and puts
228
- * of new (key, row) pairs. Rows whose new key matches the old key are no-ops.
529
+ * Two-pass, signatures-only pass 1: the first pass computes each row's new data
530
+ * key and retains only a `Set` of key SIGNATURES (hex of the key bytes) to detect
531
+ * collisions two distinct old keys collapsing to one new key under a coarser
532
+ * collation or a narrower PK. On collision we throw `CONSTRAINT` without touching
533
+ * the store. The second pass RE-SCANS the same committed store, recomputes each
534
+ * new key, and batches deletes of displaced old keys + puts of new (key, row)
535
+ * pairs into ONE atomic batch. Rows whose new key matches the old key are no-ops.
536
+ *
537
+ * Holding signatures instead of whole rows halves peak memory: the prior design
538
+ * retained the entire table in a map AND again in the batch. The re-scan trades
539
+ * O(rows) CPU (a second iterate + newKey recompute) for not buffering the table
540
+ * twice. Pass 1 and pass 2 iterate the SAME bounds over the SAME committed store
541
+ * and see identical rows: nothing writes between them — we are single-threaded
542
+ * within the ALTER, outside the coordinator, and every caller ran
543
+ * `StoreModule.ddlCommitPendingOps` first so "committed" is "everything live".
544
+ *
545
+ * The final single `batch.write()` is the ONLY thing making the re-key
546
+ * all-or-nothing — do not chunk-flush it. Its residual peak (the batch still holds
547
+ * every changed row) is irreducible without breaking atomicity; tracked separately
548
+ * in `debt-store-atomic-batch-bounded-memory`.
229
549
  *
230
550
  * Only the data store is rewritten — secondary indexes are rebuilt by the
231
551
  * caller (the keys embed the PK suffix, so they must be rebuilt whenever
@@ -244,23 +564,35 @@ export class StoreTable extends VirtualTable {
244
564
  async rekeyRows(newPkDef, newColumns = this.tableSchema.columns) {
245
565
  const store = await this.ensureStore();
246
566
  const bounds = buildFullScanBounds();
247
- const pending = new Map();
248
567
  const newPkDirections = newPkDef.map(pk => !!pk.desc);
249
568
  const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
569
+ // Both passes key rows through this one helper, so a collision judged in pass 1
570
+ // is byte-identical to the key pass 2 writes.
571
+ const computeNewKey = (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations);
572
+ // Pass 1 — collision detection only. Hold one hex signature per new key, never
573
+ // the row or old key. On a repeat, reject before any write; the store is
574
+ // untouched on rejection.
575
+ const seen = new Set();
250
576
  for await (const entry of store.iterate(bounds)) {
251
- const row = deserializeRow(entry.value);
252
- const newPkValues = newPkDef.map(pk => row[pk.index]);
253
- const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections, newPkCollations);
254
- const hex = bytesToHex(newKey);
255
- if (pending.has(hex)) {
577
+ const hex = bytesToHex(computeNewKey(deserializeRow(entry.value)));
578
+ if (seen.has(hex)) {
256
579
  throw new QuereusError(`UNIQUE constraint failed: duplicate primary key on rekey of '${this.schemaName}.${this.tableName}'`, StatusCode.CONSTRAINT);
257
580
  }
258
- pending.set(hex, { newKey, oldKey: entry.key, row });
581
+ seen.add(hex);
259
582
  }
583
+ // Pass 2 — re-scan and build the single atomic batch. Recompute each new key and
584
+ // only rewrite rows whose key actually moves (`newKey !== oldKey`).
260
585
  const batch = store.batch();
261
- for (const { newKey, oldKey, row } of pending.values()) {
262
- if (!bytesEqual(oldKey, newKey)) {
263
- batch.delete(oldKey);
586
+ for await (const entry of store.iterate(bounds)) {
587
+ const row = deserializeRow(entry.value);
588
+ const newKey = computeNewKey(row);
589
+ if (!bytesEqual(entry.key, newKey)) {
590
+ batch.delete(entry.key);
591
+ // NOTE: the row VALUE is unchanged, so `serializeRow(row)` reproduces
592
+ // `entry.value` byte-for-byte. We re-serialize rather than reuse
593
+ // `entry.value` to avoid retaining an iterator-owned buffer in the batch.
594
+ // If re-key CPU ever shows up hot, reuse `entry.value` where the backend
595
+ // guarantees the buffer is not reused across iteration.
264
596
  batch.put(newKey, serializeRow(row));
265
597
  }
266
598
  }
@@ -320,7 +652,7 @@ export class StoreTable extends VirtualTable {
320
652
  async initializeStore() {
321
653
  const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
322
654
  try {
323
- this.store = await this.storeModule.getStore(tableKey, this.config);
655
+ this.store = await this.storeModule.getStore(this.schemaName, this.tableName, this.config);
324
656
  if (!this.store) {
325
657
  throw new Error(`getStore returned null/undefined for ${tableKey}`);
326
658
  }
@@ -471,7 +803,7 @@ export class StoreTable extends VirtualTable {
471
803
  const pkAccess = this.analyzePKAccess(filterInfo);
472
804
  if (pkAccess.type === 'point') {
473
805
  const row = await this.readLiveRowByPk(pkAccess.values);
474
- if (row && this.matchesFilters(row, filterInfo)) {
806
+ if (row && this.matchesFilters(row, filterInfo, this.resolveFilterCollations(filterInfo))) {
475
807
  yield row;
476
808
  }
477
809
  return;
@@ -480,11 +812,22 @@ export class StoreTable extends VirtualTable {
480
812
  yield* this.scanPKRange(store, pkAccess, filterInfo);
481
813
  return;
482
814
  }
815
+ // Secondary-index scan arm — reached only when the predicate did NOT resolve
816
+ // to a PK point/range (PK access is cheaper and already handled above). When
817
+ // the planner chose a secondary index (idxStr carries `idx=<name>(…)`), derive
818
+ // its byte window and iterate it instead of full-scanning.
819
+ const indexAccess = this.analyzeIndexAccess(filterInfo);
820
+ if (indexAccess) {
821
+ const indexStore = await this.ensureIndexStore(indexAccess.index.name);
822
+ yield* this.scanIndex(indexStore, indexAccess, filterInfo);
823
+ return;
824
+ }
483
825
  // Full table scan
826
+ const collations = this.resolveFilterCollations(filterInfo);
484
827
  const bounds = buildFullScanBounds();
485
828
  for await (const entry of this.iterateEffective(store, bounds)) {
486
829
  const row = deserializeRow(entry.value);
487
- if (this.matchesFilters(row, filterInfo)) {
830
+ if (this.matchesFilters(row, filterInfo, collations)) {
488
831
  yield row;
489
832
  }
490
833
  }
@@ -539,6 +882,15 @@ export class StoreTable extends VirtualTable {
539
882
  yield puts[putIdx++];
540
883
  }
541
884
  }
885
+ /**
886
+ * True when a byte window over the LEADING PK column reproduces the comparator's order
887
+ * — the precondition {@link buildPKRangeBounds} needs, and the exact condition
888
+ * `StoreModule.computeBestAccessPlan` uses before claiming the range filters handled.
889
+ * When false, the range arm degrades to a full scan + `matchesFilters` residual.
890
+ */
891
+ leadingPkRangeIsOrderSafe() {
892
+ return pkOrderPreservingPrefixLength(this.db, this.tableSchema, this.pkKeyCollations, this.encodeOptions.collation ?? 'NOCASE') >= 1;
893
+ }
542
894
  /** Analyze filter info to determine PK access pattern. */
543
895
  analyzePKAccess(filterInfo) {
544
896
  const schema = this.tableSchema;
@@ -567,7 +919,10 @@ export class StoreTable extends VirtualTable {
567
919
  const firstPkCol = pkColumns[0];
568
920
  const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
569
921
  const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
570
- if (rangeConstraints.length > 0) {
922
+ // A range window is only sound when the leading PK column's key bytes order the way
923
+ // its comparator does; otherwise fall through to the full scan, where matchesFilters
924
+ // applies the range under the real comparator.
925
+ if (rangeConstraints.length > 0 && this.leadingPkRangeIsOrderSafe()) {
571
926
  return {
572
927
  type: 'range',
573
928
  columnIndex: firstPkCol,
@@ -607,11 +962,16 @@ export class StoreTable extends VirtualTable {
607
962
  * row filter. A NULL/missing bound value is likewise skipped (the planner never
608
963
  * pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
609
964
  *
610
- * Falls back to a full scan when the leading text PK column declares a
611
- * comparator-only collation with no registered byte encoder: `encodeText` would
612
- * silently key it under NOCASE bytes that do not track the column's logical
613
- * order, so a derived window could UNDER-fetch. A non-text leading column has
614
- * `pkKeyCollations[0] === undefined` and encodes type-natively always safe.
965
+ * NOTE: a range window over a text PK column is sound only when the column's key
966
+ * normalizer is ORDER-preserving with respect to its comparator i.e. the comparator
967
+ * orders two strings the way memcmp orders their normalized bytes. That is NOT implied
968
+ * by `db.registerCollation`'s normalizer contract, which promises only an equality
969
+ * partition, and a built-in NAME may be re-registered with a comparator + normalizer
970
+ * pair that preserves equality while inverting order. This method is therefore only
971
+ * ever reached under {@link leadingPkRangeIsOrderSafe} — enforced by
972
+ * {@link analyzePKAccess} here and mirrored by `StoreModule.computeBestAccessPlan`
973
+ * before it claims the range filters handled. A collation without the
974
+ * `orderPreserving` assertion costs the seek, never a row.
615
975
  */
616
976
  buildPKRangeBounds(access) {
617
977
  const full = buildFullScanBounds();
@@ -619,12 +979,6 @@ export class StoreTable extends VirtualTable {
619
979
  if (!constraints || constraints.length === 0)
620
980
  return full;
621
981
  const dir = this.pkDirections[0];
622
- const coll = this.pkKeyCollations[0];
623
- // A comparator-only collation (declared on the column, but with no registered
624
- // byte encoder) is not seekable: its key bytes fall back to NOCASE and do not
625
- // track the column's logical order. Stay full-scan; matchesFilters is authoritative.
626
- if (coll !== undefined && getCollationEncoder(coll) === undefined)
627
- return full;
628
982
  let gte = full.gte;
629
983
  let lt;
630
984
  for (const c of constraints) {
@@ -657,16 +1011,209 @@ export class StoreTable extends VirtualTable {
657
1011
  * merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
658
1012
  */
659
1013
  async *scanPKRange(store, access, filterInfo) {
1014
+ const collations = this.resolveFilterCollations(filterInfo);
660
1015
  const bounds = this.buildPKRangeBounds(access);
661
1016
  for await (const entry of this.iterateEffective(store, bounds)) {
662
1017
  const row = deserializeRow(entry.value);
663
- if (this.matchesFilters(row, filterInfo)) {
1018
+ if (this.matchesFilters(row, filterInfo, collations)) {
1019
+ yield row;
1020
+ }
1021
+ }
1022
+ }
1023
+ /**
1024
+ * Resolve the secondary index chosen by the planner from `filterInfo.idxStr`.
1025
+ *
1026
+ * The planner emits `idx=<name>(<n>);plan=…` when its access plan set both an
1027
+ * `indexName` and `seekColumnIndexes` (see `getBestAccessPlan` and
1028
+ * rule-select-access-path.ts). Decoding goes through the engine's shared
1029
+ * `decodeIdxStr`, so the store, the in-memory vtab, and the isolation overlay all
1030
+ * read one format. Returns null for the PK/scan sentinels (`_primary_`, `fullscan`,
1031
+ * `empty`), an idxStr that names no index, or a name absent from `schema.indexes` —
1032
+ * every one of which routes back to a PK/full-scan arm.
1033
+ */
1034
+ resolveIndexFromIdxStr(idxStr) {
1035
+ const spec = decodeIdxStr(idxStr);
1036
+ if (!spec)
1037
+ return null;
1038
+ const name = spec.indexName;
1039
+ if (!name || name === '_primary_')
1040
+ return null;
1041
+ const indexes = this.tableSchema?.indexes ?? [];
1042
+ return indexes.find(i => i.name.toLowerCase() === name.toLowerCase()) ?? null;
1043
+ }
1044
+ /**
1045
+ * Analyze filter info to determine a secondary-index access pattern, mirroring
1046
+ * {@link analyzePKAccess} but over the index chosen in `idxStr`.
1047
+ *
1048
+ * A contiguous leading-prefix EQ on the index columns yields a `point` window
1049
+ * (the prefix covers every entry sharing those leading values — an index seek
1050
+ * is a PREFIX scan, not a single row, since the index need not be unique and
1051
+ * the PK suffix varies); otherwise a range (LT/LE/GT/GE) on the LEADING index
1052
+ * column yields a `range` window. Returns null when neither applies or when the
1053
+ * index is unresolved. {@link matchesFilters} stays the authoritative row filter,
1054
+ * so the window need only be a SUPERSET.
1055
+ *
1056
+ * Index-column bytes are encoded under the table key collation K (NOT the index's
1057
+ * per-column declared collation — see `buildIndexKey`), while `matchesFilters` compares
1058
+ * under the index column's effective collation C. The EQ/prefix window is a superset
1059
+ * whenever K is coarser-or-equal to C, which `StoreModule.tryIndexAccessPlan` checked
1060
+ * before it named this index. The RANGE window additionally needs byte order to BE
1061
+ * comparator order, so it is gated on {@link keyOrderMatchesCollation} (which demands
1062
+ * `C === K` plus K's `orderPreserving` assertion); when that fails we return null and
1063
+ * the caller full-scans.
1064
+ */
1065
+ analyzeIndexAccess(filterInfo) {
1066
+ const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
1067
+ if (!index)
1068
+ return null;
1069
+ const indexCols = index.columns.map(c => c.index);
1070
+ const indexDirections = index.columns.map(c => !!c.desc);
1071
+ // Contiguous leading-prefix EQ → point/prefix window.
1072
+ const eqValues = [];
1073
+ for (let i = 0; i < indexCols.length; i++) {
1074
+ const eq = filterInfo.constraints?.find(c => c.constraint.iColumn === indexCols[i]
1075
+ && c.constraint.op === IndexConstraintOp.EQ
1076
+ && c.argvIndex > 0);
1077
+ if (!eq)
1078
+ break;
1079
+ eqValues.push(filterInfo.args[eq.argvIndex - 1]);
1080
+ }
1081
+ if (eqValues.length > 0) {
1082
+ const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length));
1083
+ return { index, type: 'point', bounds };
1084
+ }
1085
+ // Else a range on the LEADING index column.
1086
+ const leadingCol = indexCols[0];
1087
+ const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
1088
+ const rangeConstraints = (filterInfo.constraints ?? []).filter(c => c.constraint.iColumn === leadingCol && rangeOps.includes(c.constraint.op));
1089
+ if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index, leadingCol)) {
1090
+ const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
1091
+ op: c.constraint.op,
1092
+ value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
1093
+ })), indexDirections[0]);
1094
+ return { index, type: 'range', bounds };
1095
+ }
1096
+ return null;
1097
+ }
1098
+ /**
1099
+ * True when a byte window over `leadingCol` of `index` reproduces the comparator's
1100
+ * order. Mirrors the range arm of `StoreModule.tryIndexAccessPlan`: the window's bytes
1101
+ * come from the table key collation K, the residual compares under the index column's
1102
+ * effective collation C, so both must be the same order-preserving collation.
1103
+ */
1104
+ indexRangeIsOrderSafe(index, leadingCol) {
1105
+ const schema = this.tableSchema;
1106
+ const K = this.encodeOptions.collation ?? 'NOCASE';
1107
+ const col = schema.columns[leadingCol];
1108
+ const indexCol = index.columns.find(c => c.index === leadingCol);
1109
+ const C = indexCol?.collation ?? col?.collation ?? 'BINARY';
1110
+ return keyOrderMatchesCollation(this.db, col, K, C);
1111
+ }
1112
+ /**
1113
+ * Convert leading-index-column LT/LE/GT/GE constraints into one encoded-byte
1114
+ * `gte`/`lt` window — the secondary-index analogue of {@link buildPKRangeBounds}.
1115
+ *
1116
+ * Each bound value is encoded under {@link encodeOptions} (the table key collation K
1117
+ * and its normalizer resolver) and the leading index column's DESC `dir` — exactly as
1118
+ * {@link buildIndexKey} encodes
1119
+ * that column — via {@link buildIndexPrefixBounds}, giving the byte region
1120
+ * `[lo, hi)` whose leading column equals that value. The op maps that region's
1121
+ * endpoints onto `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path:
1122
+ *
1123
+ * | op | ASC | DESC |
1124
+ * |----|----------|----------|
1125
+ * | GE | gte = lo | lt = hi |
1126
+ * | GT | gte = hi | lt = lo |
1127
+ * | LE | lt = hi | gte = lo |
1128
+ * | LT | lt = lo | gte = hi |
1129
+ *
1130
+ * Across constraints keep the MAX lower and MIN upper. An `undefined` upper (an
1131
+ * `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
1132
+ * SUPERSET. A NULL/missing bound value is skipped (the planner never pushes
1133
+ * `= NULL`, and a range op against NULL rejects every row in matchesFilters).
1134
+ */
1135
+ buildIndexRangeBounds(constraints, dir) {
1136
+ const full = buildFullScanBounds();
1137
+ let gte = full.gte;
1138
+ let lt;
1139
+ for (const c of constraints) {
1140
+ if (c.value === undefined || c.value === null)
1141
+ continue;
1142
+ const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions, [dir]);
1143
+ const lower = !dir
1144
+ ? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
1145
+ : (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
1146
+ const upper = !dir
1147
+ ? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
1148
+ : (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
1149
+ if (lower && compareBytes(lower, gte) > 0)
1150
+ gte = lower;
1151
+ if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
1152
+ lt = upper;
1153
+ }
1154
+ return lt === undefined ? { gte } : { gte, lt };
1155
+ }
1156
+ /**
1157
+ * Scan a secondary index over `access.bounds`, resolving each index entry to its
1158
+ * base row and re-filtering.
1159
+ *
1160
+ * {@link iterateEffective} yields the committed index entries merged with this
1161
+ * transaction's pending index puts/deletes (read-your-own-writes over the
1162
+ * index), in index-key byte order — the order the isolation overlay merge relies
1163
+ * on (`isolated-table.ts` § buildSortKey). We resolve each entry to its row via
1164
+ * its stored data-key value WITHOUT reordering, so index-key order is preserved.
1165
+ *
1166
+ * Defense in depth mirroring the memory layer's live-recheck: a resolved-null
1167
+ * row (the entry's row was deleted — a pending index delete would normally
1168
+ * suppress the entry, but a committed entry can lag) is skipped, and every
1169
+ * resolved row is re-checked by {@link matchesFilters} (the byte window is only
1170
+ * a superset, and a stale entry whose indexed column no longer matches is
1171
+ * dropped).
1172
+ */
1173
+ async *scanIndex(indexStore, access, filterInfo) {
1174
+ // Re-check each resolved row under the INDEX's per-column collation (see
1175
+ // matchesFilters): the planner dropped the residual based on the index
1176
+ // column's collation, which an explicit index `COLLATE` can make differ from
1177
+ // the table column's declared collation.
1178
+ const indexCollations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
1179
+ for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
1180
+ // NOTE: a legacy index store (written before index values carried the data
1181
+ // key) holds EMPTY values; a zero-length data key is not a row key, so skip
1182
+ // it rather than resolve it to the wrong row. Because the access plan marked
1183
+ // the filter handled and dropped the residual, an indexed query over such a
1184
+ // store returns NOTHING rather than the matching rows — a silent wrong
1185
+ // result, not an error. Backwards compatibility is waived project-wide
1186
+ // (AGENTS.md) and no test provider carries on-disk data, so nothing exercises
1187
+ // this today. If real persisted stores predating this format come into play,
1188
+ // their indexes must be dropped + recreated (or the table rebuilt); the
1189
+ // durable fix is to version-stamp the index store and rebuild on open, or to
1190
+ // fall back to a full scan the first time an empty value is seen.
1191
+ if (entry.value.length === 0)
1192
+ continue;
1193
+ // NOTE: one extra data-store `get` per matched index entry — the row lives
1194
+ // in the data store, not the index (the index value carries only the data
1195
+ // key, no covering payload). Fine now; if index-covered scans ever dominate
1196
+ // a profile, consider storing the serialized row as a covering index value,
1197
+ // at the cost of an index rewrite on EVERY column change (not just indexed
1198
+ // columns) — deliberately not done here.
1199
+ const row = await this.readEffectiveRowByKey(entry.value);
1200
+ if (row && this.matchesFilters(row, filterInfo, indexCollations)) {
664
1201
  yield row;
665
1202
  }
666
1203
  }
667
1204
  }
668
- /** Check if a row matches the filter constraints. */
669
- matchesFilters(row, filterInfo) {
1205
+ /**
1206
+ * Check if a row matches the filter constraints.
1207
+ *
1208
+ * `collations` maps a constrained column index to the comparison function that
1209
+ * column must be re-checked under; every caller builds it once per scan with
1210
+ * {@link resolveFilterCollations}, whose doc comment explains how the name is
1211
+ * chosen. A column absent from the map compares BINARY — the same result
1212
+ * {@link resolveFilterCollations} produces for an undeclared collation, and the
1213
+ * only reachable absence, since both walk the constraint list under identical
1214
+ * skip conditions.
1215
+ */
1216
+ matchesFilters(row, filterInfo, collations) {
670
1217
  if (!filterInfo.constraints || filterInfo.constraints.length === 0) {
671
1218
  return true;
672
1219
  }
@@ -677,13 +1224,7 @@ export class StoreTable extends VirtualTable {
677
1224
  }
678
1225
  const rowValue = row[constraint.iColumn];
679
1226
  const filterValue = filterInfo.args[argvIndex - 1];
680
- // Compare under the column's DECLARED collation (undefined ⇒ BINARY) the
681
- // same resolution the access path's collation-cover analysis uses
682
- // (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides
683
- // a pushed constraint is fully covered, and the same source this file's
684
- // UNIQUE checks compare under. On a collation MATCH the planner drops the
685
- // residual Filter, so this filter alone must reproduce the predicate.
686
- const collation = this.tableSchema.columns[constraint.iColumn]?.collation;
1227
+ const collation = collations.get(constraint.iColumn) ?? BINARY_COLLATION;
687
1228
  if (!this.compareValues(rowValue, constraint.op, filterValue, collation)) {
688
1229
  return false;
689
1230
  }
@@ -691,19 +1232,75 @@ export class StoreTable extends VirtualTable {
691
1232
  return true;
692
1233
  }
693
1234
  /**
694
- * Compare two values according to an operator, under `collation` (the column's
695
- * declared collation; undefined BINARY). Delegates to the engine's
696
- * `compareSqlValues`, so the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM
697
- * column collation rather than a raw BINARY JS comparison — the capability
1235
+ * Resolve, ONCE per scan, the comparison collation function for every pushed
1236
+ * constraint column, keyed by column index.
1237
+ *
1238
+ * The name for a column is its DECLARED collation (absent BINARY) — the same
1239
+ * resolution the access path's collation-cover analysis uses
1240
+ * (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides a
1241
+ * pushed constraint is fully covered, and the same source this file's UNIQUE
1242
+ * checks compare under. On a collation MATCH the planner drops the residual
1243
+ * Filter, so {@link matchesFilters} alone must reproduce the predicate.
1244
+ *
1245
+ * `indexCollationNames` (from {@link indexColumnCollations}) overrides the
1246
+ * declared name for the secondary-index scan arm: the planner's MATCH there is
1247
+ * against the INDEX column's collation, which an explicit `COLLATE` on the index
1248
+ * can make differ from the table column's.
1249
+ *
1250
+ * Names resolve against {@link collationResolver}, so an unregistered collation
1251
+ * raises `no such collation sequence` at scan setup rather than byte-ordering
1252
+ * every row.
1253
+ *
1254
+ * NOTE: rebuilt on every `query()` / `scanPKRange()` / `scanIndex()` call — one
1255
+ * registry lookup per distinct constrained column, dwarfed by the scan's I/O. If a
1256
+ * point-lookup-heavy profile ever shows it, memoize on the `FilterInfo`.
1257
+ */
1258
+ resolveFilterCollations(filterInfo, indexCollationNames) {
1259
+ const resolved = new Map();
1260
+ if (!filterInfo.constraints)
1261
+ return resolved;
1262
+ for (const { constraint, argvIndex } of filterInfo.constraints) {
1263
+ if (constraint.iColumn < 0 || argvIndex <= 0)
1264
+ continue;
1265
+ if (resolved.has(constraint.iColumn))
1266
+ continue;
1267
+ const name = indexCollationNames?.has(constraint.iColumn)
1268
+ ? indexCollationNames.get(constraint.iColumn)
1269
+ : this.tableSchema.columns[constraint.iColumn]?.collation;
1270
+ resolved.set(constraint.iColumn, name ? this.collationResolver(name) : BINARY_COLLATION);
1271
+ }
1272
+ return resolved;
1273
+ }
1274
+ /**
1275
+ * Effective per-column comparison collation for a secondary index's columns:
1276
+ * the index column's own `COLLATE` when present, else the underlying table
1277
+ * column's declared collation. Mirrors the resolution `StoreModule`'s
1278
+ * index-maintenance UNIQUE dedup uses (`indexCol.collation ?? tableColumn`), so
1279
+ * a re-checked index-scan row compares under the same collation the planner used
1280
+ * to justify dropping (or keeping) the residual Filter.
1281
+ */
1282
+ indexColumnCollations(index) {
1283
+ const cols = this.tableSchema.columns;
1284
+ const map = new Map();
1285
+ for (const c of index.columns) {
1286
+ map.set(c.index, c.collation ?? cols[c.index]?.collation);
1287
+ }
1288
+ return map;
1289
+ }
1290
+ /**
1291
+ * Compare two values according to an operator, under `collationFunc` (already
1292
+ * resolved by {@link resolveFilterCollations} against this database's collation
1293
+ * registry). So the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM/custom column
1294
+ * collation rather than a raw BINARY JS comparison — the capability
698
1295
  * `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
699
1296
  * NULL on either side fails every operator except EQ-with-both-NULL (the
700
1297
  * internal point-lookup convention; the planner never pushes `= NULL`).
701
1298
  */
702
- compareValues(a, op, b, collation) {
1299
+ compareValues(a, op, b, collationFunc) {
703
1300
  if (a === null || b === null) {
704
1301
  return op === IndexConstraintOp.EQ ? a === b : false;
705
1302
  }
706
- const cmp = compareSqlValues(a, b, collation);
1303
+ const cmp = compareSqlValuesFast(a, b, collationFunc);
707
1304
  switch (op) {
708
1305
  case IndexConstraintOp.EQ: return cmp === 0;
709
1306
  case IndexConstraintOp.NE: return cmp !== 0;
@@ -1010,8 +1607,9 @@ export class StoreTable extends VirtualTable {
1010
1607
  * sides leaks the old entry. Other paths pass the same pk for both.
1011
1608
  */
1012
1609
  async updateSecondaryIndexes(inTransaction, oldRow, newRow, oldPk, newPk = oldPk) {
1013
- const schema = this.tableSchema;
1014
- const indexes = schema.indexes || [];
1610
+ // Maintain the MATERIALIZED index set: the hidden `_uc_*` per-UNIQUE index must be
1611
+ // kept in step with DML so the enforcement seek finds every live row.
1612
+ const indexes = this.materializedSchema.indexes || [];
1015
1613
  for (const index of indexes) {
1016
1614
  const indexStore = await this.ensureIndexStore(index.name);
1017
1615
  const indexCols = index.columns.map(c => c.index);
@@ -1038,13 +1636,20 @@ export class StoreTable extends VirtualTable {
1038
1636
  if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
1039
1637
  const newIndexValues = indexCols.map(i => newRow[i]);
1040
1638
  const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
1041
- // Index value is empty - we just need the key for lookups
1042
- const emptyValue = new Uint8Array(0);
1639
+ // Index value = the row's encoded DATA key. The index-entry key can
1640
+ // locate a row's byte window, but its PK suffix is not losslessly
1641
+ // recoverable to SqlValues (a NOCASE/RTRIM PK column encodes lossily)
1642
+ // and its length varies per entry in a range scan — so a scan resolves
1643
+ // each entry back to its base row via this stored data key
1644
+ // (`scanIndex` → `readEffectiveRowByKey(entry.value)`), never by
1645
+ // decoding the suffix. `newPk` is the PK the entry is keyed under, so
1646
+ // its data key byte-matches the data store's key for this row.
1647
+ const dataKeyValue = this.encodeDataKey(newPk);
1043
1648
  if (inTransaction && this.coordinator) {
1044
- this.coordinator.put(newIndexKey, emptyValue, indexStore);
1649
+ this.coordinator.put(newIndexKey, dataKeyValue, indexStore);
1045
1650
  }
1046
1651
  else {
1047
- await indexStore.put(newIndexKey, emptyValue);
1652
+ await indexStore.put(newIndexKey, dataKeyValue);
1048
1653
  }
1049
1654
  }
1050
1655
  }
@@ -1059,7 +1664,7 @@ export class StoreTable extends VirtualTable {
1059
1664
  return undefined;
1060
1665
  let compiled = this.predicateCache.get(uc);
1061
1666
  if (!compiled) {
1062
- compiled = compilePredicate(uc.predicate, this.tableSchema.columns);
1667
+ compiled = compilePredicate(uc.predicate, this.tableSchema.columns, this.tableSchema.name);
1063
1668
  this.predicateCache.set(uc, compiled);
1064
1669
  }
1065
1670
  return compiled;
@@ -1074,7 +1679,7 @@ export class StoreTable extends VirtualTable {
1074
1679
  return undefined;
1075
1680
  let compiled = this.indexPredicateCache.get(index);
1076
1681
  if (!compiled) {
1077
- compiled = compilePredicate(index.predicate, this.tableSchema.columns);
1682
+ compiled = compilePredicate(index.predicate, this.tableSchema.columns, this.tableSchema.name);
1078
1683
  this.indexPredicateCache.set(index, compiled);
1079
1684
  }
1080
1685
  return compiled;
@@ -1146,15 +1751,7 @@ export class StoreTable extends VirtualTable {
1146
1751
  const predicate = this.compileFor(uc);
1147
1752
  if (predicate && predicate.evaluate(newRow) !== true)
1148
1753
  continue;
1149
- // Prefer a linked row-time covering MV: its backing table (hosted by any
1150
- // backing-host-capable module — memory by default, this store module under
1151
- // `using store` — queried through the db with reads-own-writes) answers
1152
- // the uniqueness question, mirroring the memory enforcement path. Falls
1153
- // back to the per-scan source search when no row-time covering MV exists.
1154
- const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
1155
- const conflict = coveringMv
1156
- ? await this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks)
1157
- : await this.findUniqueConflict(uc, predicate, newRow, selfPks);
1754
+ const conflict = await this.findUniqueConflictFor(uc, predicate, newRow, selfPks);
1158
1755
  if (!conflict)
1159
1756
  continue;
1160
1757
  // Resolve action per-constraint: statement OR > per-UC default > ABORT.
@@ -1183,6 +1780,178 @@ export class StoreTable extends VirtualTable {
1183
1780
  }
1184
1781
  return null;
1185
1782
  }
1783
+ /**
1784
+ * Route one UNIQUE constraint's conflict search to the cheapest SOUND finder,
1785
+ * in descending preference:
1786
+ *
1787
+ * 1. A linked row-time covering MV — its backing table (hosted by any
1788
+ * backing-host-capable module — memory by default, this store module under
1789
+ * `using store` — queried through the db with reads-own-writes) answers the
1790
+ * uniqueness question, mirroring the memory enforcement path.
1791
+ * 2. A physical secondary index realizing the constraint — one prefix seek
1792
+ * instead of a full table scan (see {@link findIndexForUniqueConstraint}).
1793
+ * 3. The full data-store scan ({@link findUniqueConflict}) — always correct,
1794
+ * O(rows) per checked row.
1795
+ *
1796
+ * Every finder returns the SAME `{pk, row}` shape, so the caller's conflict
1797
+ * action (ABORT / IGNORE / REPLACE eviction) is finder-independent.
1798
+ */
1799
+ async findUniqueConflictFor(uc, predicate, newRow, selfPks) {
1800
+ const schema = this.tableSchema;
1801
+ const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
1802
+ if (coveringMv)
1803
+ return this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks);
1804
+ const index = this.findIndexForUniqueConstraint(uc);
1805
+ if (index) {
1806
+ const viaIndex = await this.findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks);
1807
+ if (viaIndex !== INDEX_UNUSABLE)
1808
+ return viaIndex;
1809
+ }
1810
+ return this.findUniqueConflict(uc, predicate, newRow, selfPks);
1811
+ }
1812
+ /**
1813
+ * The `schema.indexes` entry whose physical index store can serve `uc`'s
1814
+ * conflict search as a point seek, or undefined when none can.
1815
+ *
1816
+ * Every non-derived UNIQUE now carries a materialized implicit index
1817
+ * (`_uc_*`, see {@link withImplicitUniqueIndexes}), so — like the memory
1818
+ * backend — a plain column- or table-level `UNIQUE` is index-servable, not
1819
+ * only an explicit `CREATE INDEX`. A UC is index-servable when:
1820
+ *
1821
+ * - it is index-derived (`derivedFromIndex`, from `CREATE UNIQUE INDEX`) and
1822
+ * its named index is still present — the index's partial predicate then
1823
+ * equals the constraint's by construction (`appendIndexToTableSchema`); or
1824
+ * - some index's columns equal `uc.columns` positionally AND whose predicate
1825
+ * is the SAME object as `uc.predicate` (reference identity — the implicit
1826
+ * materializer sets it that way). For a FULL UNIQUE that is
1827
+ * `undefined === undefined`, which also admits any user full index over the
1828
+ * same columns. For a PARTIAL `unique(...) where p` it admits only the
1829
+ * co-scoped `_uc_*` (which holds exactly the in-scope rows); an arbitrary
1830
+ * user partial index with a DIFFERENT predicate object stays conservative
1831
+ * (declined → full scan), because it physically omits rows the constraint
1832
+ * still covers. The serving index need not be UNIQUE — a plain index over
1833
+ * the constrained columns still holds every in-scope row.
1834
+ *
1835
+ * Collation guard (see {@link indexSeekHonorsEnforcementCollation}) may still
1836
+ * reject the found index, which routes the check back to the full scan.
1837
+ */
1838
+ findIndexForUniqueConstraint(uc) {
1839
+ // NOTE: re-resolved for every constrained ROW written, and the collation guard
1840
+ // below re-derives `uniqueEnforcementCollations` each time. Both are linear in
1841
+ // `schema.indexes` / `uc.columns` and dwarfed by the seek's I/O, so this is fine
1842
+ // now. If a table with many indexes ever shows up on an insert-heavy profile,
1843
+ // memoize the (uc → index | undefined) resolution in a WeakMap keyed on the
1844
+ // frozen UniqueConstraintSchema, as `predicateCache` above does — a CREATE/DROP
1845
+ // INDEX yields fresh constraint objects, so such a cache invalidates itself.
1846
+ // Reads the MATERIALIZED index set so the hidden `_uc_*` realizing a plain UNIQUE
1847
+ // is visible (the engine-facing `tableSchema` carries only explicit/derived indexes).
1848
+ const indexes = this.materializedSchema.indexes;
1849
+ if (!indexes || indexes.length === 0)
1850
+ return undefined;
1851
+ const index = uc.derivedFromIndex
1852
+ ? indexes.find(ix => ix.name === uc.derivedFromIndex)
1853
+ : indexes.find(ix => ix.predicate === uc.predicate
1854
+ && ix.columns.length === uc.columns.length
1855
+ && ix.columns.every((c, i) => c.index === uc.columns[i]));
1856
+ if (!index)
1857
+ return undefined;
1858
+ return this.indexSeekHonorsEnforcementCollation(uc) ? index : undefined;
1859
+ }
1860
+ /**
1861
+ * True when a point seek into the index realizing `uc` returns a SUPERSET of
1862
+ * the constraint's true conflict set — the only condition under which the
1863
+ * seek may replace the full scan.
1864
+ *
1865
+ * An index key's leading (index-column) bytes are encoded under the TABLE KEY
1866
+ * collation K (`buildIndexKey` passes `this.encodeOptions`), NOT the index's
1867
+ * declared per-column COLLATE and NOT the constraint's enforcement collation
1868
+ * C (`uniqueEnforcementCollations` — the index's per-column COLLATE for an
1869
+ * index-derived UC, else the declared column collation). A seek therefore
1870
+ * fetches exactly `{rows K-equal to newRow}` while the re-validation keeps
1871
+ * `{rows C-equal to newRow}`. Soundness needs
1872
+ * `{C-equal} ⊆ {K-equal}`, i.e. K must be COARSER-OR-EQUAL to C per column:
1873
+ *
1874
+ * - non-text column → its bytes are type-native, collation-independent: safe.
1875
+ * - C == K → the sets coincide: safe.
1876
+ * - K = NOCASE, C = BINARY → K strictly coarser: safe superset.
1877
+ * - otherwise (K = BINARY over C = NOCASE/RTRIM; K = NOCASE over C = RTRIM)
1878
+ * the seek UNDER-fetches and a real duplicate would be silently accepted:
1879
+ * reject, so the caller full-scans.
1880
+ *
1881
+ * Same direction and same admitted cases as the read-side guard in
1882
+ * `StoreModule.tryIndexAccessPlan`; conservative rather than exhaustive
1883
+ * (K = RTRIM over C = BINARY is provably safe but declined), which costs an
1884
+ * optimization, never correctness. The coarseness test is only sound for the
1885
+ * built-in names: a custom K equals a custom C only when the index column names
1886
+ * that same collation, and otherwise falls through to the full scan.
1887
+ */
1888
+ indexSeekHonorsEnforcementCollation(uc) {
1889
+ const schema = this.tableSchema;
1890
+ const K = (this.encodeOptions.collation ?? 'NOCASE').toUpperCase();
1891
+ const collations = uniqueEnforcementCollations(schema, uc);
1892
+ return uc.columns.every((colIdx, i) => {
1893
+ if (!columnCanHoldText(schema.columns[colIdx]))
1894
+ return true;
1895
+ const C = (collations[i] ?? 'BINARY').toUpperCase();
1896
+ return C === K || (K === 'NOCASE' && C === 'BINARY');
1897
+ });
1898
+ }
1899
+ /**
1900
+ * The index analogue of {@link findUniqueConflict}: seek the index realizing
1901
+ * `uc` at the point formed by `newRow`'s constrained-column values, and
1902
+ * re-validate each resolved candidate exactly as the full scan does.
1903
+ *
1904
+ * The seek encodes ALL of `uc.columns` (positionally aligned with the index's
1905
+ * columns, guaranteed by `appendIndexToTableSchema` / the column-set match in
1906
+ * {@link findIndexForUniqueConstraint}) as a leading prefix of the index key,
1907
+ * under the same key collation and per-column DESC directions
1908
+ * {@link updateSecondaryIndexes} used to write it. The remaining suffix is the
1909
+ * row's PK, so the window spans every entry sharing those column values.
1910
+ * {@link iterateEffective} merges this transaction's pending index puts/deletes
1911
+ * over the committed entries, giving read-your-own-writes; each entry resolves
1912
+ * to its LIVE row through the data key stored as the entry's value.
1913
+ *
1914
+ * The seek only narrows the CANDIDATE set to a superset (guaranteed by
1915
+ * {@link indexSeekHonorsEnforcementCollation}); the authoritative comparison is
1916
+ * the identical self-PK exclusion, per-column enforcement-collation compare,
1917
+ * and partial-predicate scope check the full scan performs. A partial index
1918
+ * already excludes out-of-scope rows physically — the predicate re-check is
1919
+ * kept as defense in depth.
1920
+ *
1921
+ * Returns {@link INDEX_UNUSABLE} rather than a (possibly wrong) answer when an
1922
+ * entry carries a legacy empty value.
1923
+ */
1924
+ async findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks) {
1925
+ const indexStore = await this.ensureIndexStore(index.name);
1926
+ // Resolved once, above the candidate loop: the resolver throws on an
1927
+ // unregistered name and cannot be inlined, so a per-candidate call would be
1928
+ // pure overhead.
1929
+ 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));
1931
+ for await (const entry of this.iterateEffective(indexStore, bounds)) {
1932
+ // A legacy index store (written before index values carried the data key)
1933
+ // holds EMPTY values. `scanIndex` may skip such an entry — a read that
1934
+ // returns too few rows. Skipping here would instead ACCEPT a duplicate, so
1935
+ // abandon the index and let the caller full-scan. See the NOTE in
1936
+ // `scanIndex` for the durable fix.
1937
+ if (entry.value.length === 0)
1938
+ return INDEX_UNUSABLE;
1939
+ // Resolve to the LIVE row: a pending index delete normally suppresses the
1940
+ // entry, but a committed entry can lag a row deleted this transaction.
1941
+ const candidate = await this.readEffectiveRowByKey(entry.value);
1942
+ if (!candidate)
1943
+ continue;
1944
+ const pk = this.extractPK(candidate);
1945
+ if (selfPks.some(skip => this.keysEqual(pk, skip)))
1946
+ continue;
1947
+ if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], candidate[c], collations[i]) !== 0))
1948
+ continue;
1949
+ if (predicate && predicate.evaluate(candidate) !== true)
1950
+ continue;
1951
+ return { pk, row: candidate };
1952
+ }
1953
+ return null;
1954
+ }
1186
1955
  /**
1187
1956
  * Scan committed + pending data rows for a row matching `newRow` on
1188
1957
  * `uc.columns` whose PK is not in `selfPks`. For partial UNIQUE, candidates
@@ -1200,7 +1969,8 @@ export class StoreTable extends VirtualTable {
1200
1969
  const constrainedCols = uc.columns;
1201
1970
  // One comparison collation per constrained column — the index's per-column
1202
1971
  // COLLATE for an index-derived UNIQUE, else the declared column collation.
1203
- const collations = uniqueEnforcementCollations(this.tableSchema, uc);
1972
+ // Resolved once here, not per candidate row.
1973
+ const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
1204
1974
  const matches = (candidate) => {
1205
1975
  const pk = this.extractPK(candidate);
1206
1976
  for (const skip of selfPks) {
@@ -1209,7 +1979,7 @@ export class StoreTable extends VirtualTable {
1209
1979
  }
1210
1980
  for (let i = 0; i < constrainedCols.length; i++) {
1211
1981
  const idx = constrainedCols[i];
1212
- if (compareSqlValues(newRow[idx], candidate[idx], collations[i]) !== 0)
1982
+ if (compareSqlValuesFast(newRow[idx], candidate[idx], collations[i]) !== 0)
1213
1983
  return null;
1214
1984
  }
1215
1985
  // Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
@@ -1252,7 +2022,8 @@ export class StoreTable extends VirtualTable {
1252
2022
  */
1253
2023
  async findUniqueConflictViaCoveringMv(mv, uc, predicate, newRow, selfPks) {
1254
2024
  const newSourcePk = this.extractPK(newRow);
1255
- const collations = uniqueEnforcementCollations(this.tableSchema, uc);
2025
+ // Resolved once, above the candidate loop.
2026
+ const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
1256
2027
  const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
1257
2028
  for (const cand of candidates) {
1258
2029
  const liveRow = await this.readLiveRowByPk(cand.pk);
@@ -1270,7 +2041,7 @@ export class StoreTable extends VirtualTable {
1270
2041
  // index over a BINARY column) is declined upstream by the collation gate in
1271
2042
  // findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
1272
2043
  // reach here — the superset this re-validation can soundly filter.
1273
- if (uc.columns.some((c, i) => compareSqlValues(newRow[c], liveRow[c], collations[i]) !== 0))
2044
+ if (uc.columns.some((c, i) => compareSqlValuesFast(newRow[c], liveRow[c], collations[i]) !== 0))
1274
2045
  continue;
1275
2046
  if (predicate && predicate.evaluate(liveRow) !== true)
1276
2047
  continue;
@@ -1344,7 +2115,7 @@ export class StoreTable extends VirtualTable {
1344
2115
  * Declared secondary-UNIQUE enforcement for maintenance writes — the store
1345
2116
  * mirror of the memory manager's `enforceSecondaryUniqueOnMaintenance` (see
1346
2117
  * `vtab/backing-host.ts` § Constraint validation for the contract and
1347
- * docs/materialized-views.md § Derived-row constraint validation for the
2118
+ * docs/mv-constraints.md § Derived-row constraint validation for the
1348
2119
  * semantics). Called by `StoreBackingHost.applyMaintenance` AFTER the op
1349
2120
  * batch lands in the coordinator's pending state: post-batch is load-bearing
1350
2121
  * (a `replace-all` diff applies puts before deletes, so a per-op check would
@@ -1359,8 +2130,10 @@ export class StoreTable extends VirtualTable {
1359
2130
  * and would miss a same-batch colliding pair. The conflict action is a hard
1360
2131
  * abort (a derivation write carries no user OR clause, and a declared
1361
2132
  * `on conflict replace`/`ignore` default must not evict or drop derived
1362
- * rows). Per-image cost is one effective scan the same full-scan posture
1363
- * the store's DML UNIQUE enforcement already takes.
2133
+ * rows). Per-image cost is one effective full scan: unlike the DML path
2134
+ * ({@link findUniqueConflictFor}), this one is NOT routed through
2135
+ * {@link findUniqueConflictViaIndex}, because a backing table keeps no
2136
+ * secondary indexes by design — there is never an index store to seek.
1364
2137
  *
1365
2138
  * Zero overhead when the table declares no secondary UNIQUE (every MV-sugar
1366
2139
  * backing, and most maintained tables): one empty-array check.