@quereus/store 4.11.0 → 4.12.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 (77) hide show
  1. package/README.md +131 -10
  2. package/dist/src/common/cached-kv-store.d.ts +16 -0
  3. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  4. package/dist/src/common/cached-kv-store.js +43 -0
  5. package/dist/src/common/cached-kv-store.js.map +1 -1
  6. package/dist/src/common/index.d.ts +1 -0
  7. package/dist/src/common/index.d.ts.map +1 -1
  8. package/dist/src/common/index.js +2 -0
  9. package/dist/src/common/index.js.map +1 -1
  10. package/dist/src/common/key-builder.d.ts +9 -6
  11. package/dist/src/common/key-builder.d.ts.map +1 -1
  12. package/dist/src/common/key-builder.js +17 -6
  13. package/dist/src/common/key-builder.js.map +1 -1
  14. package/dist/src/common/kv-store.d.ts +89 -3
  15. package/dist/src/common/kv-store.d.ts.map +1 -1
  16. package/dist/src/common/kv-store.js +21 -1
  17. package/dist/src/common/kv-store.js.map +1 -1
  18. package/dist/src/common/memory-store.d.ts +1 -0
  19. package/dist/src/common/memory-store.d.ts.map +1 -1
  20. package/dist/src/common/memory-store.js +7 -0
  21. package/dist/src/common/memory-store.js.map +1 -1
  22. package/dist/src/common/pk-key-resolution.d.ts +17 -4
  23. package/dist/src/common/pk-key-resolution.d.ts.map +1 -1
  24. package/dist/src/common/pk-key-resolution.js +23 -8
  25. package/dist/src/common/pk-key-resolution.js.map +1 -1
  26. package/dist/src/common/store-module-access-plan.d.ts +1 -1
  27. package/dist/src/common/store-module-access-plan.d.ts.map +1 -1
  28. package/dist/src/common/store-module-access-plan.js +434 -105
  29. package/dist/src/common/store-module-access-plan.js.map +1 -1
  30. package/dist/src/common/store-module-alter-column.d.ts.map +1 -1
  31. package/dist/src/common/store-module-alter-column.js +25 -2
  32. package/dist/src/common/store-module-alter-column.js.map +1 -1
  33. package/dist/src/common/store-module-alter.d.ts.map +1 -1
  34. package/dist/src/common/store-module-alter.js +36 -13
  35. package/dist/src/common/store-module-alter.js.map +1 -1
  36. package/dist/src/common/store-module-catalog.d.ts +6 -4
  37. package/dist/src/common/store-module-catalog.d.ts.map +1 -1
  38. package/dist/src/common/store-module-catalog.js +8 -6
  39. package/dist/src/common/store-module-catalog.js.map +1 -1
  40. package/dist/src/common/store-module-index.d.ts +98 -7
  41. package/dist/src/common/store-module-index.d.ts.map +1 -1
  42. package/dist/src/common/store-module-index.js +236 -81
  43. package/dist/src/common/store-module-index.js.map +1 -1
  44. package/dist/src/common/store-module.d.ts.map +1 -1
  45. package/dist/src/common/store-module.js +7 -3
  46. package/dist/src/common/store-module.js.map +1 -1
  47. package/dist/src/common/store-table-base.d.ts +30 -0
  48. package/dist/src/common/store-table-base.d.ts.map +1 -1
  49. package/dist/src/common/store-table-base.js +71 -12
  50. package/dist/src/common/store-table-base.js.map +1 -1
  51. package/dist/src/common/store-table-constraints.js +3 -3
  52. package/dist/src/common/store-table-constraints.js.map +1 -1
  53. package/dist/src/common/store-table-scan.d.ts +208 -98
  54. package/dist/src/common/store-table-scan.d.ts.map +1 -1
  55. package/dist/src/common/store-table-scan.js +353 -105
  56. package/dist/src/common/store-table-scan.js.map +1 -1
  57. package/dist/src/testing/index.d.ts +17 -5
  58. package/dist/src/testing/index.d.ts.map +1 -1
  59. package/dist/src/testing/index.js +16 -5
  60. package/dist/src/testing/index.js.map +1 -1
  61. package/dist/src/testing/kv-conformance.d.ts +49 -0
  62. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  63. package/dist/src/testing/kv-conformance.js +125 -0
  64. package/dist/src/testing/kv-conformance.js.map +1 -1
  65. package/dist/src/testing/kv-lifecycle.d.ts +22 -0
  66. package/dist/src/testing/kv-lifecycle.d.ts.map +1 -0
  67. package/dist/src/testing/kv-lifecycle.js +12 -0
  68. package/dist/src/testing/kv-lifecycle.js.map +1 -0
  69. package/dist/src/testing/kv-naming-conformance.d.ts +77 -0
  70. package/dist/src/testing/kv-naming-conformance.d.ts.map +1 -0
  71. package/dist/src/testing/kv-naming-conformance.js +208 -0
  72. package/dist/src/testing/kv-naming-conformance.js.map +1 -0
  73. package/dist/src/testing/kv-reclaim-conformance.d.ts +52 -0
  74. package/dist/src/testing/kv-reclaim-conformance.d.ts.map +1 -0
  75. package/dist/src/testing/kv-reclaim-conformance.js +287 -0
  76. package/dist/src/testing/kv-reclaim-conformance.js.map +1 -0
  77. package/package.json +5 -5
