@quereus/store 4.11.0 → 4.12.1

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
@@ -8,7 +8,7 @@
8
8
  * advertise, and the scan layer there executes it. A plan that claims a filter the scan
9
9
  * cannot honor drops the residual Filter and returns wrong rows, so every soundness
10
10
  * predicate the two share lives in `pk-key-resolution.ts` (`keyOrderMatchesCollation`,
11
- * `indexLeadingRangeIsOrderSafe`, `pkOrderPreservingPrefixLength`) rather than being
11
+ * `indexRangeAtPositionIsOrderSafe`, `pkOrderPreservingPrefixLength`) rather than being
12
12
  * restated here. The declines that are NOT shared — partial indexes, the multi-seek cap,
13
13
  * and the semantic-ordering column ban on multi-seeks specifically — are stated in both
14
14
  * files, and changing one means changing the other.
@@ -17,21 +17,21 @@
17
17
  * no module state beyond the table's configured key collation, which the caller passes in.
18
18
  */
19
19
  import { AccessPlanBuilder, equalitySeekKeyCount, hasSemanticOrdering, isMultiValueEquality } from '@quereus/quereus';
20
- import { indexLeadingRangeIsOrderSafe, indexPrefixSeekIsCollationExact, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolvePkKeyCollations, } from './pk-key-resolution.js';
20
+ import { indexPrefixSeekIsCollationExact, indexRangeAtPositionIsOrderSafe, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolvePkKeyCollations, } from './pk-key-resolution.js';
21
21
  /**
22
22
  * Planner-side constraint operator groups, as `BestAccessPlanRequest.filters` spells
23
23
  * them. Kept as one source of truth for the access-plan code below: {@link computeBestAccessPlan}
24
24
  * classifies each pushed filter with these, and {@link tryIndexAccessPlan} claims a filter as
25
25
  * handled only when it falls in the group the engine's access-path rule will consume.
26
- */
27
- const EQ_OPS = ['='];
28
- /**
29
- * Secondary-index arm ONLY: an IN is N equalities served as one multi-seek (`plan=5`),
30
- * whether its members are a literal list or a runtime-valued set. The primary-key arms
31
- * deliberately keep {@link EQ_OPS} — a `_primary_` multi-seek's emission order would
32
- * break the isolation layer's primary-key merge — so an IN on the PK declines here,
33
- * runtime-valued or not. PK IN support is deferred; see
34
- * tickets/backlog/feat-store-pk-in-list-multiseek.
26
+ *
27
+ * An IN is N equalities served as one multi-seek (`plan=5`), whether its members are a
28
+ * literal list or a runtime-valued set — so EVERY equality arm here, primary key and
29
+ * secondary index alike, fills its equality roles from this group. (The primary-key arms
30
+ * used to keep a `'='`-only group, on the reasoning that a `_primary_` multi-seek's
31
+ * emission order would break the isolation layer's primary-key merge. Both halves of that
32
+ * are now false: the merge bug was fixed as `bug-isolation-multiseek-merge-order`, and
33
+ * `StoreTableScan.scanMultiSeekPrimary` emits ascending by encoded data key — which IS
34
+ * primary-key order — specifically to satisfy it.)
35
35
  */
36
36
  const EQ_OR_IN_OPS = ['=', 'IN'];
37
37
  const LOWER_BOUND_OPS = ['>', '>='];
@@ -47,6 +47,69 @@ const RANGE_OPS = [...LOWER_BOUND_OPS, ...UPPER_BOUND_OPS];
47
47
  const MAX_MULTI_SEEK_KEYS = 1000;
48
48
  /** Cost of positioning one index seek, in the same unit as the per-row fetch cost. */
49
49
  const INDEX_SEEK_COST = 0.5;
