@minnowdb/core 0.2.1 → 0.3.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 +21 -32
- package/dist/engine/artifact-cache.d.ts +2 -0
- package/dist/engine/artifact-cache.d.ts.map +1 -1
- package/dist/engine/artifact-cache.js +5 -0
- package/dist/engine/artifact-cache.js.map +1 -1
- package/dist/engine/batch.d.ts +6 -1
- package/dist/engine/batch.d.ts.map +1 -1
- package/dist/engine/batch.js +17 -6
- package/dist/engine/batch.js.map +1 -1
- package/dist/engine/catalog.d.ts +22 -2
- package/dist/engine/catalog.d.ts.map +1 -1
- package/dist/engine/catalog.js +30 -3
- package/dist/engine/catalog.js.map +1 -1
- package/dist/engine/client.d.ts +2 -0
- package/dist/engine/client.d.ts.map +1 -1
- package/dist/engine/client.js +20 -10
- package/dist/engine/client.js.map +1 -1
- package/dist/engine/database.d.ts +67 -5
- package/dist/engine/database.d.ts.map +1 -1
- package/dist/engine/database.js +3508 -369
- package/dist/engine/database.js.map +1 -1
- package/dist/engine/defaults.d.ts +4 -7
- package/dist/engine/defaults.d.ts.map +1 -1
- package/dist/engine/defaults.js +47 -21
- package/dist/engine/defaults.js.map +1 -1
- package/dist/engine/fts.d.ts.map +1 -1
- package/dist/engine/fts.js +2 -0
- package/dist/engine/fts.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/live.d.ts.map +1 -1
- package/dist/engine/live.js +10 -2
- package/dist/engine/live.js.map +1 -1
- package/dist/engine/optimizer.d.ts.map +1 -1
- package/dist/engine/optimizer.js +258 -23
- package/dist/engine/optimizer.js.map +1 -1
- package/dist/engine/query-cache.d.ts +1 -1
- package/dist/engine/query-cache.d.ts.map +1 -1
- package/dist/engine/query-cache.js +3 -1
- package/dist/engine/query-cache.js.map +1 -1
- package/dist/engine/query.d.ts +130 -22
- package/dist/engine/query.d.ts.map +1 -1
- package/dist/engine/query.js +1344 -241
- package/dist/engine/query.js.map +1 -1
- package/dist/engine/schema-wire.d.ts +6 -2
- package/dist/engine/schema-wire.d.ts.map +1 -1
- package/dist/engine/schema-wire.js +40 -33
- package/dist/engine/schema-wire.js.map +1 -1
- package/dist/engine/schema.d.ts +157 -76
- package/dist/engine/schema.d.ts.map +1 -1
- package/dist/engine/schema.js +548 -103
- package/dist/engine/schema.js.map +1 -1
- package/dist/engine/sort-keys.d.ts +4 -3
- package/dist/engine/sort-keys.d.ts.map +1 -1
- package/dist/engine/sort-keys.js +42 -27
- package/dist/engine/sort-keys.js.map +1 -1
- package/dist/engine/sql-domains.d.ts +26 -0
- package/dist/engine/sql-domains.d.ts.map +1 -0
- package/dist/engine/sql-domains.js +421 -0
- package/dist/engine/sql-domains.js.map +1 -0
- package/dist/engine/sql-driver.d.ts +19 -0
- package/dist/engine/sql-driver.d.ts.map +1 -0
- package/dist/engine/sql-driver.js +2 -0
- package/dist/engine/sql-driver.js.map +1 -0
- package/dist/engine/sql-json.d.ts +7 -8
- package/dist/engine/sql-json.d.ts.map +1 -1
- package/dist/engine/sql-json.js +28 -14
- package/dist/engine/sql-json.js.map +1 -1
- package/dist/engine/sql-semantics.d.ts +2 -0
- package/dist/engine/sql-semantics.d.ts.map +1 -1
- package/dist/engine/sql-semantics.js +69 -3
- package/dist/engine/sql-semantics.js.map +1 -1
- package/dist/engine/vector.d.ts +5 -1
- package/dist/engine/vector.d.ts.map +1 -1
- package/dist/engine/vector.js +433 -61
- package/dist/engine/vector.js.map +1 -1
- package/dist/engine/worker-host.d.ts +1 -0
- package/dist/engine/worker-host.d.ts.map +1 -1
- package/dist/engine/worker-host.js +4 -0
- package/dist/engine/worker-host.js.map +1 -1
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +3 -3
- package/dist/storage/indexeddb.d.ts +44 -5
- package/dist/storage/indexeddb.d.ts.map +1 -1
- package/dist/storage/indexeddb.js +738 -175
- package/dist/storage/indexeddb.js.map +1 -1
- package/dist/storage/memory.d.ts +27 -10
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +67 -18
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/opfs/leader.d.ts +28 -6
- package/dist/storage/opfs/leader.d.ts.map +1 -1
- package/dist/storage/opfs/leader.js +301 -34
- package/dist/storage/opfs/leader.js.map +1 -1
- package/dist/storage/opfs/rpc.d.ts.map +1 -1
- package/dist/storage/opfs/rpc.js +2 -1
- package/dist/storage/opfs/rpc.js.map +1 -1
- package/dist/storage/opfs/store.d.ts +34 -5
- package/dist/storage/opfs/store.d.ts.map +1 -1
- package/dist/storage/opfs/store.js +41 -0
- package/dist/storage/opfs/store.js.map +1 -1
- package/dist/storage/snapshot.d.ts +14 -0
- package/dist/storage/snapshot.d.ts.map +1 -1
- package/dist/storage/snapshot.js +44 -0
- package/dist/storage/snapshot.js.map +1 -1
- package/dist/storage/toolkit/index.d.ts +1 -1
- package/dist/storage/toolkit/index.d.ts.map +1 -1
- package/dist/storage/toolkit/index.js +1 -1
- package/dist/storage/toolkit/index.js.map +1 -1
- package/dist/storage/toolkit/record-core.d.ts +19 -5
- package/dist/storage/toolkit/record-core.d.ts.map +1 -1
- package/dist/storage/toolkit/record-core.js +353 -78
- package/dist/storage/toolkit/record-core.js.map +1 -1
- package/dist/storage/toolkit/wire.d.ts +15 -0
- package/dist/storage/toolkit/wire.d.ts.map +1 -1
- package/dist/storage/toolkit/wire.js +134 -0
- package/dist/storage/toolkit/wire.js.map +1 -1
- package/dist/storage/types.d.ts +213 -31
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +291 -20
- package/dist/storage/types.js.map +1 -1
- package/dist/testing/block-store-conformance.d.ts.map +1 -1
- package/dist/testing/block-store-conformance.js +153 -2
- package/dist/testing/block-store-conformance.js.map +1 -1
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +23 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/simulator.d.ts +98 -0
- package/dist/testing/simulator.d.ts.map +1 -0
- package/dist/testing/simulator.js +560 -0
- package/dist/testing/simulator.js.map +1 -0
- package/dist/testing/sqllogictest.d.ts +90 -0
- package/dist/testing/sqllogictest.d.ts.map +1 -0
- package/dist/testing/sqllogictest.js +435 -0
- package/dist/testing/sqllogictest.js.map +1 -0
- package/dist/transactions/index.d.ts +18 -3
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/transactions/index.js +64 -8
- package/dist/transactions/index.js.map +1 -1
- package/package.json +5 -3
- package/postgres-feature-profile.json +223 -0
- package/sql-feature-matrix.json +172 -252
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { CompactionJobConflictError, createManifest, createGarbageCollectionJobRecord, storeNames, advanceGarbageCollectionJobRecord, collectFtsCandidates, invalidateUncoveredFtsColumns, GarbageCollectionJobConflictError, LeaseConflictError, MANIFEST_CHECKPOINT_INTERVAL, applyManifestRecord, SnapshotManifestMissingError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, normalizeCompactionJobRecord, normalizeGarbageCollectionJobRecord, normalizeSegmentRecord, updateCompactionJobRecord, updateTransactionRecord, WriteConflictError, } from "./types.js";
|
|
2
|
-
import { selectLiveRecords, } from "./snapshot.js";
|
|
1
|
+
import { CompactionJobConflictError, createManifest, createGarbageCollectionJobRecord, storeNames, advanceGarbageCollectionJobRecord, activePostingStorageColumnIds, collectFtsCandidates, collectFtsPostings, invalidateUncoveredFtsColumns, invalidateUncoveredSecondaryIndexes, GarbageCollectionJobConflictError, LeaseConflictError, MANIFEST_CHECKPOINT_INTERVAL, applyManifestRecord, SnapshotManifestMissingError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, UniqueIndexCoverageError, normalizeCompactionJobRecord, normalizeGarbageCollectionJobRecord, normalizeSegmentRecord, updateCompactionJobRecord, updateTransactionRecord, validateTableColumns, validateSecondaryIndexes, secondaryIndexColumnIds, secondaryUniqueKeyNamespace, WriteConflictError, } from "./types.js";
|
|
2
|
+
import { selectLiveRecords, validateSnapshotCatalog, } from "./snapshot.js";
|
|
3
3
|
const SCHEMA_VERSION = 2;
|
|
4
4
|
const CURRENT_MANIFEST_KEY = "manifest/current";
|
|
5
5
|
const CATALOG_EPOCH_KEY = "catalog/epoch";
|
|
6
6
|
const SEGMENT_TABLE_INDEX = "byTable";
|
|
7
7
|
const TABLE_ID_PREFIX = "table/id/";
|
|
8
8
|
const TABLE_NAME_PREFIX = "table/name/";
|
|
9
|
+
const SECONDARY_INDEX_NAME_PREFIX = "secondary-index/name/";
|
|
9
10
|
const ROW_ID_PREFIX = "row-id/";
|
|
10
11
|
const AUTO_INCREMENT_PREFIX = "auto-increment/";
|
|
11
12
|
const UNIQUE_KEY_CHUNK_INDEX = "unique-key-chunk-index";
|
|
@@ -26,6 +27,7 @@ const UNIQUE_KEY_BASE_PART = "unique-key-base-part";
|
|
|
26
27
|
const UNIQUE_KEY_PARTITION_TARGET = 16_384;
|
|
27
28
|
const FTS_BASE_INDEX_PREFIX = "fts-base-index/";
|
|
28
29
|
const FTS_BASE_PREFIX = "fts-base/";
|
|
30
|
+
const FTS_BASE_BUILD_PREFIX = "fts-base-build/";
|
|
29
31
|
const FTS_CHUNK_PREFIX = "fts-chunk/";
|
|
30
32
|
const COMPACTION_JOB_KEY_PREFIX = "compaction-job/";
|
|
31
33
|
const GARBAGE_COLLECTION_JOB_KEY_PREFIX = "garbage-collection-job/";
|
|
@@ -35,9 +37,12 @@ const storageTextBuffer = new Uint8Array(1_024);
|
|
|
35
37
|
const SNAPSHOT_BLOCK_BATCH = 512;
|
|
36
38
|
/** Bytes per write transaction when loading a snapshot in. */
|
|
37
39
|
const SNAPSHOT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
40
|
+
const DEFAULT_UNIQUE_KEY_CACHE_BYTES = 8 * 1024 * 1024;
|
|
41
|
+
const TABLE_NAME_CACHE_LIMIT = 256;
|
|
38
42
|
export class IndexedDbBlockStore {
|
|
39
43
|
#db;
|
|
40
44
|
#durability;
|
|
45
|
+
#uniqueKeyCacheBytes;
|
|
41
46
|
/** Remembered name-to-id mappings; see getTableByName for why they need no invalidation. */
|
|
42
47
|
#tableIdsByName = new Map();
|
|
43
48
|
/**
|
|
@@ -55,11 +60,16 @@ export class IndexedDbBlockStore {
|
|
|
55
60
|
* version) re-resolves from storage inside the committing transaction.
|
|
56
61
|
*/
|
|
57
62
|
#manifestCache;
|
|
58
|
-
constructor(db, durability) {
|
|
63
|
+
constructor(db, durability, uniqueKeyCacheBytes) {
|
|
59
64
|
this.#db = db;
|
|
60
65
|
this.#durability = durability;
|
|
66
|
+
this.#uniqueKeyCacheBytes = uniqueKeyCacheBytes;
|
|
61
67
|
}
|
|
62
68
|
static async open(options) {
|
|
69
|
+
const uniqueKeyCacheBytes = options.uniqueKeyCacheBytes ?? DEFAULT_UNIQUE_KEY_CACHE_BYTES;
|
|
70
|
+
if (!Number.isSafeInteger(uniqueKeyCacheBytes) || uniqueKeyCacheBytes < 0) {
|
|
71
|
+
throw new RangeError("Unique-key cache bytes must be a non-negative whole number");
|
|
72
|
+
}
|
|
63
73
|
const factory = options.indexedDB ?? getGlobalIndexedDb();
|
|
64
74
|
if (factory === undefined)
|
|
65
75
|
throw new Error("IndexedDB is unavailable");
|
|
@@ -81,7 +91,7 @@ export class IndexedDbBlockStore {
|
|
|
81
91
|
}
|
|
82
92
|
});
|
|
83
93
|
const db = await requestResult(request);
|
|
84
|
-
return new IndexedDbBlockStore(db, options.durability ?? "relaxed");
|
|
94
|
+
return new IndexedDbBlockStore(db, options.durability ?? "relaxed", uniqueKeyCacheBytes);
|
|
85
95
|
}
|
|
86
96
|
async addBlock(id, bytes) {
|
|
87
97
|
return this.addBlocks([{ id, bytes }]);
|
|
@@ -255,6 +265,8 @@ export class IndexedDbBlockStore {
|
|
|
255
265
|
};
|
|
256
266
|
}
|
|
257
267
|
async addTable(record) {
|
|
268
|
+
validateTableColumns(record.columns);
|
|
269
|
+
validateSecondaryIndexes(record);
|
|
258
270
|
const transaction = this.#transaction("catalog", "readwrite");
|
|
259
271
|
const store = transaction.objectStore("catalog");
|
|
260
272
|
const idKey = `${TABLE_ID_PREFIX}${record.id}`;
|
|
@@ -270,6 +282,18 @@ export class IndexedDbBlockStore {
|
|
|
270
282
|
? `Table already exists: ${record.id}`
|
|
271
283
|
: `Table name already exists: ${record.name}`);
|
|
272
284
|
}
|
|
285
|
+
const indexNames = new Set();
|
|
286
|
+
for (const [indexId, index] of Object.entries(record.secondaryIndexes ?? {})) {
|
|
287
|
+
const markerKey = `${SECONDARY_INDEX_NAME_PREFIX}${index.name}`;
|
|
288
|
+
const marker = (await requestResult(store.get(markerKey)));
|
|
289
|
+
if (indexNames.has(index.name) || marker !== undefined) {
|
|
290
|
+
transaction.abort();
|
|
291
|
+
await ignoreAbort(transaction);
|
|
292
|
+
throw new TypeError(`Index already exists: ${index.name}`);
|
|
293
|
+
}
|
|
294
|
+
indexNames.add(index.name);
|
|
295
|
+
store.put({ tableId: record.id, indexId }, markerKey);
|
|
296
|
+
}
|
|
273
297
|
store.add(structuredClone(record), idKey);
|
|
274
298
|
store.add(record.id, nameKey);
|
|
275
299
|
await bumpCatalogEpoch(store);
|
|
@@ -295,8 +319,64 @@ export class IndexedDbBlockStore {
|
|
|
295
319
|
await ignoreAbort(transaction);
|
|
296
320
|
throw new TableRecordConflictError(id, expectedRevision, actualRevision);
|
|
297
321
|
}
|
|
298
|
-
|
|
299
|
-
|
|
322
|
+
if (update.expectedManifestVersion !== undefined) {
|
|
323
|
+
const manifestValue = await requestResult(store.get(CURRENT_MANIFEST_KEY));
|
|
324
|
+
const actualManifest = typeof manifestValue === "number" ? manifestValue : null;
|
|
325
|
+
if (actualManifest !== update.expectedManifestVersion.value) {
|
|
326
|
+
transaction.abort();
|
|
327
|
+
await ignoreAbort(transaction);
|
|
328
|
+
throw new WriteConflictError(update.expectedManifestVersion.value, actualManifest);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (update.uniqueKeySeed !== undefined) {
|
|
332
|
+
const seen = new Set();
|
|
333
|
+
for (const token of update.uniqueKeySeed.keyTokens) {
|
|
334
|
+
if (seen.has(token)) {
|
|
335
|
+
transaction.abort();
|
|
336
|
+
await ignoreAbort(transaction);
|
|
337
|
+
throw new UniqueKeyConflictError(update.uniqueKeySeed.namespaceId, token);
|
|
338
|
+
}
|
|
339
|
+
seen.add(token);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const { ftsColumns: previousFts, secondaryIndexes: previousSecondary, triggers: previousTriggers, ...base } = record;
|
|
343
|
+
let nextFts = update.ftsColumns === undefined ? previousFts : update.ftsColumns;
|
|
344
|
+
let nextSecondary = update.secondaryIndexes === undefined ? previousSecondary : update.secondaryIndexes;
|
|
345
|
+
const retainedColumnIds = update.columns === undefined
|
|
346
|
+
? undefined
|
|
347
|
+
: new Set(update.columns.map(({ id: columnId }) => columnId));
|
|
348
|
+
if (nextFts !== null && nextFts !== undefined && retainedColumnIds !== undefined) {
|
|
349
|
+
nextFts = Object.fromEntries(Object.entries(nextFts).filter(([columnId]) => retainedColumnIds.has(columnId)));
|
|
350
|
+
if (Object.keys(nextFts).length === 0)
|
|
351
|
+
nextFts = null;
|
|
352
|
+
}
|
|
353
|
+
if (nextSecondary !== null && nextSecondary !== undefined && retainedColumnIds !== undefined) {
|
|
354
|
+
nextSecondary = Object.fromEntries(Object.entries(nextSecondary).filter(([, index]) => secondaryIndexColumnIds(index).every((columnId) => retainedColumnIds.has(columnId))));
|
|
355
|
+
if (Object.keys(nextSecondary).length === 0)
|
|
356
|
+
nextSecondary = null;
|
|
357
|
+
}
|
|
358
|
+
const indexNames = new Set();
|
|
359
|
+
for (const [indexId, index] of Object.entries(nextSecondary ?? {})) {
|
|
360
|
+
const markerKey = `${SECONDARY_INDEX_NAME_PREFIX}${index.name}`;
|
|
361
|
+
const marker = (await requestResult(store.get(markerKey)));
|
|
362
|
+
if (indexNames.has(index.name) ||
|
|
363
|
+
(marker !== undefined && (marker.tableId !== id || marker.indexId !== indexId))) {
|
|
364
|
+
transaction.abort();
|
|
365
|
+
await ignoreAbort(transaction);
|
|
366
|
+
throw new TypeError(`Index already exists: ${index.name}`);
|
|
367
|
+
}
|
|
368
|
+
indexNames.add(index.name);
|
|
369
|
+
store.put({ tableId: id, indexId }, markerKey);
|
|
370
|
+
}
|
|
371
|
+
for (const [indexId, index] of Object.entries(previousSecondary ?? {})) {
|
|
372
|
+
const retained = nextSecondary?.[indexId];
|
|
373
|
+
if (retained?.name === index.name)
|
|
374
|
+
continue;
|
|
375
|
+
const markerKey = `${SECONDARY_INDEX_NAME_PREFIX}${index.name}`;
|
|
376
|
+
const marker = (await requestResult(store.get(markerKey)));
|
|
377
|
+
if (marker?.tableId === id && marker.indexId === indexId)
|
|
378
|
+
store.delete(markerKey);
|
|
379
|
+
}
|
|
300
380
|
const nextTriggers = update.triggers === undefined ? previousTriggers : update.triggers;
|
|
301
381
|
const updated = {
|
|
302
382
|
...base,
|
|
@@ -304,14 +384,54 @@ export class IndexedDbBlockStore {
|
|
|
304
384
|
...(nextFts === null || nextFts === undefined
|
|
305
385
|
? {}
|
|
306
386
|
: { ftsColumns: structuredClone(nextFts) }),
|
|
387
|
+
...(nextSecondary === null || nextSecondary === undefined
|
|
388
|
+
? {}
|
|
389
|
+
: { secondaryIndexes: structuredClone(nextSecondary) }),
|
|
307
390
|
...(nextTriggers === null || nextTriggers === undefined
|
|
308
391
|
? {}
|
|
309
392
|
: { triggers: structuredClone(nextTriggers) }),
|
|
310
393
|
revision: expectedRevision + 1,
|
|
311
394
|
};
|
|
395
|
+
validateSecondaryIndexes(updated);
|
|
396
|
+
if (update.uniqueKeySeed !== undefined) {
|
|
397
|
+
const ownsSeed = Object.entries(updated.secondaryIndexes ?? {}).some(([indexId, index]) => index.unique === true &&
|
|
398
|
+
index.uniqueEnforced === true &&
|
|
399
|
+
index.state === "ready" &&
|
|
400
|
+
secondaryUniqueKeyNamespace(updated.id, indexId) === update.uniqueKeySeed?.namespaceId);
|
|
401
|
+
if (!ownsSeed) {
|
|
402
|
+
transaction.abort();
|
|
403
|
+
await ignoreAbort(transaction);
|
|
404
|
+
throw new TypeError("UNIQUE-index seed does not belong to a ready catalog index");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (retainedColumnIds !== undefined) {
|
|
408
|
+
for (const column of record.columns) {
|
|
409
|
+
if (!retainedColumnIds.has(column.id)) {
|
|
410
|
+
await deleteFtsColumnRecords(store, record.id, column.id);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const retainedIndexStorage = new Set(Object.values(nextSecondary ?? {}).map((index) => index.storageColumnId));
|
|
415
|
+
for (const index of Object.values(previousSecondary ?? {})) {
|
|
416
|
+
if (!retainedIndexStorage.has(index.storageColumnId)) {
|
|
417
|
+
await deleteFtsColumnRecords(store, record.id, index.storageColumnId);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const [indexId, previous] of Object.entries(previousSecondary ?? {})) {
|
|
421
|
+
if (previous.unique === true && nextSecondary?.[indexId]?.unique !== true) {
|
|
422
|
+
await replaceUniqueKeyMembership(store, secondaryUniqueKeyNamespace(record.id, indexId), [], false);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (update.uniqueKeySeed !== undefined) {
|
|
426
|
+
await replaceUniqueKeyMembership(store, update.uniqueKeySeed.namespaceId, update.uniqueKeySeed.keyTokens, true);
|
|
427
|
+
}
|
|
312
428
|
store.put(structuredClone(updated), idKey);
|
|
313
429
|
await bumpCatalogEpoch(store);
|
|
314
430
|
await transactionDone(transaction);
|
|
431
|
+
if (update.uniqueKeySeed !== undefined ||
|
|
432
|
+
Object.entries(previousSecondary ?? {}).some(([indexId, previous]) => previous.unique === true && nextSecondary?.[indexId]?.unique !== true)) {
|
|
433
|
+
this.#uniqueKeyCache = undefined;
|
|
434
|
+
}
|
|
315
435
|
return updated;
|
|
316
436
|
}
|
|
317
437
|
async removeTable(id, expectedRevision) {
|
|
@@ -328,6 +448,15 @@ export class IndexedDbBlockStore {
|
|
|
328
448
|
catalog.delete(`${TABLE_ID_PREFIX}${id}`);
|
|
329
449
|
catalog.delete(`${TABLE_NAME_PREFIX}${record.name}`);
|
|
330
450
|
catalog.delete(`${ROW_ID_PREFIX}${id}`);
|
|
451
|
+
for (const [indexId, index] of Object.entries(record.secondaryIndexes ?? {})) {
|
|
452
|
+
const markerKey = `${SECONDARY_INDEX_NAME_PREFIX}${index.name}`;
|
|
453
|
+
const marker = (await requestResult(catalog.get(markerKey)));
|
|
454
|
+
if (marker?.tableId === id && marker.indexId === indexId)
|
|
455
|
+
catalog.delete(markerKey);
|
|
456
|
+
if (index.unique === true) {
|
|
457
|
+
await replaceUniqueKeyMembership(catalog, secondaryUniqueKeyNamespace(id, indexId), [], false);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
331
460
|
// Everything else this table owns is keyed by its id under one of a handful of prefixes,
|
|
332
461
|
// string-keyed or array-keyed. One sequential pass collects them all: a dropped table is
|
|
333
462
|
// rare and the alternative is a range read per prefix, several of which the injected test
|
|
@@ -336,6 +465,7 @@ export class IndexedDbBlockStore {
|
|
|
336
465
|
`${AUTO_INCREMENT_PREFIX}${id}/`,
|
|
337
466
|
`${FTS_BASE_INDEX_PREFIX}${id}/`,
|
|
338
467
|
`${FTS_BASE_PREFIX}${id}/`,
|
|
468
|
+
`${FTS_BASE_BUILD_PREFIX}${id}/`,
|
|
339
469
|
`${FTS_CHUNK_PREFIX}${id}/`,
|
|
340
470
|
];
|
|
341
471
|
const ownedArrayKinds = new Set([
|
|
@@ -366,18 +496,21 @@ export class IndexedDbBlockStore {
|
|
|
366
496
|
await transactionDone(transaction);
|
|
367
497
|
// The membership cache is keyed by manifest version, which a table drop does not move;
|
|
368
498
|
// without this, a lookup after the drop would answer from the dead table's keys.
|
|
369
|
-
if (this.#uniqueKeyCache?.tableId === id
|
|
499
|
+
if (this.#uniqueKeyCache?.tableId === id ||
|
|
500
|
+
this.#uniqueKeyCache?.tableId.startsWith(`${id}\u0000secondary-index\u0000`) === true) {
|
|
370
501
|
this.#uniqueKeyCache = undefined;
|
|
502
|
+
}
|
|
371
503
|
}
|
|
372
504
|
async writeFtsBase(tableId, columnId, input) {
|
|
373
505
|
const transaction = this.#transaction("catalog", "readwrite");
|
|
374
506
|
const store = transaction.objectStore("catalog");
|
|
375
507
|
const tocKey = `${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`;
|
|
376
508
|
const previous = (await requestResult(store.get(tocKey)));
|
|
377
|
-
const chunkPrefix =
|
|
509
|
+
const chunkPrefix = ftsBaseChunkPrefix(tableId, columnId);
|
|
510
|
+
const previousPrefix = ftsBaseChunkPrefix(tableId, columnId, previous?.generation);
|
|
378
511
|
const previousCount = previous?.boundaries.length ?? 0;
|
|
379
|
-
for (let ordinal =
|
|
380
|
-
store.delete(`${
|
|
512
|
+
for (let ordinal = 0; ordinal < previousCount; ordinal += 1) {
|
|
513
|
+
store.delete(`${previousPrefix}${String(ordinal).padStart(6, "0")}`);
|
|
381
514
|
}
|
|
382
515
|
const boundaries = [];
|
|
383
516
|
input.chunks.forEach((chunk, ordinal) => {
|
|
@@ -402,6 +535,102 @@ export class IndexedDbBlockStore {
|
|
|
402
535
|
}
|
|
403
536
|
}
|
|
404
537
|
store.put({ versions: surviving }, deltaIndexKey);
|
|
538
|
+
await deleteActiveFtsBaseBuild(store, tableId, columnId);
|
|
539
|
+
await transactionDone(transaction);
|
|
540
|
+
}
|
|
541
|
+
async beginFtsBaseBuild(tableId, columnId, buildId) {
|
|
542
|
+
const transaction = this.#transaction("catalog", "readwrite");
|
|
543
|
+
const store = transaction.objectStore("catalog");
|
|
544
|
+
const tableValue = await requestResult(store.get(`${TABLE_ID_PREFIX}${tableId}`));
|
|
545
|
+
const table = tableValue === undefined ? undefined : structuredClone(tableValue);
|
|
546
|
+
if (table === undefined || !activePostingStorageColumnIds(table).has(columnId)) {
|
|
547
|
+
transaction.abort();
|
|
548
|
+
await ignoreAbort(transaction);
|
|
549
|
+
throw new Error(`Postings index is no longer active: ${tableId}/${columnId}`);
|
|
550
|
+
}
|
|
551
|
+
await deleteActiveFtsBaseBuild(store, tableId, columnId);
|
|
552
|
+
store.put({ buildId, boundaries: [] }, ftsBaseBuildKey(tableId, columnId));
|
|
553
|
+
await transactionDone(transaction);
|
|
554
|
+
}
|
|
555
|
+
async writeFtsBaseBuildChunk(tableId, columnId, buildId, ordinal, chunk) {
|
|
556
|
+
const transaction = this.#transaction("catalog", "readwrite");
|
|
557
|
+
const store = transaction.objectStore("catalog");
|
|
558
|
+
const markerKey = ftsBaseBuildKey(tableId, columnId);
|
|
559
|
+
const marker = (await requestResult(store.get(markerKey)));
|
|
560
|
+
if (marker?.buildId !== buildId) {
|
|
561
|
+
transaction.abort();
|
|
562
|
+
await ignoreAbort(transaction);
|
|
563
|
+
throw new Error(`Full-text base build changed: ${buildId}`);
|
|
564
|
+
}
|
|
565
|
+
if (ordinal !== marker.boundaries.length) {
|
|
566
|
+
transaction.abort();
|
|
567
|
+
await ignoreAbort(transaction);
|
|
568
|
+
throw new Error(`Full-text base chunk is out of order: ${String(ordinal)}`);
|
|
569
|
+
}
|
|
570
|
+
store.put(structuredClone(chunk), `${ftsBaseChunkPrefix(tableId, columnId, buildId)}${String(ordinal).padStart(6, "0")}`);
|
|
571
|
+
store.put({
|
|
572
|
+
buildId,
|
|
573
|
+
boundaries: [
|
|
574
|
+
...marker.boundaries,
|
|
575
|
+
{ first: chunk[0]?.term ?? "", last: chunk[chunk.length - 1]?.term ?? "" },
|
|
576
|
+
],
|
|
577
|
+
}, markerKey);
|
|
578
|
+
await transactionDone(transaction);
|
|
579
|
+
}
|
|
580
|
+
async finishFtsBaseBuild(tableId, columnId, buildId, input) {
|
|
581
|
+
const transaction = this.#transaction("catalog", "readwrite");
|
|
582
|
+
const store = transaction.objectStore("catalog");
|
|
583
|
+
const markerKey = ftsBaseBuildKey(tableId, columnId);
|
|
584
|
+
const [marker, previous, deltaIndex, tableValue] = (await Promise.all([
|
|
585
|
+
requestResult(store.get(markerKey)),
|
|
586
|
+
requestResult(store.get(`${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`)),
|
|
587
|
+
requestResult(store.get(ftsChunkIndexKey(tableId, columnId))),
|
|
588
|
+
requestResult(store.get(`${TABLE_ID_PREFIX}${tableId}`)),
|
|
589
|
+
]));
|
|
590
|
+
if (marker?.buildId !== buildId || marker.boundaries.length !== input.chunkCount) {
|
|
591
|
+
transaction.abort();
|
|
592
|
+
await ignoreAbort(transaction);
|
|
593
|
+
throw new Error(`Full-text base build is incomplete: ${buildId}`);
|
|
594
|
+
}
|
|
595
|
+
if (tableValue === undefined ||
|
|
596
|
+
!activePostingStorageColumnIds(structuredClone(tableValue)).has(columnId)) {
|
|
597
|
+
await deleteActiveFtsBaseBuild(store, tableId, columnId);
|
|
598
|
+
await transactionDone(transaction);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const previousPrefix = ftsBaseChunkPrefix(tableId, columnId, previous?.generation);
|
|
602
|
+
for (let ordinal = 0; ordinal < (previous?.boundaries.length ?? 0); ordinal += 1) {
|
|
603
|
+
store.delete(`${previousPrefix}${String(ordinal).padStart(6, "0")}`);
|
|
604
|
+
}
|
|
605
|
+
store.put({
|
|
606
|
+
coversVersion: input.coversVersion,
|
|
607
|
+
boundaries: marker.boundaries,
|
|
608
|
+
totalTokens: input.totalTokens,
|
|
609
|
+
generation: buildId,
|
|
610
|
+
}, `${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`);
|
|
611
|
+
const surviving = [];
|
|
612
|
+
for (const version of deltaIndex?.versions ?? []) {
|
|
613
|
+
if (version <= input.coversVersion)
|
|
614
|
+
store.delete(ftsChunkKey(tableId, columnId, version));
|
|
615
|
+
else
|
|
616
|
+
surviving.push(version);
|
|
617
|
+
}
|
|
618
|
+
store.put({ versions: surviving }, ftsChunkIndexKey(tableId, columnId));
|
|
619
|
+
store.delete(markerKey);
|
|
620
|
+
await transactionDone(transaction);
|
|
621
|
+
}
|
|
622
|
+
async abortFtsBaseBuild(tableId, columnId, buildId) {
|
|
623
|
+
const transaction = this.#transaction("catalog", "readwrite");
|
|
624
|
+
const store = transaction.objectStore("catalog");
|
|
625
|
+
const marker = (await requestResult(store.get(ftsBaseBuildKey(tableId, columnId))));
|
|
626
|
+
if (marker?.buildId === buildId)
|
|
627
|
+
await deleteActiveFtsBaseBuild(store, tableId, columnId);
|
|
628
|
+
await transactionDone(transaction);
|
|
629
|
+
}
|
|
630
|
+
async removeFtsColumn(tableId, columnId) {
|
|
631
|
+
const transaction = this.#transaction("catalog", "readwrite");
|
|
632
|
+
const store = transaction.objectStore("catalog");
|
|
633
|
+
await deleteFtsColumnRecords(store, tableId, columnId);
|
|
405
634
|
await transactionDone(transaction);
|
|
406
635
|
}
|
|
407
636
|
async readFtsCandidates(tableId, columnId, terms, upToVersion) {
|
|
@@ -411,10 +640,12 @@ export class IndexedDbBlockStore {
|
|
|
411
640
|
requestResult(store.get(`${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`)),
|
|
412
641
|
requestResult(store.get(ftsChunkIndexKey(tableId, columnId))),
|
|
413
642
|
]));
|
|
414
|
-
const chunkPrefix =
|
|
415
|
-
const wantedOrdinals = (toc?.boundaries ?? []).flatMap((boundary, ordinal) => terms.some((
|
|
416
|
-
const
|
|
417
|
-
|
|
643
|
+
const chunkPrefix = ftsBaseChunkPrefix(tableId, columnId, toc?.generation);
|
|
644
|
+
const wantedOrdinals = (toc?.boundaries ?? []).flatMap((boundary, ordinal) => terms.some((query) => {
|
|
645
|
+
const lower = "term" in query ? query.term : query.lower;
|
|
646
|
+
const upper = "term" in query ? (query.prefix ? `${query.term}` : query.term) : query.upper;
|
|
647
|
+
return ((lower === undefined || lower <= boundary.last) &&
|
|
648
|
+
(upper === undefined || upper >= boundary.first));
|
|
418
649
|
})
|
|
419
650
|
? [ordinal]
|
|
420
651
|
: []);
|
|
@@ -438,6 +669,33 @@ export class IndexedDbBlockStore {
|
|
|
438
669
|
deltaChunkCount: present.length,
|
|
439
670
|
totalTokens,
|
|
440
671
|
coversVersion,
|
|
672
|
+
hasBase: toc !== undefined,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
async readFtsPostings(tableId, columnId, upToVersion) {
|
|
676
|
+
const transaction = this.#transaction("catalog", "readonly");
|
|
677
|
+
const store = transaction.objectStore("catalog");
|
|
678
|
+
const [toc, deltaIndex] = (await Promise.all([
|
|
679
|
+
requestResult(store.get(`${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`)),
|
|
680
|
+
requestResult(store.get(ftsChunkIndexKey(tableId, columnId))),
|
|
681
|
+
]));
|
|
682
|
+
const coversVersion = toc?.coversVersion ?? -1;
|
|
683
|
+
const prefix = ftsBaseChunkPrefix(tableId, columnId, toc?.generation);
|
|
684
|
+
const versions = (deltaIndex?.versions ?? []).filter((version) => version > coversVersion && version <= upToVersion);
|
|
685
|
+
const [baseChunks, deltaChunks] = await Promise.all([
|
|
686
|
+
Promise.all((toc?.boundaries ?? []).map((_, ordinal) => requestResult(store.get(`${prefix}${String(ordinal).padStart(6, "0")}`)))),
|
|
687
|
+
Promise.all(versions.map((version) => requestResult(store.get(ftsChunkKey(tableId, columnId, version))))),
|
|
688
|
+
]);
|
|
689
|
+
await transactionDone(transaction);
|
|
690
|
+
const present = deltaChunks.filter((chunk) => chunk !== undefined);
|
|
691
|
+
return {
|
|
692
|
+
postings: collectFtsPostings([
|
|
693
|
+
...baseChunks.filter((chunk) => chunk !== undefined),
|
|
694
|
+
...present.map((chunk) => chunk.postings),
|
|
695
|
+
]),
|
|
696
|
+
deltaChunkCount: present.length,
|
|
697
|
+
coversVersion,
|
|
698
|
+
hasBase: toc !== undefined,
|
|
441
699
|
};
|
|
442
700
|
}
|
|
443
701
|
/**
|
|
@@ -457,6 +715,8 @@ export class IndexedDbBlockStore {
|
|
|
457
715
|
if (cached !== undefined) {
|
|
458
716
|
const record = asTableRecord(cached);
|
|
459
717
|
if (record.name === name) {
|
|
718
|
+
this.#tableIdsByName.delete(name);
|
|
719
|
+
this.#tableIdsByName.set(name, rememberedId);
|
|
460
720
|
await transactionDone(transaction);
|
|
461
721
|
return record;
|
|
462
722
|
}
|
|
@@ -469,8 +729,14 @@ export class IndexedDbBlockStore {
|
|
|
469
729
|
if (value === undefined)
|
|
470
730
|
return undefined;
|
|
471
731
|
const record = asTableRecord(value);
|
|
472
|
-
if (record.name === name)
|
|
732
|
+
if (record.name === name) {
|
|
473
733
|
this.#tableIdsByName.set(name, record.id);
|
|
734
|
+
if (this.#tableIdsByName.size > TABLE_NAME_CACHE_LIMIT) {
|
|
735
|
+
const oldest = this.#tableIdsByName.keys().next().value;
|
|
736
|
+
if (oldest !== undefined)
|
|
737
|
+
this.#tableIdsByName.delete(oldest);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
474
740
|
return record;
|
|
475
741
|
}
|
|
476
742
|
async listTables() {
|
|
@@ -512,6 +778,13 @@ export class IndexedDbBlockStore {
|
|
|
512
778
|
await transactionDone(transaction);
|
|
513
779
|
return records.sort((left, right) => left.id.localeCompare(right.id));
|
|
514
780
|
}
|
|
781
|
+
async listSegmentPage(afterId, limit) {
|
|
782
|
+
validatePageLimit(limit);
|
|
783
|
+
const transaction = this.#transaction("segments", "readonly");
|
|
784
|
+
const records = await readCursorPage(transaction.objectStore("segments"), limit, asSegmentRecord, (key) => typeof key === "string" && (afterId === null || key > afterId), afterId ?? undefined);
|
|
785
|
+
await transactionDone(transaction);
|
|
786
|
+
return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
|
|
787
|
+
}
|
|
515
788
|
async removeSegment(id) {
|
|
516
789
|
const transaction = this.#transaction("segments", "readwrite");
|
|
517
790
|
transaction.objectStore("segments").delete(id);
|
|
@@ -585,7 +858,7 @@ export class IndexedDbBlockStore {
|
|
|
585
858
|
};
|
|
586
859
|
}
|
|
587
860
|
async getQueryCatalogState(tableNames) {
|
|
588
|
-
const transaction = this.#transaction(["catalog", "segments", "transactions"], "readonly");
|
|
861
|
+
const transaction = this.#transaction(["catalog", "manifests", "segments", "transactions"], "readonly");
|
|
589
862
|
const catalog = transaction.objectStore("catalog");
|
|
590
863
|
const [versionValue, epochValue, tableIds] = await Promise.all([
|
|
591
864
|
requestResult(catalog.get(CURRENT_MANIFEST_KEY)),
|
|
@@ -607,7 +880,23 @@ export class IndexedDbBlockStore {
|
|
|
607
880
|
// queried tables' segments, not the store's total segment count.
|
|
608
881
|
const segmentIndex = transaction.objectStore("segments").index(SEGMENT_TABLE_INDEX);
|
|
609
882
|
const segmentValues = await Promise.all(foundTableIds.map((tableId) => requestResult(segmentIndex.getAll(tableId))));
|
|
610
|
-
const
|
|
883
|
+
const manifestStore = transaction.objectStore("manifests");
|
|
884
|
+
const manifestValue = typeof versionValue === "number"
|
|
885
|
+
? await requestResult(manifestStore.get(versionValue))
|
|
886
|
+
: undefined;
|
|
887
|
+
const currentManifest = manifestValue === undefined
|
|
888
|
+
? undefined
|
|
889
|
+
: await resolveManifestInTransaction(manifestStore, asStoredManifestRecord(manifestValue));
|
|
890
|
+
const visibleBlockIds = new Set(currentManifest?.blockIds ?? []);
|
|
891
|
+
// The version, manifest and records share this readonly transaction. Historical segments
|
|
892
|
+
// retained for time travel are not inputs to a current-version query; explicit version reads
|
|
893
|
+
// take the listSegments path and continue to see them.
|
|
894
|
+
const segments = segmentValues
|
|
895
|
+
.flat()
|
|
896
|
+
.map((value) => asSegmentRecord(value))
|
|
897
|
+
.filter((record) => Object.values(record.columnBlockIds)
|
|
898
|
+
.flat()
|
|
899
|
+
.every((blockId) => visibleBlockIds.has(blockId)));
|
|
611
900
|
segments.sort((left, right) => left.id.localeCompare(right.id));
|
|
612
901
|
const transactionIds = [...new Set(segments.map((segment) => segment.transactionId))];
|
|
613
902
|
const transactionStore = transaction.objectStore("transactions");
|
|
@@ -615,6 +904,7 @@ export class IndexedDbBlockStore {
|
|
|
615
904
|
await transactionDone(transaction);
|
|
616
905
|
return {
|
|
617
906
|
manifestVersion: typeof versionValue === "number" ? versionValue : null,
|
|
907
|
+
manifestBlockIds: [...visibleBlockIds].sort(),
|
|
618
908
|
tables,
|
|
619
909
|
segments,
|
|
620
910
|
transactions: transactionValues
|
|
@@ -664,7 +954,7 @@ export class IndexedDbBlockStore {
|
|
|
664
954
|
validatePageLimit(limit);
|
|
665
955
|
const transaction = this.#transaction("manifests", "readonly");
|
|
666
956
|
const manifestStore = transaction.objectStore("manifests");
|
|
667
|
-
const stored = await readCursorPage(manifestStore, limit, asStoredManifestRecord, (key) => typeof key === "number" && (afterVersion === null || key > afterVersion));
|
|
957
|
+
const stored = await readCursorPage(manifestStore, limit, asStoredManifestRecord, (key) => typeof key === "number" && (afterVersion === null || key > afterVersion), afterVersion ?? undefined);
|
|
668
958
|
// Resolve the first record by chain walk, then advance the same set across the page.
|
|
669
959
|
const records = [];
|
|
670
960
|
let blockIds;
|
|
@@ -800,7 +1090,7 @@ export class IndexedDbBlockStore {
|
|
|
800
1090
|
async listTransactionPage(afterId, limit) {
|
|
801
1091
|
validatePageLimit(limit);
|
|
802
1092
|
const transaction = this.#transaction("transactions", "readonly");
|
|
803
|
-
const records = await readCursorPage(transaction.objectStore("transactions"), limit, asTransactionRecord, (key) => typeof key === "string" && (afterId === null || key > afterId));
|
|
1093
|
+
const records = await readCursorPage(transaction.objectStore("transactions"), limit, asTransactionRecord, (key) => typeof key === "string" && (afterId === null || key > afterId), afterId ?? undefined);
|
|
804
1094
|
await transactionDone(transaction);
|
|
805
1095
|
return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
|
|
806
1096
|
}
|
|
@@ -1045,15 +1335,35 @@ export class IndexedDbBlockStore {
|
|
|
1045
1335
|
if (foreignSegment !== undefined) {
|
|
1046
1336
|
throw new Error(`Segment ${foreignSegment.id} belongs to another transaction`);
|
|
1047
1337
|
}
|
|
1048
|
-
// The single-entry path below is the hot bulk-load path
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1338
|
+
// The single-entry path below is the hot bulk-load path. Multi-entry commits (write scopes
|
|
1339
|
+
// and tables with secondary UNIQUE indexes) replay per-namespace changes further down; they
|
|
1340
|
+
// retain one complete membership cache when its byte budget allows it.
|
|
1051
1341
|
const uniqueKeyEntries = input.uniqueKeyChanges ?? [];
|
|
1342
|
+
const coveredUniqueNamespaces = new Set(uniqueKeyEntries.map((entry) => entry.tableId));
|
|
1343
|
+
const changedTableIds = new Set([
|
|
1344
|
+
...(input.changedTableIds ?? []),
|
|
1345
|
+
...pendingSegments.map((segment) => segment.tableId),
|
|
1346
|
+
]);
|
|
1347
|
+
for (const tableId of changedTableIds) {
|
|
1348
|
+
const value = await requestResult(catalog.get(`${TABLE_ID_PREFIX}${tableId}`));
|
|
1349
|
+
if (value === undefined)
|
|
1350
|
+
continue;
|
|
1351
|
+
const changedTable = structuredClone(value);
|
|
1352
|
+
for (const [indexId, index] of Object.entries(changedTable.secondaryIndexes ?? {})) {
|
|
1353
|
+
if (index.uniqueEnforced === true &&
|
|
1354
|
+
!coveredUniqueNamespaces.has(secondaryUniqueKeyNamespace(tableId, indexId))) {
|
|
1355
|
+
transaction.abort();
|
|
1356
|
+
await ignoreAbort(transaction);
|
|
1357
|
+
throw new UniqueIndexCoverageError(tableId, index.name);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1052
1361
|
const uniqueKeyChanges = uniqueKeyEntries.length === 1 ? uniqueKeyEntries[0] : undefined;
|
|
1053
1362
|
const keyCache = this.#uniqueKeyCache;
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1363
|
+
const keyCacheVersionValid = keyCache?.version === input.expectedManifestVersion;
|
|
1364
|
+
const keyCacheValid = uniqueKeyChanges !== undefined &&
|
|
1365
|
+
keyCacheVersionValid &&
|
|
1366
|
+
keyCache.tableId === uniqueKeyChanges.tableId;
|
|
1057
1367
|
let keyState;
|
|
1058
1368
|
if (uniqueKeyChanges !== undefined) {
|
|
1059
1369
|
let conflictToken;
|
|
@@ -1097,7 +1407,15 @@ export class IndexedDbBlockStore {
|
|
|
1097
1407
|
}
|
|
1098
1408
|
for (const [tableId, entries] of byTable) {
|
|
1099
1409
|
const unionTokens = [...new Set(entries.flatMap((entry) => entry.keyTokens))];
|
|
1100
|
-
const
|
|
1410
|
+
const usesCache = keyCacheVersionValid && keyCache.tableId === tableId;
|
|
1411
|
+
const state = usesCache
|
|
1412
|
+
? {
|
|
1413
|
+
existing: new Set(unionTokens.filter((token) => keyCache.present.has(token))),
|
|
1414
|
+
index: keyCache.index,
|
|
1415
|
+
chunks: keyCache.chunks,
|
|
1416
|
+
fullPresent: keyCache.present,
|
|
1417
|
+
}
|
|
1418
|
+
: await readChunkedUniqueKeys(catalog, tableId, unionTokens);
|
|
1101
1419
|
const present = state.existing;
|
|
1102
1420
|
const original = new Set(present);
|
|
1103
1421
|
for (const entry of entries) {
|
|
@@ -1116,6 +1434,10 @@ export class IndexedDbBlockStore {
|
|
|
1116
1434
|
tableId,
|
|
1117
1435
|
addedTokens: unionTokens.filter((token) => present.has(token) && !original.has(token)),
|
|
1118
1436
|
removedTokens: unionTokens.filter((token) => !present.has(token) && original.has(token)),
|
|
1437
|
+
index: state.index,
|
|
1438
|
+
chunks: state.chunks,
|
|
1439
|
+
...(state.fullPresent === undefined ? {} : { fullPresent: state.fullPresent }),
|
|
1440
|
+
usedCache: usesCache,
|
|
1119
1441
|
});
|
|
1120
1442
|
}
|
|
1121
1443
|
}
|
|
@@ -1339,26 +1661,100 @@ export class IndexedDbBlockStore {
|
|
|
1339
1661
|
}
|
|
1340
1662
|
}
|
|
1341
1663
|
}
|
|
1664
|
+
let multiKeyCachePlan;
|
|
1665
|
+
const rememberMultiKeyState = (work, state, chunk, replacement) => {
|
|
1666
|
+
if (work.usedCache) {
|
|
1667
|
+
multiKeyCachePlan =
|
|
1668
|
+
replacement === undefined
|
|
1669
|
+
? { action: "advance", tableId: work.tableId, ...state, ...(chunk ? { chunk } : {}) }
|
|
1670
|
+
: { action: "replace", tableId: work.tableId, present: replacement, ...state };
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (multiKeyCachePlan !== undefined || work.fullPresent === undefined)
|
|
1674
|
+
return;
|
|
1675
|
+
const present = replacement ?? new Set(work.fullPresent);
|
|
1676
|
+
if (replacement === undefined && chunk !== undefined)
|
|
1677
|
+
applyChunk(present, chunk);
|
|
1678
|
+
multiKeyCachePlan = { action: "replace", tableId: work.tableId, present, ...state };
|
|
1679
|
+
};
|
|
1342
1680
|
for (const work of multiKeyWork) {
|
|
1343
|
-
{
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1681
|
+
if (work.addedTokens.length === 0 && work.removedTokens.length === 0) {
|
|
1682
|
+
rememberMultiKeyState(work, { chunks: work.chunks, index: work.index });
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
const chunk = {
|
|
1686
|
+
addedTokens: work.addedTokens,
|
|
1687
|
+
removedTokens: work.removedTokens,
|
|
1688
|
+
};
|
|
1689
|
+
if (work.index.versions.length + 1 <= UNIQUE_KEY_TAIL_CHUNK_LIMIT) {
|
|
1690
|
+
const nextIndex = {
|
|
1691
|
+
versions: [...work.index.versions, manifest.version],
|
|
1692
|
+
hasBase: work.index.hasBase,
|
|
1693
|
+
...(work.index.partitions === undefined ? {} : { partitions: work.index.partitions }),
|
|
1694
|
+
...(work.index.tokenCount === undefined ? {} : { tokenCount: work.index.tokenCount }),
|
|
1695
|
+
};
|
|
1696
|
+
catalog.put(chunk, uniqueKeyChunkKey(work.tableId, manifest.version));
|
|
1697
|
+
// Appending a tail chunk leaves the base alone, so its recorded size still holds.
|
|
1698
|
+
catalog.put(nextIndex, uniqueKeyChunkIndexKey(work.tableId));
|
|
1699
|
+
rememberMultiKeyState(work, { chunks: [...work.chunks, chunk], index: nextIndex }, chunk);
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const tailChunks = [...work.chunks, chunk];
|
|
1703
|
+
const merged = work.index.hasBase && countTailTokens(tailChunks) < UNIQUE_KEY_PARTITION_TARGET
|
|
1704
|
+
? mergeTailChunks(tailChunks)
|
|
1705
|
+
: undefined;
|
|
1706
|
+
const incremental = merged === undefined
|
|
1707
|
+
? await foldTailIntoTouchedPartitions(catalog, work.tableId, work.index, tailChunks)
|
|
1708
|
+
: undefined;
|
|
1709
|
+
for (const version of work.index.versions) {
|
|
1710
|
+
catalog.delete(uniqueKeyChunkKey(work.tableId, version));
|
|
1711
|
+
}
|
|
1712
|
+
if (merged !== undefined) {
|
|
1713
|
+
const nextIndex = {
|
|
1714
|
+
versions: [manifest.version],
|
|
1715
|
+
hasBase: true,
|
|
1716
|
+
...(work.index.partitions === undefined ? {} : { partitions: work.index.partitions }),
|
|
1717
|
+
...(work.index.tokenCount === undefined ? {} : { tokenCount: work.index.tokenCount }),
|
|
1718
|
+
};
|
|
1719
|
+
catalog.put(merged, uniqueKeyChunkKey(work.tableId, manifest.version));
|
|
1720
|
+
catalog.put(nextIndex, uniqueKeyChunkIndexKey(work.tableId));
|
|
1721
|
+
rememberMultiKeyState(work, { chunks: [merged], index: nextIndex }, chunk);
|
|
1722
|
+
continue;
|
|
1356
1723
|
}
|
|
1724
|
+
if (incremental !== undefined) {
|
|
1725
|
+
const nextIndex = {
|
|
1726
|
+
versions: [],
|
|
1727
|
+
hasBase: true,
|
|
1728
|
+
partitions: incremental.partitions,
|
|
1729
|
+
tokenCount: incremental.tokenCount,
|
|
1730
|
+
};
|
|
1731
|
+
catalog.put(nextIndex, uniqueKeyChunkIndexKey(work.tableId));
|
|
1732
|
+
rememberMultiKeyState(work, { chunks: [], index: nextIndex }, chunk);
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
let present = work.fullPresent ?? (await readAllV2BaseTokens(catalog, work.tableId, work.index));
|
|
1736
|
+
if (work.fullPresent === undefined) {
|
|
1737
|
+
for (const existing of work.chunks)
|
|
1738
|
+
applyChunk(present, existing);
|
|
1739
|
+
}
|
|
1740
|
+
else if (work.usedCache) {
|
|
1741
|
+
present = new Set(present);
|
|
1742
|
+
}
|
|
1743
|
+
applyChunk(present, chunk);
|
|
1744
|
+
await replaceUniqueKeyMembership(catalog, work.tableId, present, true);
|
|
1745
|
+
const nextIndex = {
|
|
1746
|
+
versions: [],
|
|
1747
|
+
hasBase: true,
|
|
1748
|
+
partitions: Math.max(1, Math.ceil(present.size / UNIQUE_KEY_PARTITION_TARGET)),
|
|
1749
|
+
tokenCount: present.size,
|
|
1750
|
+
};
|
|
1751
|
+
rememberMultiKeyState(work, { chunks: [], index: nextIndex }, undefined, present);
|
|
1357
1752
|
}
|
|
1358
1753
|
// Full-text deltas apply atomically with the publish; a stale writer (one that committed
|
|
1359
1754
|
// segments to an indexed table without deltas) flips the affected columns to "invalid"
|
|
1360
1755
|
// instead of failing the data commit — the index self-heals through a rebuild.
|
|
1361
1756
|
const changedFtsTableIds = new Set(pendingSegments.map((segment) => segment.tableId));
|
|
1757
|
+
const scalarChangedTableIds = input.changedTableIds === undefined ? changedFtsTableIds : new Set(input.changedTableIds);
|
|
1362
1758
|
for (const tableId of changedFtsTableIds) {
|
|
1363
1759
|
const tableValue = await requestResult(catalog.get(`${TABLE_ID_PREFIX}${tableId}`));
|
|
1364
1760
|
if (tableValue === undefined)
|
|
@@ -1366,27 +1762,78 @@ export class IndexedDbBlockStore {
|
|
|
1366
1762
|
const forTable = (input.ftsChanges ?? []).find((entry) => entry.tableId === tableId);
|
|
1367
1763
|
const covered = new Set(forTable?.columns.map((column) => column.columnId) ?? []);
|
|
1368
1764
|
const invalidated = invalidateUncoveredFtsColumns(structuredClone(tableValue), covered);
|
|
1369
|
-
|
|
1370
|
-
|
|
1765
|
+
const withFts = invalidated ?? structuredClone(tableValue);
|
|
1766
|
+
const withSecondary = scalarChangedTableIds.has(tableId)
|
|
1767
|
+
? invalidateUncoveredSecondaryIndexes(withFts, covered)
|
|
1768
|
+
: undefined;
|
|
1769
|
+
if (withSecondary !== undefined || invalidated !== undefined) {
|
|
1770
|
+
catalog.put(structuredClone(withSecondary ?? invalidated), `${TABLE_ID_PREFIX}${tableId}`);
|
|
1371
1771
|
}
|
|
1372
1772
|
}
|
|
1773
|
+
const ftsDeltaCounts = [];
|
|
1373
1774
|
for (const ftsEntry of input.ftsChanges ?? []) {
|
|
1775
|
+
const tableValue = await requestResult(catalog.get(`${TABLE_ID_PREFIX}${ftsEntry.tableId}`));
|
|
1776
|
+
if (tableValue === undefined)
|
|
1777
|
+
continue;
|
|
1778
|
+
const active = activePostingStorageColumnIds(structuredClone(tableValue));
|
|
1374
1779
|
for (const column of ftsEntry.columns) {
|
|
1780
|
+
if (!active.has(column.columnId))
|
|
1781
|
+
continue;
|
|
1375
1782
|
catalog.put(structuredClone({
|
|
1376
1783
|
postings: column.postings,
|
|
1377
1784
|
totalTokens: column.totalTokens,
|
|
1378
1785
|
}), ftsChunkKey(ftsEntry.tableId, column.columnId, manifest.version));
|
|
1379
1786
|
const indexKey = ftsChunkIndexKey(ftsEntry.tableId, column.columnId);
|
|
1380
1787
|
const chunkIndex = (await requestResult(catalog.get(indexKey)));
|
|
1381
|
-
|
|
1788
|
+
const versions = [...(chunkIndex?.versions ?? []), manifest.version];
|
|
1789
|
+
catalog.put({ versions }, indexKey);
|
|
1790
|
+
ftsDeltaCounts.push({
|
|
1791
|
+
tableId: ftsEntry.tableId,
|
|
1792
|
+
columnId: column.columnId,
|
|
1793
|
+
count: versions.length,
|
|
1794
|
+
});
|
|
1382
1795
|
}
|
|
1383
1796
|
}
|
|
1797
|
+
if (ftsDeltaCounts.length > 0)
|
|
1798
|
+
manifest.ftsDeltaCounts = ftsDeltaCounts;
|
|
1384
1799
|
transactionStore.put(committed, committed.id);
|
|
1385
1800
|
const settle = () => {
|
|
1386
1801
|
if (uniqueKeyEntries.length > 1) {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1802
|
+
if (multiKeyCachePlan?.action === "advance") {
|
|
1803
|
+
const retained = this.#uniqueKeyCache;
|
|
1804
|
+
if (retained?.tableId === multiKeyCachePlan.tableId &&
|
|
1805
|
+
retained.version === input.expectedManifestVersion) {
|
|
1806
|
+
if (multiKeyCachePlan.chunk !== undefined) {
|
|
1807
|
+
applyChunk(retained.present, multiKeyCachePlan.chunk);
|
|
1808
|
+
}
|
|
1809
|
+
retained.chunks = multiKeyCachePlan.chunks;
|
|
1810
|
+
retained.index = multiKeyCachePlan.index;
|
|
1811
|
+
retained.version = manifest.version;
|
|
1812
|
+
if (uniqueKeyCacheRetainedBytes(retained.present, retained.chunks) >
|
|
1813
|
+
this.#uniqueKeyCacheBytes) {
|
|
1814
|
+
this.#uniqueKeyCache = undefined;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
this.#uniqueKeyCache = undefined;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
else if (multiKeyCachePlan?.action === "replace") {
|
|
1822
|
+
this.#uniqueKeyCache =
|
|
1823
|
+
uniqueKeyCacheRetainedBytes(multiKeyCachePlan.present, multiKeyCachePlan.chunks) <=
|
|
1824
|
+
this.#uniqueKeyCacheBytes
|
|
1825
|
+
? {
|
|
1826
|
+
version: manifest.version,
|
|
1827
|
+
tableId: multiKeyCachePlan.tableId,
|
|
1828
|
+
present: multiKeyCachePlan.present,
|
|
1829
|
+
chunks: multiKeyCachePlan.chunks,
|
|
1830
|
+
index: multiKeyCachePlan.index,
|
|
1831
|
+
}
|
|
1832
|
+
: undefined;
|
|
1833
|
+
}
|
|
1834
|
+
else {
|
|
1835
|
+
this.#uniqueKeyCache = undefined;
|
|
1836
|
+
}
|
|
1390
1837
|
}
|
|
1391
1838
|
// The unique-key cache follows the same rule as the manifest cache below: advance it only
|
|
1392
1839
|
// after the durable commit. Commits from this instance cannot silently change another
|
|
@@ -1398,15 +1845,23 @@ export class IndexedDbBlockStore {
|
|
|
1398
1845
|
keyCache.chunks.push(keyCachePlan.chunk);
|
|
1399
1846
|
keyCache.index = keyCachePlan.index;
|
|
1400
1847
|
keyCache.version = manifest.version;
|
|
1848
|
+
if (uniqueKeyCacheRetainedBytes(keyCache.present, keyCache.chunks) >
|
|
1849
|
+
this.#uniqueKeyCacheBytes) {
|
|
1850
|
+
this.#uniqueKeyCache = undefined;
|
|
1851
|
+
}
|
|
1401
1852
|
}
|
|
1402
1853
|
else if (keyCachePlan.action === "replace") {
|
|
1403
|
-
this.#uniqueKeyCache =
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1854
|
+
this.#uniqueKeyCache =
|
|
1855
|
+
uniqueKeyCacheRetainedBytes(keyCachePlan.present, keyCachePlan.chunks) <=
|
|
1856
|
+
this.#uniqueKeyCacheBytes
|
|
1857
|
+
? {
|
|
1858
|
+
version: manifest.version,
|
|
1859
|
+
tableId: uniqueKeyChanges.tableId,
|
|
1860
|
+
present: keyCachePlan.present,
|
|
1861
|
+
chunks: keyCachePlan.chunks,
|
|
1862
|
+
index: keyCachePlan.index,
|
|
1863
|
+
}
|
|
1864
|
+
: undefined;
|
|
1410
1865
|
}
|
|
1411
1866
|
else if (this.#uniqueKeyCache?.tableId === uniqueKeyChanges.tableId) {
|
|
1412
1867
|
this.#uniqueKeyCache = undefined;
|
|
@@ -1580,7 +2035,7 @@ export class IndexedDbBlockStore {
|
|
|
1580
2035
|
const transaction = this.#transaction("gc", "readonly");
|
|
1581
2036
|
const records = await readCursorPage(transaction.objectStore("gc"), limit, (value) => asCompactionJobEnvelope(value), (key) => typeof key === "string" &&
|
|
1582
2037
|
key.startsWith(COMPACTION_JOB_KEY_PREFIX) &&
|
|
1583
|
-
(afterId === null || key > compactionJobKey(afterId)));
|
|
2038
|
+
(afterId === null || key > compactionJobKey(afterId)), afterId === null ? COMPACTION_JOB_KEY_PREFIX : compactionJobKey(afterId));
|
|
1584
2039
|
await transactionDone(transaction);
|
|
1585
2040
|
return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
|
|
1586
2041
|
}
|
|
@@ -2027,6 +2482,11 @@ export class IndexedDbBlockStore {
|
|
|
2027
2482
|
const uniqueKeyTokens = record.uniqueKeyColumnId === undefined
|
|
2028
2483
|
? []
|
|
2029
2484
|
: [...(await readAllUniqueKeyTokens(catalog, record.id))];
|
|
2485
|
+
const secondaryUniqueKeys = await Promise.all(Object.entries(record.secondaryIndexes ?? {}).flatMap(([indexId, index]) => index.uniqueEnforced === true
|
|
2486
|
+
? [
|
|
2487
|
+
readAllUniqueKeyTokens(catalog, secondaryUniqueKeyNamespace(record.id, indexId)).then((tokens) => ({ indexId, keyTokens: [...tokens] })),
|
|
2488
|
+
]
|
|
2489
|
+
: []));
|
|
2030
2490
|
const fts = [];
|
|
2031
2491
|
const ftsColumns = { ...(record.ftsColumns ?? {}) };
|
|
2032
2492
|
for (const [columnId, state] of Object.entries(ftsColumns)) {
|
|
@@ -2037,7 +2497,7 @@ export class IndexedDbBlockStore {
|
|
|
2037
2497
|
ftsColumns[columnId] = { ...state, state: "invalid" };
|
|
2038
2498
|
continue;
|
|
2039
2499
|
}
|
|
2040
|
-
const prefix =
|
|
2500
|
+
const prefix = ftsBaseChunkPrefix(record.id, columnId, toc.generation);
|
|
2041
2501
|
const chunks = (await Promise.all(toc.boundaries.map((_, ordinal) => requestResult(catalog.get(`${prefix}${String(ordinal).padStart(6, "0")}`)))));
|
|
2042
2502
|
fts.push({
|
|
2043
2503
|
columnId,
|
|
@@ -2046,11 +2506,31 @@ export class IndexedDbBlockStore {
|
|
|
2046
2506
|
chunks: chunks.map((chunk) => chunk ?? []),
|
|
2047
2507
|
});
|
|
2048
2508
|
}
|
|
2509
|
+
const secondaryIndexes = { ...(record.secondaryIndexes ?? {}) };
|
|
2510
|
+
for (const [indexId, state] of Object.entries(secondaryIndexes)) {
|
|
2511
|
+
const storageColumnId = state.storageColumnId;
|
|
2512
|
+
const toc = (await requestResult(catalog.get(`${FTS_BASE_INDEX_PREFIX}${record.id}/${storageColumnId}`)));
|
|
2513
|
+
if (state.state !== "ready" || toc?.coversVersion !== version) {
|
|
2514
|
+
const { buildId: _abandonedBuild, ...invalid } = state;
|
|
2515
|
+
void _abandonedBuild;
|
|
2516
|
+
secondaryIndexes[indexId] = { ...invalid, state: "invalid" };
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
const prefix = ftsBaseChunkPrefix(record.id, storageColumnId, toc.generation);
|
|
2520
|
+
const chunks = (await Promise.all(toc.boundaries.map((_, ordinal) => requestResult(catalog.get(`${prefix}${String(ordinal).padStart(6, "0")}`)))));
|
|
2521
|
+
fts.push({
|
|
2522
|
+
columnId: storageColumnId,
|
|
2523
|
+
coversVersion: toc.coversVersion,
|
|
2524
|
+
totalTokens: 0,
|
|
2525
|
+
chunks: chunks.map((chunk) => chunk ?? []),
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2049
2528
|
await transactionDone(transaction);
|
|
2050
2529
|
tables.push({
|
|
2051
2530
|
record: {
|
|
2052
2531
|
...record,
|
|
2053
2532
|
...(Object.keys(ftsColumns).length === 0 ? {} : { ftsColumns }),
|
|
2533
|
+
...(Object.keys(secondaryIndexes).length === 0 ? {} : { secondaryIndexes }),
|
|
2054
2534
|
},
|
|
2055
2535
|
nextRowId: rowIdValue ?? 1n,
|
|
2056
2536
|
autoIncrement: record.columns.flatMap((column, index) => {
|
|
@@ -2058,6 +2538,7 @@ export class IndexedDbBlockStore {
|
|
|
2058
2538
|
return next === undefined ? [] : [{ columnId: column.id, next }];
|
|
2059
2539
|
}),
|
|
2060
2540
|
uniqueKeyTokens,
|
|
2541
|
+
secondaryUniqueKeys,
|
|
2061
2542
|
fts,
|
|
2062
2543
|
});
|
|
2063
2544
|
}
|
|
@@ -2076,6 +2557,7 @@ export class IndexedDbBlockStore {
|
|
|
2076
2557
|
if ((await this.listTables()).length > 0) {
|
|
2077
2558
|
throw new Error("This store already holds a catalog");
|
|
2078
2559
|
}
|
|
2560
|
+
validateSnapshotCatalog(snapshot.tables);
|
|
2079
2561
|
const totalBytes = snapshot.blocks.reduce((total, block) => total + block.bytes.byteLength, 0);
|
|
2080
2562
|
let writtenBytes = 0;
|
|
2081
2563
|
options.onProgress?.({ phase: "blocks", writtenBytes, totalBytes });
|
|
@@ -2140,6 +2622,9 @@ export class IndexedDbBlockStore {
|
|
|
2140
2622
|
const catalog = transaction.objectStore("catalog");
|
|
2141
2623
|
catalog.put(structuredClone(record), `${TABLE_ID_PREFIX}${record.id}`);
|
|
2142
2624
|
catalog.put(record.id, `${TABLE_NAME_PREFIX}${record.name}`);
|
|
2625
|
+
for (const [indexId, index] of Object.entries(record.secondaryIndexes ?? {})) {
|
|
2626
|
+
catalog.put({ tableId: record.id, indexId }, `${SECONDARY_INDEX_NAME_PREFIX}${index.name}`);
|
|
2627
|
+
}
|
|
2143
2628
|
catalog.put(table.nextRowId, `${ROW_ID_PREFIX}${record.id}`);
|
|
2144
2629
|
for (const entry of table.autoIncrement) {
|
|
2145
2630
|
catalog.put(entry.next, `${AUTO_INCREMENT_PREFIX}${record.id}/${entry.columnId}`);
|
|
@@ -2160,6 +2645,13 @@ export class IndexedDbBlockStore {
|
|
|
2160
2645
|
};
|
|
2161
2646
|
catalog.put(index, uniqueKeyChunkIndexKey(record.id));
|
|
2162
2647
|
}
|
|
2648
|
+
for (const entry of table.secondaryUniqueKeys ?? []) {
|
|
2649
|
+
const index = record.secondaryIndexes?.[entry.indexId];
|
|
2650
|
+
if (index?.unique !== true) {
|
|
2651
|
+
throw new TypeError(`Snapshot has membership for an unknown UNIQUE index: ${entry.indexId}`);
|
|
2652
|
+
}
|
|
2653
|
+
await replaceUniqueKeyMembership(catalog, secondaryUniqueKeyNamespace(record.id, entry.indexId), entry.keyTokens, true);
|
|
2654
|
+
}
|
|
2163
2655
|
for (const entry of table.fts) {
|
|
2164
2656
|
const prefix = `${FTS_BASE_PREFIX}${record.id}/${entry.columnId}/`;
|
|
2165
2657
|
const boundaries = entry.chunks.map((chunk) => ({
|
|
@@ -2175,8 +2667,27 @@ export class IndexedDbBlockStore {
|
|
|
2175
2667
|
await transactionDone(transaction);
|
|
2176
2668
|
}
|
|
2177
2669
|
close() {
|
|
2670
|
+
// close() is a memory boundary too: callers often retain their service graph after tearing
|
|
2671
|
+
// down a database, so do not leave table names, a manifest, or unique-key membership hanging
|
|
2672
|
+
// from the closed adapter.
|
|
2673
|
+
this.#tableIdsByName.clear();
|
|
2674
|
+
this.#uniqueKeyCache = undefined;
|
|
2675
|
+
this.#manifestCache = undefined;
|
|
2178
2676
|
this.#db.close();
|
|
2179
2677
|
}
|
|
2678
|
+
/** Test-only counters that make the adapter's retained-state bounds regression-testable. */
|
|
2679
|
+
_residentStateForTests() {
|
|
2680
|
+
const uniqueKeyCache = this.#uniqueKeyCache;
|
|
2681
|
+
return {
|
|
2682
|
+
tableNameCacheEntries: this.#tableIdsByName.size,
|
|
2683
|
+
uniqueKeyCacheEntries: uniqueKeyCache?.present.size ?? 0,
|
|
2684
|
+
uniqueKeyCacheBytes: uniqueKeyCache === undefined
|
|
2685
|
+
? 0
|
|
2686
|
+
: uniqueKeyCacheRetainedBytes(uniqueKeyCache.present, uniqueKeyCache.chunks),
|
|
2687
|
+
uniqueKeyCacheLimitBytes: this.#uniqueKeyCacheBytes,
|
|
2688
|
+
manifestCacheBlockIds: this.#manifestCache?.blockIds.size ?? 0,
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2180
2691
|
#transaction(stores, mode) {
|
|
2181
2692
|
return this.#db.transaction(stores, mode, { durability: this.#durability });
|
|
2182
2693
|
}
|
|
@@ -2189,10 +2700,11 @@ function requestResult(request) {
|
|
|
2189
2700
|
});
|
|
2190
2701
|
});
|
|
2191
2702
|
}
|
|
2192
|
-
function readCursorPage(store, limit, decode, acceptKey) {
|
|
2703
|
+
function readCursorPage(store, limit, decode, acceptKey, seekKey) {
|
|
2193
2704
|
return new Promise((resolve, reject) => {
|
|
2194
2705
|
const records = [];
|
|
2195
2706
|
const request = store.openCursor();
|
|
2707
|
+
let seekPending = seekKey !== undefined;
|
|
2196
2708
|
request.onerror = () => reject(request.error ?? new Error("IndexedDB cursor failed"));
|
|
2197
2709
|
request.onsuccess = () => {
|
|
2198
2710
|
const cursor = request.result;
|
|
@@ -2201,6 +2713,13 @@ function readCursorPage(store, limit, decode, acceptKey) {
|
|
|
2201
2713
|
return;
|
|
2202
2714
|
}
|
|
2203
2715
|
try {
|
|
2716
|
+
if (seekPending) {
|
|
2717
|
+
seekPending = false;
|
|
2718
|
+
if (cursor.key < (seekKey ?? cursor.key)) {
|
|
2719
|
+
cursor.continue(seekKey);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2204
2723
|
if (acceptKey(cursor.key))
|
|
2205
2724
|
records.push(decode(cursor.value));
|
|
2206
2725
|
if (records.length === limit)
|
|
@@ -2361,13 +2880,6 @@ function asSegmentRecord(value) {
|
|
|
2361
2880
|
function asLeaseRecord(value) {
|
|
2362
2881
|
return structuredClone(value);
|
|
2363
2882
|
}
|
|
2364
|
-
/**
|
|
2365
|
-
* Verifies the snapshot at `version` is readable: the record exists, is not pruned, and its
|
|
2366
|
-
* delta chain down to a checkpoint is intact. Block existence is verified inductively — every
|
|
2367
|
-
* commit proves its own added blocks exist before publishing, and garbage collection only
|
|
2368
|
-
* deletes blocks unreachable from every non-pruned manifest — so this stays O(chain) instead of
|
|
2369
|
-
* probing every live block on each transaction and lease creation.
|
|
2370
|
-
*/
|
|
2371
2883
|
/**
|
|
2372
2884
|
* The transaction record a commit needs: present, active, and at the expected revision —
|
|
2373
2885
|
* otherwise a `TransactionRecordConflictError`, the caller's storage transaction still open for
|
|
@@ -2533,13 +3045,11 @@ function assertGenericTransactionUpdateAllowed(record, update) {
|
|
|
2533
3045
|
}
|
|
2534
3046
|
async function assertGarbageCollectionCandidateProvenanceInTransaction(transaction, job) {
|
|
2535
3047
|
const manifestStore = transaction.objectStore("manifests");
|
|
2536
|
-
const candidateManifestBlockIds = [];
|
|
2537
3048
|
for (const version of job.candidateManifestVersions) {
|
|
2538
3049
|
const value = await requestResult(manifestStore.get(version));
|
|
2539
3050
|
if (value === undefined) {
|
|
2540
3051
|
throw new Error(`Garbage collection candidate manifest is missing: ${String(version)}`);
|
|
2541
3052
|
}
|
|
2542
|
-
candidateManifestBlockIds.push(await resolveManifestBlockSetInTransaction(manifestStore, asStoredManifestRecord(value)));
|
|
2543
3053
|
}
|
|
2544
3054
|
for (const id of job.candidateTransactionIds) {
|
|
2545
3055
|
const value = await requestResult(transaction.objectStore("transactions").get(id));
|
|
@@ -2550,8 +3060,22 @@ async function assertGarbageCollectionCandidateProvenanceInTransaction(transacti
|
|
|
2550
3060
|
throw new Error(`Garbage collection transaction candidate is not terminal: ${id}`);
|
|
2551
3061
|
}
|
|
2552
3062
|
}
|
|
3063
|
+
const candidateBlockIds = new Set(job.candidateBlockIds);
|
|
3064
|
+
const manifestProvenBlockIds = new Set();
|
|
3065
|
+
await visitObjectStoreSequentially(manifestStore, (value) => {
|
|
3066
|
+
const record = asStoredManifestRecord(value);
|
|
3067
|
+
for (const id of record.blockIds ?? []) {
|
|
3068
|
+
if (candidateBlockIds.has(id))
|
|
3069
|
+
manifestProvenBlockIds.add(id);
|
|
3070
|
+
}
|
|
3071
|
+
for (const id of record.addedBlockIds ?? []) {
|
|
3072
|
+
if (candidateBlockIds.has(id))
|
|
3073
|
+
manifestProvenBlockIds.add(id);
|
|
3074
|
+
}
|
|
3075
|
+
return manifestProvenBlockIds.size === candidateBlockIds.size;
|
|
3076
|
+
});
|
|
2553
3077
|
const blockHasProvenance = async (id) => {
|
|
2554
|
-
if (
|
|
3078
|
+
if (manifestProvenBlockIds.has(id))
|
|
2555
3079
|
return true;
|
|
2556
3080
|
const transactionProven = await visitObjectStoreSequentially(transaction.objectStore("transactions"), (value) => {
|
|
2557
3081
|
const record = asTransactionRecord(value);
|
|
@@ -2573,33 +3097,8 @@ async function assertGarbageCollectionCandidateProvenanceInTransaction(transacti
|
|
|
2573
3097
|
throw new Error(`Garbage collection block candidate has no persisted provenance: ${id}`);
|
|
2574
3098
|
}
|
|
2575
3099
|
for (const id of job.candidateSegmentIds) {
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
return record.status === "aborted" && record.pendingSegmentIds.includes(id);
|
|
2579
|
-
});
|
|
2580
|
-
if (!proven) {
|
|
2581
|
-
proven = await visitObjectStoreSequentially(transaction.objectStore("gc"), (value) => {
|
|
2582
|
-
if (!isCompactionJobEnvelope(value))
|
|
2583
|
-
return false;
|
|
2584
|
-
const record = asCompactionJobEnvelope(value);
|
|
2585
|
-
return (isTerminalCompactionJob(record) &&
|
|
2586
|
-
(record.sourceSegmentIds.includes(id) || record.outputSegmentId === id));
|
|
2587
|
-
});
|
|
2588
|
-
}
|
|
2589
|
-
if (!proven) {
|
|
2590
|
-
const segmentValue = await requestResult(transaction.objectStore("segments").get(id));
|
|
2591
|
-
if (segmentValue !== undefined) {
|
|
2592
|
-
const blockIds = segmentBlockIds(asSegmentRecord(segmentValue));
|
|
2593
|
-
proven = blockIds.length > 0;
|
|
2594
|
-
for (const blockId of blockIds) {
|
|
2595
|
-
if (!(await blockHasProvenance(blockId))) {
|
|
2596
|
-
proven = false;
|
|
2597
|
-
break;
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
if (proven)
|
|
3100
|
+
const segmentValue = await requestResult(transaction.objectStore("segments").get(id));
|
|
3101
|
+
if (segmentValue !== undefined)
|
|
2603
3102
|
continue;
|
|
2604
3103
|
throw new Error(`Garbage collection segment candidate has no persisted provenance: ${id}`);
|
|
2605
3104
|
}
|
|
@@ -2643,68 +3142,16 @@ async function isManifestVersionPinnedInTransaction(transaction, version, curren
|
|
|
2643
3142
|
return linkedTransaction.committedVersion === version;
|
|
2644
3143
|
});
|
|
2645
3144
|
}
|
|
2646
|
-
const MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES = 4_096;
|
|
2647
3145
|
async function collectBoundedPhysicalRootsInTransaction(transaction, candidateSegmentIds, candidateBlockIds, newlyPrunedVersions) {
|
|
2648
3146
|
const candidateSegments = new Set(candidateSegmentIds);
|
|
2649
3147
|
const candidateBlocks = new Set(candidateBlockIds);
|
|
2650
|
-
const probeBlockIds = new Set(candidateBlockIds);
|
|
2651
|
-
const probeSegmentIds = new Set(candidateSegmentIds);
|
|
2652
|
-
const relatedSegments = new Map();
|
|
2653
|
-
const dependencyOverflow = await visitObjectStoreSequentially(transaction.objectStore("segments"), (value) => {
|
|
2654
|
-
const segment = asSegmentRecord(value);
|
|
2655
|
-
const ids = segmentBlockIds(segment);
|
|
2656
|
-
if (!candidateSegments.has(segment.id) && !ids.some((id) => candidateBlocks.has(id)))
|
|
2657
|
-
return;
|
|
2658
|
-
const newBlockIds = new Set(ids.filter((id) => !probeBlockIds.has(id)));
|
|
2659
|
-
if (relatedSegments.size >= MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES ||
|
|
2660
|
-
probeBlockIds.size + newBlockIds.size > MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES) {
|
|
2661
|
-
return true;
|
|
2662
|
-
}
|
|
2663
|
-
relatedSegments.set(segment.id, segment);
|
|
2664
|
-
probeSegmentIds.add(segment.id);
|
|
2665
|
-
ids.forEach((id) => probeBlockIds.add(id));
|
|
2666
|
-
return false;
|
|
2667
|
-
});
|
|
2668
|
-
if (dependencyOverflow) {
|
|
2669
|
-
return { blockIds: new Set(candidateBlockIds), segmentIds: new Set(candidateSegmentIds) };
|
|
2670
|
-
}
|
|
2671
|
-
const directBlockRoots = new Set();
|
|
2672
3148
|
const directSegmentRoots = new Set();
|
|
2673
|
-
// One ascending pass resolves every manifest with a running set (pruned records still apply
|
|
2674
|
-
// their deltas so the chain stays coherent); a probed block roots when it is a member at any
|
|
2675
|
-
// version that still counts.
|
|
2676
|
-
{
|
|
2677
|
-
const running = new Set();
|
|
2678
|
-
const unrooted = new Set(probeBlockIds);
|
|
2679
|
-
await visitObjectStoreSequentially(transaction.objectStore("manifests"), (value) => {
|
|
2680
|
-
const record = asStoredManifestRecord(value);
|
|
2681
|
-
applyManifestRecord(running, record);
|
|
2682
|
-
if (record.prunedAt !== undefined || newlyPrunedVersions.has(record.version))
|
|
2683
|
-
return;
|
|
2684
|
-
if (unrooted.size === 0)
|
|
2685
|
-
return true;
|
|
2686
|
-
for (const id of unrooted) {
|
|
2687
|
-
if (running.has(id)) {
|
|
2688
|
-
directBlockRoots.add(id);
|
|
2689
|
-
unrooted.delete(id);
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
2692
|
-
return false;
|
|
2693
|
-
});
|
|
2694
|
-
}
|
|
2695
|
-
const ownerTransactionIds = new Set([...relatedSegments.values()].map((segment) => segment.transactionId));
|
|
2696
|
-
const activeOwnerTransactionIds = new Set();
|
|
2697
3149
|
await visitObjectStoreSequentially(transaction.objectStore("transactions"), (value) => {
|
|
2698
3150
|
const record = asTransactionRecord(value);
|
|
2699
3151
|
if (record.status !== "active")
|
|
2700
3152
|
return;
|
|
2701
|
-
if (ownerTransactionIds.has(record.id))
|
|
2702
|
-
activeOwnerTransactionIds.add(record.id);
|
|
2703
|
-
for (const id of record.pendingBlockIds)
|
|
2704
|
-
if (probeBlockIds.has(id))
|
|
2705
|
-
directBlockRoots.add(id);
|
|
2706
3153
|
for (const id of record.pendingSegmentIds)
|
|
2707
|
-
if (
|
|
3154
|
+
if (candidateSegments.has(id))
|
|
2708
3155
|
directSegmentRoots.add(id);
|
|
2709
3156
|
});
|
|
2710
3157
|
await visitObjectStoreSequentially(transaction.objectStore("gc"), (value) => {
|
|
@@ -2713,39 +3160,84 @@ async function collectBoundedPhysicalRootsInTransaction(transaction, candidateSe
|
|
|
2713
3160
|
const job = asCompactionJobEnvelope(value);
|
|
2714
3161
|
if (isTerminalCompactionJob(job))
|
|
2715
3162
|
return;
|
|
2716
|
-
for (const id of job.sourceBlockIds)
|
|
2717
|
-
if (probeBlockIds.has(id))
|
|
2718
|
-
directBlockRoots.add(id);
|
|
2719
|
-
for (const id of job.outputBlockIds)
|
|
2720
|
-
if (probeBlockIds.has(id))
|
|
2721
|
-
directBlockRoots.add(id);
|
|
2722
3163
|
for (const id of job.sourceSegmentIds)
|
|
2723
|
-
if (
|
|
3164
|
+
if (candidateSegments.has(id))
|
|
2724
3165
|
directSegmentRoots.add(id);
|
|
2725
|
-
if (job.outputSegmentId !== null &&
|
|
3166
|
+
if (job.outputSegmentId !== null && candidateSegments.has(job.outputSegmentId)) {
|
|
2726
3167
|
directSegmentRoots.add(job.outputSegmentId);
|
|
2727
3168
|
}
|
|
2728
3169
|
});
|
|
3170
|
+
// A dependency fan-out must never become a permanent root. Probe one id at a time after a
|
|
3171
|
+
// small bounded cache fills: this trades extra IndexedDB cursor work for fixed working memory
|
|
3172
|
+
// on unusually wide segments while preserving exact reachability.
|
|
3173
|
+
const directBlockRootCache = new Map();
|
|
3174
|
+
const isDirectBlockRoot = async (id) => {
|
|
3175
|
+
const cached = directBlockRootCache.get(id);
|
|
3176
|
+
if (cached !== undefined)
|
|
3177
|
+
return cached;
|
|
3178
|
+
let rooted = await visitObjectStoreSequentially(transaction.objectStore("transactions"), (value) => {
|
|
3179
|
+
const record = asTransactionRecord(value);
|
|
3180
|
+
return record.status === "active" && record.pendingBlockIds.includes(id);
|
|
3181
|
+
});
|
|
3182
|
+
if (!rooted) {
|
|
3183
|
+
rooted = await visitObjectStoreSequentially(transaction.objectStore("gc"), (value) => {
|
|
3184
|
+
if (!isCompactionJobEnvelope(value))
|
|
3185
|
+
return false;
|
|
3186
|
+
const job = asCompactionJobEnvelope(value);
|
|
3187
|
+
return (!isTerminalCompactionJob(job) &&
|
|
3188
|
+
(job.sourceBlockIds.includes(id) || job.outputBlockIds.includes(id)));
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
if (!rooted) {
|
|
3192
|
+
let present = false;
|
|
3193
|
+
rooted = await visitObjectStoreSequentially(transaction.objectStore("manifests"), (value) => {
|
|
3194
|
+
const record = asStoredManifestRecord(value);
|
|
3195
|
+
if (record.blockIds !== undefined)
|
|
3196
|
+
present = record.blockIds.includes(id);
|
|
3197
|
+
else {
|
|
3198
|
+
if (record.removedBlockIds?.includes(id) === true)
|
|
3199
|
+
present = false;
|
|
3200
|
+
if (record.addedBlockIds?.includes(id) === true)
|
|
3201
|
+
present = true;
|
|
3202
|
+
}
|
|
3203
|
+
return present && record.prunedAt === undefined && !newlyPrunedVersions.has(record.version);
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
if (directBlockRootCache.size < 4_096)
|
|
3207
|
+
directBlockRootCache.set(id, rooted);
|
|
3208
|
+
return rooted;
|
|
3209
|
+
};
|
|
2729
3210
|
const rootedBlockIds = new Set();
|
|
2730
3211
|
const rootedSegmentIds = new Set();
|
|
2731
|
-
|
|
3212
|
+
const segmentStore = transaction.objectStore("segments");
|
|
3213
|
+
const transactionStore = transaction.objectStore("transactions");
|
|
3214
|
+
for (const id of candidateSegmentIds) {
|
|
3215
|
+
const value = await requestResult(segmentStore.get(id));
|
|
3216
|
+
if (value === undefined)
|
|
3217
|
+
continue;
|
|
3218
|
+
const segment = asSegmentRecord(value);
|
|
2732
3219
|
const ids = segmentBlockIds(segment);
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
3220
|
+
const ownerValue = await requestResult(transactionStore.get(segment.transactionId));
|
|
3221
|
+
const owner = ownerValue === undefined ? undefined : asTransactionRecord(ownerValue);
|
|
3222
|
+
let allBlocksRooted = ids.length > 0;
|
|
3223
|
+
for (const blockId of ids) {
|
|
3224
|
+
if (await isDirectBlockRoot(blockId))
|
|
3225
|
+
continue;
|
|
3226
|
+
allBlocksRooted = false;
|
|
3227
|
+
break;
|
|
3228
|
+
}
|
|
3229
|
+
if (directSegmentRoots.has(segment.id) || owner?.status === "active" || allBlocksRooted) {
|
|
3230
|
+
rootedSegmentIds.add(segment.id);
|
|
3231
|
+
for (const blockId of ids)
|
|
3232
|
+
if (candidateBlocks.has(blockId))
|
|
3233
|
+
rootedBlockIds.add(blockId);
|
|
2741
3234
|
}
|
|
2742
3235
|
}
|
|
2743
3236
|
for (const id of candidateBlockIds)
|
|
2744
|
-
if (
|
|
3237
|
+
if (await isDirectBlockRoot(id))
|
|
2745
3238
|
rootedBlockIds.add(id);
|
|
2746
|
-
for (const id of
|
|
2747
|
-
|
|
2748
|
-
rootedSegmentIds.add(id);
|
|
3239
|
+
for (const id of directSegmentRoots)
|
|
3240
|
+
rootedSegmentIds.add(id);
|
|
2749
3241
|
return { blockIds: rootedBlockIds, segmentIds: rootedSegmentIds };
|
|
2750
3242
|
}
|
|
2751
3243
|
function segmentBlockIds(segment) {
|
|
@@ -2819,6 +3311,43 @@ function ftsChunkKey(tableId, columnId, version) {
|
|
|
2819
3311
|
function ftsChunkIndexKey(tableId, columnId) {
|
|
2820
3312
|
return `${FTS_CHUNK_PREFIX}index/${tableId}/${columnId}`;
|
|
2821
3313
|
}
|
|
3314
|
+
function ftsBaseBuildKey(tableId, columnId) {
|
|
3315
|
+
return `${FTS_BASE_BUILD_PREFIX}${tableId}/${columnId}`;
|
|
3316
|
+
}
|
|
3317
|
+
function ftsBaseChunkPrefix(tableId, columnId, generation) {
|
|
3318
|
+
return generation === undefined
|
|
3319
|
+
? `${FTS_BASE_PREFIX}${tableId}/${columnId}/`
|
|
3320
|
+
: `${FTS_BASE_PREFIX}${tableId}/${columnId}/generation/${encodeURIComponent(generation)}/`;
|
|
3321
|
+
}
|
|
3322
|
+
async function deleteActiveFtsBaseBuild(store, tableId, columnId) {
|
|
3323
|
+
const key = ftsBaseBuildKey(tableId, columnId);
|
|
3324
|
+
const marker = (await requestResult(store.get(key)));
|
|
3325
|
+
if (marker !== undefined) {
|
|
3326
|
+
const prefix = ftsBaseChunkPrefix(tableId, columnId, marker.buildId);
|
|
3327
|
+
for (let ordinal = 0; ordinal < marker.boundaries.length; ordinal += 1) {
|
|
3328
|
+
store.delete(`${prefix}${String(ordinal).padStart(6, "0")}`);
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
store.delete(key);
|
|
3332
|
+
}
|
|
3333
|
+
async function deleteFtsColumnRecords(store, tableId, columnId) {
|
|
3334
|
+
const tocKey = `${FTS_BASE_INDEX_PREFIX}${tableId}/${columnId}`;
|
|
3335
|
+
const deltaIndexKey = ftsChunkIndexKey(tableId, columnId);
|
|
3336
|
+
const [toc, deltaIndex] = (await Promise.all([
|
|
3337
|
+
requestResult(store.get(tocKey)),
|
|
3338
|
+
requestResult(store.get(deltaIndexKey)),
|
|
3339
|
+
]));
|
|
3340
|
+
const chunkPrefix = ftsBaseChunkPrefix(tableId, columnId, toc?.generation);
|
|
3341
|
+
for (let ordinal = 0; ordinal < (toc?.boundaries.length ?? 0); ordinal += 1) {
|
|
3342
|
+
store.delete(`${chunkPrefix}${String(ordinal).padStart(6, "0")}`);
|
|
3343
|
+
}
|
|
3344
|
+
for (const version of deltaIndex?.versions ?? []) {
|
|
3345
|
+
store.delete(ftsChunkKey(tableId, columnId, version));
|
|
3346
|
+
}
|
|
3347
|
+
store.delete(tocKey);
|
|
3348
|
+
store.delete(deltaIndexKey);
|
|
3349
|
+
await deleteActiveFtsBaseBuild(store, tableId, columnId);
|
|
3350
|
+
}
|
|
2822
3351
|
function validateAutoIncrementReservation(count, atLeast) {
|
|
2823
3352
|
if (!Number.isSafeInteger(count) || count < 0) {
|
|
2824
3353
|
throw new RangeError("Auto-increment reservation count must be a non-negative whole number");
|
|
@@ -2858,15 +3387,6 @@ function isTempRunPageKey(key, ownerId, runId) {
|
|
|
2858
3387
|
function tempOwnerKey(ownerId) {
|
|
2859
3388
|
return ["owner", ownerId];
|
|
2860
3389
|
}
|
|
2861
|
-
function validateTableColumns(columns) {
|
|
2862
|
-
if (columns.length === 0)
|
|
2863
|
-
throw new TypeError("A table needs at least one column");
|
|
2864
|
-
const ids = new Set(columns.map(({ id }) => id));
|
|
2865
|
-
const names = new Set(columns.map(({ name }) => name));
|
|
2866
|
-
if (ids.size !== columns.length || names.size !== columns.length) {
|
|
2867
|
-
throw new TypeError("Table columns must have unique IDs and names");
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
3390
|
function validateTempOwnerRecord(record) {
|
|
2871
3391
|
validateTempId(record.ownerId, "Temp run owner ID");
|
|
2872
3392
|
validateLeaseExpiration(record.expiresAt);
|
|
@@ -2947,6 +3467,28 @@ function uniqueKeyChunkKey(tableId, version) {
|
|
|
2947
3467
|
function uniqueKeyBasePartKey(tableId, ordinal) {
|
|
2948
3468
|
return [UNIQUE_KEY_BASE_PART, tableId, ordinal];
|
|
2949
3469
|
}
|
|
3470
|
+
/** Replaces or removes one complete unique-membership namespace inside the caller's transaction. */
|
|
3471
|
+
async function replaceUniqueKeyMembership(store, namespaceId, keyTokens, retainEmpty) {
|
|
3472
|
+
const raw = await requestResult(store.get(uniqueKeyChunkIndexKey(namespaceId)));
|
|
3473
|
+
const previous = raw === undefined ? undefined : asUniqueKeyChunkIndex(raw);
|
|
3474
|
+
for (let ordinal = 0; ordinal < (previous?.partitions ?? (previous?.hasBase ? 1 : 0)); ordinal += 1) {
|
|
3475
|
+
store.delete(uniqueKeyBasePartKey(namespaceId, ordinal));
|
|
3476
|
+
}
|
|
3477
|
+
for (const version of previous?.versions ?? []) {
|
|
3478
|
+
store.delete(uniqueKeyChunkKey(namespaceId, version));
|
|
3479
|
+
}
|
|
3480
|
+
const tokenCount = keyTokens instanceof Set ? keyTokens.size : keyTokens.length;
|
|
3481
|
+
if (!retainEmpty && tokenCount === 0) {
|
|
3482
|
+
store.delete(uniqueKeyChunkIndexKey(namespaceId));
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
const partitions = Math.max(1, Math.ceil(tokenCount / UNIQUE_KEY_PARTITION_TARGET));
|
|
3486
|
+
const parts = Array.from({ length: partitions }, () => []);
|
|
3487
|
+
for (const token of keyTokens)
|
|
3488
|
+
parts[fnv1a(token) % partitions]?.push(token);
|
|
3489
|
+
parts.forEach((tokens, ordinal) => store.put(tokens, uniqueKeyBasePartKey(namespaceId, ordinal)));
|
|
3490
|
+
store.put({ versions: [], hasBase: true, partitions, tokenCount }, uniqueKeyChunkIndexKey(namespaceId));
|
|
3491
|
+
}
|
|
2950
3492
|
/** Accepts the legacy bare-array shape (tail only, no base) and the current object shape. */
|
|
2951
3493
|
function asUniqueKeyChunkIndex(value) {
|
|
2952
3494
|
const versionsOf = (candidate) => Array.isArray(candidate)
|
|
@@ -2997,6 +3539,27 @@ function replayChunks(chunks) {
|
|
|
2997
3539
|
applyChunk(present, chunk);
|
|
2998
3540
|
return present;
|
|
2999
3541
|
}
|
|
3542
|
+
/**
|
|
3543
|
+
* Conservative retained-size model for the optional complete-membership cache. Strings are the
|
|
3544
|
+
* dominant payload; the fixed allowance covers Set buckets, array slots, and chunk objects.
|
|
3545
|
+
* This is deliberately not the on-disk size — it models the JavaScript graph we keep alive.
|
|
3546
|
+
*/
|
|
3547
|
+
function uniqueKeyCacheRetainedBytes(present, chunks) {
|
|
3548
|
+
let bytes = 256;
|
|
3549
|
+
for (const token of present)
|
|
3550
|
+
bytes += 32 + token.length * 2;
|
|
3551
|
+
for (const chunk of chunks) {
|
|
3552
|
+
bytes += 64;
|
|
3553
|
+
// Count the strings again even when an added token is also in `present`. Engines may share
|
|
3554
|
+
// the string allocation, but a budget must not depend on that implementation detail; removed
|
|
3555
|
+
// tokens are retained only by these arrays and can be arbitrarily long.
|
|
3556
|
+
for (const token of chunk.addedTokens)
|
|
3557
|
+
bytes += 8 + token.length * 2;
|
|
3558
|
+
for (const token of chunk.removedTokens)
|
|
3559
|
+
bytes += 8 + token.length * 2;
|
|
3560
|
+
}
|
|
3561
|
+
return bytes;
|
|
3562
|
+
}
|
|
3000
3563
|
function asBasePartition(value) {
|
|
3001
3564
|
if (!isStringArray(value))
|
|
3002
3565
|
throw new Error("Unique-key base partition is invalid");
|