@@ -11,8 +11,44 @@ import { IndexConstraintOp, QuereusError, StatusCode, compareSqlValuesFast, crea
11
11
  import { bytesToHex, compareBytes } from './bytes.js';
12
12
  import { buildIndexPrefixBounds, buildFullScanBounds, } from './key-builder.js';
13
13
  import { deserializeRow, } from './serialization.js';
14
- import { indexLeadingRangeIsOrderSafe, indexPrefixSeekIsCollationExact, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolveIndexKeyTransforms, semanticProbeIsKeyFaithful } from './pk-key-resolution.js';
14
+ import { indexPrefixSeekIsCollationExact, indexRangeAtPositionIsOrderSafe, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolveIndexKeyTransforms, semanticProbeIsKeyFaithful } from './pk-key-resolution.js';
15
15
  import { StoreTableBase } from './store-table-base.js';
16
+ /**
17
+ * The pushed constraint ops that bound a byte window from one side. The scan-side twin of
18
+ * `store-module-access-plan.ts`'s `RANGE_OPS` (which names the same four in the planner's
19
+ * `PredicateConstraint` spelling), shared by all three window builders below so a new
20
+ * bound operator cannot reach one arm and not the others.
21
+ */
22
+ const RANGE_CONSTRAINT_OPS = [
23
+ IndexConstraintOp.LT,
24
+ IndexConstraintOp.LE,
25
+ IndexConstraintOp.GT,
26
+ IndexConstraintOp.GE,
27
+ ];
28
+ /**
29
+ * Index entries per row-resolution batch: the scan arms below collect up to this many
30
+ * surviving index entries (or multi-seek point keys) and resolve them with ONE
31
+ * {@link StoreTableBase.readEffectiveRowsByKeys} call, so an indexed read of N rows
32
+ * costs on the order of N / 256 store round trips instead of N serialized point reads
33
+ * (on IndexedDB, one readonly transaction instead of N). This is the bound
34
+ * {@link KVStore.getMany}'s "bounded by the caller" note refers to.
35
+ *
36
+ * 256 matches the iterate pager's default page (`paged-iterate.ts` DEFAULT_BATCH_SIZE)
37
+ * and makes the same trade that pager already makes and the {@link KVStore.iterate}
38
+ * contract documents: a little early-termination cost for throughput — `limit 1`
39
+ * collects one batch rather than one row — while keeping peak memory independent of
40
+ * the range size.
41
+ *
42
+ * NOTE: that early-termination cost is bounded but not free — `limit 1` deserializes up
43
+ * to 256 rows to yield one, and holds them all at once. It is charged against ROUND
44
+ * TRIPS, which is what dominates on the backends this exists for (the index side already
45
+ * pages 256 entries per read, so a stopped scan costs one data round trip either way),
46
+ * so it is accepted as-is. Revisit if a `limit`-shaped query (a correlated `EXISTS`, a
47
+ * semi-join probe) shows up in a profile as row-decode or allocation cost: the fix is a
48
+ * SMALLER FIRST batch that ramps toward this bound, which keeps a drain's round-trip
49
+ * count within a log factor while making a stopped scan cost ~one row again.
50
+ */
51
+ export const ROW_RESOLUTION_BATCH = 256;
16
52
  /**
17
53
  * Read-path layer of the generic KVStore-backed virtual table.
18
54
  *
@@ -146,8 +182,7 @@ export class StoreTableScan extends StoreTableBase {
146
182
  }
147
183
  // Check for range constraints on first PK column
148
184
  const firstPkCol = pkColumns[0];
149
- const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
150
- const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
185
+ const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && RANGE_CONSTRAINT_OPS.includes(c.constraint.op)) || [];
151
186
  // A range window is only sound when the leading PK column's key bytes order the way
152
187
  // its comparator does; otherwise fall through to the full scan, where matchesFilters
153
188
  // applies the range under the real comparator.
@@ -286,24 +321,32 @@ export class StoreTableScan extends StoreTableBase {
286
321
  * Analyze filter info to determine a secondary-index access pattern, mirroring
287
322
  * {@link analyzePKAccess} but over the index chosen in `idxStr`.
288
323
  *
289
- * A contiguous leading-prefix EQ on the index columns yields a `point` window
290
- * (the prefix covers every entry sharing those leading values — an index seek
291
- * is a PREFIX scan, not a single row, since the index need not be unique and
292
- * the PK suffix varies); otherwise a range (LT/LE/GT/GE) on the LEADING index
293
- * column yields a `range` window. Returns null when neither applies or when the
294
- * index is unresolved. {@link matchesFilters} stays the authoritative row filter,
295
- * so the window need only be a SUPERSET.
324
+ * Three window shapes, in the order they are tried:
325
+ *
326
+ * - a contiguous leading-prefix EQ plus a LT/LE/GT/GE bound on the NEXT index column
327
+ * (the `prefixRangeSeek` / `plan=7` plan) — one `range` window covering just the
328
+ * bounded slice INSIDE the prefix;
329
+ * - a contiguous leading-prefix EQ alone → a `point` window (the prefix covers every
330
+ * entry sharing those leading values — an index seek is a PREFIX scan, not a single
331
+ * row, since the index need not be unique and the PK suffix varies);
332
+ * - otherwise a range (LT/LE/GT/GE) on the LEADING index column → a `range` window.
333
+ *
334
+ * Returns null when none applies or when the index is unresolved.
335
+ * {@link matchesFilters} stays the authoritative row filter, so the window need only be
336
+ * a SUPERSET — which is why every degraded path below WIDENS (a prefix-range that
337
+ * cannot narrow falls back to the prefix window, a prefix that cannot encode a member
338
+ * stops short) rather than declining outright.
296
339
  *
297
340
  * Index-column bytes are encoded under each column's own key collation
298
341
  * ({@link indexKeyCollations}); {@link matchesFilters} re-checks a fetched row under the
299
342
  * index column's `COLLATE` else the table column's declared collation. The EQ/prefix
300
343
  * window is EXACTLY the qualifying set when those two agree, which
301
- * {@link indexPrefixSeekIsCollationExact} decides; the RANGE window additionally equates
302
- * memcmp of the key bytes with the residual's comparison ORDER, which
303
- * {@link indexRangeIsOrderSafe} decides. `tryIndexAccessPlan`
304
- * (store-module-access-plan.ts) consults the SAME two predicates before claiming the
305
- * filters handled, so the two decisions cannot disagree. Either failing returns null and
306
- * the caller full-scans, where the residual is authoritative.
344
+ * {@link indexPrefixSeekIsCollationExact} decides; a RANGE window additionally equates
345
+ * memcmp of the key bytes with the residual's comparison ORDER at the bounded column's
346
+ * position, which {@link indexRangeIsOrderSafe} decides. `tryIndexAccessPlan`
347
+ * (store-module-access-plan.ts) consults the SAME two predicates, at the same positions,
348
+ * before claiming the filters handled, so the two decisions cannot disagree. An EQ-prefix
349
+ * failure returns null and the caller full-scans, where the residual is authoritative.
307
350
  */
308
351
  analyzeIndexAccess(filterInfo) {
309
352
  const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
@@ -338,22 +381,96 @@ export class StoreTableScan extends StoreTableBase {
338
381
  if (!indexPrefixSeekIsCollationExact(this.tableSchema.columns, index, indexCollations, eqValues.length)) {
339
382
  return null;
340
383
  }
384
+ // Prefix-equality + trailing-range. `prefixLen` comes from the idxStr rather
385
+ // than being re-inferred from the constraint list, so a plan that meant a
386
+ // different prefix length is caught instead of silently windowing the wrong
387
+ // columns. `prefixLen > eqValues.length` means an unfaithful semantic probe cut
388
+ // the prefix short above; the bound then belongs to a column the window no
389
+ // longer reaches, so it is dropped and the shorter prefix window (a strict
390
+ // superset) serves — {@link matchesFilters} still applies it.
391
+ const prefixLen = this.prefixRangeSeekLength(filterInfo, index);
392
+ if (prefixLen !== null && prefixLen <= eqValues.length) {
393
+ const bounds = this.buildPrefixRangeWindow(filterInfo, index, eqValues.slice(0, prefixLen));
394
+ if (bounds)
395
+ return { index, type: 'range', bounds };
396
+ }
341
397
  const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length), indexCollations.slice(0, eqValues.length), this.indexKeyTransforms(index).slice(0, eqValues.length));
342
398
  return { index, type: 'point', bounds };
343
399
  }