50
+ /**
51
+ * Cost of resolving ONE secondary-index entry to its row, in the same unit as the full
52
+ * scan's 1.0-per-row sequential read.
53
+ *
54
+ * A secondary-index path reads TWO stores per matched row — the index entry, then the row
55
+ * it names — where a sequential scan reads one row per row. `AccessPlanBuilder.eqMatch` /
56
+ * `.rangeScan` charge only the first (0.3 / 0.5 per row), so the `eq`, `prefixRange` and
57
+ * `range` seek arms add this term on top of the shape's own cost. The MULTI-SEEK arm does
58
+ * not; the measured reason is at that arm in {@link tryIndexAccessPlan}.
59
+ *
60
+ * Why 1.0: `StoreTableScan` resolves entries in `ROW_RESOLUTION_BATCH`-sized `getMany`
61
+ * batches (`store-table-scan.ts`: one store round trip per 256 entries, not per row), so a resolved row
62
+ * costs about what a sequentially-iterated row costs — the index path simply pays it ON TOP
63
+ * OF the entry read. Nothing here has been measured against real storage; it is a shape
64
+ * constant like {@link ARM_SELECTIVITY}, chosen for parity rather than from a benchmark.
65
+ *
66
+ * The constant is not freely tunable upward. Because {@link ARM_SELECTIVITY} is a fixed
67
+ * FRACTION of the table, every arm's cost is linear in `estimatedRows` and so is the full
68
+ * scan's — an arm either always prices above a sequential scan or never does, for a given
69
+ * constant. The switch-over points: the `range` arm (0.3 × N × (0.5 + R) > N) flips at
70
+ * R > 2.83, `prefixRange` at R > 6.17, `eq` at R > 9.7. So raising this past ~2.8 does not
71
+ * make range seeks "more expensive", it DISABLES them outright. Real per-column statistics
72
+ * (see the NOTE on {@link ARM_SELECTIVITY}) are what would make the comparison
73
+ * discriminate per query rather than per arm.
74
+ */
75
+ const ROW_RESOLUTION_COST = 1.0;
76
+ /**
77
+ * Estimated rows each arm returns, as a fraction of the request's `estimatedRows`. The
78
+ * store keeps no per-column histograms, so these are shape constants, not statistics.
79
+ *
80
+ * `prefixRange` sits between the two pre-existing factors, as its window does: it is
81
+ * narrower than a bare leading-column range (the prefix pins every column ahead of the
82
+ * bound) and wider than the pure-equality estimate, which stands for a prefix pinning
83
+ * every column the predicate names.
84
+ *
85
+ * NOTE: these are the store's biggest cost-model gap, and no per-row cost term closes it.
86
+ * They are shape constants, so `where col = ?` is modelled at 10% of the table whatever
87
+ * the predicate actually matches, and `request.estimatedRows` is the table's row count,
88
+ * not a selectivity-adjusted estimate. A predicate matching MOST of the table therefore
89
+ * still prices as an index seek returning 10% of it, and the full-scan comparison in
90
+ * {@link computeBestAccessPlan} cannot rescue it — the fix is real per-column statistics,
91
+ * not another constant here. Two things have to land for that: the store has to KEEP a
92
+ * value distribution (`StoreTableBase.getStatistics` reports the row count only — a
93
+ * distinct count or histogram would cost a scan, which is why `ANALYZE` collects the
94
+ * per-column half itself), and `BestAccessPlanRequest` has to CARRY it, which it does not
95
+ * today — the module sees `estimatedRows` and the filters, nothing else, so even an
96
+ * analyzed table plans identically. Until then every cost decision this file makes is
97
+ * per-ARM, never per-PREDICATE.
98
+ *
99
+ * NOTE: the arms are exclusive per index, but two INDEXES compete on cost — and an index
100
+ * on the equality prefix ALONE prices its `eq` arm cheaper (0.1) than the composite index
101
+ * prices its `prefixRange` arm (0.15), so a schema carrying both `(a)` and `(a, b)` picks
102
+ * `(a)` for `a = ? and b > ?` and leaves the `b` bound residual. Answers are unaffected.
103
+ * Fine while redundant prefix indexes are rare; if one shows up as a slow plan, either
104
+ * scale the `eq` factor by how many index columns the prefix actually pins, or drop
105
+ * `prefixRange` below `eq` — do not just swap the two constants, which would then mis-rank
106
+ * a genuine leading-column range.
107
+ */
108
+ const ARM_SELECTIVITY = {
109
+ eq: 0.1,
110
+ prefixRange: 0.15,
111
+ range: 0.3,
112
+ };
50
113
  /**
51
114
  * Build `handledFilters` by claiming ONLY the constraints `rule-select-access-path`
52
115
  * actually consumes: per seek column the FIRST '=' (equality seek), or the FIRST lower
@@ -82,12 +145,65 @@ function rangeRoles(colIdx) {
82
145
  return [{ colIdx, ops: LOWER_BOUND_OPS }, { colIdx, ops: UPPER_BOUND_OPS }];
83
146
  }
84
147
  /**
85
- * The one equality role each seek column of an equality/prefix seek fills. `ops` is
86
- * explicit so the PK arm ({@link EQ_OPS}) and the secondary-index arm
87
- * ({@link EQ_OR_IN_OPS}) cannot drift into each other — see the note on EQ_OR_IN_OPS.
148
+ * The one equality role each seek column of an equality/prefix seek fills — filled by a
149
+ * `'='` or a well-formed `IN` alike, on the primary-key arms and the secondary-index arm
150
+ * identically (see the note on {@link EQ_OR_IN_OPS}).
88
151
  */
89
- function equalityRoles(colIdxs, ops) {
90
- return colIdxs.map(colIdx => ({ colIdx, ops }));
152
+ function equalityRoles(colIdxs) {
153
+ return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OR_IN_OPS }));
154
+ }
155
+ /**
156
+ * The longest LEADING run of `colIdxs` whose every column carries a filter that can fill
157
+ * an equality role, and what that run costs to seek.
158
+ *
159
+ * Per column the pin is the FIRST such filter ({@link equalitySeekKeyCount} — a `'='`, a
160
+ * non-empty literal `IN`, or a runtime-valued `IN` set). That is the same positional pick
161
+ * {@link claimFirstPerRole} claims and `rule-select-access-path`'s `eqBySeekCol` seeks on,
162
+ * so this module's claim and the rule's pick cannot disagree. A runtime-valued set
163
+ * contributes its `maxCount` ceiling — the worst case the engine may deliver — so every
164
+ * gate downstream judges the largest multi-seek it could ever be asked to perform.
165
+ *
166
+ * ONE helper for both the primary-key arms ({@link resolvePrimaryKeyPins}, which needs the
167
+ * run to cover the whole key) and the secondary-index arm (which takes whatever prefix it
168
+ * gets): separate copies of this loop are how a `'='`-only PK arm and an IN-claiming index
169
+ * arm drifted apart in the first place — see the note on {@link EQ_OR_IN_OPS}.
170
+ *
171
+ * `isMultiSeek` is NOT `seekKeyCount > 1`: a runtime-valued set is delivered as a `plan=5`
172
+ * multi-seek even at `maxCount === 1`, so it must be judged as one rather than as the plain
173
+ * point read its ceiling arithmetic would otherwise suggest. (`seekKeyCount > 1` implies
174
+ * the flag — every factor above 1 is a multi-value equality — so the flag only ever *adds*
175
+ * the `maxCount === 1` runtime-set case.)
176
+ */
177
+ function resolveEqualityPins(filters, colIdxs) {
178
+ const cols = [];
179
+ let seekKeyCount = 1;
180
+ let isMultiSeek = false;
181
+ for (const colIdx of colIdxs) {
182
+ const pin = filters.find(f => f.columnIndex === colIdx && equalitySeekKeyCount(f) !== null);
183
+ if (!pin)
184
+ break;
185
+ cols.push(colIdx);
186
+ seekKeyCount *= equalitySeekKeyCount(pin);
187
+ if (isMultiValueEquality(pin))
188
+ isMultiSeek = true;
189
+ }
190
+ return { cols, seekKeyCount, isMultiSeek };
191
+ }
192
+ /**
193
+ * The equality pins covering the WHOLE primary key, or null when a member is unpinned.
194
+ *
195
+ * A per-column pin rather than a count of pinned columns: `a = 1 and a = 2` on a composite
196
+ * PK `(a, b)` pins `a`, finds nothing for `b`, and correctly reports the key unpinned.
197
+ * Counting raw equality filters instead would read that predicate as "both PK columns
198
+ * pinned", claim both filters handled, then — with no complete PK equality set to seek —
199
+ * degrade to a sequential scan whose residual has already been discarded, returning the
200
+ * whole table.
201
+ */
202
+ function resolvePrimaryKeyPins(filters, pkColumns) {
203
+ if (pkColumns.length === 0)
204
+ return null;
205
+ const pins = resolveEqualityPins(filters, pkColumns);
206
+ return pins.cols.length === pkColumns.length ? pins : null;
91
207
  }
