@quereus/store 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +436 -317
  2. package/dist/src/common/backing-host.d.ts +198 -0
  3. package/dist/src/common/backing-host.d.ts.map +1 -0
  4. package/dist/src/common/backing-host.js +452 -0
  5. package/dist/src/common/backing-host.js.map +1 -0
  6. package/dist/src/common/bytes.d.ts +16 -0
  7. package/dist/src/common/bytes.d.ts.map +1 -0
  8. package/dist/src/common/bytes.js +33 -0
  9. package/dist/src/common/bytes.js.map +1 -0
  10. package/dist/src/common/cached-kv-store.d.ts +3 -3
  11. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  12. package/dist/src/common/cached-kv-store.js +6 -4
  13. package/dist/src/common/cached-kv-store.js.map +1 -1
  14. package/dist/src/common/encoding.d.ts +9 -1
  15. package/dist/src/common/encoding.d.ts.map +1 -1
  16. package/dist/src/common/encoding.js +12 -2
  17. package/dist/src/common/encoding.js.map +1 -1
  18. package/dist/src/common/index.d.ts +8 -6
  19. package/dist/src/common/index.d.ts.map +1 -1
  20. package/dist/src/common/index.js +7 -3
  21. package/dist/src/common/index.js.map +1 -1
  22. package/dist/src/common/key-builder.d.ts +125 -4
  23. package/dist/src/common/key-builder.d.ts.map +1 -1
  24. package/dist/src/common/key-builder.js +186 -9
  25. package/dist/src/common/key-builder.js.map +1 -1
  26. package/dist/src/common/kv-store.d.ts +75 -4
  27. package/dist/src/common/kv-store.d.ts.map +1 -1
  28. package/dist/src/common/memory-store.d.ts +3 -3
  29. package/dist/src/common/memory-store.d.ts.map +1 -1
  30. package/dist/src/common/memory-store.js +4 -2
  31. package/dist/src/common/memory-store.js.map +1 -1
  32. package/dist/src/common/store-connection.d.ts +11 -4
  33. package/dist/src/common/store-connection.d.ts.map +1 -1
  34. package/dist/src/common/store-connection.js +12 -6
  35. package/dist/src/common/store-connection.js.map +1 -1
  36. package/dist/src/common/store-module.d.ts +580 -20
  37. package/dist/src/common/store-module.d.ts.map +1 -1
  38. package/dist/src/common/store-module.js +1590 -135
  39. package/dist/src/common/store-module.js.map +1 -1
  40. package/dist/src/common/store-table.d.ts +347 -14
  41. package/dist/src/common/store-table.d.ts.map +1 -1
  42. package/dist/src/common/store-table.js +751 -98
  43. package/dist/src/common/store-table.js.map +1 -1
  44. package/dist/src/common/transaction.d.ts +135 -27
  45. package/dist/src/common/transaction.d.ts.map +1 -1
  46. package/dist/src/common/transaction.js +216 -55
  47. package/dist/src/common/transaction.js.map +1 -1
  48. package/package.json +5 -5
@@ -9,24 +9,24 @@
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, validateAndParse, compilePredicate, } from '@quereus/quereus';
12
+ import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, rowsValueIdentical, validateAndParse, compilePredicate, maintainedTableUniqueViolationError, uniqueEnforcementCollations, } from '@quereus/quereus';
13
+ import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
13
14
  import { StoreConnection } from './store-connection.js';
