@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
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State and storage plumbing shared by every layer of the KVStore-backed virtual
|
|
3
|
+
* table: schema/key-encoding state, store + coordinator handles, statistics,
|
|
4
|
+
* transaction lifecycle, and the effective-read primitives (committed store
|
|
5
|
+
* merged with the coordinator's pending ops) the read and write paths build on.
|
|
6
|
+
*
|
|
7
|
+
* Base of the store-table layer chain:
|
|
8
|
+
* StoreTableBase -> StoreTableScan -> StoreTableConstraints -> StoreTable
|
|
9
|
+
*/
|
|
10
|
+
import { VirtualTable, QuereusError, StatusCode, hasSemanticOrdering, coerceRowToSchema, } from '@quereus/quereus';
|
|
11
|
+
import { bytesToHex, compareBytes } from './bytes.js';
|
|
12
|
+
import { StoreConnection } from './store-connection.js';
|
|
13
|
+
import { buildDataKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
|
|
14
|
+
import { deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
|
|
15
|
+
import { resolveIndexKeyCollations, resolvePkKeyCollations, resolvePkKeyTransforms, resolvePkSemanticEquality, storeSemanticKeyTransform } from './pk-key-resolution.js';
|
|
16
|
+
import { withImplicitUniqueIndexes } from './implicit-unique-index.js';
|
|
17
|
+
/** Number of mutations before persisting statistics. */
|
|
18
|
+
const STATS_FLUSH_INTERVAL = 100;
|
|
19
|
+
/** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
|
|
20
|
+
function keyWithinBounds(key, bounds) {
|
|
21
|
+
if (bounds.gte && compareBytes(key, bounds.gte) < 0)
|
|
22
|
+
return false;
|
|
23
|
+
if (bounds.gt && compareBytes(key, bounds.gt) <= 0)
|
|
24
|
+
return false;
|
|
25
|
+
if (bounds.lte && compareBytes(key, bounds.lte) > 0)
|
|
26
|
+
return false;
|
|
27
|
+
if (bounds.lt && compareBytes(key, bounds.lt) >= 0)
|
|
28
|
+
return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Storage-facing base of the generic KVStore-backed virtual table.
|
|
33
|
+
*
|
|
34
|
+
* Holds the table's schema-derived key encoding state, its data/index/stats
|
|
35
|
+
* store handles and transaction coordinator, its statistics accounting, and the
|
|
36
|
+
* effective-row reads every higher layer issues. Platform-specific behavior is
|
|
37
|
+
* delegated to the {@link StoreTableModule}.
|
|
38
|
+
*
|
|
39
|
+
* NOTE: `export` here is required, not intentional API surface — `StoreTable extends`
|
|
40
|
+
* this class transitively, so TypeScript cannot emit its declaration unless every base
|
|
41
|
+
* in the chain is nameable (TS4020). The package index deliberately does not re-export
|
|
42
|
+
* this class or the two layers above it; if an API-surface extractor is ever added, it
|
|
43
|
+
* should treat them as internal rather than prompt a re-export.
|
|
44
|
+
*/
|
|
45
|
+
export class StoreTableBase extends VirtualTable {
|
|
46
|
+
storeModule;
|
|
47
|
+
config;
|
|
48
|
+
store = null;
|
|
49
|
+
storeInitPromise = null;
|
|
50
|
+
indexStores = new Map();
|
|
51
|
+
statsStore = null;
|
|
52
|
+
coordinator = null;
|
|
53
|
+
/**
|
|
54
|
+
* Disposer returned by the coordinator's `registerCallbacks`, captured on the
|
|
55
|
+
* first {@link attachCoordinator}. Run by {@link dispose} at hard eviction to
|
|
56
|
+
* deregister this instance's {stats apply/discard} pair from the module-wide
|
|
57
|
+
* coordinator; null until attached, and nulled again after dispose.
|
|
58
|
+
*/
|
|
59
|
+
coordinatorDisposer = null;
|
|
60
|
+
connection = null;
|
|
61
|
+
eventEmitter;
|
|
62
|
+
encodeOptions;
|
|
63
|
+
pkDirections;
|
|
64
|
+
/**
|
|
65
|
+
* Per-PK-column KEY collation (see {@link resolvePkKeyCollations}). Drives the
|
|
66
|
+
* physical encoding of every data key and the PK suffix of every secondary-index
|
|
67
|
+
* key, so a text PK column declared BINARY/NOCASE/RTRIM is keyed under its own
|
|
68
|
+
* collation rather than one fixed table-level collation. Recomputed on every
|
|
69
|
+
* {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
|
|
70
|
+
*/
|
|
71
|
+
pkKeyCollations;
|
|
72
|
+
/**
|
|
73
|
+
* Per-PK-column key-identity value transform (see {@link resolvePkKeyTransforms}).
|
|
74
|
+
* Threaded alongside {@link pkKeyCollations} into every data-key / PK-suffix /
|
|
75
|
+
* PK-prefix-bounds encode, so a semantic-ordering PK member (TIMESPAN) keys
|
|
76
|
+
* semantically-equal spellings to one physical key. Recomputed on every
|
|
77
|
+
* {@link updateSchema}.
|
|
78
|
+
*/
|
|
79
|
+
pkKeyTransforms;
|
|
80
|
+
/**
|
|
81
|
+
* Per-PK-column typed EQUALITY comparator for the semantic-ordering members only —
|
|
82
|
+
* `undefined` for every other member, which `StoreTableConstraints.keysEqual` still compares with
|
|
83
|
+
* strict `!==` as before. Keeps self-PK exclusion and PK-identity questions in
|
|
84
|
+
* agreement with the physical key identity above ('PT1H' row IS the 'PT60M' row).
|
|
85
|
+
*/
|
|
86
|
+
pkSemanticEquality;
|
|
87
|
+
/**
|
|
88
|
+
* `db.getCollationResolver()`, bound once at construction. Every collation-aware
|
|
89
|
+
* VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
|
|
90
|
+
* detection — resolves names through it, so a collation registered with
|
|
91
|
+
* `db.registerCollation` on *this* connection is honored rather than silently
|
|
92
|
+
* degraded to BINARY by the process-global built-in registry.
|
|
93
|
+
*
|
|
94
|
+
* Only the resolver closure is cached: it reads the live registry, so a collation
|
|
95
|
+
* registered after connect is still visible. Resolved *functions* are hoisted no
|
|
96
|
+
* further than the comparator or constraint check that resolved them.
|
|
97
|
+
*
|
|
98
|
+
* Its key-bytes counterpart, `db.getKeyNormalizerResolver()`, is bound into
|
|
99
|
+
* {@link encodeOptions} so the names in {@link pkKeyCollations} resolve to the same
|
|
100
|
+
* per-connection registry.
|
|
101
|
+
*/
|
|
102
|
+
collationResolver;
|
|
103
|
+
ddlSaved = false;
|
|
104
|
+
// Statistics tracking
|
|
105
|
+
cachedStats = null;
|
|
106
|
+
pendingStatsDelta = 0;
|
|
107
|
+
mutationCount = 0;
|
|
108
|
+
statsFlushPending = false;
|
|
109
|
+
/**
|
|
110
|
+
* {@link tableSchema} with a hidden `_uc_*` secondary index materialized per
|
|
111
|
+
* non-derived UNIQUE constraint that no explicit index already covers (see
|
|
112
|
+
* {@link withImplicitUniqueIndexes}) — the schema that drives UNIQUE ENFORCEMENT
|
|
113
|
+
* (`StoreTableConstraints.findIndexForUniqueConstraint`) and physical index MAINTENANCE
|
|
114
|
+
* (`StoreTableConstraints.updateSecondaryIndexes`).
|
|
115
|
+
*
|
|
116
|
+
* Deliberately SEPARATE from the engine-facing {@link tableSchema}: the engine
|
|
117
|
+
* reads `tableInstance.tableSchema` at CREATE (`SchemaManager.finalizeCreatedTableSchema`)
|
|
118
|
+
* and the read-query planner reads the registered schema, and NEITHER may see
|
|
119
|
+
* `_uc_*` (mirrors the memory backend keeping its materialized schema
|
|
120
|
+
* manager-local; a plain UNIQUE gets no read-side plan from `_uc_*`, only
|
|
121
|
+
* enforcement). Recomputed alongside `tableSchema` in the constructor and
|
|
122
|
+
* {@link updateSchema}.
|
|
123
|
+
*/
|
|
124
|
+
materializedSchema;
|
|
125
|
+
constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
super(db, storeModule, tableSchema.schemaName, tableSchema.name);
|
|
128
|
+
this.storeModule = storeModule;
|
|
129
|
+
// The engine-facing schema stays NON-materialized (the engine registers it and the
|
|
130
|
+
// read-query planner reads it — neither may see `_uc_*`); the hidden `_uc_*`
|
|
131
|
+
// per-UNIQUE index that lets plain UNIQUE enforce via a point-seek lives only in
|
|
132
|
+
// {@link materializedSchema}.
|
|
133
|
+
this.tableSchema = tableSchema;
|
|
134
|
+
this.materializedSchema = withImplicitUniqueIndexes(tableSchema);
|
|
135
|
+
this.config = config;
|
|
136
|
+
this.eventEmitter = eventEmitter;
|
|
137
|
+
this.collationResolver = db.getCollationResolver();
|
|
138
|
+
this.encodeOptions = {
|
|
139
|
+
collation: config.collation || 'NOCASE',
|
|
140
|
+
normalizers: db.getKeyNormalizerResolver(),
|
|
141
|
+
};
|
|
142
|
+
this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
143
|
+
this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
144
|
+
this.pkKeyTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
|
|
145
|
+
this.pkSemanticEquality = resolvePkSemanticEquality(tableSchema.primaryKeyDefinition, tableSchema.columns);
|
|
146
|
+
// Validate the MATERIALIZED schema, so a `_uc_*` whose key collations cannot key
|
|
147
|
+
// is rejected up front just like an explicit index would be.
|
|
148
|
+
this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
|
|
149
|
+
this.validateSemanticKeyTransforms(this.materializedSchema);
|
|
150
|
+
this.ddlSaved = isConnected;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reject, at DDL time, any collation this table's key encoding would need but cannot
|
|
154
|
+
* use — before a single row is written under bytes the connection cannot reproduce.
|
|
155
|
+
*
|
|
156
|
+
* A collation keys a persisted structure only if it carries a KEY NORMALIZER (the
|
|
157
|
+
* `(s) => string` whose output equality partitions strings exactly as its comparator
|
|
158
|
+
* does). `db.registerCollation(name, cmp)` with no `{ normalizer }` gives a collation
|
|
159
|
+
* that can order rows but not key them; an unregistered name gives nothing at all.
|
|
160
|
+
* Both raise here rather than at the first insert.
|
|
161
|
+
*
|
|
162
|
+
* Checked over exactly the collations the key encoding actually uses:
|
|
163
|
+
* - every defined `pkKeyCollations` entry (text-capable PK columns);
|
|
164
|
+
* - every defined index key collation of every index in the MATERIALIZED schema
|
|
165
|
+
* (`resolveIndexKeyCollations` — the index column's COLLATE, else the table
|
|
166
|
+
* column's declared collation, else BINARY; hidden `_uc_*` indexes included).
|
|
167
|
+
*
|
|
168
|
+
* Two deliberate consequences of validating the actual index key collations rather
|
|
169
|
+
* than blanket-requiring the table key collation K whenever any index column is
|
|
170
|
+
* text-capable (the pre-per-column-index-collation behavior):
|
|
171
|
+
* - an index column declaring a comparator-only collation is rejected here instead
|
|
172
|
+
* of silently keying under K;
|
|
173
|
+
* - a table whose K has no normalizer but whose key encoding never uses K (all
|
|
174
|
+
* index columns BINARY/type-native, PK needing no K) is no longer unopenable.
|
|
175
|
+
*
|
|
176
|
+
* Blast radius: this also fires on catalog rehydration. Reopening a persisted database
|
|
177
|
+
* from a connection that has not re-registered its custom collation now throws at
|
|
178
|
+
* CREATE-TABLE-from-catalog rather than silently reading rows under a key layout it
|
|
179
|
+
* cannot reproduce. See `docs/plugins.md`.
|
|
180
|
+
*
|
|
181
|
+
* Takes `pkKeyCollations` rather than reading the field so `updateSchema` can validate
|
|
182
|
+
* the incoming schema BEFORE it overwrites its own state with it.
|
|
183
|
+
*/
|
|
184
|
+
validateKeyCollations(schema, pkKeyCollations) {
|
|
185
|
+
const names = new Set();
|
|
186
|
+
for (const collation of pkKeyCollations) {
|
|
187
|
+
if (collation !== undefined)
|
|
188
|
+
names.add(collation);
|
|
189
|
+
}
|
|
190
|
+
for (const index of schema.indexes ?? []) {
|
|
191
|
+
for (const collation of resolveIndexKeyCollations(index, schema.columns)) {
|
|
192
|
+
if (collation !== undefined)
|
|
193
|
+
names.add(collation);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.assertCollationsCanKey(names);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Throw-only half of {@link validateKeyCollations}: raise unless every name in
|
|
200
|
+
* `names` resolves to a registered key normalizer.
|
|
201
|
+
*/
|
|
202
|
+
assertCollationsCanKey(names) {
|
|
203
|
+
const dbInternal = this.db;
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
if (dbInternal._getCollationNormalizer(name))
|
|
206
|
+
continue;
|
|
207
|
+
// Unregistered names raise `no such collation sequence: X` from the resolver;
|
|
208
|
+
// reaching past it means the collation exists but is comparator-only.
|
|
209
|
+
this.collationResolver(name);
|
|
210
|
+
throw new QuereusError(`collation ${name} cannot key a persisted structure: no key normalizer registered `
|
|
211
|
+
+ `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Throw-only pre-check for `StoreModuleIndex.createIndex`: reject a NEW index whose
|
|
216
|
+
* key collations cannot key (comparator-only or unregistered), BEFORE the physical
|
|
217
|
+
* index store is created — so a rejection leaves no store directory behind even for
|
|
218
|
+
* an empty table (where the build itself would encode nothing and the later
|
|
219
|
+
* `updateSchema` validation would fire only after the store exists).
|
|
220
|
+
*/
|
|
221
|
+
assertIndexKeyCollationsCanKey(indexSchema) {
|
|
222
|
+
const names = new Set();
|
|
223
|
+
for (const collation of resolveIndexKeyCollations(indexSchema, this.tableSchema.columns)) {
|
|
224
|
+
if (collation !== undefined)
|
|
225
|
+
names.add(collation);
|
|
226
|
+
}
|
|
227
|
+
this.assertCollationsCanKey(names);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Reject, at DDL time, a key member whose semantic-ordering logical type has no
|
|
231
|
+
* store key transform — before a single row is keyed under bytes whose order the
|
|
232
|
+
* type's `compare` (and so the isolation merge, the memory oracle, and Sort)
|
|
233
|
+
* disagrees with.
|
|
234
|
+
*
|
|
235
|
+
* A semantic-ordering type keys soundly only through an order- and
|
|
236
|
+
* identity-preserving transform ({@link storeSemanticKeyTransform}: the type's
|
|
237
|
+
* `groupKey`, or a store-local encoder like JSON's structural one). This gap used
|
|
238
|
+
* to be undetectable — JSON sat here transform-less for a release, keying in
|
|
239
|
+
* canonical-text order while every comparator ordered structurally, and the only
|
|
240
|
+
* symptom was a misaligned overlay merge deep inside a transaction. Unreachable
|
|
241
|
+
* today (TIMESPAN and JSON are both covered); it exists so the NEXT
|
|
242
|
+
* semantic-ordering type fails loudly at CREATE TABLE instead.
|
|
243
|
+
*
|
|
244
|
+
* Checked over the same materialized schema as {@link validateKeyCollations}:
|
|
245
|
+
* every PK member and every (explicit or hidden `_uc_*`) index column.
|
|
246
|
+
*
|
|
247
|
+
* CAVEAT: this can only check that a transform EXISTS — a `groupKey` that is
|
|
248
|
+
* identity-faithful but not order-preserving passes undetected, so a new type's
|
|
249
|
+
* transform still needs its byte order argued against its `compare` (see
|
|
250
|
+
* json-key.ts for the shape of that argument).
|
|
251
|
+
*/
|
|
252
|
+
validateSemanticKeyTransforms(schema) {
|
|
253
|
+
const check = (colIndex, member) => {
|
|
254
|
+
const col = schema.columns[colIndex];
|
|
255
|
+
const type = col?.logicalType;
|
|
256
|
+
if (!hasSemanticOrdering(type) || storeSemanticKeyTransform(type) !== undefined)
|
|
257
|
+
return;
|
|
258
|
+
throw new QuereusError(`column ${col?.name ?? String(colIndex)} (${member}): semantic-ordering type `
|
|
259
|
+
+ `${type.name} cannot key a persisted structure: its raw encoding would not `
|
|
260
|
+
+ `reproduce the type's compare order — the type needs an order-preserving `
|
|
261
|
+
+ `groupKey or a store key encoder (see storeSemanticKeyTransform)`, StatusCode.ERROR);
|
|
262
|
+
};
|
|
263
|
+
for (const def of schema.primaryKeyDefinition)
|
|
264
|
+
check(def.index, 'primary key');
|
|
265
|
+
for (const index of schema.indexes ?? []) {
|
|
266
|
+
for (const col of index.columns)
|
|
267
|
+
check(col.index, `index ${index.name}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Get the table configuration. */
|
|
271
|
+
getConfig() {
|
|
272
|
+
return this.config;
|
|
273
|
+
}
|
|
274
|
+
/** Get the table schema (engine-facing, WITHOUT the hidden `_uc_*` indexes). */
|
|
275
|
+
getSchema() {
|
|
276
|
+
return this.tableSchema;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Get the enforcement schema — {@link getSchema} plus the hidden `_uc_*`
|
|
280
|
+
* index materialized per non-derived UNIQUE. Used by the module to resolve the
|
|
281
|
+
* physical index store to build/tear down during ALTER; never registered with
|
|
282
|
+
* the engine.
|
|
283
|
+
*/
|
|
284
|
+
getMaterializedSchema() {
|
|
285
|
+
return this.materializedSchema;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Update the table schema after an ALTER TABLE / CREATE INDEX operation.
|
|
289
|
+
*
|
|
290
|
+
* Validates the incoming schema's key collations before adopting any of it, so a
|
|
291
|
+
* rejection leaves this table on its previous, consistent schema.
|
|
292
|
+
*/
|
|
293
|
+
updateSchema(newSchema) {
|
|
294
|
+
// `newSchema` is the engine-facing (non-materialized) schema; recompute the
|
|
295
|
+
// enforcement copy alongside it. Validate the MATERIALIZED copy so a `_uc_*`
|
|
296
|
+
// whose key collations cannot key is caught before adoption.
|
|
297
|
+
const materialized = withImplicitUniqueIndexes(newSchema);
|
|
298
|
+
const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
299
|
+
this.validateKeyCollations(materialized, pkKeyCollations);
|
|
300
|
+
this.validateSemanticKeyTransforms(materialized);
|
|
301
|
+
this.tableSchema = newSchema;
|
|
302
|
+
this.materializedSchema = materialized;
|
|
303
|
+
this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
304
|
+
this.pkKeyCollations = pkKeyCollations;
|
|
305
|
+
this.pkKeyTransforms = resolvePkKeyTransforms(newSchema.primaryKeyDefinition, newSchema.columns);
|
|
306
|
+
this.pkSemanticEquality = resolvePkSemanticEquality(newSchema.primaryKeyDefinition, newSchema.columns);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Mark the table's DDL as already persisted to the catalog, so the lazy
|
|
310
|
+
* first-store-access save in {@link initializeStore} is skipped. Called by
|
|
311
|
+
* `StoreModule.createIndex` / `dropIndex` after they eagerly write the catalog
|
|
312
|
+
* bundle, so a subsequent INSERT does not redundantly re-persist identical DDL.
|
|
313
|
+
*/
|
|
314
|
+
markDdlSaved() {
|
|
315
|
+
this.ddlSaved = true;
|
|
316
|
+
}
|
|
317
|
+
/** Close and forget a cached index-store handle, if any. */
|
|
318
|
+
async releaseIndexStore(indexName) {
|
|
319
|
+
const cached = this.indexStores.get(indexName);
|
|
320
|
+
if (!cached)
|
|
321
|
+
return;
|
|
322
|
+
this.indexStores.delete(indexName);
|
|
323
|
+
try {
|
|
324
|
+
await cached.close();
|
|
325
|
+
}
|
|
326
|
+
catch { /* close is best-effort */ }
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Returns true if the table has at least one LIVE row — the open transaction's
|
|
330
|
+
* pending overlay over the committed store. Stops after the first hit.
|
|
331
|
+
*
|
|
332
|
+
* Effective (not committed-only) because its one caller, `ADD COLUMN`'s NOT
|
|
333
|
+
* NULL-without-DEFAULT rejection, must see rows the same transaction inserted:
|
|
334
|
+
* they are migrated too (the arm DDL-commits before `migrateRows`), and a
|
|
335
|
+
* committed-only probe would wave them through only to give them a NULL in a
|
|
336
|
+
* NOT NULL column. Conversely, a pending delete of the last row makes the table
|
|
337
|
+
* legitimately empty. Reading effectively keeps the check throw-only and
|
|
338
|
+
* BEFORE the DDL-commit, so a rejection leaves the transaction intact.
|
|
339
|
+
*/
|
|
340
|
+
async hasAnyRows() {
|
|
341
|
+
for await (const _entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Yield the LIVE value of `colIndex` for every row — the open transaction's
|
|
348
|
+
* pending overlay over the committed store. For throw-only ALTER COLUMN probes
|
|
349
|
+
* that must run BEFORE the arm's DDL-commit (see `StoreModule.ddlCommitPendingOps`):
|
|
350
|
+
* they have to see rows this transaction wrote, because those rows are rewritten
|
|
351
|
+
* too, and a rejection here must leave the transaction intact.
|
|
352
|
+
*/
|
|
353
|
+
async *iterateEffectiveValuesAtIndex(colIndex) {
|
|
354
|
+
for await (const entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
|
|
355
|
+
yield deserializeRow(entry.value)[colIndex];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Count the LIVE rows whose column at `colIndex` holds NULL (see
|
|
360
|
+
* {@link iterateEffectiveValuesAtIndex} for why this reads effectively).
|
|
361
|
+
* Used by ALTER COLUMN SET NOT NULL to decide whether the tightening is safe.
|
|
362
|
+
*/
|
|
363
|
+
async rowsWithNullAtIndex(colIndex) {
|
|
364
|
+
let count = 0;
|
|
365
|
+
for await (const value of this.iterateEffectiveValuesAtIndex(colIndex)) {
|
|
366
|
+
if (value === null)
|
|
367
|
+
count++;
|
|
368
|
+
}
|
|
369
|
+
return count;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Ensure the data store is open and DDL is persisted.
|
|
373
|
+
* Uses a promise-based singleton pattern to prevent race conditions
|
|
374
|
+
* when multiple concurrent queries access the same table.
|
|
375
|
+
*/
|
|
376
|
+
ensureStore() {
|
|
377
|
+
if (this.store) {
|
|
378
|
+
return Promise.resolve(this.store);
|
|
379
|
+
}
|
|
380
|
+
if (this.storeInitPromise) {
|
|
381
|
+
return this.storeInitPromise;
|
|
382
|
+
}
|
|
383
|
+
this.storeInitPromise = this.initializeStore();
|
|
384
|
+
return this.storeInitPromise;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Internal method to actually initialize the store.
|
|
388
|
+
* Only called once per table instance.
|
|
389
|
+
*/
|
|
390
|
+
async initializeStore() {
|
|
391
|
+
const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
|
|
392
|
+
try {
|
|
393
|
+
this.store = await this.storeModule.getStore(this.schemaName, this.tableName, this.config);
|
|
394
|
+
if (!this.store) {
|
|
395
|
+
throw new Error(`getStore returned null/undefined for ${tableKey}`);
|
|
396
|
+
}
|
|
397
|
+
// Catalog backstop on first storage access. The authoritative write happens
|
|
398
|
+
// earlier, off the engine's `table_added` event; this is a COMPARE-write, so for
|
|
399
|
+
// an already-persisted table it reads and skips rather than re-putting identical
|
|
400
|
+
// bytes.
|
|
401
|
+
//
|
|
402
|
+
// It is retained (rather than dropped as redundant) because it is the only place a
|
|
403
|
+
// table whose persisted DDL text is UNENCODABLE — a lone surrogate in a quoted
|
|
404
|
+
// column name, a DEFAULT string literal, a CHECK constant — can raise on the
|
|
405
|
+
// statement: the `table_added` write rides the persist queue, which logs and
|
|
406
|
+
// swallows. The entry is absent there (that write threw), so the compare-write
|
|
407
|
+
// attempts it and the encoding guard throws into the DML that opened the storage.
|
|
408
|
+
if (!this.ddlSaved && this.tableSchema) {
|
|
409
|
+
await this.storeModule.persistTableCatalogEntryIfChanged(this.tableSchema);
|
|
410
|
+
this.ddlSaved = true;
|
|
411
|
+
}
|
|
412
|
+
await this.primeStats();
|
|
413
|
+
return this.store;
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
this.storeInitPromise = null;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Get or create an index store for the given index name.
|
|
422
|
+
*/
|
|
423
|
+
async ensureIndexStore(indexName) {
|
|
424
|
+
let indexStore = this.indexStores.get(indexName);
|
|
425
|
+
if (!indexStore) {
|
|
426
|
+
indexStore = await this.storeModule.getIndexStore(this.schemaName, this.tableName, indexName);
|
|
427
|
+
this.indexStores.set(indexName, indexStore);
|
|
428
|
+
}
|
|
429
|
+
return indexStore;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Read the persisted row count into {@link cachedStats} once per table instance,
|
|
433
|
+
* before any mutation can be tracked against it.
|
|
434
|
+
*
|
|
435
|
+
* `trackMutation` / `applyPendingStats` both seed a missing `cachedStats` with
|
|
436
|
+
* `rowCount: 0` and add their delta to it, so WITHOUT this priming the first write
|
|
437
|
+
* after a reopen silently restarted the count from zero: a table reopened with 500
|
|
438
|
+
* persisted rows reported 1 after a single insert, and the next
|
|
439
|
+
* {@link flushStats} made that durable. Nothing read the count for planning before,
|
|
440
|
+
* so the loss was invisible; it is not once {@link getKnownRowCount} prices access
|
|
441
|
+
* plans with it.
|
|
442
|
+
*
|
|
443
|
+
* Called from {@link initializeStore}, which every read and write path awaits
|
|
444
|
+
* before touching storage — so priming precedes the first `trackMutation` without
|
|
445
|
+
* any caller having to remember it. Costs one extra KV `get` per table per session.
|
|
446
|
+
*
|
|
447
|
+
* Statistics are advisory (same posture as `StoreModuleRename`'s stats re-key): a
|
|
448
|
+
* failure to open or read the stats store warns and leaves the count unknown rather
|
|
449
|
+
* than failing the storage open that carries the caller's actual query.
|
|
450
|
+
*
|
|
451
|
+
* NOTE: on that failure path the pre-fix behaviour returns for this table — the next
|
|
452
|
+
* `trackMutation` seeds `cachedStats` at 0 and a later {@link flushStats} makes the
|
|
453
|
+
* restarted count durable, silently discarding the persisted one. Tolerated because a
|
|
454
|
+
* stats-store read failure is already a storage-level fault and the count is advisory.
|
|
455
|
+
* If it ever needs to be safe, gate {@link flushStats} on a "primed or genuinely
|
|
456
|
+
* absent" flag rather than on `cachedStats` being non-null.
|
|
457
|
+
*/
|
|
458
|
+
async primeStats() {
|
|
459
|
+
if (this.cachedStats)
|
|
460
|
+
return;
|
|
461
|
+
try {
|
|
462
|
+
const statsStore = await this.ensureStatsStore();
|
|
463
|
+
const statsData = await statsStore.get(buildStatsKey(this.schemaName, this.tableName));
|
|
464
|
+
if (statsData)
|
|
465
|
+
this.cachedStats = deserializeStats(statsData);
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
console.warn(`[StoreModule] Failed to read persisted statistics for '${this.schemaName}.${this.tableName}': ${e instanceof Error ? e.message : String(e)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Get or create the stats store.
|
|
473
|
+
*/
|
|
474
|
+
async ensureStatsStore() {
|
|
475
|
+
if (!this.statsStore) {
|
|
476
|
+
this.statsStore = await this.storeModule.getStatsStore(this.schemaName, this.tableName);
|
|
477
|
+
}
|
|
478
|
+
return this.statsStore;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Resolve + cache the module's shared TransactionCoordinator and hook the
|
|
482
|
+
* stats lifecycle callbacks, WITHOUT creating or registering a connection.
|
|
483
|
+
* Synchronous (the coordinator is handle-addressed, never store-opening — see
|
|
484
|
+
* `StoreTableModule.getCoordinator`), so the backing host's resolution path can
|
|
485
|
+
* call it eagerly. Attaching matters for reads: `iterateEffective` /
|
|
486
|
+
* `readEffectiveRowByKey` consult `this.coordinator` for the pending merge, so a
|
|
487
|
+
* table whose only writer is the privileged backing host (which queues ops on the
|
|
488
|
+
* module coordinator, never through `update()`) must still hold the
|
|
489
|
+
* reference for its read paths to be reads-own-writes.
|
|
490
|
+
*
|
|
491
|
+
* The coordinator is module-wide, so its commit/rollback callback array holds
|
|
492
|
+
* one {stats apply/discard} pair per PARTICIPATING table; each table's
|
|
493
|
+
* `applyPendingStats` early-returns when its own `pendingStatsDelta` is 0, so a
|
|
494
|
+
* table that did no work contributes nothing. Registers only on first call per
|
|
495
|
+
* StoreTable instance (the `if (!this.coordinator)` guard); a fresh instance
|
|
496
|
+
* after drop+recreate re-registers against the shared coordinator. The OLD
|
|
497
|
+
* instance is not GC'd on its own — its callback closures capture `this` and
|
|
498
|
+
* stay pinned on the module-wide coordinator until {@link dispose} runs the
|
|
499
|
+
* captured disposer (called at the genuine eviction sites, see
|
|
500
|
+
* `StoreModule.tearDownTableStorage` / `renameTable`).
|
|
501
|
+
*/
|
|
502
|
+
attachCoordinator() {
|
|
503
|
+
if (!this.coordinator) {
|
|
504
|
+
this.coordinator = this.storeModule.getCoordinator();
|
|
505
|
+
this.coordinatorDisposer = this.coordinator.registerCallbacks({
|
|
506
|
+
onCommit: () => this.applyPendingStats(),
|
|
507
|
+
onRollback: () => this.discardPendingStats(),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return this.coordinator;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Ensure the coordinator is available and connection is registered.
|
|
514
|
+
*/
|
|
515
|
+
async ensureCoordinator() {
|
|
516
|
+
const coordinator = this.attachCoordinator();
|
|
517
|
+
if (!this.connection) {
|
|
518
|
+
this.connection = new StoreConnection(`${this.schemaName}.${this.tableName}`, coordinator);
|
|
519
|
+
await this.db.registerConnection(this.connection);
|
|
520
|
+
}
|
|
521
|
+
return coordinator;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Deliver one data-change event: buffered on the coordinator inside a transaction
|
|
525
|
+
* (fires on commit, discarded on rollback), emitted straight through otherwise.
|
|
526
|
+
* Every write path routes its event here so the two arms can never drift apart.
|
|
527
|
+
*
|
|
528
|
+
* Ordering within a transaction is the coordinator's queue order, which is the order
|
|
529
|
+
* these calls are made — the event contract's delete-before-insert promise for a
|
|
530
|
+
* relocating key change rides on that (docs/usage.md § Subscribing to Data Changes).
|
|
531
|
+
*/
|
|
532
|
+
emitOrQueueDataChange(inTransaction, event) {
|
|
533
|
+
if (inTransaction && this.coordinator) {
|
|
534
|
+
this.coordinator.queueEvent(event);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
this.eventEmitter?.emitDataChange(event);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Apply pending stats on commit. */
|
|
541
|
+
applyPendingStats() {
|
|
542
|
+
if (this.pendingStatsDelta === 0)
|
|
543
|
+
return;
|
|
544
|
+
if (!this.cachedStats) {
|
|
545
|
+
this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
|
|
546
|
+
}
|
|
547
|
+
this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + this.pendingStatsDelta);
|
|
548
|
+
this.cachedStats.updatedAt = Date.now();
|
|
549
|
+
this.mutationCount += Math.abs(this.pendingStatsDelta);
|
|
550
|
+
this.pendingStatsDelta = 0;
|
|
551
|
+
if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
|
|
552
|
+
this.statsFlushPending = true;
|
|
553
|
+
queueMicrotask(() => this.flushStats());
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** Discard pending stats on rollback. */
|
|
557
|
+
discardPendingStats() {
|
|
558
|
+
this.pendingStatsDelta = 0;
|
|
559
|
+
}
|
|
560
|
+
/** Flush statistics to the stats store. */
|
|
561
|
+
async flushStats() {
|
|
562
|
+
this.statsFlushPending = false;
|
|
563
|
+
this.mutationCount = 0;
|
|
564
|
+
if (!this.cachedStats) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const statsStore = await this.ensureStatsStore();
|
|
568
|
+
const statsKey = buildStatsKey(this.schemaName, this.tableName);
|
|
569
|
+
await statsStore.put(statsKey, serializeStats(this.cachedStats));
|
|
570
|
+
}
|
|
571
|
+
/** Create a new connection for transaction support. */
|
|
572
|
+
async createConnection() {
|
|
573
|
+
await this.ensureCoordinator();
|
|
574
|
+
return this.connection;
|
|
575
|
+
}
|
|
576
|
+
/** Get the current connection. */
|
|
577
|
+
getConnection() {
|
|
578
|
+
return this.connection ?? undefined;
|
|
579
|
+
}
|
|
580
|
+
/** Extract primary key values from a full row (positioned by column index). */
|
|
581
|
+
extractPK(row) {
|
|
582
|
+
const schema = this.tableSchema;
|
|
583
|
+
return schema.primaryKeyDefinition.map(pk => row[pk.index]);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Resolve the PK cells an UPDATE/DELETE's `oldKeyValues` addresses.
|
|
587
|
+
*
|
|
588
|
+
* `UpdateArgs.oldKeyValues` is COMPACT — one cell per `primaryKeyDefinition`
|
|
589
|
+
* entry, in that order — NOT a full row positioned by column index. Every
|
|
590
|
+
* producer builds it that way (`pkIndices.map(i => row[i])` in the DML
|
|
591
|
+
* executor and in the isolation flush/overlay), and the memory module's
|
|
592
|
+
* `buildPrimaryKeyFromValues` asserts exactly this arity.
|
|
593
|
+
*
|
|
594
|
+
* Running it through {@link extractPK} instead only coincides when every PK
|
|
595
|
+
* column sits at the index matching its position in the PK definition. For a
|
|
596
|
+
* table whose PK is not its leading columns (e.g. a generated stored PK
|
|
597
|
+
* declared after the columns it derives from) that read lands past the end of
|
|
598
|
+
* the compact array and yields `undefined`.
|
|
599
|
+
*/
|
|
600
|
+
pkFromKeyValues(keyValues) {
|
|
601
|
+
const pkDef = this.tableSchema.primaryKeyDefinition;
|
|
602
|
+
if (keyValues.length !== pkDef.length) {
|
|
603
|
+
throw new QuereusError(`Key value count mismatch for '${this.tableName}'. Expected ${pkDef.length}, got ${keyValues.length}.`, StatusCode.INTERNAL);
|
|
604
|
+
}
|
|
605
|
+
return [...keyValues];
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Coerce each cell in `row` to its declared column logical type.
|
|
609
|
+
* Mirrors the memory-table path (MemoryTableManager.performInsert/performUpdate)
|
|
610
|
+
* so INTEGER/REAL affinity is applied and JSON columns are parsed into native
|
|
611
|
+
* objects before PK extraction, serialization, and index-key construction.
|
|
612
|
+
*/
|
|
613
|
+
coerceRow(row) {
|
|
614
|
+
return coerceRowToSchema(row, this.tableSchema.columns, `${this.schemaName}.${this.tableName}`);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Iterate the effective entry state of this table's data store within
|
|
618
|
+
* `bounds`: the committed `store.iterate` stream merged with the
|
|
619
|
+
* coordinator's pending ops for the data store (read-your-own-writes).
|
|
620
|
+
*
|
|
621
|
+
* Both inputs are sorted by encoded key bytes, so this is an
|
|
622
|
+
* order-preserving two-way merge: a pending put wins over a committed entry
|
|
623
|
+
* at the same key, a pending delete suppresses its committed entry, and
|
|
624
|
+
* pending puts outside `bounds` are excluded. With no active transaction
|
|
625
|
+
* (or an empty pending bucket) it degrades to the bare committed iterate.
|
|
626
|
+
*
|
|
627
|
+
* The pending side is the module coordinator's bucket for THIS table's data
|
|
628
|
+
* store handle (`store`) — every data op is queued under that exact handle, so
|
|
629
|
+
* a sibling table's pending ops (a different data-store handle) never bleed in.
|
|
630
|
+
*/
|
|
631
|
+
async *iterateEffective(store, bounds, reverse = false) {
|
|
632
|
+
const pending = this.coordinator?.isInTransaction()
|
|
633
|
+
? this.coordinator.getOrderedPendingOps(store)
|
|
634
|
+
: null;
|
|
635
|
+
const iterOptions = reverse ? { ...bounds, reverse } : bounds;
|
|
636
|
+
if (!pending || (pending.puts.length === 0 && pending.deletes.size === 0)) {
|
|
637
|
+
yield* store.iterate(iterOptions);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const puts = pending.puts.filter(p => keyWithinBounds(p.key, bounds));
|
|
641
|
+
if (reverse)
|
|
642
|
+
puts.reverse();
|
|
643
|
+
// In iteration order, "before" means byte-less for forward and byte-greater
|
|
644
|
+
// for reverse; sign folds the direction into one comparison.
|
|
645
|
+
const sign = reverse ? -1 : 1;
|
|
646
|
+
let putIdx = 0;
|
|
647
|
+
for await (const entry of store.iterate(iterOptions)) {
|
|
648
|
+
// Emit pending puts that precede this committed entry in iteration order.
|
|
649
|
+
while (putIdx < puts.length && sign * compareBytes(puts[putIdx].key, entry.key) < 0) {
|
|
650
|
+
yield puts[putIdx++];
|
|
651
|
+
}
|
|
652
|
+
// Equal keys: the pending put shadows the committed entry.
|
|
653
|
+
if (putIdx < puts.length && compareBytes(puts[putIdx].key, entry.key) === 0) {
|
|
654
|
+
yield puts[putIdx++];
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (pending.deletes.has(bytesToHex(entry.key)))
|
|
658
|
+
continue;
|
|
659
|
+
yield entry;
|
|
660
|
+
}
|
|
661
|
+
// Pending puts beyond the last committed entry.
|
|
662
|
+
while (putIdx < puts.length) {
|
|
663
|
+
yield puts[putIdx++];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Read the live row at `pk` — this transaction's pending overlay (a pending
|
|
668
|
+
* delete ⇒ gone; a pending put ⇒ its value) shadowing the committed store.
|
|
669
|
+
* Backs `query()`'s point-lookup arm and validates covering-MV conflict
|
|
670
|
+
* candidates against the source of truth.
|
|
671
|
+
*/
|
|
672
|
+
async readLiveRowByPk(pk) {
|
|
673
|
+
return this.readEffectiveRowByKey(this.encodeDataKey(pk));
|
|
674
|
+
}
|
|
675
|
+
// ── Backing-host surface ──────────────────────────────────────────────
|
|
676
|
+
// Narrow public surface `StoreBackingHost` (backing-host.ts) drives. Each
|
|
677
|
+
// method addresses the table's CURRENT schema/encoding state, so a host
|
|
678
|
+
// resolved fresh per engine call always keys and merges consistently with
|
|
679
|
+
// the table's own read/write paths.
|
|
680
|
+
/** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
|
|
681
|
+
encodeDataKey(pkValues) {
|
|
682
|
+
return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations, this.pkKeyTransforms);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Byte bounds covering every data key whose leading PK columns equal
|
|
686
|
+
* `prefixValues` — encoded under the same per-column DESC directions and key
|
|
687
|
+
* collations as {@link encodeDataKey}, so seek + early-terminate addresses the
|
|
688
|
+
* exact slice. An empty prefix yields full-scan bounds.
|
|
689
|
+
*/
|
|
690
|
+
encodePkPrefixBounds(prefixValues) {
|
|
691
|
+
return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length), this.pkKeyTransforms.slice(0, prefixValues.length));
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Effective (pending-over-committed) point read by encoded data key: a pending
|
|
695
|
+
* delete ⇒ null, a pending put ⇒ its value, else the committed store entry.
|
|
696
|
+
*/
|
|
697
|
+
async readEffectiveRowByKey(key) {
|
|
698
|
+
const store = await this.ensureStore();
|
|
699
|
+
// Pending ops for THIS table's data store handle — see findUniqueConflict.
|
|
700
|
+
const pending = this.coordinator?.isInTransaction()
|
|
701
|
+
? this.coordinator.getPendingOpsForStore(store)
|
|
702
|
+
: null;
|
|
703
|
+
if (pending) {
|
|
704
|
+
const hex = bytesToHex(key);
|
|
705
|
+
if (pending.deletes.has(hex))
|
|
706
|
+
return null;
|
|
707
|
+
const overlay = pending.puts.get(hex);
|
|
708
|
+
if (overlay)
|
|
709
|
+
return deserializeRow(overlay.value);
|
|
710
|
+
}
|
|
711
|
+
const value = await store.get(key);
|
|
712
|
+
return value ? deserializeRow(value) : null;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Effective ordered entry scan within `bounds` (see {@link iterateEffective}).
|
|
716
|
+
* Opens the data store first — which fires the lazy first-access `saveTableDDL`
|
|
717
|
+
* for a freshly created table, the catalog write a store-backed MV backing
|
|
718
|
+
* relies on to survive reopen.
|
|
719
|
+
*/
|
|
720
|
+
async *iterateEffectiveEntries(bounds, reverse = false) {
|
|
721
|
+
const store = await this.ensureStore();
|
|
722
|
+
yield* this.iterateEffective(store, bounds, reverse);
|
|
723
|
+
}
|
|
724
|
+
/** Buffer a privileged row-count delta, applied at coordinator commit (host writes). */
|
|
725
|
+
trackPrivilegedMutation(delta) {
|
|
726
|
+
this.trackMutation(delta, true);
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Reset statistics to an absolute committed row count and flush immediately.
|
|
730
|
+
* Used by the host's `replaceContents` (create-fill / refresh), where the new
|
|
731
|
+
* count is exact, replacing any drifted delta-tracked estimate.
|
|
732
|
+
*/
|
|
733
|
+
async resetStats(rowCount) {
|
|
734
|
+
this.cachedStats = { rowCount, updatedAt: Date.now() };
|
|
735
|
+
this.pendingStatsDelta = 0;
|
|
736
|
+
this.mutationCount = 0;
|
|
737
|
+
await this.flushStats();
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Open (and cache) the data store, firing the lazy first-access `saveTableDDL`.
|
|
741
|
+
* Public for the host's `replaceContents`, which writes the store directly.
|
|
742
|
+
*/
|
|
743
|
+
openDataStore() {
|
|
744
|
+
return this.ensureStore();
|
|
745
|
+
}
|
|
746
|
+
// ── External row-write surface ────────────────────────────────────────
|
|
747
|
+
// Module-side entry point for externally-applied writes to a SOURCE table
|
|
748
|
+
// (the index-maintaining sibling of `StoreBackingHost`, which is for MV
|
|
749
|
+
// BACKING tables and deliberately keeps no indexes). Resolved per call via
|
|
750
|
+
// `StoreModule.getTableForExternalWrite`; addresses the table's CURRENT
|
|
751
|
+
// schema/encoding state so keys and index entries match its own DML paths.
|
|
752
|
+
/**
|
|
753
|
+
* Effective (pending-over-committed) point read by PK values — the public
|
|
754
|
+
* read an external writer issues before an upsert to learn the row's current
|
|
755
|
+
* image. Thin wrapper over the same private point-lookup that backs
|
|
756
|
+
* `StoreTableScan.query`'s point arm, so an external read merges pending-over-committed
|
|
757
|
+
* exactly like an engine read does.
|
|
758
|
+
*/
|
|
759
|
+
readRowByPk(pk) {
|
|
760
|
+
return this.readLiveRowByPk(pk);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Begin a table-scoped transaction by ensuring the coordinator is active.
|
|
764
|
+
*
|
|
765
|
+
* Used by the isolation layer's flush path, which treats the underlying
|
|
766
|
+
* write as an independent mini-transaction. Idempotent: if the coordinator
|
|
767
|
+
* is already in a transaction (e.g. started by a registered connection),
|
|
768
|
+
* this is a no-op.
|
|
769
|
+
*/
|
|
770
|
+
async begin() {
|
|
771
|
+
const coordinator = await this.ensureCoordinator();
|
|
772
|
+
coordinator.begin();
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Commits a table-scoped transaction, flushing any buffered writes to the KV store.
|
|
776
|
+
* No-op if the coordinator is not currently in a transaction.
|
|
777
|
+
*/
|
|
778
|
+
async commit() {
|
|
779
|
+
if (this.coordinator?.isInTransaction()) {
|
|
780
|
+
await this.coordinator.commit();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Rolls back a table-scoped transaction, discarding any buffered writes.
|
|
785
|
+
* No-op if the coordinator is not currently in a transaction.
|
|
786
|
+
*/
|
|
787
|
+
async rollback() {
|
|
788
|
+
if (this.coordinator?.isInTransaction()) {
|
|
789
|
+
this.coordinator.rollback();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** Disconnect from the store. */
|
|
793
|
+
async disconnect() {
|
|
794
|
+
// Called by Quereus after each scan completes.
|
|
795
|
+
// Do NOT clear the store - it's shared across concurrent queries.
|
|
796
|
+
// Only flush pending stats if there are mutations.
|
|
797
|
+
if (this.mutationCount > 0 && this.store) {
|
|
798
|
+
await this.flushStats();
|
|
799
|
+
}
|
|
800
|
+
// Store remains available for subsequent queries.
|
|
801
|
+
// Use destroy() to fully clean up the table.
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Hard teardown: fully detach this instance from the module-wide coordinator.
|
|
805
|
+
*
|
|
806
|
+
* Distinct from the per-scan {@link disconnect} (which only flushes stats and
|
|
807
|
+
* deliberately keeps the table hooked mid-life). Called ONLY at the genuine
|
|
808
|
+
* eviction sites — `StoreModule.tearDownTableStorage` (drop / reclaim) and
|
|
809
|
+
* `renameTable` — where this instance is removed from the module's `tables` map
|
|
810
|
+
* and will never be used again. It best-effort flushes any buffered stats (the
|
|
811
|
+
* backing store is about to be deleted / relocated, so this is the last chance,
|
|
812
|
+
* same posture as the teardown-time `disconnect` it replaces), then runs the
|
|
813
|
+
* coordinator disposer so this instance's {stats apply/discard} callback pair —
|
|
814
|
+
* and the `this` its closures capture — is spliced off the shared coordinator's
|
|
815
|
+
* array rather than pinned for the module's lifetime.
|
|
816
|
+
*
|
|
817
|
+
* Idempotent: the disposer is run at most once and both it and the coordinator
|
|
818
|
+
* reference are nulled, so a double-dispose is a no-op and a re-`attachCoordinator`
|
|
819
|
+
* after dispose registers a fresh pair instead of double-registering.
|
|
820
|
+
*/
|
|
821
|
+
async dispose() {
|
|
822
|
+
if (this.mutationCount > 0 && this.store) {
|
|
823
|
+
await this.flushStats();
|
|
824
|
+
}
|
|
825
|
+
this.coordinatorDisposer?.();
|
|
826
|
+
this.coordinatorDisposer = null;
|
|
827
|
+
this.coordinator = null;
|
|
828
|
+
}
|
|
829
|
+
/** Get the current estimated row count. */
|
|
830
|
+
async getEstimatedRowCount() {
|
|
831
|
+
if (!this.cachedStats) {
|
|
832
|
+
await this.primeStats();
|
|
833
|
+
}
|
|
834
|
+
// Still nothing on disk ⇒ this table has never been counted; report 0.
|
|
835
|
+
return this.cachedStats?.rowCount ?? 0;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* The row count this instance can answer WITHOUT any I/O — `undefined` when no
|
|
839
|
+
* count has been read or maintained yet (a table nothing has touched this session,
|
|
840
|
+
* or one that has never been counted at all).
|
|
841
|
+
*
|
|
842
|
+
* Synchronous by design: `StoreModule.getBestAccessPlan` is a synchronous engine
|
|
843
|
+
* callback, so the only count it can price a plan with is one already in memory.
|
|
844
|
+
* {@link primeStats} is what makes that the common case rather than the rare one —
|
|
845
|
+
* it loads the persisted count when storage is first opened, which every read and
|
|
846
|
+
* write path does before it can reach the planner a second time.
|
|
847
|
+
*
|
|
848
|
+
* Includes the open transaction's buffered delta, so a statement that reads what
|
|
849
|
+
* its own transaction just wrote is costed against the size it will actually see.
|
|
850
|
+
*/
|
|
851
|
+
getKnownRowCount() {
|
|
852
|
+
if (!this.cachedStats)
|
|
853
|
+
return undefined;
|
|
854
|
+
return Math.max(0, this.cachedStats.rowCount + this.pendingStatsDelta);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* The `VirtualTable.getStatistics` contract: this table's SIZE, reported from the
|
|
858
|
+
* running count the write paths already maintain and persist, in O(1) — no scan.
|
|
859
|
+
*
|
|
860
|
+
* Column statistics are deliberately absent. The store keeps no value distribution
|
|
861
|
+
* (a distinct count or histogram would cost a full scan of the table or of an index),
|
|
862
|
+
* and `ANALYZE` reads an empty `columnStats` as "this module reported its size
|
|
863
|
+
* cheaply, collect the rest by scanning" (see `runtime/emit/analyze.ts`) — so
|
|
864
|
+
* implementing this method makes the count reachable without giving up the
|
|
865
|
+
* per-column statistics ANALYZE collects today.
|
|
866
|
+
*
|
|
867
|
+
* `rowCount` is delta-tracked rather than counted, so it can drift from the true
|
|
868
|
+
* count if a write path ever mis-accounts; `ANALYZE`'s scan is the reconciliation.
|
|
869
|
+
*
|
|
870
|
+
* `lastAnalyzed` is deliberately left unset: the only timestamp this table holds is
|
|
871
|
+
* `TableStats.updatedAt`, the moment the count last MOVED, which is a different fact
|
|
872
|
+
* — reporting it here would tell a staleness check the table had been analyzed.
|
|
873
|
+
*/
|
|
874
|
+
async getStatistics() {
|
|
875
|
+
return {
|
|
876
|
+
rowCount: await this.getEstimatedRowCount(),
|
|
877
|
+
columnStats: new Map(),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/** Track a mutation and schedule lazy stats persistence. */
|
|
881
|
+
trackMutation(delta, inTransaction = false) {
|
|
882
|
+
if (inTransaction) {
|
|
883
|
+
// Buffer during transaction - stats will be applied at commit
|
|
884
|
+
this.pendingStatsDelta += delta;
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (!this.cachedStats) {
|
|
888
|
+
this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
|
|
889
|
+
}
|
|
890
|
+
this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + delta);
|
|
891
|
+
this.cachedStats.updatedAt = Date.now();
|
|
892
|
+
this.mutationCount++;
|
|
893
|
+
// Schedule lazy flush after threshold
|
|
894
|
+
if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
|
|
895
|
+
this.statsFlushPending = true;
|
|
896
|
+
queueMicrotask(() => this.flushStats());
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
//# sourceMappingURL=store-table-base.js.map
|