@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.
- package/README.md +34 -6
- package/dist/src/common/encoding.d.ts +4 -3
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +17 -13
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/events.d.ts +31 -25
- package/dist/src/common/events.d.ts.map +1 -1
- package/dist/src/common/events.js +38 -39
- package/dist/src/common/events.js.map +1 -1
- package/dist/src/common/implicit-unique-index.d.ts +80 -0
- package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
- package/dist/src/common/implicit-unique-index.js +148 -0
- package/dist/src/common/implicit-unique-index.js.map +1 -0
- package/dist/src/common/index.d.ts +7 -3
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +5 -2
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/json-key.d.ts +30 -2
- package/dist/src/common/json-key.d.ts.map +1 -1
- package/dist/src/common/json-key.js +49 -2
- package/dist/src/common/json-key.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +40 -20
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +31 -23
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +10 -0
- package/dist/src/common/kv-store.d.ts.map +1 -1
- package/dist/src/common/pk-key-resolution.d.ts +348 -0
- package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
- package/dist/src/common/pk-key-resolution.js +481 -0
- package/dist/src/common/pk-key-resolution.js.map +1 -0
- package/dist/src/common/store-module-access-plan.d.ts +32 -0
- package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
- package/dist/src/common/store-module-access-plan.js +428 -0
- package/dist/src/common/store-module-access-plan.js.map +1 -0
- package/dist/src/common/store-module-alter-column.d.ts +23 -0
- package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
- package/dist/src/common/store-module-alter-column.js +389 -0
- package/dist/src/common/store-module-alter-column.js.map +1 -0
- package/dist/src/common/store-module-alter.d.ts +55 -0
- package/dist/src/common/store-module-alter.d.ts.map +1 -0
- package/dist/src/common/store-module-alter.js +504 -0
- package/dist/src/common/store-module-alter.js.map +1 -0
- package/dist/src/common/store-module-base.d.ts +268 -0
- package/dist/src/common/store-module-base.d.ts.map +1 -0
- package/dist/src/common/store-module-base.js +362 -0
- package/dist/src/common/store-module-base.js.map +1 -0
- package/dist/src/common/store-module-catalog.d.ts +269 -0
- package/dist/src/common/store-module-catalog.d.ts.map +1 -0
- package/dist/src/common/store-module-catalog.js +462 -0
- package/dist/src/common/store-module-catalog.js.map +1 -0
- package/dist/src/common/store-module-index-build.d.ts +88 -0
- package/dist/src/common/store-module-index-build.d.ts.map +1 -0
- package/dist/src/common/store-module-index-build.js +280 -0
- package/dist/src/common/store-module-index-build.js.map +1 -0
- package/dist/src/common/store-module-index.d.ts +116 -0
- package/dist/src/common/store-module-index.d.ts.map +1 -0
- package/dist/src/common/store-module-index.js +373 -0
- package/dist/src/common/store-module-index.js.map +1 -0
- package/dist/src/common/store-module-rename.d.ts +49 -0
- package/dist/src/common/store-module-rename.d.ts.map +1 -0
- package/dist/src/common/store-module-rename.js +268 -0
- package/dist/src/common/store-module-rename.js.map +1 -0
- package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
- package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
- package/dist/src/common/store-module-schema-rewrite.js +140 -0
- package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
- package/dist/src/common/store-module-schema-sync.d.ts +232 -0
- package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
- package/dist/src/common/store-module-schema-sync.js +529 -0
- package/dist/src/common/store-module-schema-sync.js.map +1 -0
- package/dist/src/common/store-module.d.ts +43 -909
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +184 -3276
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table-base.d.ts +539 -0
- package/dist/src/common/store-table-base.d.ts.map +1 -0
- package/dist/src/common/store-table-base.js +900 -0
- package/dist/src/common/store-table-base.js.map +1 -0
- package/dist/src/common/store-table-constraints.d.ts +237 -0
- package/dist/src/common/store-table-constraints.d.ts.map +1 -0
- package/dist/src/common/store-table-constraints.js +624 -0
- package/dist/src/common/store-table-constraints.js.map +1 -0
- package/dist/src/common/store-table-scan.d.ts +452 -0
- package/dist/src/common/store-table-scan.d.ts.map +1 -0
- package/dist/src/common/store-table-scan.js +901 -0
- package/dist/src/common/store-table-scan.js.map +1 -0
- package/dist/src/common/store-table.d.ts +71 -1144
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +168 -2385
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/testing/index.d.ts +12 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +12 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/kv-assert.d.ts +19 -0
- package/dist/src/testing/kv-assert.d.ts.map +1 -0
- package/dist/src/testing/kv-assert.js +25 -0
- package/dist/src/testing/kv-assert.js.map +1 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -1
- package/dist/src/testing/kv-conformance.js +1 -15
- package/dist/src/testing/kv-conformance.js.map +1 -1
- package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
- package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-provider-conformance.js +183 -0
- package/dist/src/testing/kv-provider-conformance.js.map +1 -0
- 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 {
|
|
13
|
-
import { bytesEqual, bytesToHex
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
|
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.
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
//
|
|
713
|
-
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
for (const
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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.
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|