@quereus/store 3.3.0 → 4.1.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 +436 -317
- package/dist/src/common/backing-host.d.ts +198 -0
- package/dist/src/common/backing-host.d.ts.map +1 -0
- package/dist/src/common/backing-host.js +452 -0
- package/dist/src/common/backing-host.js.map +1 -0
- package/dist/src/common/bytes.d.ts +16 -0
- package/dist/src/common/bytes.d.ts.map +1 -0
- package/dist/src/common/bytes.js +33 -0
- package/dist/src/common/bytes.js.map +1 -0
- package/dist/src/common/cached-kv-store.d.ts +3 -3
- package/dist/src/common/cached-kv-store.d.ts.map +1 -1
- package/dist/src/common/cached-kv-store.js +6 -4
- package/dist/src/common/cached-kv-store.js.map +1 -1
- package/dist/src/common/encoding.d.ts +9 -1
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +12 -2
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/index.d.ts +8 -6
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +7 -3
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +125 -4
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +186 -9
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +75 -4
- package/dist/src/common/kv-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.d.ts +3 -3
- package/dist/src/common/memory-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.js +4 -2
- package/dist/src/common/memory-store.js.map +1 -1
- package/dist/src/common/store-connection.d.ts +11 -4
- package/dist/src/common/store-connection.d.ts.map +1 -1
- package/dist/src/common/store-connection.js +12 -6
- package/dist/src/common/store-connection.js.map +1 -1
- package/dist/src/common/store-module.d.ts +580 -20
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +1590 -135
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table.d.ts +347 -14
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +751 -98
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/common/transaction.d.ts +135 -27
- package/dist/src/common/transaction.d.ts.map +1 -1
- package/dist/src/common/transaction.js +216 -55
- package/dist/src/common/transaction.js.map +1 -1
- package/package.json +8 -8
|
@@ -9,24 +9,24 @@
|
|
|
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
11
|
*/
|
|
12
|
-
import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, validateAndParse, compilePredicate, } from '@quereus/quereus';
|
|
12
|
+
import { VirtualTable, IndexConstraintOp, ConflictResolution, QuereusError, StatusCode, compareSqlValues, rowsValueIdentical, validateAndParse, compilePredicate, maintainedTableUniqueViolationError, uniqueEnforcementCollations, } from '@quereus/quereus';
|
|
13
|
+
import { bytesEqual, bytesToHex, compareBytes } from './bytes.js';
|
|
13
14
|
import { StoreConnection } from './store-connection.js';
|
|
14
|
-
import { buildDataKey, buildIndexKey, buildFullScanBounds, buildStatsKey, } from './key-builder.js';
|
|
15
|
+
import { buildDataKey, buildIndexKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
|
|
15
16
|
import { serializeRow, deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
|
|
17
|
+
import { getCollationEncoder } from './encoding.js';
|
|
16
18
|
/** Number of mutations before persisting statistics. */
|
|
17
19
|
const STATS_FLUSH_INTERVAL = 100;
|
|
18
|
-
/**
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
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)
|
|
25
29
|
return false;
|
|
26
|
-
for (let i = 0; i < a.length; i++) {
|
|
27
|
-
if (a[i] !== b[i])
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
@@ -49,6 +49,34 @@ function resolvePkDefaultConflict(schema) {
|
|
|
49
49
|
}
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
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,
|
|
56
|
+
* so each text PK column's key must be encoded under that column's declared
|
|
57
|
+
* collation (BINARY / NOCASE / RTRIM — the registered encoders). Returns one
|
|
58
|
+
* entry per PK member, in `pkDef` order:
|
|
59
|
+
* - text member → its declared `collation` (normalized upper-case), or
|
|
60
|
+
* `fallback` (the table key collation K) when the column carries none.
|
|
61
|
+
* - non-text member → `undefined`: collation is meaningless for
|
|
62
|
+
* integer/real/blob keys (they encode type-natively), so the encoder ignores
|
|
63
|
+
* it and the data/index key bytes are identical regardless.
|
|
64
|
+
*
|
|
65
|
+
* A custom comparator-only collation with no registered byte encoder still maps
|
|
66
|
+
* to NOCASE bytes inside `encodeText` (its `?? NOCASE_ENCODER` fallback); that
|
|
67
|
+
* residual is the same one documented for store UNIQUE enforcement and is out of
|
|
68
|
+
* scope here. Shared by {@link StoreTable} (data-key + index-maintenance) and
|
|
69
|
+
* `StoreModule.buildIndexEntries` (index rebuild) so the PK suffix encoding can
|
|
70
|
+
* never drift between the two.
|
|
71
|
+
*/
|
|
72
|
+
export function resolvePkKeyCollations(pkDef, columns, fallback) {
|
|
73
|
+
return pkDef.map(def => {
|
|
74
|
+
const col = columns[def.index];
|
|
75
|
+
if (!col || !col.logicalType.isTextual)
|
|
76
|
+
return undefined;
|
|
77
|
+
return (col.collation || fallback).toUpperCase();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
52
80
|
/**
|
|
53
81
|
* Generic KVStore-backed virtual table.
|
|
54
82
|
*
|
|
@@ -64,10 +92,25 @@ export class StoreTable extends VirtualTable {
|
|
|
64
92
|
indexStores = new Map();
|
|
65
93
|
statsStore = null;
|
|
66
94
|
coordinator = null;
|
|
95
|
+
/**
|
|
96
|
+
* Disposer returned by the coordinator's `registerCallbacks`, captured on the
|
|
97
|
+
* first {@link attachCoordinator}. Run by {@link dispose} at hard eviction to
|
|
98
|
+
* deregister this instance's {stats apply/discard} pair from the module-wide
|
|
99
|
+
* coordinator; null until attached, and nulled again after dispose.
|
|
100
|
+
*/
|
|
101
|
+
coordinatorDisposer = null;
|
|
67
102
|
connection = null;
|
|
68
103
|
eventEmitter;
|
|
69
104
|
encodeOptions;
|
|
70
105
|
pkDirections;
|
|
106
|
+
/**
|
|
107
|
+
* Per-PK-column KEY collation (see {@link resolvePkKeyCollations}). Drives the
|
|
108
|
+
* physical encoding of every data key and the PK suffix of every secondary-index
|
|
109
|
+
* key, so a text PK column declared BINARY/NOCASE/RTRIM is keyed under its own
|
|
110
|
+
* collation rather than one fixed table-level collation. Recomputed on every
|
|
111
|
+
* {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
|
|
112
|
+
*/
|
|
113
|
+
pkKeyCollations;
|
|
71
114
|
ddlSaved = false;
|
|
72
115
|
// Statistics tracking
|
|
73
116
|
cachedStats = null;
|
|
@@ -79,6 +122,11 @@ export class StoreTable extends VirtualTable {
|
|
|
79
122
|
// new constraint object after CREATE/DROP INDEX produces a fresh compile;
|
|
80
123
|
// the WeakMap lets the GC reclaim entries for retired constraints.
|
|
81
124
|
predicateCache = new WeakMap();
|
|
125
|
+
// Lazy cache of compiled partial-index predicates, keyed on the IndexSchema
|
|
126
|
+
// object identity (frozen; a CREATE/DROP INDEX or reopen produces a fresh
|
|
127
|
+
// object, so the WeakMap reclaims retired entries). Mirrors predicateCache but
|
|
128
|
+
// for secondary-index maintenance rather than UNIQUE enforcement.
|
|
129
|
+
indexPredicateCache = new WeakMap();
|
|
82
130
|
constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
|
|
83
131
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
132
|
super(db, storeModule, tableSchema.schemaName, tableSchema.name);
|
|
@@ -88,6 +136,7 @@ export class StoreTable extends VirtualTable {
|
|
|
88
136
|
this.eventEmitter = eventEmitter;
|
|
89
137
|
this.encodeOptions = { collation: config.collation || 'NOCASE' };
|
|
90
138
|
this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
139
|
+
this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
91
140
|
this.ddlSaved = isConnected;
|
|
92
141
|
}
|
|
93
142
|
/** Get the table configuration. */
|
|
@@ -102,6 +151,16 @@ export class StoreTable extends VirtualTable {
|
|
|
102
151
|
updateSchema(newSchema) {
|
|
103
152
|
this.tableSchema = newSchema;
|
|
104
153
|
this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
154
|
+
this.pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Mark the table's DDL as already persisted to the catalog, so the lazy
|
|
158
|
+
* first-store-access save in {@link initializeStore} is skipped. Called by
|
|
159
|
+
* `StoreModule.createIndex` / `dropIndex` after they eagerly write the catalog
|
|
160
|
+
* bundle, so a subsequent INSERT does not redundantly re-persist identical DDL.
|
|
161
|
+
*/
|
|
162
|
+
markDdlSaved() {
|
|
163
|
+
this.ddlSaved = true;
|
|
105
164
|
}
|
|
106
165
|
/** Close and forget a cached index-store handle, if any. */
|
|
107
166
|
async releaseIndexStore(indexName) {
|
|
@@ -171,16 +230,27 @@ export class StoreTable extends VirtualTable {
|
|
|
171
230
|
* Only the data store is rewritten — secondary indexes are rebuilt by the
|
|
172
231
|
* caller (the keys embed the PK suffix, so they must be rebuilt whenever
|
|
173
232
|
* the PK changes).
|
|
233
|
+
*
|
|
234
|
+
* The new key for each row is encoded under `newColumns`'s per-column PK
|
|
235
|
+
* collations, so this drives BOTH:
|
|
236
|
+
* - `ALTER PRIMARY KEY` — the PK *columns* change; `newColumns` defaults to the
|
|
237
|
+
* current column set (their collations are unchanged), and
|
|
238
|
+
* - `ALTER COLUMN … SET COLLATE` on a PK member — the PK columns stay the same
|
|
239
|
+
* but one column's collation changes; the caller passes the post-ALTER
|
|
240
|
+
* `updatedSchema.columns` so the new key bytes follow the new collation.
|
|
241
|
+
* The OLD key is taken verbatim from the stored entry (never re-encoded), so the
|
|
242
|
+
* old collation is implicit in the existing bytes and need not be supplied.
|
|
174
243
|
*/
|
|
175
|
-
async rekeyRows(newPkDef) {
|
|
244
|
+
async rekeyRows(newPkDef, newColumns = this.tableSchema.columns) {
|
|
176
245
|
const store = await this.ensureStore();
|
|
177
246
|
const bounds = buildFullScanBounds();
|
|
178
247
|
const pending = new Map();
|
|
179
248
|
const newPkDirections = newPkDef.map(pk => !!pk.desc);
|
|
249
|
+
const newPkCollations = resolvePkKeyCollations(newPkDef, newColumns, this.encodeOptions.collation ?? 'NOCASE');
|
|
180
250
|
for await (const entry of store.iterate(bounds)) {
|
|
181
251
|
const row = deserializeRow(entry.value);
|
|
182
252
|
const newPkValues = newPkDef.map(pk => row[pk.index]);
|
|
183
|
-
const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections);
|
|
253
|
+
const newKey = buildDataKey(newPkValues, this.encodeOptions, newPkDirections, newPkCollations);
|
|
184
254
|
const hex = bytesToHex(newKey);
|
|
185
255
|
if (pending.has(hex)) {
|
|
186
256
|
throw new QuereusError(`UNIQUE constraint failed: duplicate primary key on rekey of '${this.schemaName}.${this.tableName}'`, StatusCode.CONSTRAINT);
|
|
@@ -200,16 +270,29 @@ export class StoreTable extends VirtualTable {
|
|
|
200
270
|
* Migrate all stored rows from the old column layout to a new one.
|
|
201
271
|
* The remap array maps newColumnIndex -> oldColumnIndex | -1.
|
|
202
272
|
* -1 means the column is new (fill with defaultValue).
|
|
273
|
+
*
|
|
274
|
+
* `backfill`, when supplied (ADD COLUMN with a non-foldable DEFAULT such as
|
|
275
|
+
* `new.<col>`), derives the new column's value from each existing row instead of the
|
|
276
|
+
* single `defaultValue`, and rejects a NULL it produces for a NOT NULL column. The
|
|
277
|
+
* batch is only written once every row migrates, so a throwing evaluator / NOT NULL
|
|
278
|
+
* violation leaves the store untouched for the caller's rollback.
|
|
203
279
|
*/
|
|
204
|
-
async migrateRows(remap, defaultValue) {
|
|
280
|
+
async migrateRows(remap, defaultValue, backfill) {
|
|
205
281
|
const store = await this.ensureStore();
|
|
206
282
|
const bounds = buildFullScanBounds();
|
|
207
283
|
const batch = store.batch();
|
|
208
284
|
for await (const entry of store.iterate(bounds)) {
|
|
209
285
|
const oldRow = deserializeRow(entry.value);
|
|
286
|
+
let newColumnValue = defaultValue;
|
|
287
|
+
if (backfill) {
|
|
288
|
+
newColumnValue = await backfill.evaluator(oldRow);
|
|
289
|
+
if (backfill.notNull && newColumnValue === null) {
|
|
290
|
+
throw new QuereusError(`NOT NULL constraint failed: backfilling column '${this.schemaName}.${this.tableName}.${backfill.columnName}' produced NULL for an existing row`, StatusCode.CONSTRAINT);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
210
293
|
const newRow = new Array(remap.length);
|
|
211
294
|
for (let i = 0; i < remap.length; i++) {
|
|
212
|
-
newRow[i] = remap[i] === -1 ?
|
|
295
|
+
newRow[i] = remap[i] === -1 ? newColumnValue : oldRow[remap[i]];
|
|
213
296
|
}
|
|
214
297
|
batch.put(entry.key, serializeRow(newRow));
|
|
215
298
|
}
|
|
@@ -274,22 +357,47 @@ export class StoreTable extends VirtualTable {
|
|
|
274
357
|
return this.statsStore;
|
|
275
358
|
}
|
|
276
359
|
/**
|
|
277
|
-
*
|
|
360
|
+
* Resolve + cache the module's shared TransactionCoordinator and hook the
|
|
361
|
+
* stats lifecycle callbacks, WITHOUT creating or registering a connection.
|
|
362
|
+
* Synchronous (the coordinator is handle-addressed, never store-opening — see
|
|
363
|
+
* `StoreTableModule.getCoordinator`), so the backing host's resolution path can
|
|
364
|
+
* call it eagerly. Attaching matters for reads: `iterateEffective` /
|
|
365
|
+
* `readEffectiveRowByKey` consult `this.coordinator` for the pending merge, so a
|
|
366
|
+
* table whose only writer is the privileged backing host (which queues ops on the
|
|
367
|
+
* module coordinator, never through `update()`) must still hold the
|
|
368
|
+
* reference for its read paths to be reads-own-writes.
|
|
369
|
+
*
|
|
370
|
+
* The coordinator is module-wide, so its commit/rollback callback array holds
|
|
371
|
+
* one {stats apply/discard} pair per PARTICIPATING table; each table's
|
|
372
|
+
* `applyPendingStats` early-returns when its own `pendingStatsDelta` is 0, so a
|
|
373
|
+
* table that did no work contributes nothing. Registers only on first call per
|
|
374
|
+
* StoreTable instance (the `if (!this.coordinator)` guard); a fresh instance
|
|
375
|
+
* after drop+recreate re-registers against the shared coordinator. The OLD
|
|
376
|
+
* instance is not GC'd on its own — its callback closures capture `this` and
|
|
377
|
+
* stay pinned on the module-wide coordinator until {@link dispose} runs the
|
|
378
|
+
* captured disposer (called at the genuine eviction sites, see
|
|
379
|
+
* `StoreModule.tearDownTableStorage` / `renameTable`).
|
|
278
380
|
*/
|
|
279
|
-
|
|
381
|
+
attachCoordinator() {
|
|
280
382
|
if (!this.coordinator) {
|
|
281
|
-
|
|
282
|
-
this.
|
|
283
|
-
this.coordinator.registerCallbacks({
|
|
383
|
+
this.coordinator = this.storeModule.getCoordinator();
|
|
384
|
+
this.coordinatorDisposer = this.coordinator.registerCallbacks({
|
|
284
385
|
onCommit: () => this.applyPendingStats(),
|
|
285
386
|
onRollback: () => this.discardPendingStats(),
|
|
286
387
|
});
|
|
287
388
|
}
|
|
389
|
+
return this.coordinator;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Ensure the coordinator is available and connection is registered.
|
|
393
|
+
*/
|
|
394
|
+
async ensureCoordinator() {
|
|
395
|
+
const coordinator = this.attachCoordinator();
|
|
288
396
|
if (!this.connection) {
|
|
289
|
-
this.connection = new StoreConnection(this.tableName
|
|
397
|
+
this.connection = new StoreConnection(`${this.schemaName}.${this.tableName}`, coordinator);
|
|
290
398
|
await this.db.registerConnection(this.connection);
|
|
291
399
|
}
|
|
292
|
-
return
|
|
400
|
+
return coordinator;
|
|
293
401
|
}
|
|
294
402
|
/** Apply pending stats on commit. */
|
|
295
403
|
applyPendingStats() {
|
|
@@ -349,18 +457,22 @@ export class StoreTable extends VirtualTable {
|
|
|
349
457
|
}
|
|
350
458
|
return row.map((v, i) => validateAndParse(v, cols[i].logicalType, cols[i].name));
|
|
351
459
|
}
|
|
352
|
-
/**
|
|
460
|
+
/**
|
|
461
|
+
* Query the table with optional filters.
|
|
462
|
+
*
|
|
463
|
+
* All three access arms read the EFFECTIVE row state: the committed store
|
|
464
|
+
* merged with this table's coordinator's pending ops when a transaction is
|
|
465
|
+
* active (read-your-own-writes — see {@link iterateEffective} /
|
|
466
|
+
* {@link readLiveRowByPk}). Merged emission stays in encoded-PK-key order,
|
|
467
|
+
* preserving the module's `providesOrdering` / `monotonicOn` advertisements.
|
|
468
|
+
*/
|
|
353
469
|
async *query(filterInfo) {
|
|
354
470
|
const store = await this.ensureStore();
|
|
355
471
|
const pkAccess = this.analyzePKAccess(filterInfo);
|
|
356
472
|
if (pkAccess.type === 'point') {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const row = deserializeRow(value);
|
|
361
|
-
if (this.matchesFilters(row, filterInfo)) {
|
|
362
|
-
yield row;
|
|
363
|
-
}
|
|
473
|
+
const row = await this.readLiveRowByPk(pkAccess.values);
|
|
474
|
+
if (row && this.matchesFilters(row, filterInfo)) {
|
|
475
|
+
yield row;
|
|
364
476
|
}
|
|
365
477
|
return;
|
|
366
478
|
}
|
|
@@ -370,13 +482,63 @@ export class StoreTable extends VirtualTable {
|
|
|
370
482
|
}
|
|
371
483
|
// Full table scan
|
|
372
484
|
const bounds = buildFullScanBounds();
|
|
373
|
-
for await (const entry of
|
|
485
|
+
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
374
486
|
const row = deserializeRow(entry.value);
|
|
375
487
|
if (this.matchesFilters(row, filterInfo)) {
|
|
376
488
|
yield row;
|
|
377
489
|
}
|
|
378
490
|
}
|
|
379
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* Iterate the effective entry state of this table's data store within
|
|
494
|
+
* `bounds`: the committed `store.iterate` stream merged with the
|
|
495
|
+
* coordinator's pending ops for the data store (read-your-own-writes).
|
|
496
|
+
*
|
|
497
|
+
* Both inputs are sorted by encoded key bytes, so this is an
|
|
498
|
+
* order-preserving two-way merge: a pending put wins over a committed entry
|
|
499
|
+
* at the same key, a pending delete suppresses its committed entry, and
|
|
500
|
+
* pending puts outside `bounds` are excluded. With no active transaction
|
|
501
|
+
* (or an empty pending bucket) it degrades to the bare committed iterate.
|
|
502
|
+
*
|
|
503
|
+
* The pending side is the module coordinator's bucket for THIS table's data
|
|
504
|
+
* store handle (`store`) — every data op is queued under that exact handle, so
|
|
505
|
+
* a sibling table's pending ops (a different data-store handle) never bleed in.
|
|
506
|
+
*/
|
|
507
|
+
async *iterateEffective(store, bounds, reverse = false) {
|
|
508
|
+
const pending = this.coordinator?.isInTransaction()
|
|
509
|
+
? this.coordinator.getOrderedPendingOps(store)
|
|
510
|
+
: null;
|
|
511
|
+
const iterOptions = reverse ? { ...bounds, reverse } : bounds;
|
|
512
|
+
if (!pending || (pending.puts.length === 0 && pending.deletes.size === 0)) {
|
|
513
|
+
yield* store.iterate(iterOptions);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const puts = pending.puts.filter(p => keyWithinBounds(p.key, bounds));
|
|
517
|
+
if (reverse)
|
|
518
|
+
puts.reverse();
|
|
519
|
+
// In iteration order, "before" means byte-less for forward and byte-greater
|
|
520
|
+
// for reverse; sign folds the direction into one comparison.
|
|
521
|
+
const sign = reverse ? -1 : 1;
|
|
522
|
+
let putIdx = 0;
|
|
523
|
+
for await (const entry of store.iterate(iterOptions)) {
|
|
524
|
+
// Emit pending puts that precede this committed entry in iteration order.
|
|
525
|
+
while (putIdx < puts.length && sign * compareBytes(puts[putIdx].key, entry.key) < 0) {
|
|
526
|
+
yield puts[putIdx++];
|
|
527
|
+
}
|
|
528
|
+
// Equal keys: the pending put shadows the committed entry.
|
|
529
|
+
if (putIdx < puts.length && compareBytes(puts[putIdx].key, entry.key) === 0) {
|
|
530
|
+
yield puts[putIdx++];
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (pending.deletes.has(bytesToHex(entry.key)))
|
|
534
|
+
continue;
|
|
535
|
+
yield entry;
|
|
536
|
+
}
|
|
537
|
+
// Pending puts beyond the last committed entry.
|
|
538
|
+
while (putIdx < puts.length) {
|
|
539
|
+
yield puts[putIdx++];
|
|
540
|
+
}
|
|
541
|
+
}
|
|
380
542
|
/** Analyze filter info to determine PK access pattern. */
|
|
381
543
|
analyzePKAccess(filterInfo) {
|
|
382
544
|
const schema = this.tableSchema;
|
|
@@ -418,11 +580,85 @@ export class StoreTable extends VirtualTable {
|
|
|
418
580
|
}
|
|
419
581
|
return { type: 'scan' };
|
|
420
582
|
}
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
583
|
+
/**
|
|
584
|
+
* Convert a leading-PK-column range access into one seek + early-terminate
|
|
585
|
+
* iterate window.
|
|
586
|
+
*
|
|
587
|
+
* Each LT/LE/GT/GE constraint's bound value is encoded under the SAME
|
|
588
|
+
* per-column DESC direction and key collation as the data keys (via
|
|
589
|
+
* {@link encodePkPrefixBounds}), giving the byte region `[lo, hi)` whose
|
|
590
|
+
* leading column equals that value (`lo = encode([x])`,
|
|
591
|
+
* `hi = incrementLastByte(lo)`). The op maps that region's endpoints onto a
|
|
592
|
+
* `gte`/`lt` window; because a DESC column bit-inverts its bytes (larger value
|
|
593
|
+
* ⇒ smaller bytes), the lower/upper assignment swaps with direction:
|
|
594
|
+
*
|
|
595
|
+
* | op | ASC | DESC |
|
|
596
|
+
* |----|----------|----------|
|
|
597
|
+
* | GE | gte = lo | lt = hi |
|
|
598
|
+
* | GT | gte = hi | lt = lo |
|
|
599
|
+
* | LE | lt = hi | gte = lo |
|
|
600
|
+
* | LT | lt = lo | gte = hi |
|
|
601
|
+
*
|
|
602
|
+
* Across constraints (BETWEEN ⇒ one lower + one upper; a redundant same-side
|
|
603
|
+
* pair ⇒ the tighter wins) we keep the MAX lower candidate for `gte` and the
|
|
604
|
+
* MIN upper candidate for `lt`. A candidate that resolves to `undefined` (an
|
|
605
|
+
* `hi` whose increment overflowed all-0xff) leaves that side unbounded — a safe
|
|
606
|
+
* SUPERSET, since {@link matchesFilters} stays the authoritative collation-aware
|
|
607
|
+
* row filter. A NULL/missing bound value is likewise skipped (the planner never
|
|
608
|
+
* pushes `= NULL`, and a range op against NULL rejects every row in matchesFilters).
|
|
609
|
+
*
|
|
610
|
+
* Falls back to a full scan when the leading text PK column declares a
|
|
611
|
+
* comparator-only collation with no registered byte encoder: `encodeText` would
|
|
612
|
+
* silently key it under NOCASE bytes that do not track the column's logical
|
|
613
|
+
* order, so a derived window could UNDER-fetch. A non-text leading column has
|
|
614
|
+
* `pkKeyCollations[0] === undefined` and encodes type-natively — always safe.
|
|
615
|
+
*/
|
|
616
|
+
buildPKRangeBounds(access) {
|
|
617
|
+
const full = buildFullScanBounds();
|
|
618
|
+
const constraints = access.constraints;
|
|
619
|
+
if (!constraints || constraints.length === 0)
|
|
620
|
+
return full;
|
|
621
|
+
const dir = this.pkDirections[0];
|
|
622
|
+
const coll = this.pkKeyCollations[0];
|
|
623
|
+
// A comparator-only collation (declared on the column, but with no registered
|
|
624
|
+
// byte encoder) is not seekable: its key bytes fall back to NOCASE and do not
|
|
625
|
+
// track the column's logical order. Stay full-scan; matchesFilters is authoritative.
|
|
626
|
+
if (coll !== undefined && getCollationEncoder(coll) === undefined)
|
|
627
|
+
return full;
|
|
628
|
+
let gte = full.gte;
|
|
629
|
+
let lt;
|
|
630
|
+
for (const c of constraints) {
|
|
631
|
+
if (c.value === undefined || c.value === null)
|
|
632
|
+
continue;
|
|
633
|
+
const { gte: lo, lt: hi } = this.encodePkPrefixBounds([c.value]);
|
|
634
|
+
const lower = !dir
|
|
635
|
+
? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
|
|
636
|
+
: (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
|
|
637
|
+
const upper = !dir
|
|
638
|
+
? (c.op === IndexConstraintOp.LE ? hi : c.op === IndexConstraintOp.LT ? lo : undefined)
|
|
639
|
+
: (c.op === IndexConstraintOp.GE ? hi : c.op === IndexConstraintOp.GT ? lo : undefined);
|
|
640
|
+
if (lower && compareBytes(lower, gte) > 0)
|
|
641
|
+
gte = lower;
|
|
642
|
+
if (upper && (lt === undefined || compareBytes(upper, lt) < 0))
|
|
643
|
+
lt = upper;
|
|
644
|
+
}
|
|
645
|
+
return lt === undefined ? { gte } : { gte, lt };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Scan a leading-PK-column range, seeking to the window start and
|
|
649
|
+
* early-terminating at its end.
|
|
650
|
+
*
|
|
651
|
+
* {@link buildPKRangeBounds} converts the LT/LE/GT/GE constraints into one
|
|
652
|
+
* encoded-byte `gte`/`lt` window under the same per-column DESC directions and
|
|
653
|
+
* key collations the data keys use, so the iterate visits a SUPERSET of the
|
|
654
|
+
* qualifying rows (a collation widening or the bound-byte increment can
|
|
655
|
+
* over-fetch, never under-fetch). {@link matchesFilters} stays the authoritative
|
|
656
|
+
* collation-aware row filter. {@link iterateEffective} restricts the pending
|
|
657
|
+
* merge to the same `bounds`, so read-your-own-writes holds on the narrowed window.
|
|
658
|
+
*/
|
|
659
|
+
async *scanPKRange(store, access, filterInfo) {
|
|
660
|
+
const bounds = this.buildPKRangeBounds(access);
|
|
661
|
+
for await (const entry of this.iterateEffective(store, bounds)) {
|
|
426
662
|
const row = deserializeRow(entry.value);
|
|
427
663
|
if (this.matchesFilters(row, filterInfo)) {
|
|
428
664
|
yield row;
|
|
@@ -441,26 +677,40 @@ export class StoreTable extends VirtualTable {
|
|
|
441
677
|
}
|
|
442
678
|
const rowValue = row[constraint.iColumn];
|
|
443
679
|
const filterValue = filterInfo.args[argvIndex - 1];
|
|
444
|
-
|
|
680
|
+
// Compare under the column's DECLARED collation (undefined ⇒ BINARY) — the
|
|
681
|
+
// same resolution the access path's collation-cover analysis uses
|
|
682
|
+
// (indexColumnCollationLookup / primaryKeyCollationLookup) when it decides
|
|
683
|
+
// a pushed constraint is fully covered, and the same source this file's
|
|
684
|
+
// UNIQUE checks compare under. On a collation MATCH the planner drops the
|
|
685
|
+
// residual Filter, so this filter alone must reproduce the predicate.
|
|
686
|
+
const collation = this.tableSchema.columns[constraint.iColumn]?.collation;
|
|
687
|
+
if (!this.compareValues(rowValue, constraint.op, filterValue, collation)) {
|
|
445
688
|
return false;
|
|
446
689
|
}
|
|
447
690
|
}
|
|
448
691
|
return true;
|
|
449
692
|
}
|
|
450
|
-
/**
|
|
451
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Compare two values according to an operator, under `collation` (the column's
|
|
695
|
+
* declared collation; undefined ⇒ BINARY). Delegates to the engine's
|
|
696
|
+
* `compareSqlValues`, so the LT/LE/GT/GE range bounds honour a NOCASE/RTRIM
|
|
697
|
+
* column collation rather than a raw BINARY JS comparison — the capability
|
|
698
|
+
* `StoreModule.getBestAccessPlan` advertises via `honorsCollatedRangeBounds`.
|
|
699
|
+
* NULL on either side fails every operator except EQ-with-both-NULL (the
|
|
700
|
+
* internal point-lookup convention; the planner never pushes `= NULL`).
|
|
701
|
+
*/
|
|
702
|
+
compareValues(a, op, b, collation) {
|
|
452
703
|
if (a === null || b === null) {
|
|
453
704
|
return op === IndexConstraintOp.EQ ? a === b : false;
|
|
454
705
|
}
|
|
706
|
+
const cmp = compareSqlValues(a, b, collation);
|
|
455
707
|
switch (op) {
|
|
456
|
-
case IndexConstraintOp.EQ:
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
case IndexConstraintOp.
|
|
460
|
-
case IndexConstraintOp.
|
|
461
|
-
case IndexConstraintOp.
|
|
462
|
-
case IndexConstraintOp.GT: return a > b;
|
|
463
|
-
case IndexConstraintOp.GE: return a >= b;
|
|
708
|
+
case IndexConstraintOp.EQ: return cmp === 0;
|
|
709
|
+
case IndexConstraintOp.NE: return cmp !== 0;
|
|
710
|
+
case IndexConstraintOp.LT: return cmp < 0;
|
|
711
|
+
case IndexConstraintOp.LE: return cmp <= 0;
|
|
712
|
+
case IndexConstraintOp.GT: return cmp > 0;
|
|
713
|
+
case IndexConstraintOp.GE: return cmp >= 0;
|
|
464
714
|
default: return true;
|
|
465
715
|
}
|
|
466
716
|
}
|
|
@@ -477,17 +727,47 @@ export class StoreTable extends VirtualTable {
|
|
|
477
727
|
throw new QuereusError('INSERT requires values', StatusCode.MISUSE);
|
|
478
728
|
const coerced = args.preCoerced ? values : this.coerceRow(values);
|
|
479
729
|
const pk = this.extractPK(coerced);
|
|
480
|
-
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
|
|
730
|
+
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
481
731
|
// Check for existing row (for conflict handling).
|
|
482
732
|
// Resolve PK-conflict action: statement OR > per-constraint default > ABORT.
|
|
483
733
|
const pkEffective = args.onConflict ?? resolvePkDefaultConflict(schema) ?? ConflictResolution.ABORT;
|
|
484
|
-
|
|
485
|
-
|
|
734
|
+
// Trusted-flush safety analysis — why this arm diverges from the others.
|
|
735
|
+
// The insert arm's probe stays committed-only on the trusted-flush path,
|
|
736
|
+
// while the update/delete arms below read the effective
|
|
737
|
+
// (pending-over-committed) image UNCONDITIONALLY. That divergence is
|
|
738
|
+
// safe: `flushOverlayToUnderlying` (isolation, isolated-table.ts) wraps
|
|
739
|
+
// the flush in its own coordinator mini-transaction, the overlay holds
|
|
740
|
+
// at most ONE entry per PK, and tombstone deletes are ordered before
|
|
741
|
+
// inserts/updates — so when any flush write probes its own key, no
|
|
742
|
+
// pending op exists at that key yet in the mini-transaction and the
|
|
743
|
+
// effective read equals the committed read on every trusted probe. The
|
|
744
|
+
// committed-only read kept here is therefore NOT a read-correctness
|
|
745
|
+
// requirement but a pinned INTERNAL invariant: the flush routes existing
|
|
746
|
+
// PKs to update, so a row present here is an isolation-layer violation we
|
|
747
|
+
// must surface loudly (store-backing-host-substrate analysis).
|
|
748
|
+
let existingRow;
|
|
749
|
+
if (args.trustedWrite) {
|
|
750
|
+
const committed = await store.get(key);
|
|
751
|
+
existingRow = committed ? deserializeRow(committed) : null;
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
existingRow = await this.readEffectiveRowByKey(key);
|
|
755
|
+
}
|
|
756
|
+
if (args.trustedWrite) {
|
|
757
|
+
// Trusted flush insert: the overlay flush routes existing PKs to
|
|
758
|
+
// update (via rowExistsInUnderlying), so a row already present here
|
|
759
|
+
// is an isolation-layer invariant violation. Fail loudly rather than
|
|
760
|
+
// silently overwrite — the flush try/catch rolls back and rethrows
|
|
761
|
+
// (isolation-merged-unique-stale-underlying-false-positive).
|
|
762
|
+
if (existingRow) {
|
|
763
|
+
throw new QuereusError(`Trusted flush insert on '${this.tableName}' hit an existing PK; the overlay flush should route existing PKs to update. This indicates an isolation-layer invariant violation.`, StatusCode.INTERNAL);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else if (existingRow) {
|
|
486
767
|
if (pkEffective === ConflictResolution.IGNORE) {
|
|
487
768
|
return { status: 'ok', row: undefined };
|
|
488
769
|
}
|
|
489
770
|
if (pkEffective !== ConflictResolution.REPLACE) {
|
|
490
|
-
const existingRow = deserializeRow(existing);
|
|
491
771
|
return {
|
|
492
772
|
status: 'constraint',
|
|
493
773
|
constraint: 'unique',
|
|
@@ -498,22 +778,30 @@ export class StoreTable extends VirtualTable {
|
|
|
498
778
|
}
|
|
499
779
|
// Enforce non-PK UNIQUE constraints. Pass the original statement-level
|
|
500
780
|
// onConflict so checkUniqueConstraints can resolve each UC's own
|
|
501
|
-
// defaultConflict independently of the PK's default.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
781
|
+
// defaultConflict independently of the PK's default. Secondary-UNIQUE
|
|
782
|
+
// REPLACE evictions accumulate in `evicted` for the executor pipeline.
|
|
783
|
+
// Skipped for trusted flush writes: the overlay already validated the
|
|
784
|
+
// final state and a value-swap cycle cannot pass a row-by-row re-check.
|
|
785
|
+
const evicted = [];
|
|
786
|
+
if (!args.trustedWrite) {
|
|
787
|
+
const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, [pk], args.onConflict, evicted);
|
|
788
|
+
if (ucResult)
|
|
789
|
+
return ucResult;
|
|
790
|
+
}
|
|
791
|
+
const oldRow = existingRow;
|
|
506
792
|
const serializedRow = serializeRow(coerced);
|
|
507
793
|
if (inTransaction) {
|
|
508
|
-
coordinator.put(key, serializedRow);
|
|
794
|
+
coordinator.put(key, serializedRow, store);
|
|
509
795
|
}
|
|
510
796
|
else {
|
|
511
797
|
await store.put(key, serializedRow);
|
|
512
798
|
}
|
|
513
|
-
// Update secondary indexes
|
|
799
|
+
// Update secondary indexes. An effective `oldRow` (a pending row at the
|
|
800
|
+
// same PK, evicted under REPLACE) cancels the earlier pending index-put;
|
|
801
|
+
// a commit-batch delete of a never-committed index key is a harmless no-op.
|
|
514
802
|
await this.updateSecondaryIndexes(inTransaction, oldRow, coerced, pk);
|
|
515
803
|
// Track statistics (only count as new if not replacing)
|
|
516
|
-
if (!
|
|
804
|
+
if (!existingRow) {
|
|
517
805
|
this.trackMutation(+1, inTransaction);
|
|
518
806
|
}
|
|
519
807
|
// Queue or emit event
|
|
@@ -549,7 +837,7 @@ export class StoreTable extends VirtualTable {
|
|
|
549
837
|
this.eventEmitter?.emitDataChange(insertEvent);
|
|
550
838
|
}
|
|
551
839
|
}
|
|
552
|
-
return { status: 'ok', row: coerced, replacedRow: oldRow ?? undefined };
|
|
840
|
+
return { status: 'ok', row: coerced, replacedRow: oldRow ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
|
|
553
841
|
}
|
|
554
842
|
case 'update': {
|
|
555
843
|
if (!values || !oldKeyValues)
|
|
@@ -557,23 +845,39 @@ export class StoreTable extends VirtualTable {
|
|
|
557
845
|
const coerced = args.preCoerced ? values : this.coerceRow(values);
|
|
558
846
|
const oldPk = this.extractPK(oldKeyValues);
|
|
559
847
|
const newPk = this.extractPK(coerced);
|
|
560
|
-
const oldKey = buildDataKey(oldPk, this.encodeOptions, this.pkDirections);
|
|
561
|
-
const newKey = buildDataKey(newPk, this.encodeOptions, this.pkDirections);
|
|
562
|
-
// Get old row for index updates
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
848
|
+
const oldKey = buildDataKey(oldPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
849
|
+
const newKey = buildDataKey(newPk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
850
|
+
// Get old row for index updates. Read the effective
|
|
851
|
+
// (pending-over-committed) image UNCONDITIONALLY — including the trusted
|
|
852
|
+
// flush path — so an old image written earlier in the same transaction is
|
|
853
|
+
// visible. This fixes index cleanup, the `uniqueColumnsChanged` gate, and
|
|
854
|
+
// the event's `oldRow`. Trusted is safe here (see the insert-arm comment):
|
|
855
|
+
// deletes-first ordering + one-entry-per-PK ⇒ effective ≡ committed on a
|
|
856
|
+
// flush write probing its own key.
|
|
857
|
+
const oldRow = await this.readEffectiveRowByKey(oldKey);
|
|
858
|
+
// A PK "change" only relocates the row when the ENCODED key differs.
|
|
859
|
+
// Under a non-binary PK collation (e.g. NOCASE) a case-only rewrite
|
|
860
|
+
// ('apple' → 'APPLE') keeps the same physical key, so it is an in-place
|
|
861
|
+
// update, not a relocation. Comparing raw values via keysEqual would
|
|
862
|
+
// mis-classify it as a move and then false-detect a PK conflict against
|
|
863
|
+
// the row's own existing entry at newKey (== oldKey). The encoded keys
|
|
864
|
+
// are the storage layer's source of truth (mirrors the rekey path above).
|
|
865
|
+
const pkChanged = !bytesEqual(oldKey, newKey);
|
|
566
866
|
// Resolve PK-conflict action: statement OR > per-constraint default > ABORT.
|
|
567
867
|
const pkEffective = args.onConflict ?? resolvePkDefaultConflict(schema) ?? ConflictResolution.ABORT;
|
|
568
868
|
// PK-change UPDATE collides like an INSERT at the new key.
|
|
569
869
|
// Capture the evicted row so it can be reported via `replacedRow`
|
|
570
870
|
// (consumed by the executor for ON DELETE cascade/SET NULL of the
|
|
571
|
-
// row at the new PK). Read
|
|
572
|
-
// written earlier in the same transaction
|
|
871
|
+
// row at the new PK). Read the effective (pending-over-committed) image
|
|
872
|
+
// so an evictee written earlier in the same transaction conflicts/evicts
|
|
873
|
+
// rather than being silently overwritten.
|
|
874
|
+
// Skipped for trusted flush writes — the overlay flush never changes a
|
|
875
|
+
// row's PK (oldKeyValues and the row's PK columns are the same overlay
|
|
876
|
+
// entry), so pkChanged is false there; the guard makes the intent explicit.
|
|
573
877
|
let replacedAtNewPk = null;
|
|
574
|
-
if (pkChanged) {
|
|
575
|
-
const
|
|
576
|
-
if (
|
|
878
|
+
if (pkChanged && !args.trustedWrite) {
|
|
879
|
+
const existingAtNewRow = await this.readEffectiveRowByKey(newKey);
|
|
880
|
+
if (existingAtNewRow) {
|
|
577
881
|
if (pkEffective === ConflictResolution.IGNORE) {
|
|
578
882
|
return { status: 'ok', row: undefined };
|
|
579
883
|
}
|
|
@@ -582,10 +886,10 @@ export class StoreTable extends VirtualTable {
|
|
|
582
886
|
status: 'constraint',
|
|
583
887
|
constraint: 'unique',
|
|
584
888
|
message: `UNIQUE constraint failed: ${this.tableName} PK.`,
|
|
585
|
-
existingRow:
|
|
889
|
+
existingRow: existingAtNewRow,
|
|
586
890
|
};
|
|
587
891
|
}
|
|
588
|
-
replacedAtNewPk =
|
|
892
|
+
replacedAtNewPk = existingAtNewRow;
|
|
589
893
|
}
|
|
590
894
|
}
|
|
591
895
|
// Enforce non-PK UNIQUE constraints. For same-PK UPDATE, only check
|
|
@@ -595,10 +899,16 @@ export class StoreTable extends VirtualTable {
|
|
|
595
899
|
// row we're moving. Pass the original statement-level onConflict so
|
|
596
900
|
// each UC's own defaultConflict can be resolved independently.
|
|
597
901
|
const selfPks = pkChanged ? [oldPk, newPk] : [oldPk];
|
|
598
|
-
|
|
599
|
-
|
|
902
|
+
// Skip the UNIQUE re-check for trusted flush writes: the overlay
|
|
903
|
+
// merged-view check already validated the final state, and a value-swap
|
|
904
|
+
// cycle cannot pass a row-by-row logical-UNIQUE re-check
|
|
905
|
+
// (isolation-merged-unique-stale-underlying-false-positive).
|
|
906
|
+
const shouldCheckUniques = !args.trustedWrite
|
|
907
|
+
&& (pkChanged || (oldRow ? this.uniqueColumnsChanged(oldRow, coerced) : true));
|
|
908
|
+
// Secondary-UNIQUE REPLACE evictions accumulate for the executor pipeline.
|
|
909
|
+
const evicted = [];
|
|
600
910
|
if (shouldCheckUniques) {
|
|
601
|
-
const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, selfPks, args.onConflict);
|
|
911
|
+
const ucResult = await this.checkUniqueConstraints(inTransaction, coerced, selfPks, args.onConflict, evicted);
|
|
602
912
|
if (ucResult)
|
|
603
913
|
return ucResult;
|
|
604
914
|
}
|
|
@@ -613,7 +923,7 @@ export class StoreTable extends VirtualTable {
|
|
|
613
923
|
// Delete old key if PK changed
|
|
614
924
|
if (pkChanged) {
|
|
615
925
|
if (inTransaction) {
|
|
616
|
-
coordinator.delete(oldKey);
|
|
926
|
+
coordinator.delete(oldKey, store);
|
|
617
927
|
}
|
|
618
928
|
else {
|
|
619
929
|
await store.delete(oldKey);
|
|
@@ -621,7 +931,7 @@ export class StoreTable extends VirtualTable {
|
|
|
621
931
|
}
|
|
622
932
|
const serializedRow = serializeRow(coerced);
|
|
623
933
|
if (inTransaction) {
|
|
624
|
-
coordinator.put(newKey, serializedRow);
|
|
934
|
+
coordinator.put(newKey, serializedRow, store);
|
|
625
935
|
}
|
|
626
936
|
else {
|
|
627
937
|
await store.put(newKey, serializedRow);
|
|
@@ -645,18 +955,24 @@ export class StoreTable extends VirtualTable {
|
|
|
645
955
|
else {
|
|
646
956
|
this.eventEmitter?.emitDataChange(updateEvent);
|
|
647
957
|
}
|
|
648
|
-
return { status: 'ok', row: coerced, replacedRow: replacedAtNewPk ?? undefined };
|
|
958
|
+
return { status: 'ok', row: coerced, replacedRow: replacedAtNewPk ?? undefined, evictedRows: evicted.length > 0 ? evicted : undefined };
|
|
649
959
|
}
|
|
650
960
|
case 'delete': {
|
|
651
961
|
if (!oldKeyValues)
|
|
652
962
|
throw new QuereusError('DELETE requires oldKeyValues', StatusCode.MISUSE);
|
|
653
963
|
const pk = this.extractPK(oldKeyValues);
|
|
654
|
-
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
|
|
655
|
-
// Get old row for index cleanup
|
|
656
|
-
|
|
657
|
-
|
|
964
|
+
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
965
|
+
// Get old row for index cleanup. Read the effective
|
|
966
|
+
// (pending-over-committed) image so a row inserted earlier in the same
|
|
967
|
+
// transaction is seen: this fixes index cleanup, the `-1` stats delta
|
|
968
|
+
// (netting an insert+delete to zero), and the event's `oldRow`.
|
|
969
|
+
// `coordinator.delete(key)` cancels a pending put; a commit-batch delete
|
|
970
|
+
// of a never-committed key is a harmless no-op. The trusted flush delete
|
|
971
|
+
// arm does NOT pass `trustedWrite`, but deletes-first ordering +
|
|
972
|
+
// one-entry-per-PK keep effective ≡ committed there too (see insert arm).
|
|
973
|
+
const oldRow = await this.readEffectiveRowByKey(key);
|
|
658
974
|
if (inTransaction) {
|
|
659
|
-
coordinator.delete(key);
|
|
975
|
+
coordinator.delete(key, store);
|
|
660
976
|
}
|
|
661
977
|
else {
|
|
662
978
|
await store.delete(key);
|
|
@@ -700,10 +1016,17 @@ export class StoreTable extends VirtualTable {
|
|
|
700
1016
|
const indexStore = await this.ensureIndexStore(index.name);
|
|
701
1017
|
const indexCols = index.columns.map(c => c.index);
|
|
702
1018
|
const indexDirections = index.columns.map(c => !!c.desc);
|
|
703
|
-
//
|
|
704
|
-
|
|
1019
|
+
// Partial index: only rows the predicate unambiguously accepts are
|
|
1020
|
+
// indexed (mirrors buildIndexEntries' build-time filtering). Guarding both
|
|
1021
|
+
// halves keeps a row that transitions across the predicate scope on UPDATE
|
|
1022
|
+
// correct — an in-scope→out-of-scope edit removes the old entry and adds
|
|
1023
|
+
// none; the reverse adds without a stale delete. A full index (no
|
|
1024
|
+
// predicate) always maintains its entry.
|
|
1025
|
+
const predicate = this.compileIndexFor(index);
|
|
1026
|
+
// Remove old index entry (only if the old row was within scope).
|
|
1027
|
+
if (oldRow && (!predicate || predicate.evaluate(oldRow) === true)) {
|
|
705
1028
|
const oldIndexValues = indexCols.map(i => oldRow[i]);
|
|
706
|
-
const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections);
|
|
1029
|
+
const oldIndexKey = buildIndexKey(oldIndexValues, oldPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
|
|
707
1030
|
if (inTransaction && this.coordinator) {
|
|
708
1031
|
this.coordinator.delete(oldIndexKey, indexStore);
|
|
709
1032
|
}
|
|
@@ -711,10 +1034,10 @@ export class StoreTable extends VirtualTable {
|
|
|
711
1034
|
await indexStore.delete(oldIndexKey);
|
|
712
1035
|
}
|
|
713
1036
|
}
|
|
714
|
-
// Add new index entry
|
|
715
|
-
if (newRow) {
|
|
1037
|
+
// Add new index entry (only if the new row is within scope).
|
|
1038
|
+
if (newRow && (!predicate || predicate.evaluate(newRow) === true)) {
|
|
716
1039
|
const newIndexValues = indexCols.map(i => newRow[i]);
|
|
717
|
-
const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections);
|
|
1040
|
+
const newIndexKey = buildIndexKey(newIndexValues, newPk, this.encodeOptions, indexDirections, this.pkDirections, this.pkKeyCollations);
|
|
718
1041
|
// Index value is empty - we just need the key for lookups
|
|
719
1042
|
const emptyValue = new Uint8Array(0);
|
|
720
1043
|
if (inTransaction && this.coordinator) {
|
|
@@ -741,6 +1064,21 @@ export class StoreTable extends VirtualTable {
|
|
|
741
1064
|
}
|
|
742
1065
|
return compiled;
|
|
743
1066
|
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Returns the compiled predicate for a partial secondary index, or undefined
|
|
1069
|
+
* for a full index. Compilation is memoized per IndexSchema instance so the hot
|
|
1070
|
+
* DML index-maintenance path doesn't recompile.
|
|
1071
|
+
*/
|
|
1072
|
+
compileIndexFor(index) {
|
|
1073
|
+
if (!index.predicate)
|
|
1074
|
+
return undefined;
|
|
1075
|
+
let compiled = this.indexPredicateCache.get(index);
|
|
1076
|
+
if (!compiled) {
|
|
1077
|
+
compiled = compilePredicate(index.predicate, this.tableSchema.columns);
|
|
1078
|
+
this.indexPredicateCache.set(index, compiled);
|
|
1079
|
+
}
|
|
1080
|
+
return compiled;
|
|
1081
|
+
}
|
|
744
1082
|
/** Check if two PK arrays are equal. */
|
|
745
1083
|
keysEqual(a, b) {
|
|
746
1084
|
if (a.length !== b.length)
|
|
@@ -790,8 +1128,12 @@ export class StoreTable extends VirtualTable {
|
|
|
790
1128
|
*
|
|
791
1129
|
* Reads through the transaction coordinator's pending writes when active so
|
|
792
1130
|
* intra-transaction duplicates are detected.
|
|
1131
|
+
*
|
|
1132
|
+
* REPLACE evictions (rows at OTHER PKs) are deleted from storage and pushed onto
|
|
1133
|
+
* `evicted` so the DML executor runs the full delete pipeline for each
|
|
1134
|
+
* (change-tracking, row-time MV maintenance, FK cascade, auto-events).
|
|
793
1135
|
*/
|
|
794
|
-
async checkUniqueConstraints(inTransaction, newRow, selfPks, onConflict) {
|
|
1136
|
+
async checkUniqueConstraints(inTransaction, newRow, selfPks, onConflict, evicted) {
|
|
795
1137
|
const schema = this.tableSchema;
|
|
796
1138
|
const uniqueConstraints = schema.uniqueConstraints;
|
|
797
1139
|
if (!uniqueConstraints || uniqueConstraints.length === 0)
|
|
@@ -804,7 +1146,15 @@ export class StoreTable extends VirtualTable {
|
|
|
804
1146
|
const predicate = this.compileFor(uc);
|
|
805
1147
|
if (predicate && predicate.evaluate(newRow) !== true)
|
|
806
1148
|
continue;
|
|
807
|
-
|
|
1149
|
+
// Prefer a linked row-time covering MV: its backing table (hosted by any
|
|
1150
|
+
// backing-host-capable module — memory by default, this store module under
|
|
1151
|
+
// `using store` — queried through the db with reads-own-writes) answers
|
|
1152
|
+
// the uniqueness question, mirroring the memory enforcement path. Falls
|
|
1153
|
+
// back to the per-scan source search when no row-time covering MV exists.
|
|
1154
|
+
const coveringMv = this.db._findRowTimeCoveringStructure(schema.schemaName, schema.name, uc);
|
|
1155
|
+
const conflict = coveringMv
|
|
1156
|
+
? await this.findUniqueConflictViaCoveringMv(coveringMv, uc, predicate, newRow, selfPks)
|
|
1157
|
+
: await this.findUniqueConflict(uc, predicate, newRow, selfPks);
|
|
808
1158
|
if (!conflict)
|
|
809
1159
|
continue;
|
|
810
1160
|
// Resolve action per-constraint: statement OR > per-UC default > ABORT.
|
|
@@ -814,6 +1164,13 @@ export class StoreTable extends VirtualTable {
|
|
|
814
1164
|
}
|
|
815
1165
|
if (effective === ConflictResolution.REPLACE) {
|
|
816
1166
|
await this.deleteRowAt(inTransaction, conflict.pk, conflict.row);
|
|
1167
|
+
// Report the eviction so the executor runs its full delete pipeline —
|
|
1168
|
+
// including the row-time covering-structure maintenance that drops the
|
|
1169
|
+
// evicted source row's backing entry within this statement (else a later
|
|
1170
|
+
// same-UC row sees a phantom). The executor processes the eviction before
|
|
1171
|
+
// the writing row's own bookkeeping, so the backing delete still lands
|
|
1172
|
+
// mid-statement.
|
|
1173
|
+
evicted.push(conflict.row);
|
|
817
1174
|
continue;
|
|
818
1175
|
}
|
|
819
1176
|
const colNames = uc.columns.map(i => schema.columns[i].name).join(', ');
|
|
@@ -834,18 +1191,25 @@ export class StoreTable extends VirtualTable {
|
|
|
834
1191
|
*/
|
|
835
1192
|
async findUniqueConflict(uc, predicate, newRow, selfPks) {
|
|
836
1193
|
const store = await this.ensureStore();
|
|
1194
|
+
// Pending ops for THIS table's data store handle — the same handle the
|
|
1195
|
+
// write path queues data ops under, so the merge sees only this table's
|
|
1196
|
+
// pending state, never a sibling table's on the shared module coordinator.
|
|
837
1197
|
const pending = this.coordinator?.isInTransaction()
|
|
838
1198
|
? this.coordinator.getPendingOpsForStore(store)
|
|
839
1199
|
: null;
|
|
840
1200
|
const constrainedCols = uc.columns;
|
|
1201
|
+
// One comparison collation per constrained column — the index's per-column
|
|
1202
|
+
// COLLATE for an index-derived UNIQUE, else the declared column collation.
|
|
1203
|
+
const collations = uniqueEnforcementCollations(this.tableSchema, uc);
|
|
841
1204
|
const matches = (candidate) => {
|
|
842
1205
|
const pk = this.extractPK(candidate);
|
|
843
1206
|
for (const skip of selfPks) {
|
|
844
1207
|
if (this.keysEqual(pk, skip))
|
|
845
1208
|
return null;
|
|
846
1209
|
}
|
|
847
|
-
for (
|
|
848
|
-
|
|
1210
|
+
for (let i = 0; i < constrainedCols.length; i++) {
|
|
1211
|
+
const idx = constrainedCols[i];
|
|
1212
|
+
if (compareSqlValues(newRow[idx], candidate[idx], collations[i]) !== 0)
|
|
849
1213
|
return null;
|
|
850
1214
|
}
|
|
851
1215
|
// Partial UNIQUE: candidate must also be in the predicate's scope to conflict.
|
|
@@ -877,6 +1241,269 @@ export class StoreTable extends VirtualTable {
|
|
|
877
1241
|
}
|
|
878
1242
|
return null;
|
|
879
1243
|
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Find a UNIQUE conflict through a linked row-time covering MV's backing table
|
|
1246
|
+
* (the store analogue of the memory `checkUniqueViaMaterializedView`). The
|
|
1247
|
+
* backing scan yields candidate conflicting **source** PKs (reads-own-writes via
|
|
1248
|
+
* the backing's coordinated connection); each is validated against the *live*
|
|
1249
|
+
* store row (committed + this transaction's pending overlay) so a backing entry
|
|
1250
|
+
* that lags a row deleted/updated internally this statement is skipped rather
|
|
1251
|
+
* than raised as a false conflict. Returns the first real conflict or null.
|
|
1252
|
+
*/
|
|
1253
|
+
async findUniqueConflictViaCoveringMv(mv, uc, predicate, newRow, selfPks) {
|
|
1254
|
+
const newSourcePk = this.extractPK(newRow);
|
|
1255
|
+
const collations = uniqueEnforcementCollations(this.tableSchema, uc);
|
|
1256
|
+
const candidates = await this.db._lookupCoveringConflicts(mv, uc, newRow, newSourcePk);
|
|
1257
|
+
for (const cand of candidates) {
|
|
1258
|
+
const liveRow = await this.readLiveRowByPk(cand.pk);
|
|
1259
|
+
if (!liveRow)
|
|
1260
|
+
continue; // stale backing candidate (source row gone)
|
|
1261
|
+
if (selfPks.some(pk => this.keysEqual(pk, cand.pk)))
|
|
1262
|
+
continue;
|
|
1263
|
+
// Re-validate under each column's enforcement collation (the index's
|
|
1264
|
+
// per-column COLLATE for an index-derived UNIQUE, else declared) — see
|
|
1265
|
+
// uniqueEnforcementCollations. The candidate generation
|
|
1266
|
+
// (_lookupCoveringConflicts) narrows under the SOURCE column's declared
|
|
1267
|
+
// collation, so for a FINER index (BINARY over a NOCASE column) it returns a
|
|
1268
|
+
// superset this filters down correctly. A finer/incomparable index-derived
|
|
1269
|
+
// UNIQUE whose declared candidate set could be a SUBSET (e.g. a coarser NOCASE
|
|
1270
|
+
// index over a BINARY column) is declined upstream by the collation gate in
|
|
1271
|
+
// findRowTimeCoveringStructure, so only BINARY-floor or equal-collation MVs
|
|
1272
|
+
// reach here — the superset this re-validation can soundly filter.
|
|
1273
|
+
if (uc.columns.some((c, i) => compareSqlValues(newRow[c], liveRow[c], collations[i]) !== 0))
|
|
1274
|
+
continue;
|
|
1275
|
+
if (predicate && predicate.evaluate(liveRow) !== true)
|
|
1276
|
+
continue;
|
|
1277
|
+
return { pk: cand.pk, row: liveRow };
|
|
1278
|
+
}
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Read the live row at `pk` — this transaction's pending overlay (a pending
|
|
1283
|
+
* delete ⇒ gone; a pending put ⇒ its value) shadowing the committed store.
|
|
1284
|
+
* Backs `query()`'s point-lookup arm and validates covering-MV conflict
|
|
1285
|
+
* candidates against the source of truth.
|
|
1286
|
+
*/
|
|
1287
|
+
async readLiveRowByPk(pk) {
|
|
1288
|
+
return this.readEffectiveRowByKey(this.encodeDataKey(pk));
|
|
1289
|
+
}
|
|
1290
|
+
// ── Backing-host surface ──────────────────────────────────────────────
|
|
1291
|
+
// Narrow public surface `StoreBackingHost` (backing-host.ts) drives. Each
|
|
1292
|
+
// method addresses the table's CURRENT schema/encoding state, so a host
|
|
1293
|
+
// resolved fresh per engine call always keys and merges consistently with
|
|
1294
|
+
// the table's own read/write paths.
|
|
1295
|
+
/** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
|
|
1296
|
+
encodeDataKey(pkValues) {
|
|
1297
|
+
return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Byte bounds covering every data key whose leading PK columns equal
|
|
1301
|
+
* `prefixValues` — encoded under the same per-column DESC directions and key
|
|
1302
|
+
* collations as {@link encodeDataKey}, so seek + early-terminate addresses the
|
|
1303
|
+
* exact slice. An empty prefix yields full-scan bounds.
|
|
1304
|
+
*/
|
|
1305
|
+
encodePkPrefixBounds(prefixValues) {
|
|
1306
|
+
return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length));
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Effective (pending-over-committed) point read by encoded data key: a pending
|
|
1310
|
+
* delete ⇒ null, a pending put ⇒ its value, else the committed store entry.
|
|
1311
|
+
*/
|
|
1312
|
+
async readEffectiveRowByKey(key) {
|
|
1313
|
+
const store = await this.ensureStore();
|
|
1314
|
+
// Pending ops for THIS table's data store handle — see findUniqueConflict.
|
|
1315
|
+
const pending = this.coordinator?.isInTransaction()
|
|
1316
|
+
? this.coordinator.getPendingOpsForStore(store)
|
|
1317
|
+
: null;
|
|
1318
|
+
if (pending) {
|
|
1319
|
+
const hex = bytesToHex(key);
|
|
1320
|
+
if (pending.deletes.has(hex))
|
|
1321
|
+
return null;
|
|
1322
|
+
const overlay = pending.puts.get(hex);
|
|
1323
|
+
if (overlay)
|
|
1324
|
+
return deserializeRow(overlay.value);
|
|
1325
|
+
}
|
|
1326
|
+
const value = await store.get(key);
|
|
1327
|
+
return value ? deserializeRow(value) : null;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Effective ordered entry scan within `bounds` (see {@link iterateEffective}).
|
|
1331
|
+
* Opens the data store first — which fires the lazy first-access `saveTableDDL`
|
|
1332
|
+
* for a freshly created table, the catalog write a store-backed MV backing
|
|
1333
|
+
* relies on to survive reopen.
|
|
1334
|
+
*/
|
|
1335
|
+
async *iterateEffectiveEntries(bounds, reverse = false) {
|
|
1336
|
+
const store = await this.ensureStore();
|
|
1337
|
+
yield* this.iterateEffective(store, bounds, reverse);
|
|
1338
|
+
}
|
|
1339
|
+
/** Buffer a privileged row-count delta, applied at coordinator commit (host writes). */
|
|
1340
|
+
trackPrivilegedMutation(delta) {
|
|
1341
|
+
this.trackMutation(delta, true);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Declared secondary-UNIQUE enforcement for maintenance writes — the store
|
|
1345
|
+
* mirror of the memory manager's `enforceSecondaryUniqueOnMaintenance` (see
|
|
1346
|
+
* `vtab/backing-host.ts` § Constraint validation for the contract and
|
|
1347
|
+
* docs/materialized-views.md § Derived-row constraint validation for the
|
|
1348
|
+
* semantics). Called by `StoreBackingHost.applyMaintenance` AFTER the op
|
|
1349
|
+
* batch lands in the coordinator's pending state: post-batch is load-bearing
|
|
1350
|
+
* (a `replace-all` diff applies puts before deletes, so a per-op check would
|
|
1351
|
+
* false-positive when the derived set moves a unique value between primary
|
|
1352
|
+
* keys), and checking only the WRITTEN images is complete (pre-existing
|
|
1353
|
+
* contents already satisfied the constraint).
|
|
1354
|
+
*
|
|
1355
|
+
* Reuses {@link findUniqueConflict} — pending-overlay reads, per-column
|
|
1356
|
+
* collations, NULL-pass, partial-predicate scope, self-PK exclusion — with
|
|
1357
|
+
* the covering-MV route deliberately bypassed: a covering MV over THIS table
|
|
1358
|
+
* is cascade-maintained only after the batch returns, so it lags the batch
|
|
1359
|
+
* and would miss a same-batch colliding pair. The conflict action is a hard
|
|
1360
|
+
* abort (a derivation write carries no user OR clause, and a declared
|
|
1361
|
+
* `on conflict replace`/`ignore` default must not evict or drop derived
|
|
1362
|
+
* rows). Per-image cost is one effective scan — the same full-scan posture
|
|
1363
|
+
* the store's DML UNIQUE enforcement already takes.
|
|
1364
|
+
*
|
|
1365
|
+
* Zero overhead when the table declares no secondary UNIQUE (every MV-sugar
|
|
1366
|
+
* backing, and most maintained tables): one empty-array check.
|
|
1367
|
+
*/
|
|
1368
|
+
async enforceSecondaryUniqueForMaintenance(changes) {
|
|
1369
|
+
const schema = this.tableSchema;
|
|
1370
|
+
const ucs = schema?.uniqueConstraints;
|
|
1371
|
+
if (!schema || !ucs || ucs.length === 0 || changes.length === 0)
|
|
1372
|
+
return;
|
|
1373
|
+
for (const change of changes) {
|
|
1374
|
+
if (change.op === 'delete')
|
|
1375
|
+
continue;
|
|
1376
|
+
const newRow = change.newRow;
|
|
1377
|
+
const selfPks = [this.extractPK(newRow)];
|
|
1378
|
+
for (const uc of ucs) {
|
|
1379
|
+
// SQL semantics: UNIQUE allows multiple NULLs.
|
|
1380
|
+
if (uc.columns.some(idx => newRow[idx] === null))
|
|
1381
|
+
continue;
|
|
1382
|
+
// Partial UNIQUE: an out-of-scope image contributes nothing.
|
|
1383
|
+
const predicate = this.compileFor(uc);
|
|
1384
|
+
if (predicate && predicate.evaluate(newRow) !== true)
|
|
1385
|
+
continue;
|
|
1386
|
+
const conflict = await this.findUniqueConflict(uc, predicate, newRow, selfPks);
|
|
1387
|
+
if (conflict) {
|
|
1388
|
+
const colNames = uc.columns.map(i => schema.columns[i]?.name ?? String(i));
|
|
1389
|
+
throw maintainedTableUniqueViolationError(schema.schemaName, schema.name, uc.name ?? `_uc_${colNames.join('_')}`, colNames, uc.columns.map(i => newRow[i]));
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Reset statistics to an absolute committed row count and flush immediately.
|
|
1396
|
+
* Used by the host's `replaceContents` (create-fill / refresh), where the new
|
|
1397
|
+
* count is exact, replacing any drifted delta-tracked estimate.
|
|
1398
|
+
*/
|
|
1399
|
+
async resetStats(rowCount) {
|
|
1400
|
+
this.cachedStats = { rowCount, updatedAt: Date.now() };
|
|
1401
|
+
this.pendingStatsDelta = 0;
|
|
1402
|
+
this.mutationCount = 0;
|
|
1403
|
+
await this.flushStats();
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Open (and cache) the data store, firing the lazy first-access `saveTableDDL`.
|
|
1407
|
+
* Public for the host's `replaceContents`, which writes the store directly.
|
|
1408
|
+
*/
|
|
1409
|
+
openDataStore() {
|
|
1410
|
+
return this.ensureStore();
|
|
1411
|
+
}
|
|
1412
|
+
// ── External row-write surface ────────────────────────────────────────
|
|
1413
|
+
// Module-side entry point for externally-applied writes to a SOURCE table
|
|
1414
|
+
// (the index-maintaining sibling of `StoreBackingHost`, which is for MV
|
|
1415
|
+
// BACKING tables and deliberately keeps no indexes). Resolved per call via
|
|
1416
|
+
// `StoreModule.getTableForExternalWrite`; addresses the table's CURRENT
|
|
1417
|
+
// schema/encoding state so keys and index entries match its own DML paths.
|
|
1418
|
+
/**
|
|
1419
|
+
* Effective (pending-over-committed) point read by PK values — the public
|
|
1420
|
+
* read an external writer issues before an upsert to learn the row's current
|
|
1421
|
+
* image. Thin wrapper over the same private point-lookup that backs
|
|
1422
|
+
* {@link query}'s point arm, so an external read merges pending-over-committed
|
|
1423
|
+
* exactly like an engine read does.
|
|
1424
|
+
*/
|
|
1425
|
+
readRowByPk(pk) {
|
|
1426
|
+
return this.readLiveRowByPk(pk);
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Apply externally-originated row ops directly to this source table's
|
|
1430
|
+
* COMMITTED storage: table-owned data-key put/delete, secondary-index
|
|
1431
|
+
* maintenance, and stats tracking. The index-maintaining counterpart of
|
|
1432
|
+
* `StoreBackingHost.applyMaintenance` (which targets index-less MV backings),
|
|
1433
|
+
* built for trusted replication-style writes.
|
|
1434
|
+
*
|
|
1435
|
+
* Deliberately:
|
|
1436
|
+
* - emits NO module {@link DataChangeEvent}s — the external writer owns
|
|
1437
|
+
* emission and the `remote` flag;
|
|
1438
|
+
* - opens NO coordinator transaction — writes land in committed state
|
|
1439
|
+
* immediately (`store.put`/`store.delete`, never the coordinator);
|
|
1440
|
+
* - runs NO constraint validation (PK/UNIQUE/CHECK/FK) — the origin is
|
|
1441
|
+
* trusted, mirroring the backing-host posture.
|
|
1442
|
+
*
|
|
1443
|
+
* Returns the EFFECTIVE per-op {@link BackingRowChange}s with accurate
|
|
1444
|
+
* before-images (the shape `Database.ingestExternalRowChanges` consumes),
|
|
1445
|
+
* suppressing no-ops to match the normative upsert-suppression contract in
|
|
1446
|
+
* `vtab/backing-host.ts`: a delete of an absent key, and a value-identical
|
|
1447
|
+
* upsert (`rowsValueIdentical` — byte-faithful, collation-UNAWARE, against the
|
|
1448
|
+
* effective existing row) write nothing and report nothing. A collation-equal /
|
|
1449
|
+
* byte-different upsert (e.g. a case-only rewrite under a NOCASE PK) keeps the
|
|
1450
|
+
* SAME data key (key identity is collation-aware) but IS a real update that
|
|
1451
|
+
* replaces the stored bytes and reports `update`.
|
|
1452
|
+
*
|
|
1453
|
+
* Last-writer-wins against any concurrently pending local transaction on this
|
|
1454
|
+
* table: the external write commits to storage at once, and that transaction's
|
|
1455
|
+
* pending batch may overwrite these keys when it commits. This is the same
|
|
1456
|
+
* posture the prior raw-KV sync adapter took — not a regression, now stated.
|
|
1457
|
+
*/
|
|
1458
|
+
async applyExternalRowChanges(ops) {
|
|
1459
|
+
const changes = [];
|
|
1460
|
+
if (ops.length === 0)
|
|
1461
|
+
return changes;
|
|
1462
|
+
// Route through the lazy store-open path so the first external write to a
|
|
1463
|
+
// freshly created table persists its DDL exactly like a first vtab write.
|
|
1464
|
+
const store = await this.ensureStore();
|
|
1465
|
+
for (const op of ops) {
|
|
1466
|
+
switch (op.op) {
|
|
1467
|
+
case 'delete': {
|
|
1468
|
+
const key = this.encodeDataKey(op.pk);
|
|
1469
|
+
const existing = await this.readEffectiveRowByKey(key);
|
|
1470
|
+
if (!existing)
|
|
1471
|
+
break; // absent key → no storage/index/stats op, nothing reported
|
|
1472
|
+
await store.delete(key);
|
|
1473
|
+
await this.updateSecondaryIndexes(false, existing, null, op.pk);
|
|
1474
|
+
this.trackMutation(-1, false);
|
|
1475
|
+
changes.push({ op: 'delete', oldRow: existing });
|
|
1476
|
+
break;
|
|
1477
|
+
}
|
|
1478
|
+
case 'upsert': {
|
|
1479
|
+
const pk = this.extractPK(op.row);
|
|
1480
|
+
const key = this.encodeDataKey(pk);
|
|
1481
|
+
const existing = await this.readEffectiveRowByKey(key);
|
|
1482
|
+
if (existing && rowsValueIdentical(existing, op.row)) {
|
|
1483
|
+
// Byte-identical to the effective row → a true no-op: no write, no
|
|
1484
|
+
// index touch, no stats delta, nothing reported (echo-prevention seam).
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
await store.put(key, serializeRow(op.row));
|
|
1488
|
+
// PK derives from the row, so the key never relocates: oldPk == newPk.
|
|
1489
|
+
await this.updateSecondaryIndexes(false, existing, op.row, pk);
|
|
1490
|
+
if (!existing)
|
|
1491
|
+
this.trackMutation(+1, false);
|
|
1492
|
+
changes.push(existing
|
|
1493
|
+
? { op: 'update', oldRow: existing, newRow: op.row }
|
|
1494
|
+
: { op: 'insert', newRow: op.row });
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
default: {
|
|
1498
|
+
// A new ExternalRowOp variant must extend this switch; never-assignment
|
|
1499
|
+
// makes that a compile error rather than a silent no-op.
|
|
1500
|
+
const exhaustiveCheck = op;
|
|
1501
|
+
throw new QuereusError(`Unknown external row op: ${JSON.stringify(exhaustiveCheck)}`, StatusCode.INTERNAL);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return changes;
|
|
1506
|
+
}
|
|
880
1507
|
/**
|
|
881
1508
|
* Fully delete the row at `pk` (data + secondary indexes + stats + delete event).
|
|
882
1509
|
* Used by REPLACE conflict resolution to evict a conflicting unique row before
|
|
@@ -884,9 +1511,9 @@ export class StoreTable extends VirtualTable {
|
|
|
884
1511
|
*/
|
|
885
1512
|
async deleteRowAt(inTransaction, pk, oldRow) {
|
|
886
1513
|
const store = await this.ensureStore();
|
|
887
|
-
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections);
|
|
1514
|
+
const key = buildDataKey(pk, this.encodeOptions, this.pkDirections, this.pkKeyCollations);
|
|
888
1515
|
if (inTransaction && this.coordinator) {
|
|
889
|
-
this.coordinator.delete(key);
|
|
1516
|
+
this.coordinator.delete(key, store);
|
|
890
1517
|
}
|
|
891
1518
|
else {
|
|
892
1519
|
await store.delete(key);
|
|
@@ -949,6 +1576,32 @@ export class StoreTable extends VirtualTable {
|
|
|
949
1576
|
// Store remains available for subsequent queries.
|
|
950
1577
|
// Use destroy() to fully clean up the table.
|
|
951
1578
|
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Hard teardown: fully detach this instance from the module-wide coordinator.
|
|
1581
|
+
*
|
|
1582
|
+
* Distinct from the per-scan {@link disconnect} (which only flushes stats and
|
|
1583
|
+
* deliberately keeps the table hooked mid-life). Called ONLY at the genuine
|
|
1584
|
+
* eviction sites — `StoreModule.tearDownTableStorage` (drop / reclaim) and
|
|
1585
|
+
* `renameTable` — where this instance is removed from the module's `tables` map
|
|
1586
|
+
* and will never be used again. It best-effort flushes any buffered stats (the
|
|
1587
|
+
* backing store is about to be deleted / relocated, so this is the last chance,
|
|
1588
|
+
* same posture as the teardown-time `disconnect` it replaces), then runs the
|
|
1589
|
+
* coordinator disposer so this instance's {stats apply/discard} callback pair —
|
|
1590
|
+
* and the `this` its closures capture — is spliced off the shared coordinator's
|
|
1591
|
+
* array rather than pinned for the module's lifetime.
|
|
1592
|
+
*
|
|
1593
|
+
* Idempotent: the disposer is run at most once and both it and the coordinator
|
|
1594
|
+
* reference are nulled, so a double-dispose is a no-op and a re-`attachCoordinator`
|
|
1595
|
+
* after dispose registers a fresh pair instead of double-registering.
|
|
1596
|
+
*/
|
|
1597
|
+
async dispose() {
|
|
1598
|
+
if (this.mutationCount > 0 && this.store) {
|
|
1599
|
+
await this.flushStats();
|
|
1600
|
+
}
|
|
1601
|
+
this.coordinatorDisposer?.();
|
|
1602
|
+
this.coordinatorDisposer = null;
|
|
1603
|
+
this.coordinator = null;
|
|
1604
|
+
}
|
|
952
1605
|
/** Get the current estimated row count. */
|
|
953
1606
|
async getEstimatedRowCount() {
|
|
954
1607
|
if (this.cachedStats) {
|