344
400
  // Else a range on the LEADING index column.
345
401
  const leadingCol = indexCols[0];
346
- const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
347
- const rangeConstraints = (filterInfo.constraints ?? []).filter(c => c.constraint.iColumn === leadingCol && rangeOps.includes(c.constraint.op));
348
- if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index)) {
349
- const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
350
- op: c.constraint.op,
351
- value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
352
- })), indexDirections[0], indexCollations[0], this.tableSchema.columns[leadingCol]?.logicalType, this.indexKeyTransforms(index)[0]);
402
+ const rangeConstraints = this.rangeConstraintsOn(filterInfo, leadingCol);
403
+ if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index, 0)) {
404
+ const bounds = this.buildIndexRangeBounds([], rangeConstraints, indexDirections.slice(0, 1), indexCollations.slice(0, 1), this.tableSchema.columns[leadingCol]?.logicalType, this.indexKeyTransforms(index).slice(0, 1));
353
405
  return { index, type: 'range', bounds };
354
406
  }
355
407
  return null;
356
408
  }
409
+ /**
410
+ * The prefix length a `prefixRangeSeek` (`plan=7`) FilterInfo declares, or null when
411
+ * this FilterInfo is not one.
412
+ *
413
+ * Honoring the plan's own `prefixLen` — rather than re-deriving it from the constraint
414
+ * list — is what makes a plan/scan disagreement LOUD: a value outside `1 …
415
+ * index.columns.length - 1` cannot describe a window over this index, and the plan
416
+ * already dropped the residual for the filters it claimed, so guessing a length would
417
+ * silently address the wrong columns.
418
+ */
419
+ prefixRangeSeekLength(filterInfo, index) {
420
+ const spec = decodeIdxStr(filterInfo.idxStr);
421
+ if (!spec || planKindFromCode(spec.plan) !== 'prefixRangeSeek')
422
+ return null;
423
+ const raw = spec.params.get('prefixLen');
424
+ const prefixLen = Number.parseInt(raw ?? '', 10);
425
+ if (!Number.isInteger(prefixLen) || prefixLen < 1 || prefixLen >= index.columns.length) {
426
+ this.malformedFilterInfo('prefix-range seek', filterInfo, `prefixLen=${raw} against ${index.name}'s ${index.columns.length} columns`);
427
+ }
428
+ return prefixLen;
429
+ }
430
+ /** The pushed LT/LE/GT/GE constraints on `columnIndex`, paired with their bound values. */
431
+ rangeConstraintsOn(filterInfo, columnIndex) {
432
+ return (filterInfo.constraints ?? [])
433
+ .filter(c => c.constraint.iColumn === columnIndex && RANGE_CONSTRAINT_OPS.includes(c.constraint.op))
434
+ .map(c => ({
435
+ op: c.constraint.op,
436
+ value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
437
+ }));
438
+ }
439
+ /**
440
+ * The narrowed window for a prefix-equality + trailing-range seek: the equality prefix
441
+ * bytes, further bounded by the LT/LE/GT/GE constraints on the index column right after
442
+ * it. Null when the trailing bound cannot be used, in which case the caller falls back
443
+ * to the plain prefix window — a strict SUPERSET, so the answer is unchanged and only
444
+ * the narrowing is lost. Two reasons for that:
445
+ *
446
+ * - the trailing column's key bytes do not reproduce its residual comparator's ORDER
447
+ * ({@link indexRangeIsOrderSafe}) — `tryIndexAccessPlan` degrades to the plain prefix
448
+ * seek through the same predicate, so this is the matching scan-side move rather than
449
+ * a disagreement;
450
+ * - no bound constraint survived on that column at all (nothing to narrow with).
451
+ */
452
+ buildPrefixRangeWindow(filterInfo, index, prefixValues) {
453
+ const position = prefixValues.length;
454
+ const trailingCol = index.columns[position]?.index;
455
+ if (trailingCol === undefined)
456
+ return null;
457
+ if (!this.indexRangeIsOrderSafe(index, position))
458
+ return null;
459
+ // NOTE: a `plan=7` with NO bound on the trailing column is as structurally impossible
460
+ // as a bad `prefixLen` — `rule-select-access-path` only emits the plan once it has
461
+ // found a lower or upper bound, and `seekColumns` is advertised in index-column
462
+ // order, so `index.columns[prefixLen]` IS the column the rule bounded. It stays SOFT
463
+ // anyway because the two impossibilities fail differently: a wrong `prefixLen` would
464
+ // address the wrong columns (an under-fetch, a wrong answer), while a missing bound
465
+ // only fails to narrow. If a plan source ever does hand this over, the symptom is a
466
+ // slow query, not a wrong one — make it loud only if that silence starts hiding a
467
+ // real disagreement.
468
+ const rangeConstraints = this.rangeConstraintsOn(filterInfo, trailingCol);
469
+ if (rangeConstraints.length === 0)
470
+ return null;
471
+ const width = position + 1;
472
+ return this.buildIndexRangeBounds(prefixValues, rangeConstraints, index.columns.slice(0, width).map(c => !!c.desc), this.indexKeyCollations(index).slice(0, width), this.tableSchema.columns[trailingCol]?.logicalType, this.indexKeyTransforms(index).slice(0, width));
473
+ }
357
474
  /**
358
475
  * Lazy cache behind {@link indexKeyCollations}: one resolved array per index-schema
359
476
  * object, invalidated when the column array it was resolved against is replaced (an
@@ -443,25 +560,37 @@ export class StoreTableScan extends StoreTableBase {
443
560
  });
444
561
  }
445
562
  /**
446
- * True when a byte window over the LEADING column of `index` reproduces the order the
447
- * residual comparison uses. Delegates to {@link indexLeadingRangeIsOrderSafe} — the SAME
448
- * call the range arm of `tryIndexAccessPlan` (store-module-access-plan.ts) makes before
449
- * it marks the range filters handled, so the "build a window" and "mark handled"
450
- * decisions cannot disagree. The memoized {@link indexKeyCollations} is passed as the
451
- * key side, so the predicate judges the bytes this scan actually addresses.
563
+ * True when a byte window bounded on index column `position` reproduces the order the
564
+ * residual comparison uses there. Delegates to {@link indexRangeAtPositionIsOrderSafe} —
565
+ * the SAME call `tryIndexAccessPlan` (store-module-access-plan.ts) makes, at the same
566
+ * position, before it marks the range filters handled, so the "build a window" and "mark
567
+ * handled" decisions cannot disagree. The memoized {@link indexKeyCollations} is passed
568
+ * as the key side, so the predicate judges the bytes this scan actually addresses.
569
+ *
570
+ * `position` is 0 for a standalone leading-column range and the prefix length for a
571
+ * prefix-equality + trailing-range window.
452
572
  */