92
208
  /**
93
209
  * The access plan this module advertises for `request`, before the caller stamps the
@@ -102,36 +218,80 @@ function equalityRoles(colIdxs, ops) {
102
218
  */
103
219
  export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation) {
104
220
  const estimatedRows = request.estimatedRows ?? 1000;
105
- // Check for primary key equality constraints. Count DISTINCT pinned PK columns:
106
- // counting raw '=' filters would read `a = 1 and a = 2` on a composite PK (a, b)
107
- // as "both PK columns pinned", claim both filters handled, then — with no
108
- // complete PK equality set to seek — degrade to a sequential scan whose residual
109
- // has already been discarded, returning the whole table.
110
221
  const pkColumns = tableInfo.primaryKeyDefinition.map(pk => pk.index);
111
- const pinnedPkColumns = new Set(request.filters
112
- .filter(f => f.columnIndex !== undefined && pkColumns.includes(f.columnIndex) && f.op === '=')
113
- .map(f => f.columnIndex));
114
- if (pinnedPkColumns.size === pkColumns.length && pkColumns.length > 0) {
115
- // Full PK match - point lookup (single row; no monotonic advertisement)
222
+ // Hoisted ABOVE both primary-key arms: the multi-seek arm's ordering advertisement
223
+ // needs it, and it is cheap (a short loop over the PK members). Its other consumer is
224
+ // the leading-PK range arm below, which declines when the leading PK column's key
225
+ // bytes do not order the way its comparator does (`pkOrderPreservingPrefix === 0`):
226
+ // `StoreTable.analyzePKAccess` declines the byte window under exactly that condition,
227
+ // so claiming the range filters handled would drop the residual Filter and return the
228
+ // whole table.
229
+ const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
230
+ // Primary-key equality arms. Both live INSIDE this branch — the multi-seek is tried
231
+ // BEFORE the leading-PK range arm below, which is what keeps a full-primary-key IN
232
+ // list from being shadowed by a range on the leading column (the general
233
+ // arm-competition problem is backlog `bug-store-pk-range-preempts-cheaper-index`; do
234
+ // not fix it here).
235
+ //
236
+ // **No collation gate, and that is not an oversight.** The secondary-index arm gates
237
+ // its equality window on `indexPrefixSeekIsCollationExact`; the PK arms need no
238
+ // equivalent, for two independent reasons:
239
+ // - `reconcilePkCollations` (store-module-schema-rewrite.ts) rewrites an undecorated
240
+ // text PK column's declared collation to the table key collation K at CREATE time,
241
+ // so for every PK member the key collation, the declared collation, and the
242
+ // collation `matchesFilters` re-compares under are the same name. The divergent
243
+ // shape the secondary arm declines — a collation-blind `json`/temporal column under
244
+ // an index column carrying an explicit non-BINARY `COLLATE` — cannot occur on a PK:
245
+ // column DDL type-gates that `COLLATE` out, and those types are declined by the
246
+ // semantic-ordering gate below anyway.
247
+ // - `StoreTableScan.scanMultiSeekPrimary` re-applies `matchesFilters` per resolved
248
+ // row, so even a hypothetical coarser window over-fetches and is trimmed rather
249
+ // than under-fetching.
250
+ // The multi-seek is exactly the point arm run N times, so its collation exposure is
251
+ // the point arm's, unchanged.
252
+ //
253
+ // The one divergence neither reason covers is PREDICATE-side and invisible from here:
254
+ // `where pk collate nocase in (…)` over a BINARY key column would seek exact-case
255
+ // windows and UNDER-fetch. `PredicateConstraint` carries no collation, so this module
256
+ // cannot see it and always claims; `rule-select-access-path`'s `classifyCollationCover`
257
+ // (which reads the predicate's effective collation off the source expression) is what
258
+ // declines that shape to a scan + residual. A new PK arm inherits that protection only
259
+ // by going through the rule's index-aware path — one more reason `setSeekColumns` on
260
+ // the point arm below is load-bearing.
261
+ const pkPins = resolvePrimaryKeyPins(request.filters, pkColumns);
262
+ if (pkPins && !pkPins.isMultiSeek) {
263
+ // Full PK match - point lookup (single row; no monotonic advertisement).
264
+ //
265
+ // `setSeekColumns` is load-bearing now that this arm also claims a single-element
266
+ // `IN`: it routes the plan through `rule-select-access-path`'s INDEX-AWARE arm,
267
+ // whose `eqBySeekCol` accepts a one-element IN as an equality seek key. The legacy
268
+ // PK arm it would otherwise take matches `op === '='` only, so the claimed IN would
269
+ // be seeked nowhere and come back as a reattached residual over a full scan — the
270
+ // right rows at the wrong cost. For a plain `'='` the two arms build an identical
271
+ // `_primary_` `plan=2` seek under an identical collation-cover lookup.
116
272
  return AccessPlanBuilder
117
273
  .eqMatch(1, 0.1)
118
- .setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns, EQ_OPS)))
274
+ .setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
119
275
  .setIsSet(true)