14
- import { buildDataKey, buildIndexKey, buildFullScanBounds, buildStatsKey, } from './key-builder.js';
15
+ import { buildDataKey, buildIndexKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
15
16
  import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
17
+ import { getCollationEncoder } from './encoding.js';
16
18
  /** Number of mutations before persisting statistics. */
17
19
  const STATS_FLUSH_INTERVAL = 100;
18
- /** Hex-encode a key for use as a Map/Set lookup. */
19
- function bytesToHex(key) {
20
- return Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('');
21
- }
22
- /** Byte-wise equality check for Uint8Arrays. */
23
- function bytesEqual(a, b) {
24
- if (a.length !== b.length)
20
+ /** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
21
+ function keyWithinBounds(key, bounds) {
22
+ if (bounds.gte && compareBytes(key, bounds.gte) < 0)
23
+ return false;
24
+ if (bounds.gt && compareBytes(key, bounds.gt) <= 0)
25
+ return false;
26
+ if (bounds.lte && compareBytes(key, bounds.lte) > 0)
27
+ return false;
28
+ if (bounds.lt && compareBytes(key, bounds.lt) >= 0)
25
29
  return false;
26
- for (let i = 0; i < a.length; i++) {
27
- if (a[i] !== b[i])
28
- return false;
29
- }
30
30
  return true;
31
31
  }
32
32
  /**
@@ -49,6 +49,34 @@ function resolvePkDefaultConflict(schema) {
49
49
  }
50
50
  return undefined;
51
51
  }
52
+ /**
53
+ * Resolve the per-column KEY collation for each primary-key column.
54
+ *
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
60
+ * `fallback` (the table key collation K) when the column carries none.
61
+ * - non-text member → `undefined`: collation is meaningless for
62
+ * integer/real/blob keys (they encode type-natively), so the encoder ignores
63
+ * it and the data/index key bytes are identical regardless.
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
69
+ * `StoreModule.buildIndexEntries` (index rebuild) so the PK suffix encoding can
70
+ * never drift between the two.
71
+ */
72
+ export function resolvePkKeyCollations(pkDef, columns, fallback) {
73
+ return pkDef.map(def => {
74
+ const col = columns[def.index];
75
+ if (!col || !col.logicalType.isTextual)
76
+ return undefined;
77
+ return (col.collation || fallback).toUpperCase();
78
+ });
79
+ }
52
80
  /**
53
81
  * Generic KVStore-backed virtual table.
54
82
  *
@@ -64,10 +92,25 @@ export class StoreTable extends VirtualTable {
64
92
  indexStores = new Map();
65
93
  statsStore = null;
66
94
  coordinator = null;
95
+ /**
96
+ * Disposer returned by the coordinator's `registerCallbacks`, captured on the
97
+ * first {@link attachCoordinator}. Run by {@link dispose} at hard eviction to
98
+ * deregister this instance's {stats apply/discard} pair from the module-wide
99
+ * coordinator; null until attached, and nulled again after dispose.
100
+ */
101
+ coordinatorDisposer = null;
67
102
  connection = null;
68
103
  eventEmitter;
69
104
  encodeOptions;
70
105
  pkDirections;
106
+ /**
107
+ * Per-PK-column KEY collation (see {@link resolvePkKeyCollations}). Drives the
108
+ * physical encoding of every data key and the PK suffix of every secondary-index
109
+ * key, so a text PK column declared BINARY/NOCASE/RTRIM is keyed under its own
110
+ * collation rather than one fixed table-level collation. Recomputed on every
111
+ * {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
112
+ */
113
+ pkKeyCollations;
71
114
  ddlSaved = false;
72
115
  // Statistics tracking
73
116
  cachedStats = null;
@@ -79,6 +122,11 @@ export class StoreTable extends VirtualTable {
79
122
  // new constraint object after CREATE/DROP INDEX produces a fresh compile;
80
123
  // the WeakMap lets the GC reclaim entries for retired constraints.
81
124
  predicateCache = new WeakMap();
125
+ // Lazy cache of compiled partial-index predicates, keyed on the IndexSchema
126
+ // object identity (frozen; a CREATE/DROP INDEX or reopen produces a fresh
127
+ // object, so the WeakMap reclaims retired entries). Mirrors predicateCache but
128
+ // for secondary-index maintenance rather than UNIQUE enforcement.
129
+ indexPredicateCache = new WeakMap();
82
130
  constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
83
131
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
132
  super(db, storeModule, tableSchema.schemaName, tableSchema.name);
@@ -88,6 +136,7 @@ export class StoreTable extends VirtualTable {
88
136
  this.eventEmitter = eventEmitter;
89
137
  this.encodeOptions = { collation: config.collation || 'NOCASE' };
90
138
  this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
139
+ this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
91
140
  this.ddlSaved = isConnected;
92
141
  }
93
142
  /** Get the table configuration. */
@@ -102,6 +151,16 @@ export class StoreTable extends VirtualTable {
102
151
  updateSchema(newSchema) {
103
152
  this.tableSchema = newSchema;
104
153
  this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
154
+ this.pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
155
+ }
156
+ /**
157
+ * Mark the table's DDL as already persisted to the catalog, so the lazy
158
+ * first-store-access save in {@link initializeStore} is skipped. Called by
159
+ * `StoreModule.createIndex` / `dropIndex` after they eagerly write the catalog
160
+ * bundle, so a subsequent INSERT does not redundantly re-persist identical DDL.
161
+ */
162
+ markDdlSaved() {
163
+ this.ddlSaved = true;
105
164
  }
106
165
  /** Close and forget a cached index-store handle, if any. */
107
166
  async releaseIndexStore(indexName) {
@@ -171,16 +230,27 @@ export class StoreTable extends VirtualTable {
171
230
  * Only the data store is rewritten — secondary indexes are rebuilt by the
172
231
  * caller (the keys embed the PK suffix, so they must be rebuilt whenever
173
232
  * the PK changes).
233
+ *
234
+ * The new key for each row is encoded under `newColumns`'s per-column PK
235
+ * collations, so this drives BOTH:
236
+ * - `ALTER PRIMARY KEY` — the PK *columns* change; `newColumns` defaults to the
237
+ * current column set (their collations are unchanged), and
238
+ * - `ALTER COLUMN … SET COLLATE` on a PK member — the PK columns stay the same
239
+ * but one column's collation changes; the caller passes the post-ALTER
240
+ * `updatedSchema.columns` so the new key bytes follow the new collation.
241
+ * The OLD key is taken verbatim from the stored entry (never re-encoded), so the
242
+ * old collation is implicit in the existing bytes and need not be supplied.
174
243
  */
175
- async rekeyRows(newPkDef) {
244
+ async rekeyRows(newPkDef, newColumns = this.tableSchema.columns) {
176
245
  const store = await this.ensureStore();
177
246
  const bounds = buildFullScanBounds();
178
247
  const pending = new Map();
179
248
  const newPkDirections = newPkDef.map(pk => !!pk.desc);
249
+ const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
180
250
  for await (const entry of store.iterate(bounds)) {
181
251
  const row = deserializeRow(entry.value);
182
252
  const newPkValues = newPkDef.map(pk => row[pk.index]);
183
- const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections);
253
+ const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections, newPkCollations);
184
254
  const hex = bytesToHex(newKey);
185
255
  if (pending.has(hex)) {
186
256
  throw new QuereusError(`UNIQUE constraint failed: duplicate primary key on rekey of '${this.schemaName}.${this.tableName}'`, StatusCode.CONSTRAINT);
@@ -200,16 +270,29 @@ export class StoreTable extends VirtualTable {
200
270
  * Migrate all stored rows from the old column layout to a new one.
201
271
  * The remap array maps newColumnIndex -> oldColumnIndex | -1.
202
272
  * -1 means the column is new (fill with defaultValue).
273
+ *
274
+ * `backfill`, when supplied (ADD COLUMN with a non-foldable DEFAULT such as
275
+ * `new.<col>`), derives the new column's value from each existing row instead of the
276
+ * single `defaultValue`, and rejects a NULL it produces for a NOT NULL column. The
277
+ * batch is only written once every row migrates, so a throwing evaluator / NOT NULL
278
+ * violation leaves the store untouched for the caller's rollback.
203
279
  */
204
- async migrateRows(remap, defaultValue) {
280
+ async migrateRows(remap, defaultValue, backfill) {
205
281
  const store = await this.ensureStore();
206
282
  const bounds = buildFullScanBounds();
207
283
  const batch = store.batch();
208
284
  for await (const entry of store.iterate(bounds)) {
209
285
  const oldRow = deserializeRow(entry.value);
286
+ let newColumnValue = defaultValue;
287
+ if (backfill) {
288
+ newColumnValue = await backfill.evaluator(oldRow);
289
+ if (backfill.notNull && newColumnValue === null) {
290
+ throw new QuereusError(`NOT NULL constraint failed: backfilling column '${this.schemaName}.${this.tableName}.${backfill.columnName}' produced NULL for an existing row`, StatusCode.CONSTRAINT);
291
+ }
292
+ }
210
293
  const newRow = new Array(remap.length);
211
294
  for (let i = 0; i < remap.length; i++) {
212
- newRow[i] = remap[i] === -1 ? defaultValue : oldRow[remap[i]];
295
+ newRow[i] = remap[i] === -1 ? newColumnValue : oldRow[remap[i]];
213
296
  }
214
297
  batch.put(entry.key, serializeRow(newRow));
215
298
  }
@@ -274,22 +357,47 @@ export class StoreTable extends VirtualTable {
274
357
  return this.statsStore;
275
358
  }
276
359
  /**
277
- * Ensure the coordinator is available and connection is registered.
360
+ * Resolve + cache the module's shared TransactionCoordinator and hook the
361
+ * stats lifecycle callbacks, WITHOUT creating or registering a connection.
362
+ * Synchronous (the coordinator is handle-addressed, never store-opening — see
363
+ * `StoreTableModule.getCoordinator`), so the backing host's resolution path can
364
+ * call it eagerly. Attaching matters for reads: `iterateEffective` /
365
+ * `readEffectiveRowByKey` consult `this.coordinator` for the pending merge, so a
366
+ * table whose only writer is the privileged backing host (which queues ops on the
367
+ * module coordinator, never through `update()`) must still hold the
368
+ * reference for its read paths to be reads-own-writes.
369
+ *
370
+ * The coordinator is module-wide, so its commit/rollback callback array holds
371
+ * one {stats apply/discard} pair per PARTICIPATING table; each table's
372
+ * `applyPendingStats` early-returns when its own `pendingStatsDelta` is 0, so a
373
+ * table that did no work contributes nothing. Registers only on first call per
374
+ * StoreTable instance (the `if (!this.coordinator)` guard); a fresh instance
375
+ * after drop+recreate re-registers against the shared coordinator. The OLD
376
+ * instance is not GC'd on its own — its callback closures capture `this` and
377
+ * stay pinned on the module-wide coordinator until {@link dispose} runs the
378
+ * captured disposer (called at the genuine eviction sites, see
379
+ * `StoreModule.tearDownTableStorage` / `renameTable`).
278
380
  */
279
- async ensureCoordinator() {
381
+ attachCoordinator() {
280
382
  if (!this.coordinator) {
281
- const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
282
- this.coordinator = await this.storeModule.getCoordinator(tableKey, this.config);
283
- this.coordinator.registerCallbacks({
383
+ this.coordinator = this.storeModule.getCoordinator();
384
+ this.coordinatorDisposer = this.coordinator.registerCallbacks({
284
385
  onCommit: () => this.applyPendingStats(),
285
386
  onRollback: () => this.discardPendingStats(),
286
387
  });
287
388
  }
389
+ return this.coordinator;
390
+ }
391
+ /**
392
+ * Ensure the coordinator is available and connection is registered.
393
+ */
394
+ async ensureCoordinator() {
395
+ const coordinator = this.attachCoordinator();
288
396
  if (!this.connection) {
289
- this.connection = new StoreConnection(this.tableName, this.coordinator);
397
+ this.connection = new StoreConnection(`${this.schemaName}.${this.tableName}`, coordinator);
290
398
  await this.db.registerConnection(this.connection);
291
399
  }
292
- return this.coordinator;
400
+ return coordinator;
293
401
  }
294
402
  /** Apply pending stats on commit. */
295
403
  applyPendingStats() {
@@ -349,18 +457,22 @@ export class StoreTable extends VirtualTable {
349
457
  }
350
458
  return row.map((v, i) => validateAndParse(v, cols[i].logicalType, cols[i].name));
351
459
  }
352
- /** Query the table with optional filters. */
460
+ /**
461
+ * Query the table with optional filters.
462
+ *
463
+ * All three access arms read the EFFECTIVE row state: the committed store
464
+ * merged with this table's coordinator's pending ops when a transaction is
465
+ * active (read-your-own-writes — see {@link iterateEffective} /
466
+ * {@link readLiveRowByPk}). Merged emission stays in encoded-PK-key order,
467
+ * preserving the module's `providesOrdering` / `monotonicOn` advertisements.
468
+ */
353
469
  async *query(filterInfo) {
354
470
  const store = await this.ensureStore();
355
471
  const pkAccess = this.analyzePKAccess(filterInfo);
356
472
  if (pkAccess.type === 'point') {
357
- const key = buildDataKey(pkAccess.values, this.encodeOptions, this.pkDirections);
358
- const value = await store.get(key);
359
- if (value) {
360
- const row = deserializeRow(value);
361
- if (this.matchesFilters(row, filterInfo)) {
362
- yield row;
363
- }
473
+ const row = await this.readLiveRowByPk(pkAccess.values);
474
+ if (row && this.matchesFilters(row, filterInfo)) {
475
+ yield row;
364
476
  }
365
477
  return;
366
478
  }
@@ -370,13 +482,63 @@ export class StoreTable extends VirtualTable {
370
482
  }
371
483
  // Full table scan
372
484
  const bounds = buildFullScanBounds();
373
- for await (const entry of store.iterate(bounds)) {
485
+ for await (const entry of this.iterateEffective(store, bounds)) {
374
486
  const row = deserializeRow(entry.value);
375
487
  if (this.matchesFilters(row, filterInfo)) {
376
488
  yield row;
377
489
  }
378
490
  }
379
491
  }
492
+ /**
493
+ * Iterate the effective entry state of this table's data store within
494
+ * `bounds`: the committed `store.iterate` stream merged with the
495
+ * coordinator's pending ops for the data store (read-your-own-writes).
496
+ *
497
+ * Both inputs are sorted by encoded key bytes, so this is an
498
+ * order-preserving two-way merge: a pending put wins over a committed entry
499
+ * at the same key, a pending delete suppresses its committed entry, and
500
+ * pending puts outside `bounds` are excluded. With no active transaction
501
+ * (or an empty pending bucket) it degrades to the bare committed iterate.
502
+ *
503
+ * The pending side is the module coordinator's bucket for THIS table's data
504
+ * store handle (`store`) — every data op is queued under that exact handle, so
505
+ * a sibling table's pending ops (a different data-store handle) never bleed in.
506
+ */
507
+ async *iterateEffective(store, bounds, reverse = false) {
508
+ const pending = this.coordinator?.isInTransaction()
509
+ ? this.coordinator.getOrderedPendingOps(store)
510
+ : null;
511
+ const iterOptions = reverse ? { ...bounds, reverse } : bounds;
512
+ if (!pending || (pending.puts.length === 0 && pending.deletes.size === 0)) {
513
+ yield* store.iterate(iterOptions);
514
+ return;
515
+ }
516
+ const puts = pending.puts.filter(p => keyWithinBounds(p.key, bounds));
517
+ if (reverse)
518
+ puts.reverse();
519
+ // In iteration order, "before" means byte-less for forward and byte-greater
520
+ // for reverse; sign folds the direction into one comparison.
521
+ const sign = reverse ? -1 : 1;
522
+ let putIdx = 0;
523
+ for await (const entry of store.iterate(iterOptions)) {
524
+ // Emit pending puts that precede this committed entry in iteration order.
525
+ while (putIdx < puts.length && sign * compareBytes(puts[putIdx].key, entry.key) < 0) {
526
+ yield puts[putIdx++];
527
+ }
528
+ // Equal keys: the pending put shadows the committed entry.
529
+ if (putIdx < puts.length && compareBytes(puts[putIdx].key, entry.key) === 0) {
530
+ yield puts[putIdx++];
531
+ continue;
532
+ }
533
+ if (pending.deletes.has(bytesToHex(entry.key)))
534
+ continue;
535
+ yield entry;
536
+ }
537
+ // Pending puts beyond the last committed entry.
538
+ while (putIdx < puts.length) {
539
+ yield puts[putIdx++];
540
+ }
541
+ }
380
542
  /** Analyze filter info to determine PK access pattern. */
381
543
  analyzePKAccess(filterInfo) {
382
544
  const schema = this.tableSchema;
@@ -418,11 +580,85 @@ export class StoreTable extends VirtualTable {
418
580
  }
419
581
  return { type: 'scan' };
420
582
  }
421
- /** Scan a range of PK values. */
422
- async *scanPKRange(store, _access, filterInfo) {
423
- const bounds = buildFullScanBounds();
424
- // TODO: Refine bounds based on range constraints
425
- for await (const entry of store.iterate(bounds)) {
583
+ /**
584
+ * Convert a leading-PK-column range access into one seek + early-terminate
585
+ * iterate window.
586
+ *
587
+ * Each LT/LE/GT/GE constraint's bound value is encoded under the SAME
588
+ * per-column DESC direction and key collation as the data keys (via
589
+ * {@link encodePkPrefixBounds}), giving the byte region `[lo, hi)` whose
590
+ * leading column equals that value (`lo = encode([x])`,
591
+ * `hi = incrementLastByte(lo)`). The op maps that region's endpoints onto a
592
+ * `gte`/`lt` window; because a DESC column bit-inverts its bytes (larger value
593
+ * ⇒ smaller bytes), the lower/upper assignment swaps with direction:
594
+ *
595
+ * | op | ASC | DESC |
596
+ * |----|----------|----------|
597
+ * | GE | gte = lo | lt = hi |
598
+ * | GT | gte = hi | lt = lo |
599
+ * | LE | lt = hi | gte = lo |
600
+ * | LT | lt = lo | gte = hi |
601
+ *
602
+ * Across constraints (BETWEEN ⇒ one lower + one upper; a redundant same-side
603
+ * pair ⇒ the tighter wins) we keep the MAX lower candidate for `gte` and the
604
+ * MIN upper candidate for `lt`. A candidate that resolves to `undefined` (an
605
+ * `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
606
+ * SUPERSET, since {@link matchesFilters} stays the authoritative collation-aware
607
+ * row filter. A NULL/missing bound value is likewise skipped (the planner never
608
+ * pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
609
+ *
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.
615
+ */
616
+ buildPKRangeBounds(access) {
617
+ const full = buildFullScanBounds();
618
+ const constraints = access.constraints;
619
+ if (!constraints || constraints.length === 0)
620
+ return full;
621
+ 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
+ let gte = full.gte;
629
+ let lt;
630
+ for (const c of constraints) {
631
+ if (c.value === undefined || c.value === null)
632
+ continue;
633
+ const { gte: lo, lt: hi } = this.encodePkPrefixBounds([c.value]);
634
+ const lower = !dir
635
+ ? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
636
+ : (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
637
+ const upper = !dir
638
+ ? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
639
+ : (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
640
+ if (lower && compareBytes(lower, gte) > 0)
641
+ gte = lower;
642
+ if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
643
+ lt = upper;
644
+ }
645
+ return lt === undefined ? { gte } : { gte, lt };
646
+ }
647
+ /**
648
+ * Scan a leading-PK-column range, seeking to the window start and
649
+ * early-terminating at its end.
650
+ *
651
+ * {@link buildPKRangeBounds} converts the LT/LE/GT/GE constraints into one
652
+ * encoded-byte `gte`/`lt` window under the same per-column DESC directions and
653
+ * key collations the data keys use, so the iterate visits a SUPERSET of the
654
+ * qualifying rows (a collation widening or the bound-byte increment can
655
+ * over-fetch, never under-fetch). {@link matchesFilters} stays the authoritative
656
+ * collation-aware row filter. {@link iterateEffective} restricts the pending
657
+ * merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
658
+ */
659
+ async *scanPKRange(store, access, filterInfo) {
660
+ const bounds = this.buildPKRangeBounds(access);
661
+ for await (const entry of this.iterateEffective(store, bounds)) {
426
662
  const row = deserializeRow(entry.value);
427
663
  if (this.matchesFilters(row, filterInfo)) {
428
664
  yield row;
@@ -441,26 +677,40 @@ export class StoreTable extends VirtualTable {
441
677
  }
442
678
  const rowValue = row[constraint.iColumn];
443
679
  const filterValue = filterInfo.args[argvIndex - 1];
444
- if (!this.compareValues(rowValue, constraint.op, filterValue)) {
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;
687
+ if (!this.compareValues(rowValue, constraint.op, filterValue, collation)) {
445
688
  return false;
446
689
  }
447
690
  }
448
691
  return true;
449
692
  }
450
- /** Compare two values according to an operator. */
451
- compareValues(a, op, b) {
693
+ /**
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
698
+ * `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
699
+ * NULL on either side fails every operator except EQ-with-both-NULL (the
700
+ * internal point-lookup convention; the planner never pushes `= NULL`).
701
+ */
702
+ compareValues(a, op, b, collation) {
452
703
  if (a === null || b === null) {
453
704
  return op === IndexConstraintOp.EQ ? a === b : false;
454
705
  }
706
+ const cmp = compareSqlValues(a, b, collation);
455
707
  switch (op) {
456
- case IndexConstraintOp.EQ:
457
- return a === b || (typeof a === 'string' && typeof b === 'string' &&
458
- this.config.collation === 'NOCASE' && a.toLowerCase() === b.toLowerCase());
459
- case IndexConstraintOp.NE: return a !== b;
460
- case IndexConstraintOp.LT: return a < b;
461
- case IndexConstraintOp.LE: return a <= b;
462
- case IndexConstraintOp.GT: return a > b;
463
- case IndexConstraintOp.GE: return a >= b;
708
+ case IndexConstraintOp.EQ: return cmp === 0;
709
+ case IndexConstraintOp.NE: return cmp !== 0;
710
+ case IndexConstraintOp.LT: return cmp < 0;
711
+ case IndexConstraintOp.LE: return cmp <= 0;
712
+ case IndexConstraintOp.GT: return cmp > 0;
713
+ case IndexConstraintOp.GE: return cmp >= 0;
464
714
  default: return true;
465
715
  }
466
716
  }
@@ -477,17 +727,47 @@ export class StoreTable extends VirtualTable {
477
727
  throw new QuereusError('INSERT requires values', StatusCode.MISUSE);
478
728
  const coerced = args.preCoerced ? values : this.coerceRow(values);
479
729
  const pk = this.extractPK(coerced);
480
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
730
+ const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
481
731
  // Check for existing row (for conflict handling).
482
732
  // Resolve PK-conflict action: statement OR > per-constraint default > ABORT.
483
733
  const pkEffective = args.onConflict ?? resolvePkDefaultConflict(schema) ?? ConflictResolution.ABORT;
484
- const existing = await store.get(key);
485
- if (existing) {
734
+ // Trusted-flush safety analysis — why this arm diverges from the others.
735
+ // The insert arm's probe stays committed-only on the trusted-flush path,
736
+ // while the update/delete arms below read the effective
737
+ // (pending-over-committed) image UNCONDITIONALLY. That divergence is
738
+ // safe: `flushOverlayToUnderlying` (isolation, isolated-table.ts) wraps
739
+ // the flush in its own coordinator mini-transaction, the overlay holds
740
+ // at most ONE entry per PK, and tombstone deletes are ordered before
741
+ // inserts/updates — so when any flush write probes its own key, no
742
+ // pending op exists at that key yet in the mini-transaction and the
743
+ // effective read equals the committed read on every trusted probe. The
744
+ // committed-only read kept here is therefore NOT a read-correctness
745
+ // requirement but a pinned INTERNAL invariant: the flush routes existing
746
+ // PKs to update, so a row present here is an isolation-layer violation we
747
+ // must surface loudly (store-backing-host-substrate analysis).
748
+ let existingRow;
749
+ if (args.trustedWrite) {
750
+ const committed = await store.get(key);
751
+ existingRow = committed ? deserializeRow(committed) : null;
752
+ }
753
+ else {
754
+ existingRow = await this.readEffectiveRowByKey(key);
755
+ }
756
+ if (args.trustedWrite) {
757
+ // Trusted flush insert: the overlay flush routes existing PKs to
758
+ // update (via rowExistsInUnderlying), so a row already present here
759
+ // is an isolation-layer invariant violation. Fail loudly rather than
760
+ // silently overwrite — the flush try/catch rolls back and rethrows
761
+ // (isolation-merged-unique-stale-underlying-false-positive).
762
+ if (existingRow) {
763
+ throw new QuereusError(`Trusted flush insert on '${this.tableName}' hit an existing PK; the overlay flush should route existing PKs to update. This indicates an isolation-layer invariant violation.`, StatusCode.INTERNAL);
764
+ }
765
+ }
766
+ else if (existingRow) {
486
767
  if (pkEffective === ConflictResolution.IGNORE) {
487
768
  return { status: 'ok', row: undefined };
488
769
  }
489
770
  if (pkEffective !== ConflictResolution.REPLACE) {
490
- const existingRow = deserializeRow(existing);
491
771
  return {
492
772
  status: 'constraint',
493
773
  constraint: 'unique',
@@ -498,22 +778,30 @@ export class StoreTable extends VirtualTable {
498
778
  }
499
779
  // Enforce non-PK UNIQUE constraints. Pass the original statement-level
500
780
  // onConflict so checkUniqueConstraints can resolve each UC's own
501
- // defaultConflict independently of the PK's default.
502
- const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, [pk], args.onConflict);
503
- if (ucResult)
504
- return ucResult;
505
- const oldRow = existing ? deserializeRow(existing) : null;
781
+ // defaultConflict independently of the PK's default. Secondary-UNIQUE
782
+ // REPLACE evictions accumulate in `evicted` for the executor pipeline.
783
+ // Skipped for trusted flush writes: the overlay already validated the
784
+ // final state and a value-swap cycle cannot pass a row-by-row re-check.
785
+ const evicted = [];
786
+ if (!args.trustedWrite) {
787
+ const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, [pk], args.onConflict, evicted);
788
+ if (ucResult)
789
+ return ucResult;
790
+ }
791
+ const oldRow = existingRow;
506
792
  const serializedRow = serializeRow(coerced);
507
793
  if (inTransaction) {
508
- coordinator.put(key, serializedRow);
794
+ coordinator.put(key, serializedRow, store);
509
795
  }
510
796
  else {
511
797
  await store.put(key, serializedRow);
512
798
  }
513
- // Update secondary indexes
799
+ // Update secondary indexes. An effective `oldRow` (a pending row at the
800
+ // same PK, evicted under REPLACE) cancels the earlier pending index-put;
801
+ // a commit-batch delete of a never-committed index key is a harmless no-op.
514
802
  await this.updateSecondaryIndexes(inTransaction, oldRow, coerced, pk);
515
803
  // Track statistics (only count as new if not replacing)
516
- if (!existing) {
804
+ if (!existingRow) {
517
805
  this.trackMutation(+1, inTransaction);
518
806
  }
519
807
  // Queue or emit event
@@ -549,7 +837,7 @@ export class StoreTable extends VirtualTable {
549
837
  this.eventEmitter?.emitDataChange(insertEvent);
550
838
  }
551
839
  }
552
- return { status: 'ok', row: coerced, replacedRow: oldRow ?? undefined };
840
+ return { status: 'ok', row: coerced, replacedRow: oldRow ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
553
841
  }
554
842
  case 'update': {
555
843
  if (!values || !oldKeyValues)
@@ -557,23 +845,39 @@ export class StoreTable extends VirtualTable {
557
845
  const coerced = args.preCoerced ? values : this.coerceRow(values);
558
846
  const oldPk = this.extractPK(oldKeyValues);
559
847
  const newPk = this.extractPK(coerced);
560
- const oldKey = buildDataKey(oldPk, this.encodeOptions, this.pkDirections);
561
- const newKey = buildDataKey(newPk, this.encodeOptions, this.pkDirections);
562
- // Get old row for index updates
563
- const oldRowData = await store.get(oldKey);
564
- const oldRow = oldRowData ? deserializeRow(oldRowData) : null;
565
- const pkChanged = !this.keysEqual(oldPk, newPk);
848
+ const oldKey = buildDataKey(oldPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
849
+ const newKey = buildDataKey(newPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
850
+ // Get old row for index updates. Read the effective
851
+ // (pending-over-committed) image UNCONDITIONALLY — including the trusted
852
+ // flush path so an old image written earlier in the same transaction is
853
+ // visible. This fixes index cleanup, the `uniqueColumnsChanged` gate, and
854
+ // the event's `oldRow`. Trusted is safe here (see the insert-arm comment):
855
+ // deletes-first ordering + one-entry-per-PK ⇒ effective ≡ committed on a
856
+ // flush write probing its own key.
857
+ const oldRow = await this.readEffectiveRowByKey(oldKey);
858
+ // A PK "change" only relocates the row when the ENCODED key differs.
859
+ // Under a non-binary PK collation (e.g. NOCASE) a case-only rewrite
860
+ // ('apple' → 'APPLE') keeps the same physical key, so it is an in-place
861
+ // update, not a relocation. Comparing raw values via keysEqual would
862
+ // mis-classify it as a move and then false-detect a PK conflict against
863
+ // the row's own existing entry at newKey (== oldKey). The encoded keys
864
+ // are the storage layer's source of truth (mirrors the rekey path above).
865
+ const pkChanged = !bytesEqual(oldKey, newKey);
566
866
  // Resolve PK-conflict action: statement OR > per-constraint default > ABORT.
567
867
  const pkEffective = args.onConflict ?? resolvePkDefaultConflict(schema) ?? ConflictResolution.ABORT;
568
868
  // PK-change UPDATE collides like an INSERT at the new key.
569
869
  // Capture the evicted row so it can be reported via `replacedRow`
570
870
  // (consumed by the executor for ON DELETE cascade/SET NULL of the
571
- // row at the new PK). Read through the coordinator so an evictee
572
- // written earlier in the same transaction is visible.
871
+ // row at the new PK). Read the effective (pending-over-committed) image
872
+ // so an evictee written earlier in the same transaction conflicts/evicts
873
+ // rather than being silently overwritten.
874
+ // Skipped for trusted flush writes — the overlay flush never changes a
875
+ // row's PK (oldKeyValues and the row's PK columns are the same overlay
876
+ // entry), so pkChanged is false there; the guard makes the intent explicit.
573
877
  let replacedAtNewPk = null;
574
- if (pkChanged) {
575
- const existingAtNew = await store.get(newKey);
576
- if (existingAtNew) {
878
+ if (pkChanged && !args.trustedWrite) {
879
+ const existingAtNewRow = await this.readEffectiveRowByKey(newKey);
880
+ if (existingAtNewRow) {
577
881
  if (pkEffective === ConflictResolution.IGNORE) {
578
882
  return { status: 'ok', row: undefined };
579
883
  }
@@ -582,10 +886,10 @@ export class StoreTable extends VirtualTable {
582
886
  status: 'constraint',
583
887
  constraint: 'unique',
584
888
  message: `UNIQUE constraint failed: ${this.tableName} PK.`,
585
- existingRow: deserializeRow(existingAtNew),
889
+ existingRow: existingAtNewRow,
586
890
  };
587
891
  }
588
- replacedAtNewPk = deserializeRow(existingAtNew);
892
+ replacedAtNewPk = existingAtNewRow;
589
893
  }
590
894
  }
591
895
  // Enforce non-PK UNIQUE constraints. For same-PK UPDATE, only check
@@ -595,10 +899,16 @@ export class StoreTable extends VirtualTable {
595
899
  // row we're moving. Pass the original statement-level onConflict so
596
900
  // each UC's own defaultConflict can be resolved independently.
597
901
  const selfPks = pkChanged ? [oldPk, newPk] : [oldPk];
598
- const shouldCheckUniques = pkChanged
599
- || (oldRow ? this.uniqueColumnsChanged(oldRow, coerced) : true);
902
+ // Skip the UNIQUE re-check for trusted flush writes: the overlay
903
+ // merged-view check already validated the final state, and a value-swap
904
+ // cycle cannot pass a row-by-row logical-UNIQUE re-check
905
+ // (isolation-merged-unique-stale-underlying-false-positive).
906
+ const shouldCheckUniques = !args.trustedWrite
907
+ && (pkChanged || (oldRow ? this.uniqueColumnsChanged(oldRow, coerced) : true));
908
+ // Secondary-UNIQUE REPLACE evictions accumulate for the executor pipeline.
909
+ const evicted = [];
600
910
  if (shouldCheckUniques) {
601
- const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, selfPks, args.onConflict);
911
+ const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, selfPks, args.onConflict, evicted);
602
912
  if (ucResult)
603
913
  return ucResult;
604
914
  }
@@ -613,7 +923,7 @@ export class StoreTable extends VirtualTable {
613
923
  // Delete old key if PK changed
614
924
  if (pkChanged) {
615
925
  if (inTransaction) {
616
- coordinator.delete(oldKey);
926
+ coordinator.delete(oldKey, store);
617
927
  }
618
928
  else {
619
929
  await store.delete(oldKey);
@@ -621,7 +931,7 @@ export class StoreTable extends VirtualTable {
621
931
  }
622
932
  const serializedRow = serializeRow(coerced);
623
933
  if (inTransaction) {
624
- coordinator.put(newKey, serializedRow);
934
+ coordinator.put(newKey, serializedRow, store);
625
935
  }
626
936
  else {
627
937
  await store.put(newKey, serializedRow);
@@ -645,18 +955,24 @@ export class StoreTable extends VirtualTable {
645
955
  else {
646
956
  this.eventEmitter?.emitDataChange(updateEvent);
647
957
  }
648
- return { status: 'ok', row: coerced, replacedRow: replacedAtNewPk ?? undefined };
958
+ return { status: 'ok', row: coerced, replacedRow: replacedAtNewPk ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
649
959
  }
650
960
  case 'delete': {
651
961
  if (!oldKeyValues)
652
962
  throw new QuereusError('DELETE requires oldKeyValues', StatusCode.MISUSE);
653
963
  const pk = this.extractPK(oldKeyValues);
654
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
655
- // Get old row for index cleanup
656
- const oldRowData = await store.get(key);
657
- const oldRow = oldRowData ? deserializeRow(oldRowData) : null;
964
+ const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
965
+ // Get old row for index cleanup. Read the effective
966
+ // (pending-over-committed) image so a row inserted earlier in the same
967
+ // transaction is seen: this fixes index cleanup, the `-1` stats delta
968
+ // (netting an insert+delete to zero), and the event's `oldRow`.
969
+ // `coordinator.delete(key)` cancels a pending put; a commit-batch delete
970
+ // of a never-committed key is a harmless no-op. The trusted flush delete
971
+ // arm does NOT pass `trustedWrite`, but deletes-first ordering +
972
+ // one-entry-per-PK keep effective ≡ committed there too (see insert arm).
973
+ const oldRow = await this.readEffectiveRowByKey(key);
658
974
  if (inTransaction) {
659
- coordinator.delete(key);
975
+ coordinator.delete(key, store);
660
976
  }
661
977
  else {
662
978
  await store.delete(key);
@@ -700,10 +1016,17 @@ export class StoreTable extends VirtualTable {
700
1016
  const indexStore = await this.ensureIndexStore(index.name);
701
1017
  const indexCols = index.columns.map(c => c.index);
702
1018
  const indexDirections = index.columns.map(c => !!c.desc);
703
- // Remove old index entry
704
- if (oldRow) {
1019
+ // Partial index: only rows the predicate unambiguously accepts are
1020
+ // indexed (mirrors buildIndexEntries' build-time filtering). Guarding both
1021
+ // halves keeps a row that transitions across the predicate scope on UPDATE
1022
+ // correct — an in-scope→out-of-scope edit removes the old entry and adds
1023
+ // none; the reverse adds without a stale delete. A full index (no
1024
+ // predicate) always maintains its entry.
1025
+ const predicate = this.compileIndexFor(index);
1026
+ // Remove old index entry (only if the old row was within scope).
1027
+ if (oldRow && (!predicate || predicate.evaluate(oldRow) === true)) {
705
1028
  const oldIndexValues = indexCols.map(i => oldRow[i]);
706
- const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections);
1029
+ const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
707
1030
  if (inTransaction && this.coordinator) {
708
1031
  this.coordinator.delete(oldIndexKey, indexStore);
709
1032
  }
@@ -711,10 +1034,10 @@ export class StoreTable extends VirtualTable {
711
1034
  await indexStore.delete(oldIndexKey);
712
1035
  }
713
1036
  }
714
- // Add new index entry
715
- if (newRow) {
1037
+ // Add new index entry (only if the new row is within scope).
1038
+ if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
716
1039
  const newIndexValues = indexCols.map(i => newRow[i]);
717
- const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections);
1040
+ const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
718
1041
  // Index value is empty - we just need the key for lookups
719
1042
  const emptyValue = new Uint8Array(0);
720
1043
  if (inTransaction && this.coordinator) {
@@ -741,6 +1064,21 @@ export class StoreTable extends VirtualTable {
741
1064
  }
742
1065
  return compiled;
743
1066
  }
1067
+ /**
1068
+ * Returns the compiled predicate for a partial secondary index, or undefined
1069
+ * for a full index. Compilation is memoized per IndexSchema instance so the hot
1070
+ * DML index-maintenance path doesn't recompile.
1071
+ */
1072
+ compileIndexFor(index) {
1073
+ if (!index.predicate)
1074
+ return undefined;
1075
+ let compiled = this.indexPredicateCache.get(index);
1076
+ if (!compiled) {
1077
+ compiled = compilePredicate(index.predicate, this.tableSchema.columns);
1078
+ this.indexPredicateCache.set(index, compiled);
1079
+ }
1080
+ return compiled;
1081
+ }
744
1082
  /** Check if two PK arrays are equal. */
745
1083
  keysEqual(a, b) {
746
1084
  if (a.length !== b.length)
@@ -790,8 +1128,12 @@ export class StoreTable extends VirtualTable {
790
1128
  *
791
1129
  * Reads through the transaction coordinator's pending writes when active so
792
1130
  * intra-transaction duplicates are detected.
1131
+ *
1132
+ * REPLACE evictions (rows at OTHER PKs) are deleted from storage and pushed onto
1133
+ * `evicted` so the DML executor runs the full delete pipeline for each
1134
+ * (change-tracking, row-time MV maintenance, FK cascade, auto-events).
793
1135
  */
794
- async checkUniqueConstraints(inTransaction, newRow, selfPks, onConflict) {
1136
+ async checkUniqueConstraints(inTransaction, newRow, selfPks, onConflict, evicted) {
795
1137
  const schema = this.tableSchema;
796
1138
  const uniqueConstraints = schema.uniqueConstraints;
797
1139
  if (!uniqueConstraints || uniqueConstraints.length === 0)
@@ -804,7 +1146,15 @@ export class StoreTable extends VirtualTable {
804
1146
  const predicate = this.compileFor(uc);
805
1147
  if (predicate && predicate.evaluate(newRow) !== true)
806
1148
  continue;
807
- const conflict = await this.findUniqueConflict(uc, predicate, newRow, selfPks);
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);
808
1158
  if (!conflict)
809
1159
  continue;
810
1160
  // Resolve action per-constraint: statement OR > per-UC default > ABORT.
@@ -814,6 +1164,13 @@ export class StoreTable extends VirtualTable {
814
1164
  }
815
1165
  if (effective === ConflictResolution.REPLACE) {
816
1166
  await this.deleteRowAt(inTransaction, conflict.pk, conflict.row);
1167
+ // Report the eviction so the executor runs its full delete pipeline —
1168
+ // including the row-time covering-structure maintenance that drops the
1169
+ // evicted source row's backing entry within this statement (else a later
1170
+ // same-UC row sees a phantom). The executor processes the eviction before
1171
+ // the writing row's own bookkeeping, so the backing delete still lands
1172
+ // mid-statement.
1173
+ evicted.push(conflict.row);
817
1174
  continue;
818
1175
  }
819
1176
  const colNames = uc.columns.map(i => schema.columns[i].name).join(', ');
@@ -834,18 +1191,25 @@ export class StoreTable extends VirtualTable {
834
1191
  */
835
1192
  async findUniqueConflict(uc, predicate, newRow, selfPks) {
836
1193
  const store = await this.ensureStore();
1194
+ // Pending ops for THIS table's data store handle — the same handle the
1195
+ // write path queues data ops under, so the merge sees only this table's
1196
+ // pending state, never a sibling table's on the shared module coordinator.
837
1197
  const pending = this.coordinator?.isInTransaction()
838
1198
  ? this.coordinator.getPendingOpsForStore(store)
839
1199
  : null;
840
1200
  const constrainedCols = uc.columns;
1201
+ // One comparison collation per constrained column — the index's per-column
1202
+ // COLLATE for an index-derived UNIQUE, else the declared column collation.
1203
+ const collations = uniqueEnforcementCollations(this.tableSchema, uc);
841
1204
  const matches = (candidate) => {
842
1205
  const pk = this.extractPK(candidate);
843
1206
  for (const skip of selfPks) {
844
1207
  if (this.keysEqual(pk, skip))
845
1208
  return null;
846
1209
  }
847
- for (const idx of constrainedCols) {
848
- if (compareSqlValues(newRow[idx], candidate[idx]) !== 0)
1210
+ for (let i = 0; i < constrainedCols.length; i++) {
1211
+ const idx = constrainedCols[i];
1212
+ if (compareSqlValues(newRow[idx], candidate[idx], collations[i]) !== 0)
849
1213
  return null;
850
1214
  }
851
1215
  // Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
@@ -877,6 +1241,269 @@ export class StoreTable extends VirtualTable {
877
1241
  }
878
1242
  return null;
879
1243
  }
1244
+ /**
1245
+ * Find a UNIQUE conflict through a linked row-time covering MV's backing table
1246
+ * (the store analogue of the memory `checkUniqueViaMaterializedView`). The
1247
+ * backing scan yields candidate conflicting **source** PKs (reads-own-writes via
1248
+ * the backing's coordinated connection); each is validated against the *live*
1249
+ * store row (committed + this transaction's pending overlay) so a backing entry
1250
+ * that lags a row deleted/updated internally this statement is skipped rather
1251
+ * than raised as a false conflict. Returns the first real conflict or null.
1252
+ */
1253
+ async findUniqueConflictViaCoveringMv(mv, uc, predicate, newRow, selfPks) {
1254
+ const newSourcePk = this.extractPK(newRow);
1255
+ const collations = uniqueEnforcementCollations(this.tableSchema, uc);
1256
+ const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
1257
+ for (const cand of candidates) {
1258
+ const liveRow = await this.readLiveRowByPk(cand.pk);
1259
+ if (!liveRow)
1260
+ continue; // stale backing candidate (source row gone)
1261
+ if (selfPks.some(pk => this.keysEqual(pk, cand.pk)))
1262
+ continue;
1263
+ // Re-validate under each column's enforcement collation (the index's
1264
+ // per-column COLLATE for an index-derived UNIQUE, else declared) — see
1265
+ // uniqueEnforcementCollations. The candidate generation
1266
+ // (_lookupCoveringConflicts) narrows under the SOURCE column's declared
1267
+ // collation, so for a FINER index (BINARY over a NOCASE column) it returns a
1268
+ // superset this filters down correctly. A finer/incomparable index-derived
1269
+ // UNIQUE whose declared candidate set could be a SUBSET (e.g. a coarser NOCASE
1270
+ // index over a BINARY column) is declined upstream by the collation gate in
1271
+ // findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
1272
+ // 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))
1274
+ continue;
1275
+ if (predicate && predicate.evaluate(liveRow) !== true)
1276
+ continue;
1277
+ return { pk: cand.pk, row: liveRow };
1278
+ }
1279
+ return null;
1280
+ }
1281
+ /**
1282
+ * Read the live row at `pk` — this transaction's pending overlay (a pending
1283
+ * delete ⇒ gone; a pending put ⇒ its value) shadowing the committed store.
1284
+ * Backs `query()`'s point-lookup arm and validates covering-MV conflict
1285
+ * candidates against the source of truth.
1286
+ */
1287
+ async readLiveRowByPk(pk) {
1288
+ return this.readEffectiveRowByKey(this.encodeDataKey(pk));
1289
+ }
1290
+ // ── Backing-host surface ──────────────────────────────────────────────
1291
+ // Narrow public surface `StoreBackingHost` (backing-host.ts) drives. Each
1292
+ // method addresses the table's CURRENT schema/encoding state, so a host
1293
+ // resolved fresh per engine call always keys and merges consistently with
1294
+ // the table's own read/write paths.
1295
+ /** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
1296
+ encodeDataKey(pkValues) {
1297
+ return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
1298
+ }
1299
+ /**
1300
+ * Byte bounds covering every data key whose leading PK columns equal
1301
+ * `prefixValues` — encoded under the same per-column DESC directions and key
1302
+ * collations as {@link encodeDataKey}, so seek + early-terminate addresses the
1303
+ * exact slice. An empty prefix yields full-scan bounds.
1304
+ */
1305
+ encodePkPrefixBounds(prefixValues) {
1306
+ return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length));
1307
+ }
1308
+ /**
1309
+ * Effective (pending-over-committed) point read by encoded data key: a pending
1310
+ * delete ⇒ null, a pending put ⇒ its value, else the committed store entry.
1311
+ */
1312
+ async readEffectiveRowByKey(key) {
1313
+ const store = await this.ensureStore();
1314
+ // Pending ops for THIS table's data store handle — see findUniqueConflict.
1315
+ const pending = this.coordinator?.isInTransaction()
1316
+ ? this.coordinator.getPendingOpsForStore(store)
1317
+ : null;
1318
+ if (pending) {
1319
+ const hex = bytesToHex(key);
1320
+ if (pending.deletes.has(hex))
1321
+ return null;
1322
+ const overlay = pending.puts.get(hex);
1323
+ if (overlay)
1324
+ return deserializeRow(overlay.value);
1325
+ }
1326
+ const value = await store.get(key);
1327
+ return value ? deserializeRow(value) : null;
1328
+ }
1329
+ /**
1330
+ * Effective ordered entry scan within `bounds` (see {@link iterateEffective}).
1331
+ * Opens the data store first — which fires the lazy first-access `saveTableDDL`
1332
+ * for a freshly created table, the catalog write a store-backed MV backing
1333
+ * relies on to survive reopen.
1334
+ */
1335
+ async *iterateEffectiveEntries(bounds, reverse = false) {
1336
+ const store = await this.ensureStore();
1337
+ yield* this.iterateEffective(store, bounds, reverse);
1338
+ }
1339
+ /** Buffer a privileged row-count delta, applied at coordinator commit (host writes). */
1340
+ trackPrivilegedMutation(delta) {
1341
+ this.trackMutation(delta, true);
1342
+ }
1343
+ /**
1344
+ * Declared secondary-UNIQUE enforcement for maintenance writes — the store
1345
+ * mirror of the memory manager's `enforceSecondaryUniqueOnMaintenance` (see
1346
+ * `vtab/backing-host.ts` § Constraint validation for the contract and
1347
+ * docs/materialized-views.md § Derived-row constraint validation for the
1348
+ * semantics). Called by `StoreBackingHost.applyMaintenance` AFTER the op
1349
+ * batch lands in the coordinator's pending state: post-batch is load-bearing
1350
+ * (a `replace-all` diff applies puts before deletes, so a per-op check would
1351
+ * false-positive when the derived set moves a unique value between primary
1352
+ * keys), and checking only the WRITTEN images is complete (pre-existing
1353
+ * contents already satisfied the constraint).
1354
+ *
1355
+ * Reuses {@link findUniqueConflict} — pending-overlay reads, per-column
1356
+ * collations, NULL-pass, partial-predicate scope, self-PK exclusion — with
1357
+ * the covering-MV route deliberately bypassed: a covering MV over THIS table
1358
+ * is cascade-maintained only after the batch returns, so it lags the batch
1359
+ * and would miss a same-batch colliding pair. The conflict action is a hard
1360
+ * abort (a derivation write carries no user OR clause, and a declared
1361
+ * `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.
1364
+ *
1365
+ * Zero overhead when the table declares no secondary UNIQUE (every MV-sugar
1366
+ * backing, and most maintained tables): one empty-array check.
1367
+ */
1368
+ async enforceSecondaryUniqueForMaintenance(changes) {
1369
+ const schema = this.tableSchema;
1370
+ const ucs = schema?.uniqueConstraints;
1371
+ if (!schema || !ucs || ucs.length === 0 || changes.length === 0)
1372
+ return;
1373
+ for (const change of changes) {
1374
+ if (change.op === 'delete')
1375
+ continue;
1376
+ const newRow = change.newRow;
1377
+ const selfPks = [this.extractPK(newRow)];
1378
+ for (const uc of ucs) {
1379
+ // SQL semantics: UNIQUE allows multiple NULLs.
1380
+ if (uc.columns.some(idx => newRow[idx] === null))
1381
+ continue;
1382
+ // Partial UNIQUE: an out-of-scope image contributes nothing.
1383
+ const predicate = this.compileFor(uc);
1384
+ if (predicate && predicate.evaluate(newRow) !== true)
1385
+ continue;
1386
+ const conflict = await this.findUniqueConflict(uc, predicate, newRow, selfPks);
1387
+ if (conflict) {
1388
+ const colNames = uc.columns.map(i => schema.columns[i]?.name ?? String(i));
1389
+ throw maintainedTableUniqueViolationError(schema.schemaName, schema.name, uc.name ?? `_uc_${colNames.join('_')}`, colNames, uc.columns.map(i => newRow[i]));
1390
+ }
1391
+ }
1392
+ }
1393
+ }
1394
+ /**
1395
+ * Reset statistics to an absolute committed row count and flush immediately.
1396
+ * Used by the host's `replaceContents` (create-fill / refresh), where the new
1397
+ * count is exact, replacing any drifted delta-tracked estimate.
1398
+ */
1399
+ async resetStats(rowCount) {
1400
+ this.cachedStats = { rowCount, updatedAt: Date.now() };
1401
+ this.pendingStatsDelta = 0;
1402
+ this.mutationCount = 0;
1403
+ await this.flushStats();
1404
+ }
1405
+ /**
1406
+ * Open (and cache) the data store, firing the lazy first-access `saveTableDDL`.
1407
+ * Public for the host's `replaceContents`, which writes the store directly.
1408
+ */
1409
+ openDataStore() {
1410
+ return this.ensureStore();
1411
+ }
1412
+ // ── External row-write surface ────────────────────────────────────────
1413
+ // Module-side entry point for externally-applied writes to a SOURCE table
1414
+ // (the index-maintaining sibling of `StoreBackingHost`, which is for MV
1415
+ // BACKING tables and deliberately keeps no indexes). Resolved per call via
1416
+ // `StoreModule.getTableForExternalWrite`; addresses the table's CURRENT
1417
+ // schema/encoding state so keys and index entries match its own DML paths.
1418
+ /**
1419
+ * Effective (pending-over-committed) point read by PK values — the public
1420
+ * read an external writer issues before an upsert to learn the row's current
1421
+ * image. Thin wrapper over the same private point-lookup that backs
1422
+ * {@link query}'s point arm, so an external read merges pending-over-committed
1423
+ * exactly like an engine read does.
1424
+ */
1425
+ readRowByPk(pk) {
1426
+ return this.readLiveRowByPk(pk);
1427
+ }
1428
+ /**
1429
+ * Apply externally-originated row ops directly to this source table's
1430
+ * COMMITTED storage: table-owned data-key put/delete, secondary-index
1431
+ * maintenance, and stats tracking. The index-maintaining counterpart of
1432
+ * `StoreBackingHost.applyMaintenance` (which targets index-less MV backings),
1433
+ * built for trusted replication-style writes.
1434
+ *
1435
+ * Deliberately:
1436
+ * - emits NO module {@link DataChangeEvent}s — the external writer owns
1437
+ * emission and the `remote` flag;
1438
+ * - opens NO coordinator transaction — writes land in committed state
1439
+ * immediately (`store.put`/`store.delete`, never the coordinator);
1440
+ * - runs NO constraint validation (PK/UNIQUE/CHECK/FK) — the origin is
1441
+ * trusted, mirroring the backing-host posture.
1442
+ *
1443
+ * Returns the EFFECTIVE per-op {@link BackingRowChange}s with accurate
1444
+ * before-images (the shape `Database.ingestExternalRowChanges` consumes),
1445
+ * suppressing no-ops to match the normative upsert-suppression contract in
1446
+ * `vtab/backing-host.ts`: a delete of an absent key, and a value-identical
1447
+ * upsert (`rowsValueIdentical` — byte-faithful, collation-UNAWARE, against the
1448
+ * effective existing row) write nothing and report nothing. A collation-equal /
1449
+ * byte-different upsert (e.g. a case-only rewrite under a NOCASE PK) keeps the
1450
+ * SAME data key (key identity is collation-aware) but IS a real update that
1451
+ * replaces the stored bytes and reports `update`.
1452
+ *
1453
+ * Last-writer-wins against any concurrently pending local transaction on this
1454
+ * table: the external write commits to storage at once, and that transaction's
1455
+ * pending batch may overwrite these keys when it commits. This is the same
1456
+ * posture the prior raw-KV sync adapter took — not a regression, now stated.
1457
+ */
1458
+ async applyExternalRowChanges(ops) {
1459
+ const changes = [];
1460
+ if (ops.length === 0)
1461
+ return changes;
1462
+ // Route through the lazy store-open path so the first external write to a
1463
+ // freshly created table persists its DDL exactly like a first vtab write.
1464
+ const store = await this.ensureStore();
1465
+ for (const op of ops) {
1466
+ switch (op.op) {
1467
+ case 'delete': {
1468
+ const key = this.encodeDataKey(op.pk);
1469
+ const existing = await this.readEffectiveRowByKey(key);
1470
+ if (!existing)
1471
+ break; // absent key → no storage/index/stats op, nothing reported
1472
+ await store.delete(key);
1473
+ await this.updateSecondaryIndexes(false, existing, null, op.pk);
1474
+ this.trackMutation(-1, false);
1475
+ changes.push({ op: 'delete', oldRow: existing });
1476
+ break;
1477
+ }
1478
+ case 'upsert': {
1479
+ const pk = this.extractPK(op.row);
1480
+ const key = this.encodeDataKey(pk);
1481
+ const existing = await this.readEffectiveRowByKey(key);
1482
+ if (existing && rowsValueIdentical(existing, op.row)) {
1483
+ // Byte-identical to the effective row → a true no-op: no write, no
1484
+ // index touch, no stats delta, nothing reported (echo-prevention seam).
1485
+ break;
1486
+ }
1487
+ await store.put(key, serializeRow(op.row));
1488
+ // PK derives from the row, so the key never relocates: oldPk == newPk.
1489
+ await this.updateSecondaryIndexes(false, existing, op.row, pk);
1490
+ if (!existing)
1491
+ this.trackMutation(+1, false);
1492
+ changes.push(existing
1493
+ ? { op: 'update', oldRow: existing, newRow: op.row }
1494
+ : { op: 'insert', newRow: op.row });
1495
+ break;
1496
+ }
1497
+ default: {
1498
+ // A new ExternalRowOp variant must extend this switch; never-assignment
1499
+ // makes that a compile error rather than a silent no-op.
1500
+ const exhaustiveCheck = op;
1501
+ throw new QuereusError(`Unknown external row op: ${JSON.stringify(exhaustiveCheck)}`, StatusCode.INTERNAL);
1502
+ }
1503
+ }
1504
+ }
1505
+ return changes;
1506
+ }
880
1507
  /**
881
1508
  * Fully delete the row at `pk` (data + secondary indexes + stats + delete event).
882
1509
  * Used by REPLACE conflict resolution to evict a conflicting unique row before
@@ -884,9 +1511,9 @@ export class StoreTable extends VirtualTable {
884
1511
  */
885
1512
  async deleteRowAt(inTransaction, pk, oldRow) {
886
1513
  const store = await this.ensureStore();
887
- const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
1514
+ const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
888
1515
  if (inTransaction && this.coordinator) {
889
- this.coordinator.delete(key);
1516
+ this.coordinator.delete(key, store);
890
1517
  }
891
1518
  else {
892
1519
  await store.delete(key);
@@ -949,6 +1576,32 @@ export class StoreTable extends VirtualTable {
949
1576
  // Store remains available for subsequent queries.
950
1577
  // Use destroy() to fully clean up the table.
951
1578
  }
1579
+ /**
1580
+ * Hard teardown: fully detach this instance from the module-wide coordinator.
1581
+ *
1582
+ * Distinct from the per-scan {@link disconnect} (which only flushes stats and
1583
+ * deliberately keeps the table hooked mid-life). Called ONLY at the genuine
1584
+ * eviction sites — `StoreModule.tearDownTableStorage` (drop / reclaim) and
1585
+ * `renameTable` — where this instance is removed from the module's `tables` map
1586
+ * and will never be used again. It best-effort flushes any buffered stats (the
1587
+ * backing store is about to be deleted / relocated, so this is the last chance,
1588
+ * same posture as the teardown-time `disconnect` it replaces), then runs the
1589
+ * coordinator disposer so this instance's {stats apply/discard} callback pair —
1590
+ * and the `this` its closures capture — is spliced off the shared coordinator's
1591
+ * array rather than pinned for the module's lifetime.
1592
+ *
1593
+ * Idempotent: the disposer is run at most once and both it and the coordinator
1594
+ * reference are nulled, so a double-dispose is a no-op and a re-`attachCoordinator`
1595
+ * after dispose registers a fresh pair instead of double-registering.
1596
+ */
1597
+ async dispose() {
1598
+ if (this.mutationCount > 0 && this.store) {
1599
+ await this.flushStats();
1600
+ }
1601
+ this.coordinatorDisposer?.();
1602
+ this.coordinatorDisposer = null;
1603
+ this.coordinator = null;
1604
+ }
952
1605
  /** Get the current estimated row count. */
953
1606
  async getEstimatedRowCount() {
954
1607
  if (this.cachedStats) {