@quereus/store 4.5.0 → 4.7.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 (107) hide show
  1. package/README.md +34 -6
  2. package/dist/src/common/encoding.d.ts +4 -3
  3. package/dist/src/common/encoding.d.ts.map +1 -1
  4. package/dist/src/common/encoding.js +17 -13
  5. package/dist/src/common/encoding.js.map +1 -1
  6. package/dist/src/common/events.d.ts +31 -25
  7. package/dist/src/common/events.d.ts.map +1 -1
  8. package/dist/src/common/events.js +38 -39
  9. package/dist/src/common/events.js.map +1 -1
  10. package/dist/src/common/implicit-unique-index.d.ts +80 -0
  11. package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
  12. package/dist/src/common/implicit-unique-index.js +148 -0
  13. package/dist/src/common/implicit-unique-index.js.map +1 -0
  14. package/dist/src/common/index.d.ts +7 -3
  15. package/dist/src/common/index.d.ts.map +1 -1
  16. package/dist/src/common/index.js +5 -2
  17. package/dist/src/common/index.js.map +1 -1
  18. package/dist/src/common/json-key.d.ts +30 -2
  19. package/dist/src/common/json-key.d.ts.map +1 -1
  20. package/dist/src/common/json-key.js +49 -2
  21. package/dist/src/common/json-key.js.map +1 -1
  22. package/dist/src/common/key-builder.d.ts +40 -20
  23. package/dist/src/common/key-builder.d.ts.map +1 -1
  24. package/dist/src/common/key-builder.js +31 -23
  25. package/dist/src/common/key-builder.js.map +1 -1
  26. package/dist/src/common/kv-store.d.ts +10 -0
  27. package/dist/src/common/kv-store.d.ts.map +1 -1
  28. package/dist/src/common/pk-key-resolution.d.ts +348 -0
  29. package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
  30. package/dist/src/common/pk-key-resolution.js +481 -0
  31. package/dist/src/common/pk-key-resolution.js.map +1 -0
  32. package/dist/src/common/store-module-access-plan.d.ts +32 -0
  33. package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
  34. package/dist/src/common/store-module-access-plan.js +428 -0
  35. package/dist/src/common/store-module-access-plan.js.map +1 -0
  36. package/dist/src/common/store-module-alter-column.d.ts +23 -0
  37. package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
  38. package/dist/src/common/store-module-alter-column.js +389 -0
  39. package/dist/src/common/store-module-alter-column.js.map +1 -0
  40. package/dist/src/common/store-module-alter.d.ts +55 -0
  41. package/dist/src/common/store-module-alter.d.ts.map +1 -0
  42. package/dist/src/common/store-module-alter.js +504 -0
  43. package/dist/src/common/store-module-alter.js.map +1 -0
  44. package/dist/src/common/store-module-base.d.ts +268 -0
  45. package/dist/src/common/store-module-base.d.ts.map +1 -0
  46. package/dist/src/common/store-module-base.js +362 -0
  47. package/dist/src/common/store-module-base.js.map +1 -0
  48. package/dist/src/common/store-module-catalog.d.ts +269 -0
  49. package/dist/src/common/store-module-catalog.d.ts.map +1 -0
  50. package/dist/src/common/store-module-catalog.js +462 -0
  51. package/dist/src/common/store-module-catalog.js.map +1 -0
  52. package/dist/src/common/store-module-index-build.d.ts +88 -0
  53. package/dist/src/common/store-module-index-build.d.ts.map +1 -0
  54. package/dist/src/common/store-module-index-build.js +280 -0
  55. package/dist/src/common/store-module-index-build.js.map +1 -0
  56. package/dist/src/common/store-module-index.d.ts +116 -0
  57. package/dist/src/common/store-module-index.d.ts.map +1 -0
  58. package/dist/src/common/store-module-index.js +373 -0
  59. package/dist/src/common/store-module-index.js.map +1 -0
  60. package/dist/src/common/store-module-rename.d.ts +49 -0
  61. package/dist/src/common/store-module-rename.d.ts.map +1 -0
  62. package/dist/src/common/store-module-rename.js +268 -0
  63. package/dist/src/common/store-module-rename.js.map +1 -0
  64. package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
  65. package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
  66. package/dist/src/common/store-module-schema-rewrite.js +140 -0
  67. package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
  68. package/dist/src/common/store-module-schema-sync.d.ts +232 -0
  69. package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
  70. package/dist/src/common/store-module-schema-sync.js +529 -0
  71. package/dist/src/common/store-module-schema-sync.js.map +1 -0
  72. package/dist/src/common/store-module.d.ts +43 -909
  73. package/dist/src/common/store-module.d.ts.map +1 -1
  74. package/dist/src/common/store-module.js +184 -3276
  75. package/dist/src/common/store-module.js.map +1 -1
  76. package/dist/src/common/store-table-base.d.ts +539 -0
  77. package/dist/src/common/store-table-base.d.ts.map +1 -0
  78. package/dist/src/common/store-table-base.js +900 -0
  79. package/dist/src/common/store-table-base.js.map +1 -0
  80. package/dist/src/common/store-table-constraints.d.ts +237 -0
  81. package/dist/src/common/store-table-constraints.d.ts.map +1 -0
  82. package/dist/src/common/store-table-constraints.js +624 -0
  83. package/dist/src/common/store-table-constraints.js.map +1 -0
  84. package/dist/src/common/store-table-scan.d.ts +452 -0
  85. package/dist/src/common/store-table-scan.d.ts.map +1 -0
  86. package/dist/src/common/store-table-scan.js +901 -0
  87. package/dist/src/common/store-table-scan.js.map +1 -0
  88. package/dist/src/common/store-table.d.ts +71 -1144
  89. package/dist/src/common/store-table.d.ts.map +1 -1
  90. package/dist/src/common/store-table.js +168 -2385
  91. package/dist/src/common/store-table.js.map +1 -1
  92. package/dist/src/testing/index.d.ts +12 -0
  93. package/dist/src/testing/index.d.ts.map +1 -0
  94. package/dist/src/testing/index.js +12 -0
  95. package/dist/src/testing/index.js.map +1 -0
  96. package/dist/src/testing/kv-assert.d.ts +19 -0
  97. package/dist/src/testing/kv-assert.d.ts.map +1 -0
  98. package/dist/src/testing/kv-assert.js +25 -0
  99. package/dist/src/testing/kv-assert.js.map +1 -0
  100. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  101. package/dist/src/testing/kv-conformance.js +1 -15
  102. package/dist/src/testing/kv-conformance.js.map +1 -1
  103. package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
  104. package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
  105. package/dist/src/testing/kv-provider-conformance.js +183 -0
  106. package/dist/src/testing/kv-provider-conformance.js.map +1 -0
  107. package/package.json +8 -8
@@ -8,27 +8,19 @@
8
8
  * - Data store: {schema}.{table} - row data keyed by encoded PK
9
9
  * - Index stores: {schema}.{table}_idx_{name} - one per secondary index
10
10
  * - Stats store: __stats__ - unified store for all table statistics, keyed by {schema}.{table}
11
+ *
12
+ * The class is layered across four files, each adding one job to the one below:
13
+ * `store-table-base.ts` - state, store handles, stats, transaction lifecycle
14
+ * `store-table-scan.ts` - the read path (predicate -> byte window -> rows)
15
+ * `store-table-constraints.ts` - secondary-index maintenance + UNIQUE enforcement
16
+ * `store-table.ts` - this file: the write path and bulk row maintenance
11
17
  */