120
276
  .setIndexName('_primary_')
277
+ .setSeekColumns(pkColumns)
121
278
  .setExplanation('Store primary key lookup')
122
279
  .build();
123
280
  }
281
+ if (pkPins) {
282
+ const plan = primaryKeyMultiSeekPlan(tableInfo, request, pkColumns, pkPins, estimatedRows, pkOrderPreservingPrefix);
283
+ // A gate decline FALLS THROUGH to the arms below rather than returning cost-only.
284
+ // That is safe: an `IN` is not in {@link RANGE_OPS}, so the leading-PK-range arm
285
+ // cannot grab it, and it lands on the secondary-index arms / full scan exactly as
286
+ // it did before this arm existed.
287
+ if (plan)
288
+ return plan;
289
+ }
124
290
  // Check for range constraints on the leading PK column.
125
291
  // The legacy access-path rule (rule-select-access-path.ts) only forwards
126
292
  // range bounds for primaryKeyDefinition[0]; ranges on later PK columns
127
293
  // are silently dropped if marked handled. So only claim handled=true
128
294
  // when the range is on the first PK column.
129
- //
130
- // The seek is also declined when the leading PK column's key bytes do not order the
131
- // way its comparator does (`pkOrderPreservingPrefix === 0`): `StoreTable.analyzePKAccess`
132
- // declines the byte window under exactly that condition, so claiming the range filters
133
- // handled here would drop the residual Filter and return the whole table.
134
- const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
135
295
  const firstPkColumn = tableInfo.primaryKeyDefinition[0]?.index;
136
296
  const hasLeadingPkRange = firstPkColumn !== undefined
137
297
  && pkOrderPreservingPrefix >= 1
@@ -153,36 +313,87 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
153
313
  return { ...plan, ...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
154
314
  }
155
315
  // Check for secondary index usage. `StoreTable.query` now implements the
156
- // secondary-index scan arm (leading-prefix EQ point / leading-column range),
316
+ // secondary-index scan arm (leading-prefix EQ point / leading-column range /
317
+ // prefix-equality + trailing-column range),
157
318
  // so we advertise the index with `indexName` + `seekColumns` and mark the
158
319
  // covered filters handled — subject to the range arm's order-safety gate in
159
320
  // {@link tryIndexAccessPlan}. A cost-only plan (no seek) is kept as a fallback
160
321
  // when no index yields a sound seek, preserving the prior "cheaper cost, filters
161
322
  // unhandled, residual retained" behavior.
323
+ //
324
+ // The sequential scan this module would otherwise fall back to, built up-front so the
325
+ // seek arms can be PRICED AGAINST its cost below: `rule-select-access-path` takes
326
+ // whatever single plan this function returns and never compares it with an alternative,
327
+ // so a seek that costs more than reading the table start to finish is only rejected if
328
+ // the module rejects it here. Built once and re-explained on the way out rather than
329
+ // re-deriving `AccessPlanBuilder.fullScan`'s cost formula for the comparison — the
330
+ // drift {@link AccessPlanBuilder.addCost} exists to prevent.
331
+ const scanPlan = {
332
+ ...AccessPlanBuilder
333
+ .fullScan(estimatedRows)
334
+ .setHandledFilters(new Array(request.filters.length).fill(false))
335
+ .setExplanation('Store full table scan')
336
+ .build(),
337
+ ...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix),
338
+ };
162
339
  const indexes = tableInfo.indexes || [];
163
340
  let bestSeekPlan = null;
164
341
  let costOnlyFallback = null;
165
342
  for (const index of indexes) {
166
343
  if (index.columns.length === 0)
167
344
  continue;
168
- const plan = tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows);
169
- if (!plan)
345
+ const candidate = tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows);
346
+ if (!candidate)
170
347
  continue;
348
+ const { plan } = candidate;
171
349
  // A fully-handled seek (indexName + seekColumns set) is a candidate: keep the
172
350
  // cheapest one seen so far rather than the first, so declaration order of the
173
351
  // indexes doesn't decide the plan. Strict '<' so ties keep the first candidate,
174
352
  // matching MemoryTableModule.findBestAccessPlan's `indexPlan.cost < bestPlan.cost`.
175
353
  if (plan.seekColumnIndexes && plan.seekColumnIndexes.length > 0) {
176
- if (!bestSeekPlan || plan.cost < bestSeekPlan.cost)
177
- bestSeekPlan = plan;
354
+ if (!bestSeekPlan || plan.cost < bestSeekPlan.plan.cost)
355
+ bestSeekPlan = candidate;
178
356
  continue;
179
357
  }
180
358
  // Otherwise remember the first cost-only advertisement as a fallback.
181
359
  if (!costOnlyFallback)
182
360
  costOnlyFallback = plan;
183
361
  }