453
- indexRangeIsOrderSafe(index) {
454
- return indexLeadingRangeIsOrderSafe(this.db, this.tableSchema.columns, index, this.indexKeyCollations(index));
573
+ indexRangeIsOrderSafe(index, position) {
574
+ return indexRangeAtPositionIsOrderSafe(this.db, this.tableSchema.columns, index, this.indexKeyCollations(index), position);
455
575
  }
456
576
  /**
457
- * Convert leading-index-column LT/LE/GT/GE constraints into one encoded-byte
458
- * `gte`/`lt` window — the secondary-index analogue of {@link buildPKRangeBounds}.
577
+ * Convert LT/LE/GT/GE constraints on ONE index column into a single encoded-byte
578
+ * `gte`/`lt` window, optionally nested inside a fixed equality prefix — the
579
+ * secondary-index analogue of {@link buildPKRangeBounds}.
459
580
  *
460
- * Each bound value is encoded under the leading index column's own key `collation`
461
- * ({@link indexKeyCollations}) and DESC `dir` — exactly as {@link buildIndexKey}
462
- * encodes that column — via {@link buildIndexPrefixBounds}, giving the byte region
463
- * `[lo, hi)` whose leading column equals that value. The op maps that region's
464
- * endpoints onto `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path:
581
+ * The bounded column sits at position `prefixValues.length`; `directions`,
582
+ * `collations` and `transforms` are the index's own per-column arrays sliced to cover
583
+ * the prefix AND that column. An empty `prefixValues` is the standalone leading-column
584
+ * range; a non-empty one is the trailing bound of a `prefixRangeSeek`.
585
+ *
586
+ * Each bound value is encoded BEHIND the prefix, under the bounded column's own key
587
+ * collation ({@link indexKeyCollations}) and DESC direction — exactly as
588
+ * {@link buildIndexKey} encodes that column — via {@link buildIndexPrefixBounds},
589
+ * giving the byte region `[lo, hi)` whose prefix columns equal `prefixValues` and whose
590
+ * bounded column equals that value. The op maps that region's endpoints onto
591
+ * `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path — driven by the BOUNDED
592
+ * column's direction alone, since a DESC prefix column merely inverts bytes *inside* a
593
+ * prefix that stays fixed:
465
594
  *
466
595
  * | op | ASC | DESC |
467
596
  * |----|----------|----------|
@@ -470,29 +599,34 @@ export class StoreTableScan extends StoreTableBase {
470
599
  * | LE | lt = hi | gte = lo |
471
600
  * | LT | lt = lo | gte = hi |
472
601
  *
473
- * Across constraints keep the MAX lower and MIN upper. An `undefined` upper (an
474
- * `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
475
- * SUPERSET. A NULL/missing bound value is skipped (the planner never pushes
476
- * `= NULL`, and a range op against NULL rejects every row in matchesFilters).
477
- * A bound over a semantic-ordering leading column (`logicalType`) whose VALUE has
478
- * no faithful byte position ({@link semanticProbeIsKeyFaithful}) is skipped the
479
- * same way — a dropped RANGE bound only widens the window, and matchesFilters
480
- * reproduces the predicate under the type's compare; see the widen-vs-decline
481
- * note on {@link buildPKRangeBounds}. `transform` is the leading column's key
482
- * value transform ({@link indexKeyTransforms}), so a surviving bound addresses
483
- * the same transformed bytes the index store holds.
602
+ * Both sides START at the prefix's own window (`[P, incr(P))`, or unbounded for an
603
+ * empty prefix) and are then tightened: keep the MAX lower and MIN upper across
604
+ * constraints. So every degraded path lands back on the prefix window, never wider:
605
+ * an `undefined` upper (an `hi` whose increment overflowed all-0xff), a NULL/missing
606
+ * bound value (the planner never pushes `= NULL`, and a range op against NULL rejects
607
+ * every row in matchesFilters), and a bound over a semantic-ordering column
608
+ * (`logicalType`) whose VALUE has no faithful byte position
609
+ * ({@link semanticProbeIsKeyFaithful}) are all simply skipped — a dropped RANGE bound
610
+ * only widens the window, and matchesFilters reproduces the predicate under the type's
611
+ * compare; see the widen-vs-decline note on {@link buildPKRangeBounds}. `transforms`
612
+ * carry each column's key value transform ({@link indexKeyTransforms}), so a surviving
613
+ * bound addresses the same transformed bytes the index store holds.
484
614
  */
485
- buildIndexRangeBounds(constraints, dir, collation, logicalType, transform) {
486
- const full = buildFullScanBounds();
487
- let gte = full.gte;
488
- let lt;
615
+ buildIndexRangeBounds(prefixValues, constraints, directions, collations, logicalType, transforms) {
616
+ const position = prefixValues.length;
617
+ const dir = directions[position];
618
+ // The prefix's own window is the fallback for BOTH sides: `buildIndexPrefixBounds`
619
+ // answers full-scan bounds for an empty prefix, reproducing the leading-column arm.
620
+ const base = buildIndexPrefixBounds([...prefixValues], this.encodeOptions, directions.slice(0, position), collations.slice(0, position), transforms.slice(0, position));
621
+ let gte = base.gte;
622
+ let lt = base.lt;
489
623
  for (const c of constraints) {
490
624
  if (c.value === undefined || c.value === null)
491
625
  continue;
492
626
  // Widen, don't decline — see the doc comment above.
493
627
  if (!semanticProbeIsKeyFaithful(logicalType, c.value))
494
628
  continue;
495
- const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions, [dir], [collation], [transform]);
629
+ const { gte: lo, lt: hi } = buildIndexPrefixBounds([...prefixValues, c.value], this.encodeOptions, directions, collations, transforms);
496
630
  const lower = !dir
497
631
  ? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
498
632
  : (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
@@ -510,27 +644,36 @@ export class StoreTableScan extends StoreTableBase {
510
644
  * Scan a secondary index over `access.bounds`, resolving each index entry to its
511
645
  * base row and re-filtering.
512
646
  *
513
- * {@link iterateEffective} yields the committed index entries merged with this
514
- * transaction's pending index puts/deletes (read-your-own-writes over the
515
- * index), in index-key byte order — the order the isolation overlay merge relies
516
- * on (`isolated-table.ts` § buildSortKey). We resolve each entry to its row via
517
- * its stored data-key value WITHOUT reordering, so index-key order is preserved.
518
- *
519
- * Defense in depth mirroring the memory layer's live-recheck: a resolved-null
520
- * row (the entry's row was deleted — a pending index delete would normally
521
- * suppress the entry, but a committed entry can lag) is skipped, and every
522
- * resolved row is re-checked by {@link matchesFilters} (the byte window is only
523
- * a superset, and a stale entry whose indexed column no longer matches is
524
- * dropped).
647
+ * One window of the producer/consumer pair below: {@link produceIndexEntries}
648
+ * walks the window's index entries and {@link resolveIndexEntries} resolves them
649
+ * to rows in {@link ROW_RESOLUTION_BATCH}-bounded batches — one
650
+ * {@link StoreTableBase.readEffectiveRowsByKeys} round trip per batch instead of
651
+ * one awaited point read per entry. A multi-seek runs the SAME consumer over the
652
+ * concatenation of every window's producer instead of calling this method — see
653
+ * {@link scanMultiSeek}.
525
654
  */
526
- async *scanIndex(indexStore, access, filterInfo, multi) {
655
+ async *scanIndex(indexStore, access, filterInfo) {
527
656
  // Re-check each resolved row under the INDEX's per-column collation (see
528
657
  // matchesFilters): the planner dropped the residual based on the index
529
658
  // column's collation, which an explicit index `COLLATE` can make differ from
530
659
  // the table column's declared collation.
531
- const indexCollations = multi?.collations
532
- ?? this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
533
- for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
660
+ const indexCollations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
661
+ yield* this.resolveIndexEntries(this.produceIndexEntries(indexStore, access.bounds, [filterInfo]), indexCollations);
662
+ }
663
+ /**
664
+ * Producer half of the index-scan pair: walk one byte window's index entries —
665
+ * {@link iterateEffective} yields the committed entries merged with this
666
+ * transaction's pending index puts/deletes (read-your-own-writes over the index),
667
+ * in index-key byte order, the order the isolation overlay merge relies on
668
+ * (`isolated-table.ts` § buildSortKey) — and yield, per surviving entry, its data
669
+ * key plus the FilterInfos a resolved row may match. Entries stay in visit order
670
+ * end to end, so index-key order is preserved through the batching consumer.
671
+ *
672
+ * Only the defenses that need nothing but the entry itself run here, BEFORE the
673
+ * row read; everything row-dependent lives in {@link resolveRowBatch}.
674
+ */
675
+ async *produceIndexEntries(indexStore, bounds, infos, seen) {
676
+ for await (const entry of this.iterateEffective(indexStore, bounds)) {
534
677
  // NOTE: a legacy index store (written before index values carried the data
535
678
  // key) holds EMPTY values; a zero-length data key is not a row key, so skip
536
679
  // it rather than resolve it to the wrong row. Because the access plan marked
@@ -544,31 +687,97 @@ export class StoreTableScan extends StoreTableBase {
544
687
  // fall back to a full scan the first time an empty value is seen.
545
688
  if (entry.value.length === 0)
546
689
  continue;
547
- // Cross-window dedup for a multi-seek: a data key an earlier window already
690
+ // Cross-window dedup for a multi-seek: a data key an EARLIER window already
548
691
  // YIELDED is skipped before the data-store read, so a duplicate costs no
549
- // extra `get`. The seen-set is only ever ADDED to on a yield (below) — adding
550
- // at visit time would let a stale index entry that fails its residual poison
551
- // the set and suppress the row's live entry in a later window.
552
- const dataKeyHex = multi ? bytesToHex(entry.value) : undefined;
553
- if (dataKeyHex !== undefined && multi.seen.has(dataKeyHex))
554
- continue;
555
- // NOTE: one extra data-store `get` per matched index entry — the row lives
556
- // in the data store, not the index (the index value carries only the data
557
- // key, no covering payload). Fine now; if index-covered scans ever dominate
558
- // a profile, consider storing the serialized row as a covering index value,
559
- // at the cost of an index rewrite on EVERY column change (not just indexed
560
- // columns) — deliberately not done here.
561
- const row = await this.readEffectiveRowByKey(entry.value);
692
+ // extra point read. This is only a PRE-check — a duplicate inside one
693
+ // not-yet-resolved batch is invisible to it and is caught by
694
+ // {@link resolveRowBatch}'s post-read re-check. The seen-set is only ever
695
+ // ADDED to on a yield (there) — adding at visit time would let a stale index
696
+ // entry that fails its residual poison the set and suppress the row's live
697
+ // entry in a later window.
698
+ let dataKeyHex;
699
+ if (seen) {
700
+ dataKeyHex = bytesToHex(entry.value);
701
+ if (seen.has(dataKeyHex))
702
+ continue;
703
+ }
704
+ yield { dataKey: entry.value, dataKeyHex, infos };
705
+ }
706
+ }
707
+ /**
708
+ * Consumer half of the index-scan pair: collect up to {@link ROW_RESOLUTION_BATCH}
709
+ * produced entries and resolve each batch through {@link resolveRowBatch} — one
710
+ * store round trip per batch. Batching changes the round-trip count, never the row
711
+ * set or its order: batches are resolved and yielded in entry order.
712
+ *
713
+ * Early termination: a downstream `break` mid-batch finishes this generator, whose
714
+ * `for await` closes `entries` — and the iterate cursor under it — on the way out;
715
+ * no further batch is collected or read.
716
+ */
717
+ async *resolveIndexEntries(entries, collations, seen) {
718
+ let batch = [];
719
+ for await (const entry of entries) {
720
+ batch.push(entry);
721
+ if (batch.length >= ROW_RESOLUTION_BATCH) {
722
+ yield* this.resolveRowBatch(batch, collations, seen);
723
+ batch = [];
724
+ }
725
+ }
726
+ if (batch.length > 0) {
727
+ yield* this.resolveRowBatch(batch, collations, seen);
728
+ }
729
+ }
730
+ /**
731
+ * Resolve one bounded batch of index entries to rows — a single
732
+ * {@link StoreTableBase.readEffectiveRowsByKeys} call (which applies this
733
+ * transaction's pending deletes/puts BEFORE touching the committed store; the
734
+ * precedence is not re-implemented here) — then run the row-dependent defenses in
735
+ * entry order, mirroring the memory layer's live-recheck:
736
+ *
737
+ * - a resolved-null row (the entry's row was deleted — a pending index delete
738
+ * would normally suppress the entry, but a committed entry can lag) is skipped,
739
+ * and cannot shift later positions (the read answers positionally);
740
+ * - the multi-seek `seen` set is RE-checked after the read: the producer's
741
+ * pre-check ran before this batch was collected, so two entries at one data key
742
+ * inside a single batch (a stale index entry) are only caught here — without
743
+ * the re-check the row would yield twice;
744
+ * - a row is accepted when it matches ANY of the entry's FilterInfos (the byte
745
+ * window is only a superset, and a stale entry whose indexed column no longer
746
+ * matches is dropped), and `seen` is added to on yield ALONE — a stale entry
747
+ * that fails its residual must not suppress the row's live entry later.
748
+ *
749
+ * NOTE: still one data-store point read per matched index entry — the row lives in
750
+ * the data store, not the index (the index value carries only the data key, no
751
+ * covering payload); batching collapses the reads' round trips, not their count.
752
+ * If index-covered scans ever dominate a profile, consider storing the serialized
753
+ * row as a covering index value, at the cost of an index rewrite on EVERY column
754
+ * change (not just indexed columns) — deliberately not done here.
755
+ */
756
+ async *resolveRowBatch(batch, collations, seen) {
757
+ const rows = await this.readEffectiveRowsByKeys(batch.map(e => e.dataKey));
758
+ for (let i = 0; i < batch.length; i++) {
759
+ const row = rows[i];
562
760
  if (!row)
563
761
  continue;
564
- if (this.matchesFilters(row, filterInfo, indexCollations)
565
- || (multi !== undefined && multi.extraTuples.some(fi => this.matchesFilters(row, fi, indexCollations)))) {
566
- if (dataKeyHex !== undefined)
567
- multi.seen.add(dataKeyHex);
762
+ const { dataKeyHex, infos } = batch[i];
763
+ if (seen && dataKeyHex !== undefined && seen.has(dataKeyHex))
764
+ continue;
765
+ if (infos.some(fi => this.matchesFilters(row, fi, collations))) {
766
+ if (seen && dataKeyHex !== undefined)
767
+ seen.add(dataKeyHex);
568
768
  yield row;
569
769
  }
570
770
  }
571
771
  }
772
+ /**
773
+ * Fail loudly on a structurally impossible FilterInfo — one whose own idxStr describes
774
+ * an access this table's schema cannot express. Not a soundness fallback: everything
775
+ * that is merely UNSERVEABLE degrades to a wider window plus the {@link matchesFilters}
776
+ * residual instead of raising. `kind` names the plan shape for the message.
777
+ */
778
+ malformedFilterInfo(kind, filterInfo, why) {
779
+ throw new QuereusError(`Malformed ${kind} FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
780
+ }
572
781
  /**
573
782
  * Fail loudly on a multi-seek FilterInfo this table cannot serve. The plan already
574
783
  * dropped the residual Filter, and {@link matchesFilters} ANDs every pushed
@@ -576,7 +785,7 @@ export class StoreTableScan extends StoreTableBase {
576
785
  * equalities and return zero rows: a silent wrong answer, not a slow one.
577
786
  */
578
787
  multiSeekMalformed(filterInfo, why) {
579
- throw new QuereusError(`Malformed multi-seek FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
788
+ this.malformedFilterInfo('multi-seek', filterInfo, why);
580
789
  }
581
790
  /**
582
791
  * Decode a multi-seek FilterInfo into its N seek tuples of width W. Tuple i pairs
@@ -645,16 +854,20 @@ export class StoreTableScan extends StoreTableBase {
645
854
  * away before scanning:
646
855
  * - Tuples whose encoded prefix byte-matches (duplicate bound parameters, or
647
856
  * case variants under a NOCASE key collation C) share ONE window, each kept
648
- * as a residual alternative — see {@link MultiSeekWindowContext}.
857
+ * as a residual alternative — see {@link PendingIndexEntry}.
649
858
  * - A window with no finite upper bound (all-0xff prefix — see
650
859
  * buildIndexPrefixBounds) contains every later-sorting window outright (any
651
860
  * key ≥ an all-0xff prefix necessarily starts with it), so those windows fold
652
861
  * into it instead of re-scanning an overlapping range out of order.
653
862
  *
654
- * Emission is lazy per window — `… in (…) limit 1` stops after the first yielded
655
- * row without materializing the remaining windows. Each window goes through
656
- * {@link scanIndex} → {@link iterateEffective}, so read-your-own-writes and the
657
- * stale-entry / deleted-row defenses hold per seek key.
863
+ * Emission is lazy and batched ACROSS windows: every window's
864
+ * {@link produceIndexEntries} feeds ONE {@link resolveIndexEntries} consumer, whose
865
+ * batches are bounded by ENTRY count, not window count — so a hundred single-row
866
+ * windows collapse into a single row-resolution round trip, and
867
+ * `… in (…) limit 1` materializes at most one {@link ROW_RESOLUTION_BATCH} batch
868
+ * rather than every window. Each window's entries still come from
869
+ * {@link iterateEffective}, so read-your-own-writes and the stale-entry /
870
+ * deleted-row defenses hold per seek key.
658
871
  */
659
872
  async *scanMultiSeek(spec, filterInfo) {
660
873
  const { tuples, seekWidth } = this.decodeMultiSeekTuples(spec, filterInfo);
@@ -726,20 +939,45 @@ export class StoreTableScan extends StoreTableBase {
726
939
  // Identical for every window (resolveFilterCollations dedups by column), so
727
940
  // resolve once and thread it through.
728
941
  const collations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(index));
942
+ // Data-key hexes already YIELDED, across ALL windows — the multi-seek dedup set
943
+ // (see produceIndexEntries / resolveRowBatch for the pre-check / add-on-yield
944
+ // split). NOTE: holds one hex string per row the multi-seek yields, for the
945
+ // whole seek — the only unbounded allocation on this path. Windows are
946
+ // byte-disjoint, so the only real duplicate source is a stale index entry; if a
947
+ // large-result `IN` ever shows up as a memory problem, the set can be scoped
948
+ // per window (or dropped for a consistent store).
729
949
  const seen = new Set();
730
- for (const w of disjoint) {
731
- yield* this.scanIndex(indexStore, { index, type: 'point', bounds: w.bounds }, w.infos[0], { seen, collations, extraTuples: w.infos.slice(1) });
950
+ // ONE consumer over the concatenation of every window's producer: the windows
951
+ // are sorted disjoint by encoded key, so the concatenation is already the
952
+ // emission order, and bounding batches by entry count gives the cross-window
953
+ // collapse (a hundred single-row windows become one round trip).
954
+ yield* this.resolveIndexEntries(this.produceMultiSeekEntries(indexStore, disjoint, seen), collations, seen);
955
+ }
956
+ /**
957
+ * Concatenate the disjoint multi-seek windows' producers, in window (byte) order.
958
+ * Each entry carries its window's full FilterInfo list — the primary tuple plus
959
+ * the byte-equal merged alternatives — so acceptance stays per row, in order (see
960
+ * {@link PendingIndexEntry}).
961
+ */
962
+ async *produceMultiSeekEntries(indexStore, windows, seen) {
963
+ for (const w of windows) {
964
+ yield* this.produceIndexEntries(indexStore, w.bounds, w.infos, seen);
732
965
  }
733
966
  }
734
967
  /**
735
968
  * Multi-seek over the PRIMARY key: each tuple is a full PK, resolved by a point
736
969
  * read in ascending encoded-data-key order (the table's native emission order).
737
970
  *
738
- * NOT reachable from this module's own plans today: `computeBestAccessPlan` (store-module-access-plan.ts)
739
- * claims IN-list filters only for secondary indexes (see its EQ_OPS vs EQ_OR_IN_OPS
740
- * split and tickets/backlog/feat-store-pk-in-list-multiseek). The branch exists so
741
- * a `_primary_` multi-seek arriving from a future plan gets a correct answer rather
742
- * than the scan arm's silent zero rows, and it is what PK-IN enablement will build on.
971
+ * Reached from `computeBestAccessPlan`'s primary-key multi-seek arm
972
+ * (store-module-access-plan.ts), which claims a whole-primary-key `IN` — literal list
973
+ * or runtime-valued set — through `EQ_OR_IN_OPS` and advertises `_primary_` with every
974
+ * PK column as its seek columns. That arm declines exactly the two shapes the
975
+ * {@link multiSeekMalformed} throws below assert against (over the seek cap, and a
976
+ * semantic-ordering PK member), so a throw here means the plan and the scan disagreed.
977
+ *
978
+ * The ascending-encoded-key emission order is a contract, not an implementation
979
+ * detail: the isolation layer merges this stream with its overlay by primary key
980
+ * (`bug-isolation-multiseek-merge-order`), and the arm advertises PK ordering off it.
743
981
  */
744
982
  async *scanMultiSeekPrimary(tuples, seekWidth, filterInfo) {
745
983
  const pkColumns = this.tableSchema.primaryKeyDefinition.map(pk => pk.index);
@@ -772,10 +1010,20 @@ export class StoreTableScan extends StoreTableBase {
772
1010
  points.set(hex, { key, infos: [info] });
773
1011
  }
774
1012
  const collations = this.resolveFilterCollations(filterInfo);
775
- for (const p of [...points.values()].sort((a, b) => compareBytes(a.key, b.key))) {
776
- const row = await this.readEffectiveRowByKey(p.key);
777
- if (row && p.infos.some(fi => this.matchesFilters(row, fi, collations))) {
778
- yield row;
1013
+ const sorted = [...points.values()].sort((a, b) => compareBytes(a.key, b.key));
1014
+ // Bounded batches over the sorted, deduplicated key list — "fetch N rows by
1015
+ // key" is exactly the shape readEffectiveRowsByKeys batches (one round trip
1016
+ // per ROW_RESOLUTION_BATCH keys, pending overlay resolved first, positional
1017
+ // nulls for missing keys). Emission order is unchanged (ascending encoded data
1018
+ // key), and a downstream break mid-batch reads no further batch.
1019
+ for (let start = 0; start < sorted.length; start += ROW_RESOLUTION_BATCH) {
1020
+ const slice = sorted.slice(start, start + ROW_RESOLUTION_BATCH);
1021
+ const rows = await this.readEffectiveRowsByKeys(slice.map(p => p.key));
1022
+ for (let i = 0; i < slice.length; i++) {
1023
+ const row = rows[i];
1024
+ if (row && slice[i].infos.some(fi => this.matchesFilters(row, fi, collations))) {
1025
+ yield row;
1026
+ }
779
1027
  }
780
1028
  }
781
1029
  }