12
- import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, compareSqlValuesFast, createTypedComparator, hasSemanticOrdering, semanticKeyTransform, resolveUniqueEnforcementCollations, uniqueEnforcementComparators, BINARY_COLLATION, rowsValueIdentical, coerceRowToSchema, compilePredicate, decodeIdxStr, planKindFromCode, maintainedTableUniqueViolationError, uniqueEnforcementCollations, logicalTypeCanHoldText, pkKeyCollationName, JSON_TYPE, } from '@quereus/quereus';
13
- import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
14
- import { StoreConnection } from './store-connection.js';
15
- import { buildDataKey, buildIndexKey, buildIndexPrefixBounds, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
16
- import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
17
- import { jsonStructuralKey } from './json-key.js';
18
- /** Number of mutations before persisting statistics. */
19
- const STATS_FLUSH_INTERVAL = 100;
20
- /** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
21
- function keyWithinBounds(key, bounds) {
22
- if (bounds.gte && compareBytes(key, bounds.gte) < 0)
23
- return false;
24
- if (bounds.gt && compareBytes(key, bounds.gt) <= 0)
25
- return false;
26
- if (bounds.lte && compareBytes(key, bounds.lte) > 0)
27
- return false;
28
- if (bounds.lt && compareBytes(key, bounds.lt) >= 0)
29
- return false;
30
- return true;
31
- }
18
+ import { ConflictResolution, QuereusError, StatusCode, formatKeyValue, rowsValueIdentical, } from '@quereus/quereus';
19
+ import { bytesEqual, bytesToHex } from './bytes.js';
20
+ import { buildDataKey, buildFullScanBounds, } from './key-builder.js';
21
+ import { serializeRow, deserializeRow, } from './serialization.js';
22
+ import { resolvePkKeyCollations, resolvePkKeyTransforms } from './pk-key-resolution.js';
23
+ import { StoreTableConstraints } from './store-table-constraints.js';
32
24
  /**
33
25
  * Resolves the per-constraint default conflict action for PK conflicts.
34
26
  * Prefers the table-level `PRIMARY KEY (...) ON CONFLICT <action>` clause
@@ -49,283 +41,6 @@ function resolvePkDefaultConflict(schema) {
49
41
  }
50
42
  return undefined;
51
43
  }
52
- /**
53
- * Resolve the per-column KEY collation for each primary-key column.
54
- *
55
- * The store encodes PRIMARY KEY uniqueness/ordering PHYSICALLY in the key bytes, so each
56
- * text-capable PK column's key must be encoded under the collation the engine actually
57
- * COMPARES that column under. Returns one entry per PK member, in `pkDef` order:
58
- * - `isTextual` member (`text`) → its declared `collation` (normalized upper-case), or
59
- * `fallback` (the table key collation K) when the column carries none.
60
- * - text-capable but not `isTextual` member — `any`, `json`, and the temporal types
61
- * (`date` / `time` / `datetime` / `timespan`) → hard-coded `'BINARY'`, see below.
62
- * - never-text member → `undefined`: collation is meaningless for
63
- * integer/real/blob keys (they encode type-natively), so the encoder ignores
64
- * it and the data/index key bytes are identical regardless.
65
- *
66
- * Note that `undefined` does NOT mean "encode type-natively" to `encodeValue` — it means
67
- * "fall back to `options.collation`", the table key collation K. Only a never-text column
68
- * may be left `undefined`, because only it never reaches the collation branch.
69
- *
70
- * Every name returned here is encoded through the key-normalizer resolver carried
71
- * in `EncodeOptions.normalizers` (`db.getKeyNormalizerResolver()`), so a collation
72
- * registered or overridden with `db.registerCollation` produces key bytes that agree
73
- * with the comparator the store's UNIQUE enforcement uses. A collation that cannot
74
- * key — unregistered, or registered with a comparator but no normalizer — is rejected
75
- * at DDL time by {@link StoreTable.validateKeyCollations}, so no encode call ever has
76
- * to fall back. Shared by {@link StoreTable} (data-key + index-maintenance) and
77
- * `StoreModule.buildIndexEntries` (index rebuild) so the PK suffix encoding can
78
- * never drift between the two.
79
- */
80
- export function resolvePkKeyCollations(pkDef, columns, fallback) {
81
- return pkDef.map(def => {
82
- const col = columns[def.index];
83
- // Delegates the isTextual/text-capable/never-text branch to the engine's
84
- // `pkKeyCollationName` — the same decision `quereus-isolation`'s modified-PK set
85
- // makes for its own key normalizers, so the two can never drift apart again. Only
86
- // the fallback-to-K and uppercase-normalization are store-specific.
87
- const name = pkKeyCollationName(col);
88
- return name === undefined ? undefined : (name || fallback).toUpperCase();
89
- });
90
- }
91
- /**
92
- * The store's per-type key VALUE TRANSFORM for a semantic-ordering logical type, or
93
- * `undefined` when raw values already key faithfully.
94
- *
95
- * The contract is TWO-sided — identity AND order. `StoreTable` implements no
96
- * `comparePrimaryKey`, so the isolation layer merges overlay against underlying with
97
- * its own fallback comparator (the PK type's `compare` via `createTypedComparator`)
98
- * and needs this table's key BYTE order to agree with it; and byte equality must be
99
- * exactly the type's equality, or two spellings of one value land on two keys.
100
- *
101
- * - JSON → the store-local structural encoder ({@link jsonStructuralKey}): a
102
- * `Uint8Array` whose memcmp order reproduces the type's structural `compare`
103
- * (canonical-text bytes are identity-faithful but sort `[10]` before `[2]`,
104
- * which broke overlay shadowing — see json-key.ts).
105
- * - Everything else → the engine's {@link semanticKeyTransform} (the type's
106
- * `groupKey`). Today that is TIMESPAN: total seconds against the same reference
107
- * date as `TIMESPAN.compare`, so 'PT1H' and 'PT60M' collide on one key AND byte
108
- * order is elapsed-time order.
109
- *
110
- * JSON deliberately gets NO engine `groupKey` — `groupKey` also drives GROUP BY /
111
- * `IN` / hash-join identity, where canonical-text identity is already correct and a
112
- * `Uint8Array` key would be a behaviour change for no benefit. Hence this
113
- * store-local seam. A semantic-ordering type with NEITHER a `groupKey` nor an entry
114
- * here cannot key a persisted structure soundly; `StoreTable` rejects it at DDL time
115
- * (see `validateSemanticKeyTransforms`).
116
- */
117
- export function storeSemanticKeyTransform(type) {
118
- // Matched by NAME, not object identity (`type === JSON_TYPE`): the engine and this
119
- // package can be loaded as two module instances (e.g. the logic suite runs the
120
- // engine from src via ts-node while the store resolves `@quereus/quereus` to dist),
121
- // and each instance carries its own JSON_TYPE singleton. The `hasSemanticOrdering`
122
- // gate keeps a hypothetical plain custom type named 'JSON' out of this branch.
123
- if (hasSemanticOrdering(type) && type.name === JSON_TYPE.name)
124
- return jsonStructuralKey;
125
- return semanticKeyTransform(type);
126
- }
127
- /**
128
- * Resolve the per-column key value transform for each primary-key column —
129
- * {@link storeSemanticKeyTransform} of the member's logical type. One entry per PK
130
- * member in `pkDef` order; `undefined` for every type whose raw value already keys
131
- * faithfully. The companion of {@link resolvePkKeyCollations}: every call site that
132
- * threads the collations MUST thread these too, or key identity/order drifts
133
- * between the write path and the rebuild/lookup path.
134
- */
135
- export function resolvePkKeyTransforms(pkDef, columns) {
136
- return pkDef.map(def => storeSemanticKeyTransform(columns[def.index]?.logicalType));
137
- }
138
- /**
139
- * Per-column key value transforms for a secondary index's OWN columns (the
140
- * index half of `buildIndexKey`), positionally aligned with `index.columns`.
141
- * Same rule as {@link resolvePkKeyTransforms}: a semantic-ordering member
142
- * canonicalizes through {@link storeSemanticKeyTransform}, everything else
143
- * encodes raw.
144
- */
145
- export function resolveIndexKeyTransforms(index, columns) {
146
- return index.columns.map(col => storeSemanticKeyTransform(columns[col.index]?.logicalType));
147
- }
148
- /**
149
- * Per-PK-column typed equality comparator for semantic-ordering members, `undefined`
150
- * elsewhere. Backs {@link StoreTable.keysEqual}: two PK arrays naming the same
151
- * physical key under {@link resolvePkKeyTransforms} ('PT1H' / 'PT60M') must also
152
- * answer "same row" here, or self-PK exclusion in the UNIQUE checks false-conflicts
153
- * a row against its own differently-spelled image.
154
- */
155
- function resolvePkSemanticEquality(pkDef, columns) {
156
- return pkDef.map(def => {
157
- const logicalType = columns[def.index]?.logicalType;
158
- return hasSemanticOrdering(logicalType) ? createTypedComparator(logicalType) : undefined;
159
- });
160
- }
161
- /**
162
- * True when `col` can produce a TEXT value at runtime, and so when its physical
163
- * key bytes are produced by a collation normalizer rather than a type-native
164
- * encoding. The `ColumnSchema`-shaped wrapper over the engine's
165
- * {@link logicalTypeCanHoldText}; both collation-safety guards over the store's
166
- * secondary indexes — the write-side {@link StoreTable.indexSeekHonorsEnforcementCollation}
167
- * and the read-side `StoreModule.tryIndexAccessPlan` — exempt a never-text column
168
- * from their K-vs-C comparison, so a false "non-text" answer is a silent
169
- * wrong-result (a seek under the wrong collation, with the residual dropped).
170
- */
171
- export function columnCanHoldText(col) {
172
- return logicalTypeCanHoldText(col?.logicalType);
173
- }
174
- /**
175
- * True when a byte window — or a byte-order advertisement — over `column` is SOUND.
176
- *
177
- * The store physically orders rows by memcmp of each column's key bytes, which for a
178
- * text-capable column are produced by `keyCollation`'s normalizer. A range seek (and the
179
- * `providesOrdering` / `monotonicOn` advertisement) equates that byte order with the order
180
- * `compareCollation`'s comparator defines. Two things must hold for that equation:
181
- *
182
- * - **Same collation on both sides** (`compareCollation === keyCollation`). A `keyCollation`
183
- * merely COARSER than `compareCollation` — the relaxation the EQUALITY seek is allowed to
184
- * make, see `StoreModule.tryIndexAccessPlan` — is not enough here: it would need the key
185
- * normalizer to be monotone with respect to the OTHER collation's order. It generally is
186
- * not, even for built-ins: with `keyCollation = NOCASE` and `compareCollation = BINARY`,
187
- * 'K' (U+212A KELVIN SIGN) is `> 'z'` under BINARY, yet its key bytes are
188
- * `toLowerCase('K') = 'k'`, which sorts BEFORE 'z' — the row falls outside a `> 'z'`
189
- * window and is silently dropped.
190
- * - **The normalizer preserves order** (`db._isCollationOrderPreserving`). `registerCollation`
191
- * promises only that a normalizer partitions strings the way the comparator calls them
192
- * equal; a custom pair may agree on equality and disagree on order.
193
- *
194
- * A never-text column is exempt: its key bytes are type-native and collation-independent.
195
- *
196
- * Returning `false` costs an optimization (the caller full-scans and lets the
197
- * collation-aware `matchesFilters` residual decide); returning `true` wrongly is a silent
198
- * wrong-result. Shared by `StoreTable`'s read arms and `StoreModule`'s access planner so the
199
- * "mark handled" and "build a window" decisions can never disagree.
200
- */
201
- export function keyOrderMatchesCollation(db, column, keyCollation, compareCollation) {
202
- // A semantic-ordering logical type (TIMESPAN, JSON — see docs/types.md "Semantic
203
- // ordering") is ordered by its `compare` (elapsed time / structural), which the
204
- // engine's Sort, comparison operators, and the memory backend all use. Both types'
205
- // key bytes now encode through an order-preserving transform (TIMESPAN's `groupKey`
206
- // total seconds; JSON's structural encoding — see `storeSemanticKeyTransform`), so
207
- // byte order DOES match the type's `compare` for every well-formed value and this
208
- // blanket decline is conservative, costing the seek/sort-elision, never a row.
209
- // Re-opening the window/advertisement (and the byte-EQ arms in `analyzePKAccess` /
210
- // `analyzeIndexAccess`) is tracked in backlog `feat-reopen-timespan-store-seeks`.
211
- if (hasSemanticOrdering(column?.logicalType))
212
- return false;
213
- if (!columnCanHoldText(column))
214
- return true;
215
- if (compareCollation.toUpperCase() !== keyCollation.toUpperCase())
216
- return false;
217
- return db._isCollationOrderPreserving(keyCollation);
218
- }
219
- /**
220
- * How many LEADING primary-key members have key bytes whose memcmp order matches their
221
- * declared collation's comparator order, per {@link keyOrderMatchesCollation}.
222
- *
223
- * `pkKeyCollations` is {@link resolvePkKeyCollations}' output for this table; an `undefined`
224
- * entry belongs to a never-text member, which {@link keyOrderMatchesCollation} exempts
225
- * outright — `tableKeyCollation` is passed only so the exempt branch has a name to ignore.
226
- * The comparison collation is the column's declared one, which `reconcilePkCollations` has
227
- * already aligned with the key collation for every *textual* PK member.
228
- *
229
- * What the advertisement is measured against is the order the planner's `Sort` would have
230
- * produced. For most members Sort orders under the operand's COLLATION, so a collation match
231
- * is the question; for a member whose logical type carries SEMANTIC ordering (TIMESPAN, JSON
232
- * — see docs/types.md "Semantic ordering") Sort now ranks by `logicalType.compare`, which no
233
- * text-byte encoding reproduces, and {@link keyOrderMatchesCollation} closes the gate for
234
- * such members outright (the memory backend's typed BTree order is the canonical order under
235
- * that ruling; the store declines rather than advertising a divergent byte order).
236
- *
237
- * `0` ⇒ even the leading member is unsafe: no range seek, no PK-order advertisement.
238
- * A prefix shorter than the PK truncates the ordering advertisement rather than voiding it.
239
- */
240
- export function pkOrderPreservingPrefixLength(db, schema, pkKeyCollations, tableKeyCollation) {
241
- const pk = schema.primaryKeyDefinition;
242
- let n = 0;
243
- while (n < pk.length) {
244
- const col = schema.columns[pk[n].index];
245
- const keyCollation = pkKeyCollations[n] ?? tableKeyCollation;
246
- // NOTE: an explicit `k any collate nocase` PK declines its seek here — the key
247
- // collation is BINARY (what ANY_TYPE.compare actually uses) while `col.collation` is
248
- // the declared-but-ignored NOCASE, so the two sides differ and the gate closes. Both
249
- // sides genuinely compare under BINARY, so the decline is conservative: it costs the
250
- // seek, never a row. If that declaration ever shows up in practice, resolve the
251
- // comparison collation the way `resolvePkKeyCollations` resolves the key collation
252
- // rather than widening the gate.
253
- if (!keyOrderMatchesCollation(db, col, keyCollation, col?.collation ?? 'BINARY'))
254
- break;
255
- n++;
256
- }
257
- return n;
258
- }
259
- /**
260
- * Deterministic name of the implicit covering index materialized for a
261
- * NON-DERIVED UNIQUE constraint `uc`: the constraint's own name when it has one,
262
- * else the auto-name `_uc_<colNames>`. This MUST equal the engine's
263
- * `catalog.ts` `implicitIndexName`, so a name that ever reaches the catalog
264
- * bundle generator is recognized as hidden / exposed-implicit rather than a user
265
- * index.
266
- */
267
- export function implicitUniqueIndexName(schema, uc) {
268
- return uc.name ?? `_uc_${uc.columns.map(i => schema.columns[i]?.name ?? String(i)).join('_')}`;
269
- }
270
- /**
271
- * Return `schema` with a synthetic secondary index materialized into
272
- * `schema.indexes` for every NON-DERIVED UNIQUE constraint that lacks one — the
273
- * store analogue of `MemoryTableManager.ensureUniqueConstraintIndexes`.
274
- *
275
- * This is what lets a plain column- or table-level `UNIQUE` be enforced by a
276
- * bounded index point-seek ({@link StoreTable.findUniqueConflictViaIndex})
277
- * instead of an O(rows) full scan: DML maintenance
278
- * ({@link StoreTable.updateSecondaryIndexes}) and the UNIQUE check both iterate
279
- * `schema.indexes`, so once the `_uc_*` entry is present the physical index is
280
- * maintained and seeked with no new code path.
281
- *
282
- * The result is held ONLY in {@link StoreTable.materializedSchema} (the
283
- * enforcement copy), never in the engine-facing `tableSchema` the store returns
284
- * from `getSchema()` / registers with the engine. So the read-query planner and
285
- * the catalog bundle never see `_uc_*` (it is fully derived on open from
286
- * `uniqueConstraints`); like the memory backend, a plain UNIQUE gets no read-side
287
- * plan from `_uc_*`, only enforcement / maintenance.
288
- *
289
- * Idempotent — a name already present is left untouched — and returns a frozen
290
- * schema (or the input unchanged when there is nothing to add). A
291
- * `derivedFromIndex` UC is skipped (its `CREATE UNIQUE INDEX` is already in
292
- * `schema.indexes`). A partial UNIQUE's synthetic index carries the SAME
293
- * `uc.predicate` object reference, which
294
- * {@link StoreTable.findIndexForUniqueConstraint} matches on identity.
295
- */
296
- export function withImplicitUniqueIndexes(schema) {
297
- const ucs = schema.uniqueConstraints;
298
- if (!ucs || ucs.length === 0)
299
- return schema;
300
- const existing = schema.indexes ?? [];
301
- const present = new Set(existing.map(idx => idx.name.toLowerCase()));
302
- const additions = [];
303
- for (const uc of ucs) {
304
- if (uc.derivedFromIndex)
305
- continue;
306
- const name = implicitUniqueIndexName(schema, uc);
307
- if (present.has(name.toLowerCase()))
308
- continue;
309
- present.add(name.toLowerCase());
310
- additions.push({
311
- name,
312
- columns: uc.columns.map(idx => ({ index: idx, collation: schema.columns[idx]?.collation })),
313
- // SAME reference as `uc.predicate` (undefined for a full UNIQUE) — load-bearing:
314
- // findIndexForUniqueConstraint admits a partial `_uc_*` by `ix.predicate === uc.predicate`.
315
- predicate: uc.predicate,
316
- });
317
- }
318
- if (additions.length === 0)
319
- return schema;
320
- return Object.freeze({ ...schema, indexes: Object.freeze([...existing, ...additions]) });
321
- }
322
- /**
323
- * Returned by {@link StoreTable.findUniqueConflictViaIndex} when the index
324
- * cannot soundly answer the check and the caller must fall back to the full
325
- * data-store scan. Distinct from `null`, which means "the index answered: no
326
- * conflict".
327
- */
328
- const INDEX_UNUSABLE = Symbol('store.uniqueIndexUnusable');
329
44
  /**
330
45
  * Generic KVStore-backed virtual table.
331
46
  *
@@ -333,311 +48,7 @@ const INDEX_UNUSABLE = Symbol('store.uniqueIndexUnusable');
333
48
  * storage backends. Platform-specific behavior is delegated to the
334
49
  * StoreTableModule.
335
50
  */
336
- export class StoreTable extends VirtualTable {
337
- storeModule;
338
- config;
339
- store = null;
340
- storeInitPromise = null;
341
- indexStores = new Map();
342
- statsStore = null;
343
- coordinator = null;
344
- /**
345
- * Disposer returned by the coordinator's `registerCallbacks`, captured on the
346
- * first {@link attachCoordinator}. Run by {@link dispose} at hard eviction to
347
- * deregister this instance's {stats apply/discard} pair from the module-wide
348
- * coordinator; null until attached, and nulled again after dispose.
349
- */
350
- coordinatorDisposer = null;
351
- connection = null;
352
- eventEmitter;
353
- encodeOptions;
354
- pkDirections;
355
- /**
356
- * Per-PK-column KEY collation (see {@link resolvePkKeyCollations}). Drives the
357
- * physical encoding of every data key and the PK suffix of every secondary-index
358
- * key, so a text PK column declared BINARY/NOCASE/RTRIM is keyed under its own
359
- * collation rather than one fixed table-level collation. Recomputed on every
360
- * {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
361
- */
362
- pkKeyCollations;
363
- /**
364
- * Per-PK-column key-identity value transform (see {@link resolvePkKeyTransforms}).
365
- * Threaded alongside {@link pkKeyCollations} into every data-key / PK-suffix /
366
- * PK-prefix-bounds encode, so a semantic-ordering PK member (TIMESPAN) keys
367
- * semantically-equal spellings to one physical key. Recomputed on every
368
- * {@link updateSchema}.
369
- */
370
- pkKeyTransforms;
371
- /**
372
- * Per-PK-column typed EQUALITY comparator for the semantic-ordering members only —
373
- * `undefined` for every other member, which {@link keysEqual} still compares with
374
- * strict `!==` as before. Keeps self-PK exclusion and PK-identity questions in
375
- * agreement with the physical key identity above ('PT1H' row IS the 'PT60M' row).
376
- */
377
- pkSemanticEquality;
378
- /**
379
- * `db.getCollationResolver()`, bound once at construction. Every collation-aware
380
- * VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
381
- * detection — resolves names through it, so a collation registered with
382
- * `db.registerCollation` on *this* connection is honored rather than silently
383
- * degraded to BINARY by the process-global built-in registry.
384
- *
385
- * Only the resolver closure is cached: it reads the live registry, so a collation
386
- * registered after connect is still visible. Resolved *functions* are hoisted no
387
- * further than the comparator or constraint check that resolved them.
388
- *
389
- * Its key-bytes counterpart, `db.getKeyNormalizerResolver()`, is bound into
390
- * {@link encodeOptions} so the names in {@link pkKeyCollations} resolve to the same
391
- * per-connection registry.
392
- */
393
- collationResolver;
394
- ddlSaved = false;
395
- // Statistics tracking
396
- cachedStats = null;
397
- pendingStatsDelta = 0;
398
- mutationCount = 0;
399
- statsFlushPending = false;
400
- // Lazy cache of compiled partial-UNIQUE predicates. Keyed on the
401
- // UniqueConstraintSchema object identity — UC schemas are frozen and a
402
- // new constraint object after CREATE/DROP INDEX produces a fresh compile;
403
- // the WeakMap lets the GC reclaim entries for retired constraints.
404
- predicateCache = new WeakMap();
405
- // Lazy cache of compiled partial-index predicates, keyed on the IndexSchema
406
- // object identity (frozen; a CREATE/DROP INDEX or reopen produces a fresh
407
- // object, so the WeakMap reclaims retired entries). Mirrors predicateCache but
408
- // for secondary-index maintenance rather than UNIQUE enforcement.
409
- indexPredicateCache = new WeakMap();
410
- /**
411
- * {@link tableSchema} with a hidden `_uc_*` secondary index materialized per
412
- * non-derived UNIQUE constraint (see {@link withImplicitUniqueIndexes}) — the
413
- * schema that drives UNIQUE ENFORCEMENT ({@link findIndexForUniqueConstraint})
414
- * and physical index MAINTENANCE ({@link updateSecondaryIndexes}).
415
- *
416
- * Deliberately SEPARATE from the engine-facing {@link tableSchema}: the engine
417
- * reads `tableInstance.tableSchema` at CREATE (`SchemaManager.finalizeCreatedTableSchema`)
418
- * and the read-query planner reads the registered schema, and NEITHER may see
419
- * `_uc_*` (mirrors the memory backend keeping its materialized schema
420
- * manager-local; a plain UNIQUE gets no read-side plan from `_uc_*`, only
421
- * enforcement). Recomputed alongside `tableSchema` in the constructor and
422
- * {@link updateSchema}.
423
- */
424
- materializedSchema;
425
- constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
426
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
- super(db, storeModule, tableSchema.schemaName, tableSchema.name);
428
- this.storeModule = storeModule;
429
- // The engine-facing schema stays NON-materialized (the engine registers it and the
430
- // read-query planner reads it — neither may see `_uc_*`); the hidden `_uc_*`
431
- // per-UNIQUE index that lets plain UNIQUE enforce via a point-seek lives only in
432
- // {@link materializedSchema}.
433
- this.tableSchema = tableSchema;
434
- this.materializedSchema = withImplicitUniqueIndexes(tableSchema);
435
- this.config = config;
436
- this.eventEmitter = eventEmitter;
437
- this.collationResolver = db.getCollationResolver();
438
- this.encodeOptions = {
439
- collation: config.collation || 'NOCASE',
440
- normalizers: db.getKeyNormalizerResolver(),
441
- };
442
- this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
443
- this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
444
- this.pkKeyTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
445
- this.pkSemanticEquality = resolvePkSemanticEquality(tableSchema.primaryKeyDefinition, tableSchema.columns);
446
- // Validate the MATERIALIZED schema, so a `_uc_*` over a text column that needs the
447
- // table key collation K is rejected up front just like an explicit index would be.
448
- this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
449
- this.validateSemanticKeyTransforms(this.materializedSchema);
450
- this.ddlSaved = isConnected;
451
- }
452
- /**
453
- * Reject, at DDL time, any collation this table's key encoding would need but cannot
454
- * use — before a single row is written under bytes the connection cannot reproduce.
455
- *
456
- * A collation keys a persisted structure only if it carries a KEY NORMALIZER (the
457
- * `(s) => string` whose output equality partitions strings exactly as its comparator
458
- * does). `db.registerCollation(name, cmp)` with no `{ normalizer }` gives a collation
459
- * that can order rows but not key them; an unregistered name gives nothing at all.
460
- * Both raise here rather than at the first insert.
461
- *
462
- * Checked over exactly the collations the key encoding actually uses:
463
- * - every defined `pkKeyCollations` entry (text-capable PK columns);
464
- * - the table key collation K, but only when it is reachable — a secondary index
465
- * encodes a TEXT-CAPABLE index column's bytes under K (`buildIndexKey`).
466
- *
467
- * So neither a table with an integer PK and no secondary index, nor one whose every
468
- * index column is type-natively keyed, is made unopenable by a K it never encodes with.
469
- *
470
- * Blast radius: this also fires on catalog rehydration. Reopening a persisted database
471
- * from a connection that has not re-registered its custom collation now throws at
472
- * CREATE-TABLE-from-catalog rather than silently reading rows under a key layout it
473
- * cannot reproduce. See `docs/plugins.md`.
474
- *
475
- * Takes `pkKeyCollations` rather than reading the field so `updateSchema` can validate
476
- * the incoming schema BEFORE it overwrites its own state with it.
477
- */
478
- validateKeyCollations(schema, pkKeyCollations) {
479
- const names = new Set();
480
- for (const collation of pkKeyCollations) {
481
- if (collation !== undefined)
482
- names.add(collation);
483
- }
484
- const indexKeysText = (schema.indexes ?? []).some(index => index.columns.some(col => columnCanHoldText(schema.columns[col.index])));
485
- if (indexKeysText) {
486
- names.add((this.encodeOptions.collation ?? 'NOCASE').toUpperCase());
487
- }
488
- const dbInternal = this.db;
489
- for (const name of names) {
490
- if (dbInternal._getCollationNormalizer(name))
491
- continue;
492
- // Unregistered names raise `no such collation sequence: X` from the resolver;
493
- // reaching past it means the collation exists but is comparator-only.
494
- this.collationResolver(name);
495
- throw new QuereusError(`collation ${name} cannot key a persisted structure: no key normalizer registered `
496
- + `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
497
- }
498
- }
499
- /**
500
- * Reject, at DDL time, a key member whose semantic-ordering logical type has no
501
- * store key transform — before a single row is keyed under bytes whose order the
502
- * type's `compare` (and so the isolation merge, the memory oracle, and Sort)
503
- * disagrees with.
504
- *
505
- * A semantic-ordering type keys soundly only through an order- and
506
- * identity-preserving transform ({@link storeSemanticKeyTransform}: the type's
507
- * `groupKey`, or a store-local encoder like JSON's structural one). This gap used
508
- * to be undetectable — JSON sat here transform-less for a release, keying in
509
- * canonical-text order while every comparator ordered structurally, and the only
510
- * symptom was a misaligned overlay merge deep inside a transaction. Unreachable
511
- * today (TIMESPAN and JSON are both covered); it exists so the NEXT
512
- * semantic-ordering type fails loudly at CREATE TABLE instead.
513
- *
514
- * Checked over the same materialized schema as {@link validateKeyCollations}:
515
- * every PK member and every (explicit or hidden `_uc_*`) index column.
516
- *
517
- * CAVEAT: this can only check that a transform EXISTS — a `groupKey` that is
518
- * identity-faithful but not order-preserving passes undetected, so a new type's
519
- * transform still needs its byte order argued against its `compare` (see
520
- * json-key.ts for the shape of that argument).
521
- */
522
- validateSemanticKeyTransforms(schema) {
523
- const check = (colIndex, member) => {
524
- const col = schema.columns[colIndex];
525
- const type = col?.logicalType;
526
- if (!hasSemanticOrdering(type) || storeSemanticKeyTransform(type) !== undefined)
527
- return;
528
- throw new QuereusError(`column ${col?.name ?? String(colIndex)} (${member}): semantic-ordering type `
529
- + `${type.name} cannot key a persisted structure: its raw encoding would not `
530
- + `reproduce the type's compare order — the type needs an order-preserving `
531
- + `groupKey or a store key encoder (see storeSemanticKeyTransform)`, StatusCode.ERROR);
532
- };
533
- for (const def of schema.primaryKeyDefinition)
534
- check(def.index, 'primary key');
535
- for (const index of schema.indexes ?? []) {
536
- for (const col of index.columns)
537
- check(col.index, `index ${index.name}`);
538
- }
539
- }
540
- /** Get the table configuration. */
541
- getConfig() {
542
- return this.config;
543
- }
544
- /** Get the table schema (engine-facing, WITHOUT the hidden `_uc_*` indexes). */
545
- getSchema() {
546
- return this.tableSchema;
547
- }
548
- /**
549
- * Get the enforcement schema — {@link getSchema} plus the hidden `_uc_*`
550
- * index materialized per non-derived UNIQUE. Used by the module to resolve the
551
- * physical index store to build/tear down during ALTER; never registered with
552
- * the engine.
553
- */
554
- getMaterializedSchema() {
555
- return this.materializedSchema;
556
- }
557
- /**
558
- * Update the table schema after an ALTER TABLE / CREATE INDEX operation.
559
- *
560
- * Validates the incoming schema's key collations before adopting any of it, so a
561
- * rejection leaves this table on its previous, consistent schema.
562
- */
563
- updateSchema(newSchema) {
564
- // `newSchema` is the engine-facing (non-materialized) schema; recompute the
565
- // enforcement copy alongside it. Validate the MATERIALIZED copy so a `_uc_*` over a
566
- // text column that needs the table key collation K is caught before adoption.
567
- const materialized = withImplicitUniqueIndexes(newSchema);
568
- const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
569
- this.validateKeyCollations(materialized, pkKeyCollations);
570
- this.validateSemanticKeyTransforms(materialized);
571
- this.tableSchema = newSchema;
572
- this.materializedSchema = materialized;
573
- this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
574
- this.pkKeyCollations = pkKeyCollations;
575
- this.pkKeyTransforms = resolvePkKeyTransforms(newSchema.primaryKeyDefinition, newSchema.columns);
576
- this.pkSemanticEquality = resolvePkSemanticEquality(newSchema.primaryKeyDefinition, newSchema.columns);
577
- }
578
- /**
579
- * Mark the table's DDL as already persisted to the catalog, so the lazy
580
- * first-store-access save in {@link initializeStore} is skipped. Called by
581
- * `StoreModule.createIndex` / `dropIndex` after they eagerly write the catalog
582
- * bundle, so a subsequent INSERT does not redundantly re-persist identical DDL.
583
- */
584
- markDdlSaved() {
585
- this.ddlSaved = true;
586
- }
587
- /** Close and forget a cached index-store handle, if any. */
588
- async releaseIndexStore(indexName) {
589
- const cached = this.indexStores.get(indexName);
590
- if (!cached)
591
- return;
592
- this.indexStores.delete(indexName);
593
- try {
594
- await cached.close();
595
- }
596
- catch { /* close is best-effort */ }
597
- }
598
- /**
599
- * Returns true if the table has at least one LIVE row — the open transaction's
600
- * pending overlay over the committed store. Stops after the first hit.
601
- *
602
- * Effective (not committed-only) because its one caller, `ADD COLUMN`'s NOT
603
- * NULL-without-DEFAULT rejection, must see rows the same transaction inserted:
604
- * they are migrated too (the arm DDL-commits before `migrateRows`), and a
605
- * committed-only probe would wave them through only to give them a NULL in a
606
- * NOT NULL column. Conversely, a pending delete of the last row makes the table
607
- * legitimately empty. Reading effectively keeps the check throw-only and
608
- * BEFORE the DDL-commit, so a rejection leaves the transaction intact.
609
- */
610
- async hasAnyRows() {
611
- for await (const _entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
612
- return true;
613
- }
614
- return false;
615
- }
616
- /**
617
- * Yield the LIVE value of `colIndex` for every row — the open transaction's
618
- * pending overlay over the committed store. For throw-only ALTER COLUMN probes
619
- * that must run BEFORE the arm's DDL-commit (see `StoreModule.ddlCommitPendingOps`):
620
- * they have to see rows this transaction wrote, because those rows are rewritten
621
- * too, and a rejection here must leave the transaction intact.
622
- */
623
- async *iterateEffectiveValuesAtIndex(colIndex) {
624
- for await (const entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
625
- yield deserializeRow(entry.value)[colIndex];
626
- }
627
- }
628
- /**
629
- * Count the LIVE rows whose column at `colIndex` holds NULL (see
630
- * {@link iterateEffectiveValuesAtIndex} for why this reads effectively).
631
- * Used by ALTER COLUMN SET NOT NULL to decide whether the tightening is safe.
632
- */
633
- async rowsWithNullAtIndex(colIndex) {
634
- let count = 0;
635
- for await (const value of this.iterateEffectiveValuesAtIndex(colIndex)) {
636
- if (value === null)
637
- count++;
638
- }
639
- return count;
640
- }
51
+ export class StoreTable extends StoreTableConstraints {
641
52
  /**
642
53
  * Apply a per-row mapping function to every stored row, in place (re-writing
643
54
  * the same key). The mapper may throw QuereusError — propagated to the caller;
@@ -666,6 +77,90 @@ export class StoreTable extends VirtualTable {
666
77
  }
667
78
  await batch.write();
668
79
  }
80
+ /**
81
+ * Closure computing a row's data key under a NEW primary-key definition and
82
+ * column set — the exact bytes {@link rekeyRows} pass 2 writes. Shared by
83
+ * `rekeyRows`' two passes and {@link validateRekeyedPrimaryKey}'s probes so a
84
+ * collision judged by a probe is byte-identical to the key the re-key would
85
+ * write. Deliberately NOT the `dedupeRowSignature` / `KeyNormalizerResolver`
86
+ * path the UNIQUE validators use — the two disagree for at least an `any`-typed
87
+ * PK member, whose key bytes pin BINARY regardless of its declared collation
88
+ * (pinned by `any-json-pk-binary-key.spec.ts`).
89
+ *
90
+ * The transforms matter too: an ALTER PRIMARY KEY onto (or a SET DATA TYPE
91
+ * creating) a semantic-ordering member must collapse equal spellings, so
92
+ * 'PT1H'/'PT60M' land on one key and are judged as the duplicate they are.
93
+ */
94
+ rekeyedKeyComputer(newPkDef, newColumns) {
95
+ const newPkDirections = newPkDef.map(pk => !!pk.desc);
96
+ const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
97
+ const newPkTransforms = resolvePkKeyTransforms(newPkDef, newColumns);
98
+ return (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations, newPkTransforms);
99
+ }
100
+ /**
101
+ * The store's counterpart of the memory backend's
102
+ * `MemoryTableManager.validateRekeyedPrimaryKey`: the two throw-only questions a
103
+ * PK re-key must answer, over two DIFFERENT row sets, BEFORE anything is flushed
104
+ * or mutated (see docs/memory-table.md §"A collation change on a PRIMARY KEY
105
+ * column obeys a stricter rule"):
106
+ *
107
+ * 1. **Is the change legal?** — over `effectiveRows`, the rows the DDL-issuing
108
+ * transaction can SEE (a wrapper's `EffectiveRowSource` when the isolation
109
+ * layer holds the transaction's staged rows outside this store, else this
110
+ * table's own effective stream). Two rows on one new key here is a duplicate
111
+ * a `select` in this transaction would return, so the change is invalid →
112
+ * `CONSTRAINT`, naming the colliding key.
113
+ * 2. **Can the store carry it?** — over this store's COMMITTED rows, the set a
114
+ * `rollback` must be able to restore. The data store holds one row per key,
115
+ * so a committed pair collapsing onto one new key cannot be represented even
116
+ * when the transaction has deleted one of them → `BUSY`, with the memory
117
+ * module's "commit/rollback and retry" posture.
118
+ *
119
+ * Probe order is what makes the statuses right, with no backend sniffing: the
120
+ * committed probe can only fire once the effective one passed, i.e. only when the
121
+ * committed rows are NOT a subset of the effective ones — which happens exactly
122
+ * when the transaction has DELETED a committed row (staged in a wrapper's overlay,
123
+ * or buffered in this module's own coordinator). A collision among rows the
124
+ * transaction can still see therefore always reports `CONSTRAINT`, never `BUSY`.
125
+ *
126
+ * The buffered-delete case makes the bare module stricter than it used to be: the
127
+ * old post-flush pass committed the delete first and then re-keyed happily, quietly
128
+ * spending the transaction's rollback. It now refuses with the same `BUSY` the
129
+ * wrapped path gives, and `commit; <retry>` still lands the change.
130
+ *
131
+ * Both probes key through {@link rekeyedKeyComputer}, so they and the re-key agree
132
+ * byte-for-byte.
133
+ *
134
+ * NOTE: these two probes put the SET COLLATE re-key at four full table scans (two
135
+ * here, then {@link rekeyRows}' pass 1 backstop and pass 2), each holding one hex
136
+ * key signature per row. Fine for a statement this rare; if a huge table ever makes
137
+ * it slow, drop pass 1 for callers that pre-validated — it cannot fire for them.
138
+ */
139
+ async validateRekeyedPrimaryKey(newPkDef, newColumns, effectiveRows) {
140
+ const computeNewKey = this.rekeyedKeyComputer(newPkDef, newColumns);
141
+ const seenEffective = new Set();
142
+ for await (const row of effectiveRows) {
143
+ const hex = bytesToHex(computeNewKey(row));
144
+ if (seenEffective.has(hex)) {
145
+ // Mirror the memory module's diagnostic, naming the key from the second
146
+ // (colliding) row's PK values.
147
+ const parts = newPkDef.map(pk => formatKeyValue(row[pk.index]));
148
+ throw new QuereusError(`UNIQUE constraint failed: ${this.tableName} primary key collides under the new key definition (key: ${parts.join(', ')})`, StatusCode.CONSTRAINT);
149
+ }
150
+ seenEffective.add(hex);
151
+ }
152
+ const store = await this.ensureStore();
153
+ const seenCommitted = new Set();
154
+ for await (const entry of store.iterate(buildFullScanBounds())) {
155
+ const hex = bytesToHex(computeNewKey(deserializeRow(entry.value)));
156
+ if (seenCommitted.has(hex)) {
157
+ throw new QuereusError(`Cannot re-key the primary key of table ${this.tableName}: `
158
+ + `rows this transaction has removed still collide under the new key definition and must survive a rollback. `
159
+ + `Commit/rollback and retry.`, StatusCode.BUSY);
160
+ }
161
+ seenCommitted.add(hex);
162
+ }
163
+ }
669
164
  /**
670
165
  * Re-key every stored row under a new primary-key definition.
671
166
  *
@@ -673,7 +168,11 @@ export class StoreTable extends VirtualTable {
673
168
  * key and retains only a `Set` of key SIGNATURES (hex of the key bytes) to detect
674
169
  * collisions — two distinct old keys collapsing to one new key under a coarser
675
170
  * collation or a narrower PK. On collision we throw `CONSTRAINT` without touching
676
- * the store. The second pass RE-SCANS the same committed store, recomputes each
171
+ * the store. For `ALTER PRIMARY KEY` this pass is the gate; for `ALTER COLUMN …
172
+ * SET COLLATE` on a PK member it is only a backstop — that arm has already run
173
+ * {@link validateRekeyedPrimaryKey}'s two probes before the DDL flush, so a
174
+ * refusal there leaves the enclosing transaction alive. The second pass RE-SCANS
175
+ * the same committed store, recomputes each
677
176
  * new key, and batches deletes of displaced old keys + puts of new (key, row)
678
177
  * pairs into ONE atomic batch. Rows whose new key matches the old key are no-ops.
679
178
  *
@@ -707,15 +206,10 @@ export class StoreTable extends VirtualTable {
707
206
  async rekeyRows(newPkDef, newColumns = this.tableSchema.columns) {
708
207
  const store = await this.ensureStore();
709
208
  const bounds = buildFullScanBounds();
710
- const newPkDirections = newPkDef.map(pk => !!pk.desc);
711
- const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
712
- // Post-ALTER key-identity transforms too: an ALTER PRIMARY KEY onto (or a SET
713
- // DATA TYPE creating) a semantic-ordering member must collapse equal spellings
714
- // — pass 1 then rejects 'PT1H'/'PT60M' as a duplicate PK, all-or-nothing.
715
- const newPkTransforms = resolvePkKeyTransforms(newPkDef, newColumns);
716
- // Both passes key rows through this one helper, so a collision judged in pass 1
717
- // is byte-identical to the key pass 2 writes.
718
- const computeNewKey = (row) => buildDataKey(newPkDef.map(pk => row[pk.index]), this.encodeOptions, newPkDirections, newPkCollations, newPkTransforms);
209
+ // Both passes key rows through this one helper — shared with the SET COLLATE
210
+ // arm's pre-flush probes — so a collision judged anywhere is byte-identical to
211
+ // the key pass 2 writes.
212
+ const computeNewKey = this.rekeyedKeyComputer(newPkDef, newColumns);
719
213
  // Pass 1 — collision detection only. Hold one hex signature per new key, never
720
214
  // the row or old key. On a repeat, reject before any write; the store is
721
215
  // untouched on rejection.
@@ -731,974 +225,51 @@ export class StoreTable extends VirtualTable {
731
225
  // only rewrite rows whose key actually moves (`newKey !== oldKey`).
732
226
  const batch = store.batch();
733
227
  for await (const entry of store.iterate(bounds)) {
734
- const row = deserializeRow(entry.value);
735
- const newKey = computeNewKey(row);
736
- if (!bytesEqual(entry.key, newKey)) {
737
- batch.delete(entry.key);
738
- // NOTE: the row VALUE is unchanged, so `serializeRow(row)` reproduces
739
- // `entry.value` byte-for-byte. We re-serialize rather than reuse
740
- // `entry.value` to avoid retaining an iterator-owned buffer in the batch.
741
- // If re-key CPU ever shows up hot, reuse `entry.value` where the backend
742
- // guarantees the buffer is not reused across iteration.
743
- batch.put(newKey, serializeRow(row));
744
- }
745
- }
746
- await batch.write();
747
- }
748
- /**
749
- * Migrate all stored rows from the old column layout to a new one.
750
- * The remap array maps newColumnIndex -> oldColumnIndex | -1.
751
- * -1 means the column is new (fill with defaultValue).
752
- *
753
- * `backfill`, when supplied (ADD COLUMN with a non-foldable DEFAULT such as
754
- * `new.<col>`), derives the new column's value from each existing row instead of the
755
- * single `defaultValue`, and rejects a NULL it produces for a NOT NULL column. The
756
- * batch is only written once every row migrates, so a throwing evaluator / NOT NULL
757
- * violation leaves the store untouched for the caller's rollback.
758
- */
759
- async migrateRows(remap, defaultValue, backfill) {
760
- const store = await this.ensureStore();
761
- const bounds = buildFullScanBounds();
762
- const batch = store.batch();
763
- for await (const entry of store.iterate(bounds)) {
764
- const oldRow = deserializeRow(entry.value);
765
- let newColumnValue = defaultValue;
766
- if (backfill) {
767
- newColumnValue = await backfill.evaluator(oldRow);
768
- if (backfill.notNull && newColumnValue === null) {
769
- throw new QuereusError(`NOT NULL constraint failed: backfilling column '${this.schemaName}.${this.tableName}.${backfill.columnName}' produced NULL for an existing row`, StatusCode.CONSTRAINT);
770
- }
771
- }
772
- const newRow = new Array(remap.length);
773
- for (let i = 0; i < remap.length; i++) {
774
- newRow[i] = remap[i] === -1 ? newColumnValue : oldRow[remap[i]];
775
- }
776
- batch.put(entry.key, serializeRow(newRow));
777
- }
778
- await batch.write();
779
- }
780
- /**
781
- * Ensure the data store is open and DDL is persisted.
782
- * Uses a promise-based singleton pattern to prevent race conditions
783
- * when multiple concurrent queries access the same table.
784
- */
785
- ensureStore() {
786
- if (this.store) {
787
- return Promise.resolve(this.store);
788
- }
789
- if (this.storeInitPromise) {
790
- return this.storeInitPromise;
791
- }
792
- this.storeInitPromise = this.initializeStore();
793
- return this.storeInitPromise;
794
- }
795
- /**
796
- * Internal method to actually initialize the store.
797
- * Only called once per table instance.
798
- */
799
- async initializeStore() {
800
- const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
801
- try {
802
- this.store = await this.storeModule.getStore(this.schemaName, this.tableName, this.config);
803
- if (!this.store) {
804
- throw new Error(`getStore returned null/undefined for ${tableKey}`);
805
- }
806
- // Save DDL on first access (only for newly created tables)
807
- if (!this.ddlSaved && this.tableSchema) {
808
- await this.storeModule.saveTableDDL(this.tableSchema);
809
- this.ddlSaved = true;
810
- }
811
- return this.store;
812
- }
813
- catch (error) {
814
- this.storeInitPromise = null;
815
- throw error;
816
- }
817
- }
818
- /**
819
- * Get or create an index store for the given index name.
820
- */
821
- async ensureIndexStore(indexName) {
822
- let indexStore = this.indexStores.get(indexName);
823
- if (!indexStore) {
824
- indexStore = await this.storeModule.getIndexStore(this.schemaName, this.tableName, indexName);
825
- this.indexStores.set(indexName, indexStore);
826
- }
827
- return indexStore;
828
- }
829
- /**
830
- * Get or create the stats store.
831
- */
832
- async ensureStatsStore() {
833
- if (!this.statsStore) {
834
- this.statsStore = await this.storeModule.getStatsStore(this.schemaName, this.tableName);
835
- }
836
- return this.statsStore;
837
- }
838
- /**
839
- * Resolve + cache the module's shared TransactionCoordinator and hook the
840
- * stats lifecycle callbacks, WITHOUT creating or registering a connection.
841
- * Synchronous (the coordinator is handle-addressed, never store-opening — see
842
- * `StoreTableModule.getCoordinator`), so the backing host's resolution path can
843
- * call it eagerly. Attaching matters for reads: `iterateEffective` /
844
- * `readEffectiveRowByKey` consult `this.coordinator` for the pending merge, so a
845
- * table whose only writer is the privileged backing host (which queues ops on the
846
- * module coordinator, never through `update()`) must still hold the
847
- * reference for its read paths to be reads-own-writes.
848
- *
849
- * The coordinator is module-wide, so its commit/rollback callback array holds
850
- * one {stats apply/discard} pair per PARTICIPATING table; each table's
851
- * `applyPendingStats` early-returns when its own `pendingStatsDelta` is 0, so a
852
- * table that did no work contributes nothing. Registers only on first call per
853
- * StoreTable instance (the `if (!this.coordinator)` guard); a fresh instance
854
- * after drop+recreate re-registers against the shared coordinator. The OLD
855
- * instance is not GC'd on its own — its callback closures capture `this` and
856
- * stay pinned on the module-wide coordinator until {@link dispose} runs the
857
- * captured disposer (called at the genuine eviction sites, see
858
- * `StoreModule.tearDownTableStorage` / `renameTable`).
859
- */
860
- attachCoordinator() {
861
- if (!this.coordinator) {
862
- this.coordinator = this.storeModule.getCoordinator();
863
- this.coordinatorDisposer = this.coordinator.registerCallbacks({
864
- onCommit: () => this.applyPendingStats(),
865
- onRollback: () => this.discardPendingStats(),
866
- });
867
- }
868
- return this.coordinator;
869
- }
870
- /**
871
- * Ensure the coordinator is available and connection is registered.
872
- */
873
- async ensureCoordinator() {
874
- const coordinator = this.attachCoordinator();
875
- if (!this.connection) {
876
- this.connection = new StoreConnection(`${this.schemaName}.${this.tableName}`, coordinator);
877
- await this.db.registerConnection(this.connection);
878
- }
879
- return coordinator;
880
- }
881
- /** Apply pending stats on commit. */
882
- applyPendingStats() {
883
- if (this.pendingStatsDelta === 0)
884
- return;
885
- if (!this.cachedStats) {
886
- this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
887
- }
888
- this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + this.pendingStatsDelta);
889
- this.cachedStats.updatedAt = Date.now();
890
- this.mutationCount += Math.abs(this.pendingStatsDelta);
891
- this.pendingStatsDelta = 0;
892
- if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
893
- this.statsFlushPending = true;
894
- queueMicrotask(() => this.flushStats());
895
- }
896
- }
897
- /** Discard pending stats on rollback. */
898
- discardPendingStats() {
899
- this.pendingStatsDelta = 0;
900
- }
901
- /** Flush statistics to the stats store. */
902
- async flushStats() {
903
- this.statsFlushPending = false;
904
- this.mutationCount = 0;
905
- if (!this.cachedStats) {
906
- return;
907
- }
908
- const statsStore = await this.ensureStatsStore();
909
- const statsKey = buildStatsKey(this.schemaName, this.tableName);
910
- await statsStore.put(statsKey, serializeStats(this.cachedStats));
911
- }
912
- /** Create a new connection for transaction support. */
913
- async createConnection() {
914
- await this.ensureCoordinator();
915
- return this.connection;
916
- }
917
- /** Get the current connection. */
918
- getConnection() {
919
- return this.connection ?? undefined;
920
- }
921
- /** Extract primary key values from a row. */
922
- extractPK(row) {
923
- const schema = this.tableSchema;
924
- return schema.primaryKeyDefinition.map(pk => row[pk.index]);
925
- }
926
- /**
927
- * Coerce each cell in `row` to its declared column logical type.
928
- * Mirrors the memory-table path (MemoryTableManager.performInsert/performUpdate)
929
- * so INTEGER/REAL affinity is applied and JSON columns are parsed into native
930
- * objects before PK extraction, serialization, and index-key construction.
931
- */
932
- coerceRow(row) {
933
- return coerceRowToSchema(row, this.tableSchema.columns, `${this.schemaName}.${this.tableName}`);
934
- }
935
- /**
936
- * Query the table with optional filters.
937
- *
938
- * All three access arms read the EFFECTIVE row state: the committed store
939
- * merged with this table's coordinator's pending ops when a transaction is
940
- * active (read-your-own-writes — see {@link iterateEffective} /
941
- * {@link readLiveRowByPk}). Merged emission stays in encoded-PK-key order,
942
- * preserving the module's `providesOrdering` / `monotonicOn` advertisements.
943
- */
944
- async *query(filterInfo) {
945
- const store = await this.ensureStore();
946
- // Multi-seek (`plan=5`, an IN-list served as N point seeks) dispatches FIRST —
947
- // before analyzePKAccess: the FilterInfo carries N*W EQ constraints, so on a
948
- // table whose PK column is also the leading column of the chosen index,
949
- // analyzePKAccess would match the FIRST equality and answer a single-value
950
- // point lookup — one list value's rows, a silent wrong result.
951
- const idxSpec = decodeIdxStr(filterInfo.idxStr);
952
- if (idxSpec && planKindFromCode(idxSpec.plan) === 'multiSeek') {
953
- yield* this.scanMultiSeek(idxSpec, filterInfo);
954
- return;
955
- }
956
- const pkAccess = this.analyzePKAccess(filterInfo);
957
- if (pkAccess.type === 'point') {
958
- const row = await this.readLiveRowByPk(pkAccess.values);
959
- if (row && this.matchesFilters(row, filterInfo, this.resolveFilterCollations(filterInfo))) {
960
- yield row;
961
- }
962
- return;
963
- }
964
- if (pkAccess.type === 'range') {
965
- yield* this.scanPKRange(store, pkAccess, filterInfo);
966
- return;
967
- }
968
- // Secondary-index scan arm — reached only when the predicate did NOT resolve
969
- // to a PK point/range (PK access is cheaper and already handled above). When
970
- // the planner chose a secondary index (idxStr carries `idx=<name>(…)`), derive
971
- // its byte window and iterate it instead of full-scanning.
972
- const indexAccess = this.analyzeIndexAccess(filterInfo);
973
- if (indexAccess) {
974
- const indexStore = await this.ensureIndexStore(indexAccess.index.name);
975
- yield* this.scanIndex(indexStore, indexAccess, filterInfo);
976
- return;
977
- }
978
- // Full table scan
979
- const collations = this.resolveFilterCollations(filterInfo);
980
- const bounds = buildFullScanBounds();
981
- for await (const entry of this.iterateEffective(store, bounds)) {
982
- const row = deserializeRow(entry.value);
983
- if (this.matchesFilters(row, filterInfo, collations)) {
984
- yield row;
985
- }
986
- }
987
- }
988
- /**
989
- * Iterate the effective entry state of this table's data store within
990
- * `bounds`: the committed `store.iterate` stream merged with the
991
- * coordinator's pending ops for the data store (read-your-own-writes).
992
- *
993
- * Both inputs are sorted by encoded key bytes, so this is an
994
- * order-preserving two-way merge: a pending put wins over a committed entry
995
- * at the same key, a pending delete suppresses its committed entry, and
996
- * pending puts outside `bounds` are excluded. With no active transaction
997
- * (or an empty pending bucket) it degrades to the bare committed iterate.
998
- *
999
- * The pending side is the module coordinator's bucket for THIS table's data
1000
- * store handle (`store`) — every data op is queued under that exact handle, so
1001
- * a sibling table's pending ops (a different data-store handle) never bleed in.
1002
- */
1003
- async *iterateEffective(store, bounds, reverse = false) {
1004
- const pending = this.coordinator?.isInTransaction()
1005
- ? this.coordinator.getOrderedPendingOps(store)
1006
- : null;
1007
- const iterOptions = reverse ? { ...bounds, reverse } : bounds;
1008
- if (!pending || (pending.puts.length === 0 && pending.deletes.size === 0)) {
1009
- yield* store.iterate(iterOptions);
1010
- return;
1011
- }
1012
- const puts = pending.puts.filter(p => keyWithinBounds(p.key, bounds));
1013
- if (reverse)
1014
- puts.reverse();
1015
- // In iteration order, "before" means byte-less for forward and byte-greater
1016
- // for reverse; sign folds the direction into one comparison.
1017
- const sign = reverse ? -1 : 1;
1018
- let putIdx = 0;
1019
- for await (const entry of store.iterate(iterOptions)) {
1020
- // Emit pending puts that precede this committed entry in iteration order.
1021
- while (putIdx < puts.length && sign * compareBytes(puts[putIdx].key, entry.key) < 0) {
1022
- yield puts[putIdx++];
1023
- }
1024
- // Equal keys: the pending put shadows the committed entry.
1025
- if (putIdx < puts.length && compareBytes(puts[putIdx].key, entry.key) === 0) {
1026
- yield puts[putIdx++];
1027
- continue;
1028
- }
1029
- if (pending.deletes.has(bytesToHex(entry.key)))
1030
- continue;
1031
- yield entry;
1032
- }
1033
- // Pending puts beyond the last committed entry.
1034
- while (putIdx < puts.length) {
1035
- yield puts[putIdx++];
1036
- }
1037
- }
1038
- /**
1039
- * True when a byte window over the LEADING PK column reproduces the comparator's order
1040
- * — the precondition {@link buildPKRangeBounds} needs, and the exact condition
1041
- * `StoreModule.computeBestAccessPlan` uses before claiming the range filters handled.
1042
- * When false, the range arm degrades to a full scan + `matchesFilters` residual.
1043
- */
1044
- leadingPkRangeIsOrderSafe() {
1045
- return pkOrderPreservingPrefixLength(this.db, this.tableSchema, this.pkKeyCollations, this.encodeOptions.collation ?? 'NOCASE') >= 1;
1046
- }
1047
- /**
1048
- * True when some PK member's logical type carries semantic ordering (TIMESPAN,
1049
- * JSON). Such PKs decline the point arm and full-scan, where
1050
- * {@link matchesFilters} applies the type's compare. Historically required —
1051
- * byte-key equality was a strict subset of the type's equality ('PT1H' ≡
1052
- * 'PT60M' elapsed-time-equal, byte-distinct) and a point window under-fetched
1053
- * with no residual able to resurrect a skipped row. Both types' key transforms
1054
- * (see {@link storeSemanticKeyTransform}) now make byte equality the type's
1055
- * equality, so the decline is conservative; re-opening is tracked in backlog
1056
- * `feat-reopen-timespan-store-seeks`. (Range windows and ordering
1057
- * advertisements are already declined by {@link keyOrderMatchesCollation}.)
1058
- */
1059
- pkHasSemanticOrderingMember() {
1060
- const schema = this.tableSchema;
1061
- return schema.primaryKeyDefinition.some(def => hasSemanticOrdering(schema.columns[def.index]?.logicalType));
1062
- }
1063
- /** Analyze filter info to determine PK access pattern. */
1064
- analyzePKAccess(filterInfo) {
1065
- const schema = this.tableSchema;
1066
- const pkColumns = schema.primaryKeyDefinition.map(pk => pk.index);
1067
- if (pkColumns.length === 0) {
1068
- return { type: 'scan' };
1069
- }
1070
- // Check for equality on all PK columns
1071
- const eqValues = new Array(pkColumns.length);
1072
- let allEq = true;
1073
- for (let i = 0; i < pkColumns.length; i++) {
1074
- const pkColIdx = pkColumns[i];
1075
- const eqConstraintEntry = filterInfo.constraints?.find(c => c.constraint.iColumn === pkColIdx && c.constraint.op === IndexConstraintOp.EQ);
1076
- if (eqConstraintEntry && eqConstraintEntry.argvIndex > 0) {
1077
- eqValues[i] = filterInfo.args[eqConstraintEntry.argvIndex - 1];
1078
- }
1079
- else {
1080
- allEq = false;
1081
- break;
1082
- }
1083
- }
1084
- if (allEq && !this.pkHasSemanticOrderingMember()) {
1085
- return { type: 'point', values: eqValues };
1086
- }
1087
- // Check for range constraints on first PK column
1088
- const firstPkCol = pkColumns[0];
1089
- const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
1090
- const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
1091
- // A range window is only sound when the leading PK column's key bytes order the way
1092
- // its comparator does; otherwise fall through to the full scan, where matchesFilters
1093
- // applies the range under the real comparator.
1094
- if (rangeConstraints.length > 0 && this.leadingPkRangeIsOrderSafe()) {
1095
- return {
1096
- type: 'range',
1097
- columnIndex: firstPkCol,
1098
- constraints: rangeConstraints.map(c => ({
1099
- columnIndex: c.constraint.iColumn,
1100
- op: c.constraint.op,
1101
- value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
1102
- })),
1103
- };
1104
- }
1105
- return { type: 'scan' };
1106
- }
1107
- /**
1108
- * Convert a leading-PK-column range access into one seek + early-terminate
1109
- * iterate window.
1110
- *
1111
- * Each LT/LE/GT/GE constraint's bound value is encoded under the SAME
1112
- * per-column DESC direction and key collation as the data keys (via
1113
- * {@link encodePkPrefixBounds}), giving the byte region `[lo, hi)` whose
1114
- * leading column equals that value (`lo = encode([x])`,
1115
- * `hi = incrementLastByte(lo)`). The op maps that region's endpoints onto a
1116
- * `gte`/`lt` window; because a DESC column bit-inverts its bytes (larger value
1117
- * ⇒ smaller bytes), the lower/upper assignment swaps with direction:
1118
- *
1119
- * | op | ASC | DESC |
1120
- * |----|----------|----------|
1121
- * | GE | gte = lo | lt = hi |
1122
- * | GT | gte = hi | lt = lo |
1123
- * | LE | lt = hi | gte = lo |
1124
- * | LT | lt = lo | gte = hi |
1125
- *
1126
- * Across constraints (BETWEEN ⇒ one lower + one upper; a redundant same-side
1127
- * pair ⇒ the tighter wins) we keep the MAX lower candidate for `gte` and the
1128
- * MIN upper candidate for `lt`. A candidate that resolves to `undefined` (an
1129
- * `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
1130
- * SUPERSET, since {@link matchesFilters} stays the authoritative collation-aware
1131
- * row filter. A NULL/missing bound value is likewise skipped (the planner never
1132
- * pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
1133
- *
1134
- * NOTE: a range window over a text PK column is sound only when the column's key
1135
- * normalizer is ORDER-preserving with respect to its comparator — i.e. the comparator
1136
- * orders two strings the way memcmp orders their normalized bytes. That is NOT implied
1137
- * by `db.registerCollation`'s normalizer contract, which promises only an equality
1138
- * partition, and a built-in NAME may be re-registered with a comparator + normalizer
1139
- * pair that preserves equality while inverting order. This method is therefore only
1140
- * ever reached under {@link leadingPkRangeIsOrderSafe} — enforced by
1141
- * {@link analyzePKAccess} here and mirrored by `StoreModule.computeBestAccessPlan`
1142
- * before it claims the range filters handled. A collation without the
1143
- * `orderPreserving` assertion costs the seek, never a row.
1144
- */
1145
- buildPKRangeBounds(access) {
1146
- const full = buildFullScanBounds();
1147
- const constraints = access.constraints;
1148
- if (!constraints || constraints.length === 0)
1149
- return full;
1150
- const dir = this.pkDirections[0];
1151
- let gte = full.gte;
1152
- let lt;
1153
- for (const c of constraints) {
1154
- if (c.value === undefined || c.value === null)
1155
- continue;
1156
- const { gte: lo, lt: hi } = this.encodePkPrefixBounds([c.value]);
1157
- const lower = !dir
1158
- ? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
1159
- : (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
1160
- const upper = !dir
1161
- ? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
1162
- : (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
1163
- if (lower && compareBytes(lower, gte) > 0)
1164
- gte = lower;
1165
- if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
1166
- lt = upper;
1167
- }
1168
- return lt === undefined ? { gte } : { gte, lt };
1169
- }
1170
- /**
1171
- * Scan a leading-PK-column range, seeking to the window start and
1172
- * early-terminating at its end.
1173
- *
1174
- * {@link buildPKRangeBounds} converts the LT/LE/GT/GE constraints into one
1175
- * encoded-byte `gte`/`lt` window under the same per-column DESC directions and
1176
- * key collations the data keys use, so the iterate visits a SUPERSET of the
1177
- * qualifying rows (a collation widening or the bound-byte increment can
1178
- * over-fetch, never under-fetch). {@link matchesFilters} stays the authoritative
1179
- * collation-aware row filter. {@link iterateEffective} restricts the pending
1180
- * merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
1181
- */
1182
- async *scanPKRange(store, access, filterInfo) {
1183
- const collations = this.resolveFilterCollations(filterInfo);
1184
- const bounds = this.buildPKRangeBounds(access);
1185
- for await (const entry of this.iterateEffective(store, bounds)) {
1186
- const row = deserializeRow(entry.value);
1187
- if (this.matchesFilters(row, filterInfo, collations)) {
1188
- yield row;
1189
- }
1190
- }
1191
- }
1192
- /**
1193
- * Resolve the secondary index chosen by the planner from `filterInfo.idxStr`.
1194
- *
1195
- * The planner emits `idx=<name>(<n>);plan=…` when its access plan set both an
1196
- * `indexName` and `seekColumnIndexes` (see `getBestAccessPlan` and
1197
- * rule-select-access-path.ts). Decoding goes through the engine's shared
1198
- * `decodeIdxStr`, so the store, the in-memory vtab, and the isolation overlay all
1199
- * read one format. Returns null for the PK/scan sentinels (`_primary_`, `fullscan`,
1200
- * `empty`), an idxStr that names no index, or a name absent from `schema.indexes` —
1201
- * every one of which routes back to a PK/full-scan arm.
1202
- */
1203
- resolveIndexFromIdxStr(idxStr) {
1204
- const spec = decodeIdxStr(idxStr);
1205
- if (!spec)
1206
- return null;
1207
- const name = spec.indexName;
1208
- if (!name || name === '_primary_')
1209
- return null;
1210
- const indexes = this.tableSchema?.indexes ?? [];
1211
- return indexes.find(i => i.name.toLowerCase() === name.toLowerCase()) ?? null;
1212
- }
1213
- /**
1214
- * Analyze filter info to determine a secondary-index access pattern, mirroring
1215
- * {@link analyzePKAccess} but over the index chosen in `idxStr`.
1216
- *
1217
- * A contiguous leading-prefix EQ on the index columns yields a `point` window
1218
- * (the prefix covers every entry sharing those leading values — an index seek
1219
- * is a PREFIX scan, not a single row, since the index need not be unique and
1220
- * the PK suffix varies); otherwise a range (LT/LE/GT/GE) on the LEADING index
1221
- * column yields a `range` window. Returns null when neither applies or when the
1222
- * index is unresolved. {@link matchesFilters} stays the authoritative row filter,
1223
- * so the window need only be a SUPERSET.
1224
- *
1225
- * Index-column bytes are encoded under the table key collation K (NOT the index's
1226
- * per-column declared collation — see `buildIndexKey`), while `matchesFilters` compares
1227
- * under the index column's effective collation C. The EQ/prefix window is a superset
1228
- * whenever K is coarser-or-equal to C, which `StoreModule.tryIndexAccessPlan` checked
1229
- * before it named this index. The RANGE window additionally needs byte order to BE
1230
- * comparator order, so it is gated on {@link keyOrderMatchesCollation} (which demands
1231
- * `C === K` plus K's `orderPreserving` assertion); when that fails we return null and
1232
- * the caller full-scans.
1233
- */
1234
- analyzeIndexAccess(filterInfo) {
1235
- const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
1236
- if (!index)
1237
- return null;
1238
- const indexCols = index.columns.map(c => c.index);
1239
- const indexDirections = index.columns.map(c => !!c.desc);
1240
- // Contiguous leading-prefix EQ → point/prefix window. A prefix member whose
1241
- // logical type carries semantic ordering stops the prefix: its byte-equality
1242
- // window under-fetches the type's equality (see pkHasSemanticOrderingMember),
1243
- // and a skipped row cannot be resurrected by the residual.
1244
- const eqValues = [];
1245
- for (let i = 0; i < indexCols.length; i++) {
1246
- if (hasSemanticOrdering(this.tableSchema.columns[indexCols[i]]?.logicalType))
1247
- break;
1248
- const eq = filterInfo.constraints?.find(c => c.constraint.iColumn === indexCols[i]
1249
- && c.constraint.op === IndexConstraintOp.EQ
1250
- && c.argvIndex > 0);
1251
- if (!eq)
1252
- break;
1253
- eqValues.push(filterInfo.args[eq.argvIndex - 1]);
1254
- }
1255
- if (eqValues.length > 0) {
1256
- const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length));
1257
- return { index, type: 'point', bounds };
1258
- }
1259
- // Else a range on the LEADING index column.
1260
- const leadingCol = indexCols[0];
1261
- const rangeOps = [IndexConstraintOp.LT, IndexConstraintOp.LE, IndexConstraintOp.GT, IndexConstraintOp.GE];
1262
- const rangeConstraints = (filterInfo.constraints ?? []).filter(c => c.constraint.iColumn === leadingCol && rangeOps.includes(c.constraint.op));
1263
- if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index, leadingCol)) {
1264
- const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
1265
- op: c.constraint.op,
1266
- value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
1267
- })), indexDirections[0]);
1268
- return { index, type: 'range', bounds };
1269
- }
1270
- return null;
1271
- }
1272
- /**
1273
- * True when a byte window over `leadingCol` of `index` reproduces the comparator's
1274
- * order. Mirrors the range arm of `StoreModule.tryIndexAccessPlan`: the window's bytes
1275
- * come from the table key collation K, the residual compares under the index column's
1276
- * effective collation C, so both must be the same order-preserving collation.
1277
- */
1278
- indexRangeIsOrderSafe(index, leadingCol) {
1279
- const schema = this.tableSchema;
1280
- const K = this.encodeOptions.collation ?? 'NOCASE';
1281
- const col = schema.columns[leadingCol];
1282
- const indexCol = index.columns.find(c => c.index === leadingCol);
1283
- const C = indexCol?.collation ?? col?.collation ?? 'BINARY';
1284
- return keyOrderMatchesCollation(this.db, col, K, C);
1285
- }
1286
- /**
1287
- * Convert leading-index-column LT/LE/GT/GE constraints into one encoded-byte
1288
- * `gte`/`lt` window — the secondary-index analogue of {@link buildPKRangeBounds}.
1289
- *
1290
- * Each bound value is encoded under {@link encodeOptions} (the table key collation K
1291
- * and its normalizer resolver) and the leading index column's DESC `dir` — exactly as
1292
- * {@link buildIndexKey} encodes
1293
- * that column — via {@link buildIndexPrefixBounds}, giving the byte region
1294
- * `[lo, hi)` whose leading column equals that value. The op maps that region's
1295
- * endpoints onto `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path:
1296
- *
1297
- * | op | ASC | DESC |
1298
- * |----|----------|----------|
1299
- * | GE | gte = lo | lt = hi |
1300
- * | GT | gte = hi | lt = lo |
1301
- * | LE | lt = hi | gte = lo |
1302
- * | LT | lt = lo | gte = hi |
1303
- *
1304
- * Across constraints keep the MAX lower and MIN upper. An `undefined` upper (an
1305
- * `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
1306
- * SUPERSET. A NULL/missing bound value is skipped (the planner never pushes
1307
- * `= NULL`, and a range op against NULL rejects every row in matchesFilters).
1308
- */
1309
- buildIndexRangeBounds(constraints, dir) {
1310
- const full = buildFullScanBounds();
1311
- let gte = full.gte;
1312
- let lt;
1313
- for (const c of constraints) {
1314
- if (c.value === undefined || c.value === null)
1315
- continue;
1316
- const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions, [dir]);
1317
- const lower = !dir
1318
- ? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
1319
- : (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
1320
- const upper = !dir
1321
- ? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
1322
- : (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
1323
- if (lower && compareBytes(lower, gte) > 0)
1324
- gte = lower;
1325
- if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
1326
- lt = upper;
1327
- }
1328
- return lt === undefined ? { gte } : { gte, lt };
1329
- }
1330
- /**
1331
- * Scan a secondary index over `access.bounds`, resolving each index entry to its
1332
- * base row and re-filtering.
1333
- *
1334
- * {@link iterateEffective} yields the committed index entries merged with this
1335
- * transaction's pending index puts/deletes (read-your-own-writes over the
1336
- * index), in index-key byte order — the order the isolation overlay merge relies
1337
- * on (`isolated-table.ts` § buildSortKey). We resolve each entry to its row via
1338
- * its stored data-key value WITHOUT reordering, so index-key order is preserved.
1339
- *
1340
- * Defense in depth mirroring the memory layer's live-recheck: a resolved-null
1341
- * row (the entry's row was deleted — a pending index delete would normally
1342
- * suppress the entry, but a committed entry can lag) is skipped, and every
1343
- * resolved row is re-checked by {@link matchesFilters} (the byte window is only
1344
- * a superset, and a stale entry whose indexed column no longer matches is
1345
- * dropped).
1346
- */
1347
- async *scanIndex(indexStore, access, filterInfo, multi) {
1348
- // Re-check each resolved row under the INDEX's per-column collation (see
1349
- // matchesFilters): the planner dropped the residual based on the index
1350
- // column's collation, which an explicit index `COLLATE` can make differ from
1351
- // the table column's declared collation.
1352
- const indexCollations = multi?.collations
1353
- ?? this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
1354
- for await (const entry of this.iterateEffective(indexStore, access.bounds)) {
1355
- // NOTE: a legacy index store (written before index values carried the data
1356
- // key) holds EMPTY values; a zero-length data key is not a row key, so skip
1357
- // it rather than resolve it to the wrong row. Because the access plan marked
1358
- // the filter handled and dropped the residual, an indexed query over such a
1359
- // store returns NOTHING rather than the matching rows — a silent wrong
1360
- // result, not an error. Backwards compatibility is waived project-wide
1361
- // (AGENTS.md) and no test provider carries on-disk data, so nothing exercises
1362
- // this today. If real persisted stores predating this format come into play,
1363
- // their indexes must be dropped + recreated (or the table rebuilt); the
1364
- // durable fix is to version-stamp the index store and rebuild on open, or to
1365
- // fall back to a full scan the first time an empty value is seen.
1366
- if (entry.value.length === 0)
1367
- continue;
1368
- // Cross-window dedup for a multi-seek: a data key an earlier window already
1369
- // YIELDED is skipped before the data-store read, so a duplicate costs no
1370
- // extra `get`. The seen-set is only ever ADDED to on a yield (below) — adding
1371
- // at visit time would let a stale index entry that fails its residual poison
1372
- // the set and suppress the row's live entry in a later window.
1373
- const dataKeyHex = multi ? bytesToHex(entry.value) : undefined;
1374
- if (dataKeyHex !== undefined && multi.seen.has(dataKeyHex))
1375
- continue;
1376
- // NOTE: one extra data-store `get` per matched index entry — the row lives
1377
- // in the data store, not the index (the index value carries only the data
1378
- // key, no covering payload). Fine now; if index-covered scans ever dominate
1379
- // a profile, consider storing the serialized row as a covering index value,
1380
- // at the cost of an index rewrite on EVERY column change (not just indexed
1381
- // columns) — deliberately not done here.
1382
- const row = await this.readEffectiveRowByKey(entry.value);
1383
- if (!row)
1384
- continue;
1385
- if (this.matchesFilters(row, filterInfo, indexCollations)
1386
- || (multi !== undefined && multi.extraTuples.some(fi => this.matchesFilters(row, fi, indexCollations)))) {
1387
- if (dataKeyHex !== undefined)
1388
- multi.seen.add(dataKeyHex);
1389
- yield row;
1390
- }
1391
- }
1392
- }
1393
- /**
1394
- * Fail loudly on a multi-seek FilterInfo this table cannot serve. The plan already
1395
- * dropped the residual Filter, and {@link matchesFilters} ANDs every pushed
1396
- * constraint — so falling through to the scan arm would AND N mutually-exclusive
1397
- * equalities and return zero rows: a silent wrong answer, not a slow one.
1398
- */
1399
- multiSeekMalformed(filterInfo, why) {
1400
- throw new QuereusError(`Malformed multi-seek FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
1401
- }
1402
- /**
1403
- * Decode a multi-seek FilterInfo into its N seek tuples of width W. Tuple i pairs
1404
- * `args[i*W … i*W+W-1]` with the same slice of `constraints` (argvIndex runs
1405
- * 1…N*W in order — see rule-select-access-path's multiSeek emission). A tuple with
1406
- * a NULL/undefined component is dropped: IN is set membership and a NULL component
1407
- * matches nothing. The planner pre-reduces literal lists, but a parameter-bound /
1408
- * mixed-binding list reaches here unreduced, so this skip is the only line of
1409
- * defense for those.
1410
- */
1411
- decodeMultiSeekTuples(spec, filterInfo) {
1412
- const seekWidth = Number.parseInt(spec.params.get('seekWidth') ?? '1', 10);
1413
- const inCount = Number.parseInt(spec.params.get('inCount') ?? '0', 10);
1414
- if (!Number.isInteger(seekWidth) || seekWidth < 1) {
1415
- this.multiSeekMalformed(filterInfo, `seekWidth=${spec.params.get('seekWidth')}`);
1416
- }
1417
- if (!Number.isInteger(inCount) || inCount < 1) {
1418
- this.multiSeekMalformed(filterInfo, `inCount=${spec.params.get('inCount')}`);
1419
- }
1420
- const constraints = filterInfo.constraints ?? [];
1421
- if (constraints.length < inCount * seekWidth) {
1422
- this.multiSeekMalformed(filterInfo, `${constraints.length} constraints for ${inCount}×${seekWidth} seek keys`);
1423
- }
1424
- if (filterInfo.args.length < inCount * seekWidth) {
1425
- this.multiSeekMalformed(filterInfo, `${filterInfo.args.length} args for ${inCount}×${seekWidth} seek keys`);
1426
- }
1427
- const tuples = [];
1428
- for (let t = 0; t < inCount; t++) {
1429
- const entries = constraints.slice(t * seekWidth, (t + 1) * seekWidth);
1430
- if (entries.some(e => e.constraint.op !== IndexConstraintOp.EQ || e.argvIndex <= 0)) {
1431
- this.multiSeekMalformed(filterInfo, `seek tuple ${t} carries a non-EQ or unbound constraint`);
1432
- }
1433
- const values = entries.map(e => filterInfo.args[e.argvIndex - 1]);
1434
- if (values.some(v => v === null || v === undefined))
1435
- continue;
1436
- tuples.push({ entries, values });
1437
- }
1438
- return { tuples, seekWidth };
1439
- }
1440
- /**
1441
- * Order one tuple's values to match `keyCols` (the leading index columns, or the
1442
- * PK definition) using each constraint entry's column. The plan's seek columns and
1443
- * the constraint list must agree; a tuple that does not cover exactly those
1444
- * columns is malformed.
1445
- */
1446
- orderTupleValues(tuple, keyCols, filterInfo) {
1447
- const byCol = new Map();
1448
- tuple.entries.forEach((e, i) => byCol.set(e.constraint.iColumn, tuple.values[i]));
1449
- if (byCol.size !== keyCols.length) {
1450
- this.multiSeekMalformed(filterInfo, `seek tuple covers ${byCol.size} columns, expected ${keyCols.length}`);
1451
- }
1452
- return keyCols.map(colIdx => {
1453
- if (!byCol.has(colIdx))
1454
- this.multiSeekMalformed(filterInfo, `seek tuple missing column ${colIdx}`);
1455
- return byCol.get(colIdx);
1456
- });
1457
- }
1458
- /**
1459
- * Serve a multi-seek (`plan=5`) FilterInfo: one point window per distinct
1460
- * IN-list tuple, scanned in ascending encoded-key order.
1461
- *
1462
- * Window order is not cosmetic. Encoded-byte order IS index-key order (per-column
1463
- * DESC inversion is baked into the bytes), and the isolation overlay merges an
1464
- * index scan with its pending rows by (indexKey, PK) — an out-of-order underlying
1465
- * stream misplaces overlay rows in the output. Two overlap hazards are folded
1466
- * away before scanning:
1467
- * - Tuples whose K-encoded prefix byte-matches (duplicate bound parameters, or
1468
- * case variants under a NOCASE table key collation) share ONE window, each
1469
- * kept as a residual alternative — see {@link MultiSeekWindowContext} for why
1470
- * a single tuple's residual would drop rows when the comparison collation is
1471
- * finer than K.
1472
- * - A window with no finite upper bound (all-0xff prefix — see
1473
- * buildIndexPrefixBounds) contains every later-sorting window outright (any
1474
- * key ≥ an all-0xff prefix necessarily starts with it), so those windows fold
1475
- * into it instead of re-scanning an overlapping range out of order.
1476
- *
1477
- * Emission is lazy per window — `… in (…) limit 1` stops after the first yielded
1478
- * row without materializing the remaining windows. Each window goes through
1479
- * {@link scanIndex} → {@link iterateEffective}, so read-your-own-writes and the
1480
- * stale-entry / deleted-row defenses hold per seek key.
1481
- */
1482
- async *scanMultiSeek(spec, filterInfo) {
1483
- const { tuples, seekWidth } = this.decodeMultiSeekTuples(spec, filterInfo);
1484
- if (spec.indexName === '_primary_') {
1485
- yield* this.scanMultiSeekPrimary(tuples, seekWidth, filterInfo);
1486
- return;
1487
- }
1488
- const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
1489
- if (!index)
1490
- this.multiSeekMalformed(filterInfo, `no index named '${spec.indexName}'`);
1491
- const indexCols = index.columns.map(c => c.index);
1492
- const indexDirections = index.columns.map(c => !!c.desc);
1493
- if (seekWidth > indexCols.length) {
1494
- this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} exceeds ${index.name}'s ${indexCols.length} columns`);
1495
- }
1496
- const seekCols = indexCols.slice(0, seekWidth);
1497
- // The K-encoded byte windows below carry no residual able to resurrect a
1498
- // skipped row, so a seek column whose byte equality under-fetches the type's
1499
- // equality (TIMESPAN, JSON — see pkHasSemanticOrderingMember) cannot be
1500
- // multi-seeked. StoreModule.tryIndexAccessPlan declines such plans; one
1501
- // arriving anyway is malformed.
1502
- if (seekCols.some(colIdx => hasSemanticOrdering(this.tableSchema.columns[colIdx]?.logicalType))) {
1503
- this.multiSeekMalformed(filterInfo, 'semantic-ordering seek column');
1504
- }
1505
- // One window per distinct K-encoded tuple prefix; K-equal tuples merge into
1506
- // one window, each kept as a residual alternative.
1507
- const windows = new Map();
1508
- for (const tuple of tuples) {
1509
- const ordered = this.orderTupleValues(tuple, seekCols, filterInfo);
1510
- const bounds = buildIndexPrefixBounds(ordered, this.encodeOptions, indexDirections.slice(0, seekWidth));
1511
- const hex = bytesToHex(bounds.gte);
1512
- const info = { ...filterInfo, constraints: tuple.entries };
1513
- const existing = windows.get(hex);
1514
- if (existing)
1515
- existing.infos.push(info);
1516
- else
1517
- windows.set(hex, { bounds, infos: [info] });
1518
- }
1519
- if (windows.size === 0)
1520
- return; // every tuple had a NULL component — nothing matches
1521
- const sorted = [...windows.values()].sort((a, b) => compareBytes(a.bounds.gte, b.bounds.gte));
1522
- const disjoint = [];
1523
- for (const w of sorted) {
1524
- const last = disjoint[disjoint.length - 1];
1525
- if (last && last.bounds.lt === undefined)
1526
- last.infos.push(...w.infos);
1527
- else
1528
- disjoint.push(w);
1529
- }
1530
- const indexStore = await this.ensureIndexStore(index.name);
1531
- // Identical for every window (resolveFilterCollations dedups by column), so
1532
- // resolve once and thread it through.
1533
- const collations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(index));
1534
- const seen = new Set();
1535
- for (const w of disjoint) {
1536
- yield* this.scanIndex(indexStore, { index, type: 'point', bounds: w.bounds }, w.infos[0], { seen, collations, extraTuples: w.infos.slice(1) });
1537
- }
1538
- }
1539
- /**
1540
- * Multi-seek over the PRIMARY key: each tuple is a full PK, resolved by a point
1541
- * read in ascending encoded-data-key order (the table's native emission order).
1542
- *
1543
- * NOT reachable from this module's own plans today: `StoreModule.computeBestAccessPlan`
1544
- * claims IN-list filters only for secondary indexes (see its EQ_OPS vs EQ_OR_IN_OPS
1545
- * split and tickets/backlog/feat-store-pk-in-list-multiseek). The branch exists so
1546
- * a `_primary_` multi-seek arriving from a future plan gets a correct answer rather
1547
- * than the scan arm's silent zero rows, and it is what PK-IN enablement will build on.
1548
- */
1549
- async *scanMultiSeekPrimary(tuples, seekWidth, filterInfo) {
1550
- const pkColumns = this.tableSchema.primaryKeyDefinition.map(pk => pk.index);
1551
- if (pkColumns.length === 0) {
1552
- this.multiSeekMalformed(filterInfo, 'primary-key multi-seek on a table with no primary key');
1553
- }
1554
- if (seekWidth !== pkColumns.length) {
1555
- this.multiSeekMalformed(filterInfo, `seekWidth ${seekWidth} does not cover the ${pkColumns.length}-column primary key`);
1556
- }
1557
- // Mirrors analyzePKAccess's conservative decline of the point arm for such PKs
1558
- // (see pkHasSemanticOrderingMember); with the residual gone there is no scan to
1559
- // degrade to, so fail loudly rather than risk an under-fetch.
1560
- if (this.pkHasSemanticOrderingMember()) {
1561
- this.multiSeekMalformed(filterInfo, 'semantic-ordering primary-key member');
1562
- }
1563
- // Dedup by encoded data key (collapses K-equal tuples), keeping every merged
1564
- // tuple's residual as an alternative — same rationale as the index branch.
1565
- const points = new Map();
1566
- for (const tuple of tuples) {
1567
- const ordered = this.orderTupleValues(tuple, pkColumns, filterInfo);
1568
- const key = this.encodeDataKey(ordered);
1569
- const hex = bytesToHex(key);
1570
- const info = { ...filterInfo, constraints: tuple.entries };
1571
- const existing = points.get(hex);
1572
- if (existing)
1573
- existing.infos.push(info);
1574
- else
1575
- points.set(hex, { key, infos: [info] });
1576
- }
1577
- const collations = this.resolveFilterCollations(filterInfo);
1578
- for (const p of [...points.values()].sort((a, b) => compareBytes(a.key, b.key))) {
1579
- const row = await this.readEffectiveRowByKey(p.key);
1580
- if (row && p.infos.some(fi => this.matchesFilters(row, fi, collations))) {
1581
- yield row;
228
+ const row = deserializeRow(entry.value);
229
+ const newKey = computeNewKey(row);
230
+ if (!bytesEqual(entry.key, newKey)) {
231
+ batch.delete(entry.key);
232
+ // NOTE: the row VALUE is unchanged, so `serializeRow(row)` reproduces
233
+ // `entry.value` byte-for-byte. We re-serialize rather than reuse
234
+ // `entry.value` to avoid retaining an iterator-owned buffer in the batch.
235
+ // If re-key CPU ever shows up hot, reuse `entry.value` where the backend
236
+ // guarantees the buffer is not reused across iteration.
237
+ batch.put(newKey, serializeRow(row));
1582
238
  }
1583
239
  }
240
+ await batch.write();
1584
241
  }
1585
242
  /**
1586
- * Check if a row matches the filter constraints.
243
+ * Migrate all stored rows from the old column layout to a new one.
244
+ * The remap array maps newColumnIndex -> oldColumnIndex | -1.
245
+ * -1 means the column is new (fill with defaultValue).
1587
246
  *
1588
- * `collations` maps a constrained column index to the comparison function that
1589
- * column must be re-checked under; every caller builds it once per scan with
1590
- * {@link resolveFilterCollations}, whose doc comment explains how the name is
1591
- * chosen. A column absent from the map compares BINARY — the same result
1592
- * {@link resolveFilterCollations} produces for an undeclared collation, and the
1593
- * only reachable absence, since both walk the constraint list under identical
1594
- * skip conditions.
247
+ * `backfill`, when supplied (ADD COLUMN with a non-foldable DEFAULT such as
248
+ * `new.<col>`), derives the new column's value from each existing row instead of the
249
+ * single `defaultValue`, and rejects a NULL it produces for a NOT NULL column. The
250
+ * batch is only written once every row migrates, so a throwing evaluator / NOT NULL
251
+ * violation leaves the store untouched for the caller's rollback.
1595
252
  */
1596
- matchesFilters(row, filterInfo, collations) {
1597
- if (!filterInfo.constraints || filterInfo.constraints.length === 0) {
1598
- return true;
1599
- }
1600
- for (const constraintEntry of filterInfo.constraints) {
1601
- const { constraint, argvIndex } = constraintEntry;
1602
- if (constraint.iColumn < 0 || argvIndex <= 0) {
1603
- continue;
253
+ async migrateRows(remap, defaultValue, backfill) {
254
+ const store = await this.ensureStore();
255
+ const bounds = buildFullScanBounds();
256
+ const batch = store.batch();
257
+ for await (const entry of store.iterate(bounds)) {
258
+ const oldRow = deserializeRow(entry.value);
259
+ let newColumnValue = defaultValue;
260
+ if (backfill) {
261
+ newColumnValue = await backfill.evaluator(oldRow);
262
+ if (backfill.notNull && newColumnValue === null) {
263
+ throw new QuereusError(`NOT NULL constraint failed: backfilling column '${this.schemaName}.${this.tableName}.${backfill.columnName}' produced NULL for an existing row`, StatusCode.CONSTRAINT);
264
+ }
1604
265
  }
1605
- const rowValue = row[constraint.iColumn];
1606
- const filterValue = filterInfo.args[argvIndex - 1];
1607
- const collation = collations.get(constraint.iColumn) ?? BINARY_COLLATION;
1608
- // A semantic-ordering column (TIMESPAN, JSON) must filter under the type's
1609
- // compare — the same order the engine's operators and Sort use — or a pushed
1610
- // `d > 'PT90M'` would text-compare and drop rows the predicate admits. The
1611
- // comparator is built per row only on this rare column kind; if a profile
1612
- // ever shows it, resolve it once per scan alongside the collations.
1613
- const logicalType = this.tableSchema.columns[constraint.iColumn]?.logicalType;
1614
- const compare = hasSemanticOrdering(logicalType)
1615
- ? createTypedComparator(logicalType, collation)
1616
- : undefined;
1617
- if (!this.compareValues(rowValue, constraint.op, filterValue, collation, compare)) {
1618
- return false;
266
+ const newRow = new Array(remap.length);
267
+ for (let i = 0; i < remap.length; i++) {
268
+ newRow[i] = remap[i] === -1 ? newColumnValue : oldRow[remap[i]];
1619
269
  }
270
+ batch.put(entry.key, serializeRow(newRow));
1620
271
  }
1621
- return true;
1622
- }
1623
- /**
1624
- * Resolve, ONCE per scan, the comparison collation function for every pushed
1625
- * constraint column, keyed by column index.
1626
- *
1627
- * The name for a column is its DECLARED collation (absent ⇒ BINARY) — the same
1628
- * resolution the access path's collation-cover analysis uses
1629
- * (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides a
1630
- * pushed constraint is fully covered, and the same source this file's UNIQUE
1631
- * checks compare under. On a collation MATCH the planner drops the residual
1632
- * Filter, so {@link matchesFilters} alone must reproduce the predicate.
1633
- *
1634
- * `indexCollationNames` (from {@link indexColumnCollations}) overrides the
1635
- * declared name for the secondary-index scan arm: the planner's MATCH there is
1636
- * against the INDEX column's collation, which an explicit `COLLATE` on the index
1637
- * can make differ from the table column's.
1638
- *
1639
- * Names resolve against {@link collationResolver}, so an unregistered collation
1640
- * raises `no such collation sequence` at scan setup rather than byte-ordering
1641
- * every row.
1642
- *
1643
- * NOTE: rebuilt on every `query()` / `scanPKRange()` / `scanIndex()` call — one
1644
- * registry lookup per distinct constrained column, dwarfed by the scan's I/O. If a
1645
- * point-lookup-heavy profile ever shows it, memoize on the `FilterInfo`.
1646
- */
1647
- resolveFilterCollations(filterInfo, indexCollationNames) {
1648
- const resolved = new Map();
1649
- if (!filterInfo.constraints)
1650
- return resolved;
1651
- for (const { constraint, argvIndex } of filterInfo.constraints) {
1652
- if (constraint.iColumn < 0 || argvIndex <= 0)
1653
- continue;
1654
- if (resolved.has(constraint.iColumn))
1655
- continue;
1656
- const name = indexCollationNames?.has(constraint.iColumn)
1657
- ? indexCollationNames.get(constraint.iColumn)
1658
- : this.tableSchema.columns[constraint.iColumn]?.collation;
1659
- resolved.set(constraint.iColumn, name ? this.collationResolver(name) : BINARY_COLLATION);
1660
- }
1661
- return resolved;
1662
- }
1663
- /**
1664
- * Effective per-column comparison collation for a secondary index's columns:
1665
- * the index column's own `COLLATE` when present, else the underlying table
1666
- * column's declared collation. Mirrors the resolution `StoreModule`'s
1667
- * index-maintenance UNIQUE dedup uses (`indexCol.collation ?? tableColumn`), so
1668
- * a re-checked index-scan row compares under the same collation the planner used
1669
- * to justify dropping (or keeping) the residual Filter.
1670
- */
1671
- indexColumnCollations(index) {
1672
- const cols = this.tableSchema.columns;
1673
- const map = new Map();
1674
- for (const c of index.columns) {
1675
- map.set(c.index, c.collation ?? cols[c.index]?.collation);
1676
- }
1677
- return map;
1678
- }
1679
- /**
1680
- * Compare two values according to an operator, under `collationFunc` (already
1681
- * resolved by {@link resolveFilterCollations} against this database's collation
1682
- * registry). So the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM/custom column
1683
- * collation rather than a raw BINARY JS comparison — the capability
1684
- * `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
1685
- * NULL on either side fails every operator except EQ-with-both-NULL (the
1686
- * internal point-lookup convention; the planner never pushes `= NULL`).
1687
- */
1688
- compareValues(a, op, b, collationFunc, semanticCompare) {
1689
- if (a === null || b === null) {
1690
- return op === IndexConstraintOp.EQ ? a === b : false;
1691
- }
1692
- const cmp = semanticCompare ? semanticCompare(a, b) : compareSqlValuesFast(a, b, collationFunc);
1693
- switch (op) {
1694
- case IndexConstraintOp.EQ: return cmp === 0;
1695
- case IndexConstraintOp.NE: return cmp !== 0;
1696
- case IndexConstraintOp.LT: return cmp < 0;
1697
- case IndexConstraintOp.LE: return cmp <= 0;
1698
- case IndexConstraintOp.GT: return cmp > 0;
1699
- case IndexConstraintOp.GE: return cmp >= 0;
1700
- default: return true;
1701
- }
272
+ await batch.write();
1702
273
  }
1703
274
  /** Perform an update operation (INSERT, UPDATE, DELETE). */
1704
275
  async update(args) {
@@ -1790,46 +361,20 @@ export class StoreTable extends VirtualTable {
1790
361
  if (!existingRow) {
1791
362
  this.trackMutation(+1, inTransaction);
1792
363
  }
1793
- // Queue or emit event
1794
- if (oldRow) {
1795
- // REPLACE — emit as update
1796
- const updateEvent = {
1797
- type: 'update',
1798
- schemaName: schema.schemaName,
1799
- tableName: schema.name,
1800
- key: pk,
1801
- oldRow,
1802
- newRow: coerced,
1803
- };
1804
- if (inTransaction) {
1805
- coordinator.queueEvent(updateEvent);
1806
- }
1807
- else {
1808
- this.eventEmitter?.emitDataChange(updateEvent);
1809
- }
1810
- }
1811
- else {
1812
- const insertEvent = {
1813
- type: 'insert',
1814
- schemaName: schema.schemaName,
1815
- tableName: schema.name,
1816
- key: pk,
1817
- newRow: coerced,
1818
- };
1819
- if (inTransaction) {
1820
- coordinator.queueEvent(insertEvent);
1821
- }
1822
- else {
1823
- this.eventEmitter?.emitDataChange(insertEvent);
1824
- }
1825
- }
364
+ // Queue or emit event. A REPLACE at the SAME key is an in-place update, so
365
+ // it keeps the single `update` shape — the contract's split rule applies to
366
+ // a key change that MOVES the row, which this arm cannot produce.
367
+ const insertEventBase = { schemaName: schema.schemaName, tableName: schema.name, key: pk };
368
+ this.emitOrQueueDataChange(inTransaction, oldRow
369
+ ? { ...insertEventBase, type: 'update', oldRow, newRow: coerced }
370
+ : { ...insertEventBase, type: 'insert', newRow: coerced });
1826
371
  return { status: 'ok', row: coerced, replacedRow: oldRow ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
1827
372
  }
1828
373
  case 'update': {
1829
374
  if (!values || !oldKeyValues)
1830
375
  throw new QuereusError('UPDATE requires values and oldKeyValues', StatusCode.MISUSE);
1831
376
  const coerced = args.preCoerced ? values : this.coerceRow(values);
1832
- const oldPk = this.extractPK(oldKeyValues);
377
+ const oldPk = this.pkFromKeyValues(oldKeyValues);
1833
378
  const newPk = this.extractPK(coerced);
1834
379
  const oldKey = this.encodeDataKey(oldPk);
1835
380
  const newKey = this.encodeDataKey(newPk);
@@ -1926,27 +471,30 @@ export class StoreTable extends VirtualTable {
1926
471
  // at oldPk and the new entry must land at newPk; for same-PK UPDATE
1927
472
  // both halves use the same key.
1928
473
  await this.updateSecondaryIndexes(inTransaction, oldRow, coerced, oldPk, newPk);
1929
- // Queue or emit event
1930
- const updateEvent = {
1931
- type: 'update',
1932
- schemaName: schema.schemaName,
1933
- tableName: schema.name,
1934
- key: newPk,
1935
- oldRow: oldRow || undefined,
1936
- newRow: coerced,
1937
- };
1938
- if (inTransaction) {
1939
- coordinator.queueEvent(updateEvent);
474
+ // Queue or emit the event(s). An update that RELOCATED the row is delivered
475
+ // as a `delete` at the old key then an `insert` at the new one, never a single
476
+ // `update` — the event contract's split rule, which lets a listener retire the
477
+ // old identity without knowing which columns form the key (docs/usage.md
478
+ // § Subscribing to Data Changes). `pkChanged` is exactly the relocation test the
479
+ // contract names: encoded data keys fold each PK column's collation, so a NOCASE
480
+ // case-only rewrite stays one in-place `update`, keyed — like every
481
+ // non-relocating update — by the POST-image `newPk`. Any `replacedAtNewPk`
482
+ // eviction already emitted its own delete above (deleteRowAt), so the delivered
483
+ // order is evict-delete, move-delete, move-insert.
484
+ const eventBase = { schemaName: schema.schemaName, tableName: schema.name };
485
+ if (pkChanged) {
486
+ this.emitOrQueueDataChange(inTransaction, { ...eventBase, type: 'delete', key: oldPk, oldRow: oldRow || undefined });
487
+ this.emitOrQueueDataChange(inTransaction, { ...eventBase, type: 'insert', key: newPk, newRow: coerced });
1940
488
  }
1941
489
  else {
1942
- this.eventEmitter?.emitDataChange(updateEvent);
490
+ this.emitOrQueueDataChange(inTransaction, { ...eventBase, type: 'update', key: newPk, oldRow: oldRow || undefined, newRow: coerced });
1943
491
  }
1944
492
  return { status: 'ok', row: coerced, replacedRow: replacedAtNewPk ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
1945
493
  }
1946
494
  case 'delete': {
1947
495
  if (!oldKeyValues)
1948
496
  throw new QuereusError('DELETE requires oldKeyValues', StatusCode.MISUSE);
1949
- const pk = this.extractPK(oldKeyValues);
497
+ const pk = this.pkFromKeyValues(oldKeyValues);
1950
498
  const key = this.encodeDataKey(pk);
1951
499
  // Get old row for index cleanup. Read the effective
1952
500
  // (pending-over-committed) image so a row inserted earlier in the same
@@ -1969,652 +517,19 @@ export class StoreTable extends VirtualTable {
1969
517
  this.trackMutation(-1, inTransaction);
1970
518
  }
1971
519
  // Queue or emit event
1972
- const deleteEvent = {
520
+ this.emitOrQueueDataChange(inTransaction, {
1973
521
  type: 'delete',
1974
522
  schemaName: schema.schemaName,
1975
523
  tableName: schema.name,
1976
524
  key: pk,
1977
525
  oldRow: oldRow || undefined,
1978
- };
1979
- if (inTransaction) {
1980
- coordinator.queueEvent(deleteEvent);
1981
- }
1982
- else {
1983
- this.eventEmitter?.emitDataChange(deleteEvent);
1984
- }
526
+ });
1985
527
  return { status: 'ok', row: oldRow || undefined };
1986
528
  }
1987
529
  default:
1988
530
  throw new QuereusError(`Unknown operation: ${operation}`, StatusCode.MISUSE);
1989
531
  }
1990
532
  }
1991
- /**
1992
- * Update secondary indexes after a row change.
1993
- *
1994
- * For PK-change UPDATE, `oldPk` (where the existing entry lives) and `newPk`
1995
- * (where the relocated entry will live) differ; using a single pk for both
1996
- * sides leaks the old entry. Other paths pass the same pk for both.
1997
- */
1998
- async updateSecondaryIndexes(inTransaction, oldRow, newRow, oldPk, newPk = oldPk) {
1999
- // Maintain the MATERIALIZED index set: the hidden `_uc_*` per-UNIQUE index must be
2000
- // kept in step with DML so the enforcement seek finds every live row.
2001
- const indexes = this.materializedSchema.indexes || [];
2002
- for (const index of indexes) {
2003
- const indexStore = await this.ensureIndexStore(index.name);
2004
- const indexCols = index.columns.map(c => c.index);
2005
- const indexDirections = index.columns.map(c => !!c.desc);
2006
- // Canonicalize semantic-ordering index members ('PT1H'/'PT60M' → one key) so a
2007
- // UNIQUE enforcement seek and delete-then-insert maintenance address one entry.
2008
- const indexTransforms = resolveIndexKeyTransforms(index, this.tableSchema.columns);
2009
- // Partial index: only rows the predicate unambiguously accepts are
2010
- // indexed (mirrors buildIndexEntries' build-time filtering). Guarding both
2011
- // halves keeps a row that transitions across the predicate scope on UPDATE
2012
- // correct — an in-scope→out-of-scope edit removes the old entry and adds
2013
- // none; the reverse adds without a stale delete. A full index (no
2014
- // predicate) always maintains its entry.
2015
- const predicate = this.compileIndexFor(index);
2016
- // Remove old index entry (only if the old row was within scope).
2017
- if (oldRow && (!predicate || predicate.evaluate(oldRow) === true)) {
2018
- const oldIndexValues = indexCols.map(i => oldRow[i]);
2019
- const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations, indexTransforms, this.pkKeyTransforms);
2020
- if (inTransaction && this.coordinator) {
2021
- this.coordinator.delete(oldIndexKey, indexStore);
2022
- }
2023
- else {
2024
- await indexStore.delete(oldIndexKey);
2025
- }
2026
- }
2027
- // Add new index entry (only if the new row is within scope).
2028
- if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
2029
- const newIndexValues = indexCols.map(i => newRow[i]);
2030
- const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations, indexTransforms, this.pkKeyTransforms);
2031
- // Index value = the row's encoded DATA key. The index-entry key can
2032
- // locate a row's byte window, but its PK suffix is not losslessly
2033
- // recoverable to SqlValues (a NOCASE/RTRIM PK column encodes lossily)
2034
- // and its length varies per entry in a range scan — so a scan resolves
2035
- // each entry back to its base row via this stored data key
2036
- // (`scanIndex` → `readEffectiveRowByKey(entry.value)`), never by
2037
- // decoding the suffix. `newPk` is the PK the entry is keyed under, so
2038
- // its data key byte-matches the data store's key for this row.
2039
- const dataKeyValue = this.encodeDataKey(newPk);
2040
- if (inTransaction && this.coordinator) {
2041
- this.coordinator.put(newIndexKey, dataKeyValue, indexStore);
2042
- }
2043
- else {
2044
- await indexStore.put(newIndexKey, dataKeyValue);
2045
- }
2046
- }
2047
- }
2048
- }
2049
- /**
2050
- * Returns the compiled predicate for a partial-UNIQUE constraint, or undefined
2051
- * when the constraint covers the full table. Compilation is memoized per
2052
- * UniqueConstraintSchema instance so the hot UNIQUE-check path doesn't recompile.
2053
- */
2054
- compileFor(uc) {
2055
- if (!uc.predicate)
2056
- return undefined;
2057
- let compiled = this.predicateCache.get(uc);
2058
- if (!compiled) {
2059
- compiled = compilePredicate(uc.predicate, this.tableSchema.columns, this.tableSchema.name);
2060
- this.predicateCache.set(uc, compiled);
2061
- }
2062
- return compiled;
2063
- }
2064
- /**
2065
- * Returns the compiled predicate for a partial secondary index, or undefined
2066
- * for a full index. Compilation is memoized per IndexSchema instance so the hot
2067
- * DML index-maintenance path doesn't recompile.
2068
- */
2069
- compileIndexFor(index) {
2070
- if (!index.predicate)
2071
- return undefined;
2072
- let compiled = this.indexPredicateCache.get(index);
2073
- if (!compiled) {
2074
- compiled = compilePredicate(index.predicate, this.tableSchema.columns, this.tableSchema.name);
2075
- this.indexPredicateCache.set(index, compiled);
2076
- }
2077
- return compiled;
2078
- }
2079
- /**
2080
- * Check if two PK arrays name the same row. A semantic-ordering member compares
2081
- * through its type's `compare` (see {@link resolvePkSemanticEquality}) so
2082
- * differently-spelled equal values ('PT1H' / 'PT60M') answer "same row",
2083
- * agreeing with the physical key identity {@link encodeDataKey} produces; every
2084
- * other member keeps the original strict `!==`.
2085
- */
2086
- keysEqual(a, b) {
2087
- if (a.length !== b.length)
2088
- return false;
2089
- for (let i = 0; i < a.length; i++) {
2090
- const semantic = this.pkSemanticEquality[i];
2091
- if (semantic ? semantic(a[i], b[i]) !== 0 : a[i] !== b[i])
2092
- return false;
2093
- }
2094
- return true;
2095
- }
2096
- /**
2097
- * Per-constrained-column comparison functions for one UNIQUE constraint: the
2098
- * type's `compare` for a semantic-ordering column (so 'PT1H' conflicts with
2099
- * 'PT60M', matching the memory backend's typed BTree), else the enforcement
2100
- * collation the caller resolved (`resolveUniqueEnforcementCollations`) through
2101
- * `compareSqlValuesFast` — the exact comparison every finder used before.
2102
- *
2103
- * Thin wrapper over the engine's `uniqueEnforcementComparators`, which memory
2104
- * and the isolation overlay share so the three backends cannot drift.
2105
- */
2106
- uniqueColumnComparators(uc, collations) {
2107
- return uniqueEnforcementComparators(this.tableSchema.columns, uc.columns, collations);
2108
- }
2109
- /**
2110
- * Returns true if any column covered by a UNIQUE constraint differs between
2111
- * oldRow and newRow, or — for partial UNIQUE — any column referenced by the
2112
- * partial predicate differs (which can transition the row across the
2113
- * predicate scope and re-trigger the uniqueness check).
2114
- */
2115
- uniqueColumnsChanged(oldRow, newRow) {
2116
- const ucs = this.tableSchema?.uniqueConstraints;
2117
- if (!ucs || ucs.length === 0)
2118
- return false;
2119
- for (const uc of ucs) {
2120
- for (const colIdx of uc.columns) {
2121
- if (compareSqlValues(oldRow[colIdx], newRow[colIdx]) !== 0)
2122
- return true;
2123
- }
2124
- if (uc.predicate) {
2125
- const compiled = this.compileFor(uc);
2126
- if (compiled) {
2127
- for (const colIdx of compiled.referencedColumns) {
2128
- if (compareSqlValues(oldRow[colIdx], newRow[colIdx]) !== 0)
2129
- return true;
2130
- }
2131
- }
2132
- }
2133
- }
2134
- return false;
2135
- }
2136
- /**
2137
- * Enforce table-level UNIQUE constraints against the prospective newRow.
2138
- * Honors `onConflict`: IGNORE returns an ok-with-undefined-row; REPLACE
2139
- * deletes the conflicting row(s) and continues; otherwise returns a
2140
- * constraint result. Returns null when all constraints pass.
2141
- *
2142
- * Rows whose PK is in `selfPks` are skipped (the row being inserted/updated).
2143
- * NULL in any covered column skips that constraint (multiple NULLs are allowed
2144
- * per SQL standard).
2145
- *
2146
- * Reads through the transaction coordinator's pending writes when active so
2147
- * intra-transaction duplicates are detected.
2148
- *
2149
- * REPLACE evictions (rows at OTHER PKs) are deleted from storage and pushed onto
2150
- * `evicted` so the DML executor runs the full delete pipeline for each
2151
- * (change-tracking, row-time MV maintenance, FK cascade, auto-events).
2152
- */
2153
- async checkUniqueConstraints(inTransaction, newRow, selfPks, onConflict, evicted) {
2154
- const schema = this.tableSchema;
2155
- const uniqueConstraints = schema.uniqueConstraints;
2156
- if (!uniqueConstraints || uniqueConstraints.length === 0)
2157
- return null;
2158
- for (const uc of uniqueConstraints) {
2159
- if (uc.columns.some(idx => newRow[idx] === null))
2160
- continue;
2161
- // Partial UNIQUE: a row whose predicate is not unambiguously TRUE is
2162
- // outside the index's scope and contributes nothing to uniqueness.
2163
- const predicate = this.compileFor(uc);
2164
- if (predicate && predicate.evaluate(newRow) !== true)
2165
- continue;
2166
- const conflict = await this.findUniqueConflictFor(uc, predicate, newRow, selfPks);
2167
- if (!conflict)
2168
- continue;
2169
- // Resolve action per-constraint: statement OR > per-UC default > ABORT.
2170
- const effective = onConflict ?? uc.defaultConflict ?? ConflictResolution.ABORT;
2171
- if (effective === ConflictResolution.IGNORE) {
2172
- return { status: 'ok', row: undefined };
2173
- }
2174
- if (effective === ConflictResolution.REPLACE) {
2175
- await this.deleteRowAt(inTransaction, conflict.pk, conflict.row);
2176
- // Report the eviction so the executor runs its full delete pipeline —
2177
- // including the row-time covering-structure maintenance that drops the
2178
- // evicted source row's backing entry within this statement (else a later
2179
- // same-UC row sees a phantom). The executor processes the eviction before
2180
- // the writing row's own bookkeeping, so the backing delete still lands
2181
- // mid-statement.
2182
- evicted.push(conflict.row);
2183
- continue;
2184
- }
2185
- const colNames = uc.columns.map(i => schema.columns[i].name).join(', ');
2186
- return {
2187
- status: 'constraint',
2188
- constraint: 'unique',
2189
- message: `UNIQUE constraint failed: ${schema.name} (${colNames})`,
2190
- existingRow: conflict.row,
2191
- };
2192
- }
2193
- return null;
2194
- }
2195
- /**
2196
- * Route one UNIQUE constraint's conflict search to the cheapest SOUND finder,
2197
- * in descending preference:
2198
- *
2199
- * 1. A linked row-time covering MV — its backing table (hosted by any
2200
- * backing-host-capable module — memory by default, this store module under
2201
- * `using store` — queried through the db with reads-own-writes) answers the
2202
- * uniqueness question, mirroring the memory enforcement path.
2203
- * 2. A physical secondary index realizing the constraint — one prefix seek
2204
- * instead of a full table scan (see {@link findIndexForUniqueConstraint}).
2205
- * 3. The full data-store scan ({@link findUniqueConflict}) — always correct,
2206
- * O(rows) per checked row.
2207
- *
2208
- * Every finder returns the SAME `{pk, row}` shape, so the caller's conflict
2209
- * action (ABORT / IGNORE / REPLACE eviction) is finder-independent.
2210
- */
2211
- async findUniqueConflictFor(uc, predicate, newRow, selfPks) {
2212
- const schema = this.tableSchema;
2213
- const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
2214
- if (coveringMv)
2215
- return this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks);
2216
- const index = this.findIndexForUniqueConstraint(uc);
2217
- if (index) {
2218
- const viaIndex = await this.findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks);
2219
- if (viaIndex !== INDEX_UNUSABLE)
2220
- return viaIndex;
2221
- }
2222
- return this.findUniqueConflict(uc, predicate, newRow, selfPks);
2223
- }
2224
- /**
2225
- * The `schema.indexes` entry whose physical index store can serve `uc`'s
2226
- * conflict search as a point seek, or undefined when none can.
2227
- *
2228
- * Every non-derived UNIQUE now carries a materialized implicit index
2229
- * (`_uc_*`, see {@link withImplicitUniqueIndexes}), so — like the memory
2230
- * backend — a plain column- or table-level `UNIQUE` is index-servable, not
2231
- * only an explicit `CREATE INDEX`. A UC is index-servable when:
2232
- *
2233
- * - it is index-derived (`derivedFromIndex`, from `CREATE UNIQUE INDEX`) and
2234
- * its named index is still present — the index's partial predicate then
2235
- * equals the constraint's by construction (`appendIndexToTableSchema`); or
2236
- * - some index's columns equal `uc.columns` positionally AND whose predicate
2237
- * is the SAME object as `uc.predicate` (reference identity — the implicit
2238
- * materializer sets it that way). For a FULL UNIQUE that is
2239
- * `undefined === undefined`, which also admits any user full index over the
2240
- * same columns. For a PARTIAL `unique(...) where p` it admits only the
2241
- * co-scoped `_uc_*` (which holds exactly the in-scope rows); an arbitrary
2242
- * user partial index with a DIFFERENT predicate object stays conservative
2243
- * (declined → full scan), because it physically omits rows the constraint
2244
- * still covers. The serving index need not be UNIQUE — a plain index over
2245
- * the constrained columns still holds every in-scope row.
2246
- *
2247
- * Collation guard (see {@link indexSeekHonorsEnforcementCollation}) may still
2248
- * reject the found index, which routes the check back to the full scan.
2249
- */
2250
- findIndexForUniqueConstraint(uc) {
2251
- // NOTE: re-resolved for every constrained ROW written, and the collation guard
2252
- // below re-derives `uniqueEnforcementCollations` each time. Both are linear in
2253
- // `schema.indexes` / `uc.columns` and dwarfed by the seek's I/O, so this is fine
2254
- // now. If a table with many indexes ever shows up on an insert-heavy profile,
2255
- // memoize the (uc → index | undefined) resolution in a WeakMap keyed on the
2256
- // frozen UniqueConstraintSchema, as `predicateCache` above does — a CREATE/DROP
2257
- // INDEX yields fresh constraint objects, so such a cache invalidates itself.
2258
- // Reads the MATERIALIZED index set so the hidden `_uc_*` realizing a plain UNIQUE
2259
- // is visible (the engine-facing `tableSchema` carries only explicit/derived indexes).
2260
- const indexes = this.materializedSchema.indexes;
2261
- if (!indexes || indexes.length === 0)
2262
- return undefined;
2263
- const index = uc.derivedFromIndex
2264
- ? indexes.find(ix => ix.name === uc.derivedFromIndex)
2265
- : indexes.find(ix => ix.predicate === uc.predicate
2266
- && ix.columns.length === uc.columns.length
2267
- && ix.columns.every((c, i) => c.index === uc.columns[i]));
2268
- if (!index)
2269
- return undefined;
2270
- return this.indexSeekHonorsEnforcementCollation(uc) ? index : undefined;
2271
- }
2272
- /**
2273
- * True when a point seek into the index realizing `uc` returns a SUPERSET of
2274
- * the constraint's true conflict set — the only condition under which the
2275
- * seek may replace the full scan.
2276
- *
2277
- * An index key's leading (index-column) bytes are encoded under the TABLE KEY
2278
- * collation K (`buildIndexKey` passes `this.encodeOptions`), NOT the index's
2279
- * declared per-column COLLATE and NOT the constraint's enforcement collation
2280
- * C (`uniqueEnforcementCollations` — the index's per-column COLLATE for an
2281
- * index-derived UC, else the declared column collation). A seek therefore
2282
- * fetches exactly `{rows K-equal to newRow}` while the re-validation keeps
2283
- * `{rows C-equal to newRow}`. Soundness needs
2284
- * `{C-equal} ⊆ {K-equal}`, i.e. K must be COARSER-OR-EQUAL to C per column:
2285
- *
2286
- * - non-text column → its bytes are type-native, collation-independent: safe.
2287
- * - C == K → the sets coincide: safe.
2288
- * - K = NOCASE, C = BINARY → K strictly coarser: safe superset.
2289
- * - otherwise (K = BINARY over C = NOCASE/RTRIM; K = NOCASE over C = RTRIM)
2290
- * the seek UNDER-fetches and a real duplicate would be silently accepted:
2291
- * reject, so the caller full-scans.
2292
- *
2293
- * Same direction and same admitted cases as the read-side guard in
2294
- * `StoreModule.tryIndexAccessPlan`; conservative rather than exhaustive
2295
- * (K = RTRIM over C = BINARY is provably safe but declined), which costs an
2296
- * optimization, never correctness. The coarseness test is only sound for the
2297
- * built-in names: a custom K equals a custom C only when the index column names
2298
- * that same collation, and otherwise falls through to the full scan.
2299
- */
2300
- indexSeekHonorsEnforcementCollation(uc) {
2301
- const schema = this.tableSchema;
2302
- const K = (this.encodeOptions.collation ?? 'NOCASE').toUpperCase();
2303
- const collations = uniqueEnforcementCollations(schema, uc);
2304
- return uc.columns.every((colIdx, i) => {
2305
- if (!columnCanHoldText(schema.columns[colIdx]))
2306
- return true;
2307
- const C = (collations[i] ?? 'BINARY').toUpperCase();
2308
- return C === K || (K === 'NOCASE' && C === 'BINARY');
2309
- });
2310
- }
2311
- /**
2312
- * The index analogue of {@link findUniqueConflict}: seek the index realizing
2313
- * `uc` at the point formed by `newRow`'s constrained-column values, and
2314
- * re-validate each resolved candidate exactly as the full scan does.
2315
- *
2316
- * The seek encodes ALL of `uc.columns` (positionally aligned with the index's
2317
- * columns, guaranteed by `appendIndexToTableSchema` / the column-set match in
2318
- * {@link findIndexForUniqueConstraint}) as a leading prefix of the index key,
2319
- * under the same key collation and per-column DESC directions
2320
- * {@link updateSecondaryIndexes} used to write it. The remaining suffix is the
2321
- * row's PK, so the window spans every entry sharing those column values.
2322
- * {@link iterateEffective} merges this transaction's pending index puts/deletes
2323
- * over the committed entries, giving read-your-own-writes; each entry resolves
2324
- * to its LIVE row through the data key stored as the entry's value.
2325
- *
2326
- * The seek only narrows the CANDIDATE set to a superset (guaranteed by
2327
- * {@link indexSeekHonorsEnforcementCollation}); the authoritative comparison is
2328
- * the identical self-PK exclusion, per-column enforcement-collation compare,
2329
- * and partial-predicate scope check the full scan performs. A partial index
2330
- * already excludes out-of-scope rows physically — the predicate re-check is
2331
- * kept as defense in depth.
2332
- *
2333
- * Returns {@link INDEX_UNUSABLE} rather than a (possibly wrong) answer when an
2334
- * entry carries a legacy empty value.
2335
- */
2336
- async findUniqueConflictViaIndex(index, uc, predicate, newRow, selfPks) {
2337
- const indexStore = await this.ensureIndexStore(index.name);
2338
- // Resolved once, above the candidate loop: the resolver throws on an
2339
- // unregistered name and cannot be inlined, so a per-candidate call would be
2340
- // pure overhead.
2341
- const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
2342
- const compares = this.uniqueColumnComparators(uc, collations);
2343
- // Same per-column transforms `updateSecondaryIndexes` wrote the entries under, so
2344
- // the probe for 'PT60M' lands on the window holding the 'PT1H' entry.
2345
- const bounds = buildIndexPrefixBounds(uc.columns.map(c => newRow[c]), this.encodeOptions, index.columns.map(c => !!c.desc), resolveIndexKeyTransforms(index, this.tableSchema.columns));
2346
- for await (const entry of this.iterateEffective(indexStore, bounds)) {
2347
- // A legacy index store (written before index values carried the data key)
2348
- // holds EMPTY values. `scanIndex` may skip such an entry — a read that
2349
- // returns too few rows. Skipping here would instead ACCEPT a duplicate, so
2350
- // abandon the index and let the caller full-scan. See the NOTE in
2351
- // `scanIndex` for the durable fix.
2352
- if (entry.value.length === 0)
2353
- return INDEX_UNUSABLE;
2354
- // Resolve to the LIVE row: a pending index delete normally suppresses the
2355
- // entry, but a committed entry can lag a row deleted this transaction.
2356
- const candidate = await this.readEffectiveRowByKey(entry.value);
2357
- if (!candidate)
2358
- continue;
2359
- const pk = this.extractPK(candidate);
2360
- if (selfPks.some(skip => this.keysEqual(pk, skip)))
2361
- continue;
2362
- if (uc.columns.some((c, i) => compares[i](newRow[c], candidate[c]) !== 0))
2363
- continue;
2364
- if (predicate && predicate.evaluate(candidate) !== true)
2365
- continue;
2366
- return { pk, row: candidate };
2367
- }
2368
- return null;
2369
- }
2370
- /**
2371
- * Scan committed + pending data rows for a row matching `newRow` on
2372
- * `uc.columns` whose PK is not in `selfPks`. For partial UNIQUE, candidates
2373
- * whose row does not satisfy the predicate are skipped. Returns the first
2374
- * match or null.
2375
- */
2376
- async findUniqueConflict(uc, predicate, newRow, selfPks) {
2377
- const store = await this.ensureStore();
2378
- // Pending ops for THIS table's data store handle — the same handle the
2379
- // write path queues data ops under, so the merge sees only this table's
2380
- // pending state, never a sibling table's on the shared module coordinator.
2381
- const pending = this.coordinator?.isInTransaction()
2382
- ? this.coordinator.getPendingOpsForStore(store)
2383
- : null;
2384
- const constrainedCols = uc.columns;
2385
- // One comparison collation per constrained column — the index's per-column
2386
- // COLLATE for an index-derived UNIQUE, else the declared column collation.
2387
- // Resolved once here, not per candidate row.
2388
- const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
2389
- const compares = this.uniqueColumnComparators(uc, collations);
2390
- const matches = (candidate) => {
2391
- const pk = this.extractPK(candidate);
2392
- for (const skip of selfPks) {
2393
- if (this.keysEqual(pk, skip))
2394
- return null;
2395
- }
2396
- for (let i = 0; i < constrainedCols.length; i++) {
2397
- const idx = constrainedCols[i];
2398
- if (compares[i](newRow[idx], candidate[idx]) !== 0)
2399
- return null;
2400
- }
2401
- // Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
2402
- if (predicate && predicate.evaluate(candidate) !== true)
2403
- return null;
2404
- return { pk, row: candidate };
2405
- };
2406
- const seen = new Set();
2407
- const bounds = buildFullScanBounds();
2408
- for await (const entry of store.iterate(bounds)) {
2409
- const hex = bytesToHex(entry.key);
2410
- seen.add(hex);
2411
- if (pending?.deletes.has(hex))
2412
- continue;
2413
- const overlay = pending?.puts.get(hex);
2414
- const value = overlay ? overlay.value : entry.value;
2415
- const found = matches(deserializeRow(value));
2416
- if (found)
2417
- return found;
2418
- }
2419
- if (pending) {
2420
- for (const [hex, op] of pending.puts) {
2421
- if (seen.has(hex))
2422
- continue;
2423
- const found = matches(deserializeRow(op.value));
2424
- if (found)
2425
- return found;
2426
- }
2427
- }
2428
- return null;
2429
- }
2430
- /**
2431
- * Find a UNIQUE conflict through a linked row-time covering MV's backing table
2432
- * (the store analogue of the memory `checkUniqueViaMaterializedView`). The
2433
- * backing scan yields candidate conflicting **source** PKs (reads-own-writes via
2434
- * the backing's coordinated connection); each is validated against the *live*
2435
- * store row (committed + this transaction's pending overlay) so a backing entry
2436
- * that lags a row deleted/updated internally this statement is skipped rather
2437
- * than raised as a false conflict. Returns the first real conflict or null.
2438
- */
2439
- async findUniqueConflictViaCoveringMv(mv, uc, predicate, newRow, selfPks) {
2440
- const newSourcePk = this.extractPK(newRow);
2441
- // Resolved once, above the candidate loop.
2442
- const collations = resolveUniqueEnforcementCollations(this.tableSchema, uc, this.collationResolver);
2443
- const compares = this.uniqueColumnComparators(uc, collations);
2444
- const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
2445
- for (const cand of candidates) {
2446
- const liveRow = await this.readLiveRowByPk(cand.pk);
2447
- if (!liveRow)
2448
- continue; // stale backing candidate (source row gone)
2449
- if (selfPks.some(pk => this.keysEqual(pk, cand.pk)))
2450
- continue;
2451
- // Re-validate under each column's enforcement collation (the index's
2452
- // per-column COLLATE for an index-derived UNIQUE, else declared) — see
2453
- // uniqueEnforcementCollations. The candidate generation
2454
- // (_lookupCoveringConflicts) narrows under the SOURCE column's declared
2455
- // collation, so for a FINER index (BINARY over a NOCASE column) it returns a
2456
- // superset this filters down correctly. A finer/incomparable index-derived
2457
- // UNIQUE whose declared candidate set could be a SUBSET (e.g. a coarser NOCASE
2458
- // index over a BINARY column) is declined upstream by the collation gate in
2459
- // findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
2460
- // reach here — the superset this re-validation can soundly filter.
2461
- if (uc.columns.some((c, i) => compares[i](newRow[c], liveRow[c]) !== 0))
2462
- continue;
2463
- if (predicate && predicate.evaluate(liveRow) !== true)
2464
- continue;
2465
- return { pk: cand.pk, row: liveRow };
2466
- }
2467
- return null;
2468
- }
2469
- /**
2470
- * Read the live row at `pk` — this transaction's pending overlay (a pending
2471
- * delete ⇒ gone; a pending put ⇒ its value) shadowing the committed store.
2472
- * Backs `query()`'s point-lookup arm and validates covering-MV conflict
2473
- * candidates against the source of truth.
2474
- */
2475
- async readLiveRowByPk(pk) {
2476
- return this.readEffectiveRowByKey(this.encodeDataKey(pk));
2477
- }
2478
- // ── Backing-host surface ──────────────────────────────────────────────
2479
- // Narrow public surface `StoreBackingHost` (backing-host.ts) drives. Each
2480
- // method addresses the table's CURRENT schema/encoding state, so a host
2481
- // resolved fresh per engine call always keys and merges consistently with
2482
- // the table's own read/write paths.
2483
- /** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
2484
- encodeDataKey(pkValues) {
2485
- return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations, this.pkKeyTransforms);
2486
- }
2487
- /**
2488
- * Byte bounds covering every data key whose leading PK columns equal
2489
- * `prefixValues` — encoded under the same per-column DESC directions and key
2490
- * collations as {@link encodeDataKey}, so seek + early-terminate addresses the
2491
- * exact slice. An empty prefix yields full-scan bounds.
2492
- */
2493
- encodePkPrefixBounds(prefixValues) {
2494
- return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length), this.pkKeyTransforms.slice(0, prefixValues.length));
2495
- }
2496
- /**
2497
- * Effective (pending-over-committed) point read by encoded data key: a pending
2498
- * delete ⇒ null, a pending put ⇒ its value, else the committed store entry.
2499
- */
2500
- async readEffectiveRowByKey(key) {
2501
- const store = await this.ensureStore();
2502
- // Pending ops for THIS table's data store handle — see findUniqueConflict.
2503
- const pending = this.coordinator?.isInTransaction()
2504
- ? this.coordinator.getPendingOpsForStore(store)
2505
- : null;
2506
- if (pending) {
2507
- const hex = bytesToHex(key);
2508
- if (pending.deletes.has(hex))
2509
- return null;
2510
- const overlay = pending.puts.get(hex);
2511
- if (overlay)
2512
- return deserializeRow(overlay.value);
2513
- }
2514
- const value = await store.get(key);
2515
- return value ? deserializeRow(value) : null;
2516
- }
2517
- /**
2518
- * Effective ordered entry scan within `bounds` (see {@link iterateEffective}).
2519
- * Opens the data store first — which fires the lazy first-access `saveTableDDL`
2520
- * for a freshly created table, the catalog write a store-backed MV backing
2521
- * relies on to survive reopen.
2522
- */
2523
- async *iterateEffectiveEntries(bounds, reverse = false) {
2524
- const store = await this.ensureStore();
2525
- yield* this.iterateEffective(store, bounds, reverse);
2526
- }
2527
- /** Buffer a privileged row-count delta, applied at coordinator commit (host writes). */
2528
- trackPrivilegedMutation(delta) {
2529
- this.trackMutation(delta, true);
2530
- }
2531
- /**
2532
- * Declared secondary-UNIQUE enforcement for maintenance writes — the store
2533
- * mirror of the memory manager's `enforceSecondaryUniqueOnMaintenance` (see
2534
- * `vtab/backing-host.ts` § Constraint validation for the contract and
2535
- * docs/mv-constraints.md § Derived-row constraint validation for the
2536
- * semantics). Called by `StoreBackingHost.applyMaintenance` AFTER the op
2537
- * batch lands in the coordinator's pending state: post-batch is load-bearing
2538
- * (a `replace-all` diff applies puts before deletes, so a per-op check would
2539
- * false-positive when the derived set moves a unique value between primary
2540
- * keys), and checking only the WRITTEN images is complete (pre-existing
2541
- * contents already satisfied the constraint).
2542
- *
2543
- * Reuses {@link findUniqueConflict} — pending-overlay reads, per-column
2544
- * collations, NULL-pass, partial-predicate scope, self-PK exclusion — with
2545
- * the covering-MV route deliberately bypassed: a covering MV over THIS table
2546
- * is cascade-maintained only after the batch returns, so it lags the batch
2547
- * and would miss a same-batch colliding pair. The conflict action is a hard
2548
- * abort (a derivation write carries no user OR clause, and a declared
2549
- * `on conflict replace`/`ignore` default must not evict or drop derived
2550
- * rows). Per-image cost is one effective full scan: unlike the DML path
2551
- * ({@link findUniqueConflictFor}), this one is NOT routed through
2552
- * {@link findUniqueConflictViaIndex}, because a backing table keeps no
2553
- * secondary indexes by design — there is never an index store to seek.
2554
- *
2555
- * Zero overhead when the table declares no secondary UNIQUE (every MV-sugar
2556
- * backing, and most maintained tables): one empty-array check.
2557
- */
2558
- async enforceSecondaryUniqueForMaintenance(changes) {
2559
- const schema = this.tableSchema;
2560
- const ucs = schema?.uniqueConstraints;
2561
- if (!schema || !ucs || ucs.length === 0 || changes.length === 0)
2562
- return;
2563
- for (const change of changes) {
2564
- if (change.op === 'delete')
2565
- continue;
2566
- const newRow = change.newRow;
2567
- const selfPks = [this.extractPK(newRow)];
2568
- for (const uc of ucs) {
2569
- // SQL semantics: UNIQUE allows multiple NULLs.
2570
- if (uc.columns.some(idx => newRow[idx] === null))
2571
- continue;
2572
- // Partial UNIQUE: an out-of-scope image contributes nothing.
2573
- const predicate = this.compileFor(uc);
2574
- if (predicate && predicate.evaluate(newRow) !== true)
2575
- continue;
2576
- const conflict = await this.findUniqueConflict(uc, predicate, newRow, selfPks);
2577
- if (conflict) {
2578
- const colNames = uc.columns.map(i => schema.columns[i]?.name ?? String(i));
2579
- throw maintainedTableUniqueViolationError(schema.schemaName, schema.name, uc.name ?? `_uc_${colNames.join('_')}`, colNames, uc.columns.map(i => newRow[i]));
2580
- }
2581
- }
2582
- }
2583
- }
2584
- /**
2585
- * Reset statistics to an absolute committed row count and flush immediately.
2586
- * Used by the host's `replaceContents` (create-fill / refresh), where the new
2587
- * count is exact, replacing any drifted delta-tracked estimate.
2588
- */
2589
- async resetStats(rowCount) {
2590
- this.cachedStats = { rowCount, updatedAt: Date.now() };
2591
- this.pendingStatsDelta = 0;
2592
- this.mutationCount = 0;
2593
- await this.flushStats();
2594
- }
2595
- /**
2596
- * Open (and cache) the data store, firing the lazy first-access `saveTableDDL`.
2597
- * Public for the host's `replaceContents`, which writes the store directly.
2598
- */
2599
- openDataStore() {
2600
- return this.ensureStore();
2601
- }
2602
- // ── External row-write surface ────────────────────────────────────────
2603
- // Module-side entry point for externally-applied writes to a SOURCE table
2604
- // (the index-maintaining sibling of `StoreBackingHost`, which is for MV
2605
- // BACKING tables and deliberately keeps no indexes). Resolved per call via
2606
- // `StoreModule.getTableForExternalWrite`; addresses the table's CURRENT
2607
- // schema/encoding state so keys and index entries match its own DML paths.
2608
- /**
2609
- * Effective (pending-over-committed) point read by PK values — the public
2610
- * read an external writer issues before an upsert to learn the row's current
2611
- * image. Thin wrapper over the same private point-lookup that backs
2612
- * {@link query}'s point arm, so an external read merges pending-over-committed
2613
- * exactly like an engine read does.
2614
- */
2615
- readRowByPk(pk) {
2616
- return this.readLiveRowByPk(pk);
2617
- }
2618
533
  /**
2619
534
  * Apply externally-originated row ops directly to this source table's
2620
535
  * COMMITTED storage: table-owned data-key put/delete, secondary-index
@@ -2694,137 +609,5 @@ export class StoreTable extends VirtualTable {
2694
609
  }
2695
610
  return changes;
2696
611
  }
2697
- /**
2698
- * Fully delete the row at `pk` (data + secondary indexes + stats + delete event).
2699
- * Used by REPLACE conflict resolution to evict a conflicting unique row before
2700
- * the caller's insert/update proceeds.
2701
- */
2702
- async deleteRowAt(inTransaction, pk, oldRow) {
2703
- const store = await this.ensureStore();
2704
- const key = this.encodeDataKey(pk);
2705
- if (inTransaction && this.coordinator) {
2706
- this.coordinator.delete(key, store);
2707
- }
2708
- else {
2709
- await store.delete(key);
2710
- }
2711
- await this.updateSecondaryIndexes(inTransaction, oldRow, null, pk);
2712
- this.trackMutation(-1, inTransaction);
2713
- const schema = this.tableSchema;
2714
- const deleteEvent = {
2715
- type: 'delete',
2716
- schemaName: schema.schemaName,
2717
- tableName: schema.name,
2718
- key: pk,
2719
- oldRow,
2720
- };
2721
- if (inTransaction && this.coordinator) {
2722
- this.coordinator.queueEvent(deleteEvent);
2723
- }
2724
- else {
2725
- this.eventEmitter?.emitDataChange(deleteEvent);
2726
- }
2727
- }
2728
- /**
2729
- * Begin a table-scoped transaction by ensuring the coordinator is active.
2730
- *
2731
- * Used by the isolation layer's flush path, which treats the underlying
2732
- * write as an independent mini-transaction. Idempotent: if the coordinator
2733
- * is already in a transaction (e.g. started by a registered connection),
2734
- * this is a no-op.
2735
- */
2736
- async begin() {
2737
- const coordinator = await this.ensureCoordinator();
2738
- coordinator.begin();
2739
- }
2740
- /**
2741
- * Commits a table-scoped transaction, flushing any buffered writes to the KV store.
2742
- * No-op if the coordinator is not currently in a transaction.
2743
- */
2744
- async commit() {
2745
- if (this.coordinator?.isInTransaction()) {
2746
- await this.coordinator.commit();
2747
- }
2748
- }
2749
- /**
2750
- * Rolls back a table-scoped transaction, discarding any buffered writes.
2751
- * No-op if the coordinator is not currently in a transaction.
2752
- */
2753
- async rollback() {
2754
- if (this.coordinator?.isInTransaction()) {
2755
- this.coordinator.rollback();
2756
- }
2757
- }
2758
- /** Disconnect from the store. */
2759
- async disconnect() {
2760
- // Called by Quereus after each scan completes.
2761
- // Do NOT clear the store - it's shared across concurrent queries.
2762
- // Only flush pending stats if there are mutations.
2763
- if (this.mutationCount > 0 && this.store) {
2764
- await this.flushStats();
2765
- }
2766
- // Store remains available for subsequent queries.
2767
- // Use destroy() to fully clean up the table.
2768
- }
2769
- /**
2770
- * Hard teardown: fully detach this instance from the module-wide coordinator.
2771
- *
2772
- * Distinct from the per-scan {@link disconnect} (which only flushes stats and
2773
- * deliberately keeps the table hooked mid-life). Called ONLY at the genuine
2774
- * eviction sites — `StoreModule.tearDownTableStorage` (drop / reclaim) and
2775
- * `renameTable` — where this instance is removed from the module's `tables` map
2776
- * and will never be used again. It best-effort flushes any buffered stats (the
2777
- * backing store is about to be deleted / relocated, so this is the last chance,
2778
- * same posture as the teardown-time `disconnect` it replaces), then runs the
2779
- * coordinator disposer so this instance's {stats apply/discard} callback pair —
2780
- * and the `this` its closures capture — is spliced off the shared coordinator's
2781
- * array rather than pinned for the module's lifetime.
2782
- *
2783
- * Idempotent: the disposer is run at most once and both it and the coordinator
2784
- * reference are nulled, so a double-dispose is a no-op and a re-`attachCoordinator`
2785
- * after dispose registers a fresh pair instead of double-registering.
2786
- */
2787
- async dispose() {
2788
- if (this.mutationCount > 0 && this.store) {
2789
- await this.flushStats();
2790
- }
2791
- this.coordinatorDisposer?.();
2792
- this.coordinatorDisposer = null;
2793
- this.coordinator = null;
2794
- }
2795
- /** Get the current estimated row count. */
2796
- async getEstimatedRowCount() {
2797
- if (this.cachedStats) {
2798
- return this.cachedStats.rowCount;
2799
- }
2800
- const statsStore = await this.ensureStatsStore();
2801
- const statsKey = buildStatsKey(this.schemaName, this.tableName);
2802
- const statsData = await statsStore.get(statsKey);
2803
- if (statsData) {
2804
- this.cachedStats = deserializeStats(statsData);
2805
- return this.cachedStats.rowCount;
2806
- }
2807
- // No stats yet, return 0
2808
- return 0;
2809
- }
2810
- /** Track a mutation and schedule lazy stats persistence. */
2811
- trackMutation(delta, inTransaction = false) {
2812
- if (inTransaction) {
2813
- // Buffer during transaction - stats will be applied at commit
2814
- this.pendingStatsDelta += delta;
2815
- return;
2816
- }
2817
- if (!this.cachedStats) {
2818
- this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
2819
- }
2820
- this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + delta);
2821
- this.cachedStats.updatedAt = Date.now();
2822
- this.mutationCount++;
2823
- // Schedule lazy flush after threshold
2824
- if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
2825
- this.statsFlushPending = true;
2826
- queueMicrotask(() => this.flushStats());
2827
- }
2828
- }
2829
612
  }
2830
613
  //# sourceMappingURL=store-table.js.map