184
- if (bestSeekPlan)
185
- return bestSeekPlan;
362
+ if (bestSeekPlan) {
363
+ //
364
+ // The MULTI-SEEK arm is exempt, and that is a measured decision rather than an
365
+ // oversight. `rule-key-set-seek` (engine side) does not consume this arm as a PLAN —
366
+ // it probes `getBestAccessPlan` with SYNTHESIZED runtime-set filters at 2 and 1000
367
+ // keys, reads the two costs as a straight line, and interpolates the key count at
368
+ // which the seek would overtake a scan. A probe answer that stops naming an index is
369
+ // read as "the module declined" and the whole rewrite is abandoned. Since nothing
370
+ // here distinguishes a probe from a user's own `col in (…)`, substituting this
371
+ // module's own scan verdict at 1000 keys silently switches key-set semi joins off:
372
+ // MEASURED, it fails 11 tests in `key-set-seek-store.spec.ts` on tables of 200 and
373
+ // 300 rows where the seek is unambiguously the right plan at the key counts actually
374
+ // used. The arm's brakes stay `inCount × INDEX_SEEK_COST` (which is what the engine
375
+ // interpolates) and {@link MAX_MULTI_SEEK_KEYS}; the engine makes the scan comparison
376
+ // this exemption skips, off the same numbers.
377
+ //
378
+ // Ties keep the seek: it returns fewer rows for the rest of the plan to carry.
379
+ if (bestSeekPlan.isMultiSeek || bestSeekPlan.plan.cost <= scanPlan.cost)
380
+ return bestSeekPlan.plan;
381
+ // Losing the seek is SAFE, never a wrong answer: the scan claims no filters, so the
382
+ // engine keeps every one of them as a residual Filter and the row set is identical.
383
+ // Deliberately returns the scan rather than falling through to `costOnlyFallback`
384
+ // below — a cost-only plan performs this same sequential scan while advertising an
385
+ // index arm's (cheaper) cost, so falling through would undo the comparison.
386
+ //
387
+ // NOTE: a vetoed seek is indistinguishable from a DECLINE to the engine, and
388
+ // `rule-key-set-seek` reads a probe answer that names no index as "the module
389
+ // declined" and abandons its rewrite. Harmless while the veto is reachable only
390
+ // where an arm's estimate is the whole table (a one-row table, per the arithmetic on
391
+ // {@link ROW_RESOLUTION_COST}) — a key-set seek there is worthless anyway. If real
392
+ // per-column statistics ever make the veto fire on tables worth seeking, the
393
+ // comparison has to exempt any request carrying a runtime-valued set, not just the
394
+ // arm this module happens to pick for it.
395
+ return { ...scanPlan, explains: 'Store full table scan (cheaper than the best index seek)' };
396
+ }
186
397
  // NOTE: a cost-only plan carries no PK-order advertisement even though the store still
187
398
  // iterates in PK key order for it (`StoreTable.query` full-scans), so `... where v > 'x'
188
399
  // order by <pk>` picks up a Sort it did not need. Still true, just rarer than it was: an
@@ -200,14 +411,65 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
200
411
  return costOnlyFallback;
201
412
  // Fallback to full scan. The store iterates rows in PK key order
202
413
  // (see StoreTable.query / store.iterate over buildFullScanBounds), so
203
- // the scan is monotonic on the leading PK column. Advertise that so
414
+ // the scan is monotonic on the leading PK column. `scanPlan` advertises that so
204
415
  // downstream rules (merge-join, asof-scan) can fire on store-backed
205
416
  // tables, matching memory-mode behavior.
417
+ return scanPlan;
418
+ }
419
+ /**
420
+ * The `_primary_` multi-seek advertisement for a WHOLE-primary-key IN (`where pk in (…)`,
421
+ * or a runtime-valued set on the PK), or null when a gate declines it.
422
+ *
423
+ * `StoreTableScan.scanMultiSeekPrimary` is the runtime twin: it encodes one data key per
424
+ * tuple, deduplicates, sorts ascending by encoded key, and point-reads each in bounded
425
+ * batches. Both gates below mirror one of its `multiSeekMalformed` throws, which stay in
426
+ * place as the assertion that the plan never produced a shape it cannot serve.
427
+ */
428
+ function primaryKeyMultiSeekPlan(tableInfo, request, pkColumns, pins, estimatedRows, pkOrderPreservingPrefix) {
429
+ if (pins.seekKeyCount > MAX_MULTI_SEEK_KEYS)
430
+ return null;
431
+ // Mirrors `StoreTableScan.pkHasSemanticOrderingMember`, which `scanMultiSeekPrimary`
432
+ // throws `multiSeekMalformed` on: N merged point windows ARE the whole access, with
433
+ // the residual already dropped, so an IN member with no faithful byte position would
434
+ // silently lose its tuple's rows. The single-value point arm has an escape this does
435
+ // not (it can decline to the scan arm per PROBE), which is why only this one declines
436
+ // per SCHEMA. Re-opening it is backlog `feat-store-semantic-key-multiseek`.
437
+ if (pkColumns.some(colIdx => hasSemanticOrdering(tableInfo.columns[colIdx]?.logicalType)))
438
+ return null;
439
+ // The PK is unique, so each seek key matches at most one row — no `multiRows` clamp
440
+ // artifact (contrast the secondary arm's), and no ROW_RESOLUTION_COST: the point read
441
+ // IS the row read, with no index-entry → row indirection to charge for.
442
+ //
443
+ // `Math.max(1, …)` is LOAD-BEARING, not defensive. `rows: 0` on a plan that claims
444
+ // every filter makes `rule-select-access-path` replace the whole table access with an
445
+ // `EmptyResultNode` (its "the module proved the predicate unsatisfiable" fold), so a
446
+ // table that is empty at PLAN time would return nothing for rows written by the same
447
+ // statement.
448
+ const rows = Math.max(1, Math.min(estimatedRows, pins.seekKeyCount));
206
449
  const plan = AccessPlanBuilder
207
- .fullScan(estimatedRows)
208
- .setHandledFilters(new Array(request.filters.length).fill(false))
209
- .setExplanation('Store full table scan')
450
+ // `setIsSet(false)` is likewise load-bearing: `eqMatch` defaults `isSet` to
451
+ // `rows <= 1`, which a one-key runtime set would satisfy.
452
+ .eqMatch(rows, pins.seekKeyCount * INDEX_SEEK_COST)
453
+ .setIsSet(false)
454
+ .setIndexName('_primary_')
455
+ // EVERY primary-key column, in `primaryKeyDefinition` order: `scanMultiSeekPrimary`
456
+ // throws when `seekWidth` does not cover the whole key, and the rule derives
457
+ // `seekWidth` from this list.
458
+ .setSeekColumns(pkColumns)
459
+ .setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
460
+ .setExplanation(`Store primary key multi-seek(${pins.seekKeyCount})`)
210
461
  .build();
462
+ // This arm returns straight out of the PK branch, so it never reaches the
463
+ // seek-versus-scan comparison further down `computeBestAccessPlan` — the same
464
+ // exemption the secondary multi-seek arm gets explicitly, for the same reason:
465
+ // `rule-key-set-seek` reads this cost as a straight line at 2 and 1000 keys and
466
+ // abandons its rewrite if either probe stops naming an index. Do not "fix" this by
467
+ // adding the comparison.
468
+ //
469
+ // `scanMultiSeekPrimary` sorts its points ascending by encoded data key, which IS
470
+ // primary-key order (per-column DESC inversion is baked into the bytes), so the plan
471
+ // goes through the same ordering gate every other primary-key arm uses and
472
+ // `… where pk in (…) order by pk` elides its Sort.
211
473
  return { ...plan, ...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
212
474
  }
213
475
  /**
@@ -215,8 +477,9 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
215
477
  * the index is not usable for this predicate.
216
478
  *
217
479
  * Usable = a contiguous leading-prefix EQ on the index columns (an index seek /
218
- * point), or a LT/LE/GT/GE range on the LEADING index column. These mirror the
219
- * two windows `StoreTable.analyzeIndexAccess` can build.
480
+ * point), a LT/LE/GT/GE range on the LEADING index column, or a strict leading-prefix EQ
481
+ * plus a LT/LE/GT/GE range on the NEXT index column (`prefixRange`, `plan=7`). These
482
+ * mirror the three windows `StoreTable.analyzeIndexAccess` can build.
220
483
  *
221
484
  * **Collation safety.** Index-column key bytes are encoded under each column's own key
222
485
  * collation (`resolveIndexKeyCollations`); `StoreTable.matchesFilters` re-checks a fetched
@@ -229,13 +492,22 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
229
492
  * the same resolution the residual uses); a collation-blind column (`json`, the
230
493
  * temporal types) under an index column with an explicit non-BINARY COLLATE does not
231
494
  * (its key bytes are hard-BINARY) and declines.
232
- * - RANGE — {@link indexLeadingRangeIsOrderSafe}: the same agreement PLUS the collation's
233
- * `orderPreserving` assertion, because a byte window also equates memcmp of the key
234
- * bytes with the residual comparator's order.
495
+ * - RANGE — {@link indexRangeAtPositionIsOrderSafe}: the same agreement PLUS the
496
+ * collation's `orderPreserving` assertion, because a byte window also equates memcmp of
497
+ * the key bytes with the residual comparator's order. Asked at position 0 for a
498
+ * leading-column range, and at the prefix length for a `prefixRange` trailing bound —
499
+ * whose PREFIX additionally passes the EQUALITY gate above.
235
500
  *
236
501
  * `StoreTable.analyzeIndexAccess` gates its windows on the same two helpers, so the "mark
237
502
  * handled" and "build a window" decisions cannot disagree. A decline returns a cost-only
238
503
  * plan: cheaper cost, filters unhandled, residual retained; correct, just not sped up.
504
+ *
505
+ * **Cost.** Each single-window seek arm pays {@link ROW_RESOLUTION_COST} per row it expects
506
+ * to return, on top of the `AccessPlanBuilder` shape's own per-row term: the shape prices
507
+ * reading the index ENTRY, and the store must then read the ROW that entry names out of the
508
+ * data store. Two arms deliberately do NOT pay it — a cost-only decline (it resolves
509
+ * nothing; the scan reads rows directly) and the multi-seek, for the measured reason
510
+ * recorded at that arm below.
239
511
  */
240
512
  function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
241
513
  // Exclude PARTIAL indexes from access planning: neither the engine nor this
@@ -247,65 +519,83 @@ function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
247
519
  if (index.predicate)
248
520
  return null;
249
521
  const indexColIndexes = index.columns.map(c => c.index);
250
- // Contiguous leading-prefix equality → point/prefix seek. An IN filter counts as
251
- // an equality here (it is N equalities, served as one multi-seek by
252
- // StoreTable.scanMultiSeek); `inCount` is the cross-product of the per-column
253
- // seek-key counts (1 for a plain '='), matching the rule's seek-key count. The
254
- // FIRST role-filling filter per column is what the rule seeks on, so its
255
- // cardinality is the one that counts.
256
- //
257
- // A runtime-valued IN set contributes its `maxCount` ceiling — the worst case the
258
- // engine may deliver — so every gate below (the MAX_MULTI_SEEK_KEYS cap, the
259
- // semantic-ordering decline) judges it against the largest multi-seek it could
260
- // ever be asked to perform.
261
- const eqCols = [];
262
- let inCount = 1;
263
- // `isMultiSeek` is NOT `inCount > 1`: a runtime set is delivered as a `plan=5`
264
- // multi-seek even at `maxCount === 1`, so the gates below must judge it as one
265
- // rather than as the plain EQ its ceiling arithmetic would otherwise suggest.
266
- // (`inCount > 1` implies this flag — every factor above 1 is a multi-value
267
- // equality — so the flag only ever *adds* the maxCount-1 runtime-set case.)
268
- let isMultiSeek = false;
269
- for (const colIdx of indexColIndexes) {
270
- const eqFilter = request.filters.find(f => f.columnIndex === colIdx && equalitySeekKeyCount(f) !== null);
271
- if (!eqFilter)
272
- break;
273
- eqCols.push(colIdx);
274
- inCount *= equalitySeekKeyCount(eqFilter);
275
- if (isMultiValueEquality(eqFilter))
276
- isMultiSeek = true;
277
- }
522
+ // Contiguous leading-prefix equality → point/prefix seek. An IN filter counts as an
523
+ // equality here (it is N equalities, served as one multi-seek by
524
+ // StoreTable.scanMultiSeek); `inCount` is the cross-product of the per-column seek-key
525
+ // counts, matching the rule's seek-key count. See {@link resolveEqualityPins} for the
526
+ // positional pick and the runtime-set ceiling both arms share.
527
+ const { cols: eqCols, seekKeyCount: inCount, isMultiSeek } = resolveEqualityPins(request.filters, indexColIndexes);
278
528
  const leadingCol = indexColIndexes[0];
279
529
  const hasLeadingRange = request.filters.some(f => f.columnIndex === leadingCol && RANGE_OPS.includes(f.op));
530
+ // Prefix-equality + trailing-range: the equality prefix is a STRICT, non-empty prefix
531
+ // of the index columns and the NEXT index column carries a bound. `StoreTable
532
+ // .analyzeIndexAccess` builds the matching one-window `[prefix||bound]` range.
533
+ //
534
+ // A multi-value prefix is EXCLUDED: `rule-select-access-path` can only seek a
535
+ // single-valued prefix key, so `a in (1, 2) and b > 15` over `(a, b)` would come back
536
+ // as a sequential scan with both predicates residual — worse than the multi-seek this
537
+ // index can still serve on the IN alone. MemoryTableModule.evaluateIndexAccess records
538
+ // the same restriction for the same reason.
539
+ const nextCol = eqCols.length > 0 && eqCols.length < indexColIndexes.length
540
+ ? indexColIndexes[eqCols.length]
541
+ : undefined;
542
+ const trailingRangeCol = (!isMultiSeek && nextCol !== undefined
543
+ && request.filters.some(f => f.columnIndex === nextCol && RANGE_OPS.includes(f.op)))
544
+ ? nextCol
545
+ : undefined;
280
546
  let seekCols;
281
- let isRange;
282
- if (eqCols.length > 0) {
547
+ let arm;
548
+ if (trailingRangeCol !== undefined) {
549
+ seekCols = [...eqCols, trailingRangeCol];
550
+ arm = 'prefixRange';
551
+ }
552
+ else if (eqCols.length > 0) {
283
553
  seekCols = eqCols;
284
- isRange = false;
554
+ arm = 'eq';
285
555
  }
286
556
  else if (hasLeadingRange) {
287
557
  seekCols = [leadingCol];
288
- isRange = true;
558
+ arm = 'range';
289
559
  }
290
560
  else {
291
561
  return null; // this index cannot serve this predicate
292
562
  }
293
- const rows = isRange
294
- ? Math.max(1, Math.floor(estimatedRows * 0.3))
295
- : Math.max(1, Math.floor(estimatedRows * 0.1));
296
- const costOnly = (why) => (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
297
- .setHandledFilters(new Array(request.filters.length).fill(false))
298
- .setExplanation(`Store index scan on ${index.name} (${why})`)
299
- .build();
300
563
  // Collation gates — see the doc comment above. `StoreTable.analyzeIndexAccess` declines
301
- // exactly the same windows through the same two helpers.
564
+ // exactly the same windows through the same two helpers, at the same positions.
302
565
  const indexKeyCollations = resolveIndexKeyCollations(index, tableInfo.columns);
303
- if (isRange) {
304
- if (!indexLeadingRangeIsOrderSafe(db, tableInfo.columns, index, indexKeyCollations)) {
566
+ // A trailing bound whose byte order does not reproduce its residual comparator's order
567
+ // costs only the bound, not the whole plan: DEGRADE to the equality-prefix seek and
568
+ // leave the bound unclaimed, so it survives in the residual Filter. The scan side makes
569
+ // the same move (a `plan=2` eqSeek arrives and it windows the prefix), and keeping the
570
+ // prefix seek is strictly better than the cost-only fallback below.
571
+ if (arm === 'prefixRange'
572
+ && !indexRangeAtPositionIsOrderSafe(db, tableInfo.columns, index, indexKeyCollations, eqCols.length)) {
573
+ arm = 'eq';
574
+ seekCols = eqCols;
575
+ }
576
+ const isRange = arm !== 'eq';
577
+ const rows = Math.max(1, Math.floor(estimatedRows * ARM_SELECTIVITY[arm]));
578
+ /** The arm's shape — `AccessPlanBuilder`'s per-row index-entry term, no resolution. */
579
+ const armShape = () => isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3);
580
+ /** The arm's shape plus the per-fetched-row {@link ROW_RESOLUTION_COST}. */
581
+ const seekingArm = () => armShape().addCost(rows * ROW_RESOLUTION_COST);
582
+ const costOnly = (why) => ({
583
+ plan: armShape()
584
+ .setHandledFilters(new Array(request.filters.length).fill(false))
585
+ .setExplanation(`Store index scan on ${index.name} (${why})`)
586
+ .build(),
587
+ // Cost-only never reaches the comparison (it claims no seek columns), so the flag
588
+ // is immaterial; false states the fact — this plan seeks nothing at all.
589
+ isMultiSeek: false,
590
+ });
591
+ if (arm === 'range') {
592
+ if (!indexRangeAtPositionIsOrderSafe(db, tableInfo.columns, index, indexKeyCollations, 0)) {
305
593
  return costOnly('cost-only; index range needs an order-preserving key collation');
306
594
  }
307
595
  }
308
- else if (!indexPrefixSeekIsCollationExact(tableInfo.columns, index, indexKeyCollations, seekCols.length)) {
596
+ else if (!indexPrefixSeekIsCollationExact(tableInfo.columns, index, indexKeyCollations, eqCols.length)) {
597
+ // Covers the `prefixRange` arm's PREFIX too: its pinned columns need exactly the
598
+ // equality arm's guarantee, and its trailing column was gated just above.
309
599
  return costOnly('cost-only; index key collation differs from the comparison collation');
310
600
  }
311
601
  // Multi-seek declines. Cost-only keeps the residual, so the answer stays right
@@ -324,29 +614,68 @@ function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
324
614
  // backlog `feat-store-semantic-key-multiseek`.
325
615
  return costOnly('cost-only; semantic-ordering seek column cannot multi-seek');
326
616
  }
327
- // Claim positionally — see {@link claimFirstPerRole}.
328
- const handledFilters = claimFirstPerRole(request.filters, isRange ? rangeRoles(leadingCol) : equalityRoles(eqCols, EQ_OR_IN_OPS));
617
+ // Claim positionally — see {@link claimFirstPerRole}. The `prefixRange` arm claims the
618
+ // prefix equalities AND the first lower/upper bound on the trailing column; a redundant
619
+ // same-side bound (`date > a and date > b`) stays unclaimed and survives in the residual.
620
+ // A prefixRange DEGRADED to 'eq' above claims the prefix only, so its trailing bounds
621
+ // likewise survive.
622
+ const handledFilters = claimFirstPerRole(request.filters, arm === 'range'
623
+ ? rangeRoles(leadingCol)
624
+ : arm === 'prefixRange'
625
+ ? [...equalityRoles(eqCols), ...rangeRoles(trailingRangeCol)]
626
+ : equalityRoles(eqCols));
329
627
  if (isMultiSeek) {
330
628
  // Multi-seek (plan=5): inCount point seeks, `rows` matched per seek key. The
331
629
  // per-seek positioning term keeps a 500-key IN over a 10-row table from pricing
332
630
  // below a full scan and issuing 500 seeks to read 10 rows. `isSet` false mirrors
333
631
  // MemoryTableModule.evaluateIndexAccess's setIsSet(!isMultiSeek). No ordering is
334
632
  // advertised — window emission order is encoded-key order, not any column order.
633
+ //
634
+ // NOTE: this arm alone does NOT pay {@link ROW_RESOLUTION_COST}, though it resolves
635
+ // every entry it matches exactly like the single-window arms do. The reason is
636
+ // `multiRows`, not the physics: `inCount × rows` reaches `estimatedRows` at ten seek
637
+ // keys (`rows` is the fixed 0.1 × N equality shape constant) and at two or three on a
638
+ // handful-of-rows table, so beyond that the figure is a CLAMP — "the whole table" —
639
+ // rather than an estimate of what the keys match. Charging a per-row term against a
640
+ // clamp prices the estimator's artifact:
641
+ // - MEASURED: it fails 16 tests in `key-set-seek-store.spec.ts`, every one a
642
+ // runtime key-set semi join over a 3-to-4-row table that stops seeking.
643
+ // - The engine's `rule-key-set-seek` interpolates a break-even from THIS cost at 2
644
+ // and 1000 keys. With the term, `cost(1000 keys) = 500 + 1.3 × N` exceeds a scan's
645
+ // `N` for every N, so the break-even can never reach the engine's 1000-key ceiling
646
+ // — key sets above ~710 keys would stop seeking on a table of ANY size, including
647
+ // the 10M-row tables where an index seek is the entire point.
648
+ // The price of leaving it off is a narrower mis-ranking: against another index's `eq`
649
+ // arm (which does pay the term) a small-key IN now looks relatively cheaper than it
650
+ // is — `where a in (x, y) and b = ?` over a 1000-row table prices ix_a at 61 and ix_b
651
+ // at 130 and picks ix_a, though ix_a's own estimate says it fetches 200 rows to ix_b's
652
+ // 100. Charging the term everywhere fixes that ranking and breaks the feature above;
653
+ // the union estimate has to stop clamping before both can hold. See
654
+ // backlog/debt-store-multi-seek-union-row-estimate.
335
655
  const multiRows = Math.min(estimatedRows, inCount * rows);
336
- return AccessPlanBuilder.eqMatch(multiRows, inCount * INDEX_SEEK_COST)
337
- .setIsSet(false)
656
+ return {
657
+ plan: AccessPlanBuilder.eqMatch(multiRows, inCount * INDEX_SEEK_COST)
658
+ .setIsSet(false)
659
+ .setHandledFilters(handledFilters)
660
+ .setIndexName(index.name)
661
+ .setSeekColumns(seekCols)
662
+ .setExplanation(`Store index multi-seek(${inCount}) on ${index.name}`)
663
+ .build(),
664
+ isMultiSeek: true,
665
+ };
666
+ }
667
+ const armLabel = arm === 'prefixRange'
668
+ ? `prefix-range seek(prefix=${eqCols.length})`
669
+ : arm === 'range' ? 'range scan' : 'seek';
670
+ return {
671
+ plan: seekingArm()
338
672
  .setHandledFilters(handledFilters)
339
673
  .setIndexName(index.name)
340
674
  .setSeekColumns(seekCols)
341
- .setExplanation(`Store index multi-seek(${inCount}) on ${index.name}`)
342
- .build();
343
- }
344
- return (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
345
- .setHandledFilters(handledFilters)
346
- .setIndexName(index.name)
347
- .setSeekColumns(seekCols)
348
- .setExplanation(`Store index ${isRange ? 'range scan' : 'seek'} on ${index.name}`)
349
- .build();
675
+ .setExplanation(`Store index ${armLabel} on ${index.name}`)
676
+ .build(),
677
+ isMultiSeek: false,
678
+ };
350
679
  }
351
680
  /**
352
681
  * Compute the PK-ordering advertisement for a scan-style plan. Returns the