@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.
Files changed (145) hide show
  1. package/README.md +21 -32
  2. package/dist/engine/artifact-cache.d.ts +2 -0
  3. package/dist/engine/artifact-cache.d.ts.map +1 -1
  4. package/dist/engine/artifact-cache.js +5 -0
  5. package/dist/engine/artifact-cache.js.map +1 -1
  6. package/dist/engine/batch.d.ts +6 -1
  7. package/dist/engine/batch.d.ts.map +1 -1
  8. package/dist/engine/batch.js +17 -6
  9. package/dist/engine/batch.js.map +1 -1
  10. package/dist/engine/catalog.d.ts +22 -2
  11. package/dist/engine/catalog.d.ts.map +1 -1
  12. package/dist/engine/catalog.js +30 -3
  13. package/dist/engine/catalog.js.map +1 -1
  14. package/dist/engine/client.d.ts +2 -0
  15. package/dist/engine/client.d.ts.map +1 -1
  16. package/dist/engine/client.js +20 -10
  17. package/dist/engine/client.js.map +1 -1
  18. package/dist/engine/database.d.ts +67 -5
  19. package/dist/engine/database.d.ts.map +1 -1
  20. package/dist/engine/database.js +3508 -369
  21. package/dist/engine/database.js.map +1 -1
  22. package/dist/engine/defaults.d.ts +4 -7
  23. package/dist/engine/defaults.d.ts.map +1 -1
  24. package/dist/engine/defaults.js +47 -21
  25. package/dist/engine/defaults.js.map +1 -1
  26. package/dist/engine/fts.d.ts.map +1 -1
  27. package/dist/engine/fts.js +2 -0
  28. package/dist/engine/fts.js.map +1 -1
  29. package/dist/engine/index.d.ts +1 -0
  30. package/dist/engine/index.d.ts.map +1 -1
  31. package/dist/engine/index.js +1 -0
  32. package/dist/engine/index.js.map +1 -1
  33. package/dist/engine/live.d.ts.map +1 -1
  34. package/dist/engine/live.js +10 -2
  35. package/dist/engine/live.js.map +1 -1
  36. package/dist/engine/optimizer.d.ts.map +1 -1
  37. package/dist/engine/optimizer.js +258 -23
  38. package/dist/engine/optimizer.js.map +1 -1
  39. package/dist/engine/query-cache.d.ts +1 -1
  40. package/dist/engine/query-cache.d.ts.map +1 -1
  41. package/dist/engine/query-cache.js +3 -1
  42. package/dist/engine/query-cache.js.map +1 -1
  43. package/dist/engine/query.d.ts +130 -22
  44. package/dist/engine/query.d.ts.map +1 -1
  45. package/dist/engine/query.js +1344 -241
  46. package/dist/engine/query.js.map +1 -1
  47. package/dist/engine/schema-wire.d.ts +6 -2
  48. package/dist/engine/schema-wire.d.ts.map +1 -1
  49. package/dist/engine/schema-wire.js +40 -33
  50. package/dist/engine/schema-wire.js.map +1 -1
  51. package/dist/engine/schema.d.ts +157 -76
  52. package/dist/engine/schema.d.ts.map +1 -1
  53. package/dist/engine/schema.js +548 -103
  54. package/dist/engine/schema.js.map +1 -1
  55. package/dist/engine/sort-keys.d.ts +4 -3
  56. package/dist/engine/sort-keys.d.ts.map +1 -1
  57. package/dist/engine/sort-keys.js +42 -27
  58. package/dist/engine/sort-keys.js.map +1 -1
  59. package/dist/engine/sql-domains.d.ts +26 -0
  60. package/dist/engine/sql-domains.d.ts.map +1 -0
  61. package/dist/engine/sql-domains.js +421 -0
  62. package/dist/engine/sql-domains.js.map +1 -0
  63. package/dist/engine/sql-driver.d.ts +19 -0
  64. package/dist/engine/sql-driver.d.ts.map +1 -0
  65. package/dist/engine/sql-driver.js +2 -0
  66. package/dist/engine/sql-driver.js.map +1 -0
  67. package/dist/engine/sql-json.d.ts +7 -8
  68. package/dist/engine/sql-json.d.ts.map +1 -1
  69. package/dist/engine/sql-json.js +28 -14
  70. package/dist/engine/sql-json.js.map +1 -1
  71. package/dist/engine/sql-semantics.d.ts +2 -0
  72. package/dist/engine/sql-semantics.d.ts.map +1 -1
  73. package/dist/engine/sql-semantics.js +69 -3
  74. package/dist/engine/sql-semantics.js.map +1 -1
  75. package/dist/engine/vector.d.ts +5 -1
  76. package/dist/engine/vector.d.ts.map +1 -1
  77. package/dist/engine/vector.js +433 -61
  78. package/dist/engine/vector.js.map +1 -1
  79. package/dist/engine/worker-host.d.ts +1 -0
  80. package/dist/engine/worker-host.d.ts.map +1 -1
  81. package/dist/engine/worker-host.js +4 -0
  82. package/dist/engine/worker-host.js.map +1 -1
  83. package/dist/plan/index.d.ts +3 -3
  84. package/dist/plan/index.js +3 -3
  85. package/dist/storage/indexeddb.d.ts +44 -5
  86. package/dist/storage/indexeddb.d.ts.map +1 -1
  87. package/dist/storage/indexeddb.js +738 -175
  88. package/dist/storage/indexeddb.js.map +1 -1
  89. package/dist/storage/memory.d.ts +27 -10
  90. package/dist/storage/memory.d.ts.map +1 -1
  91. package/dist/storage/memory.js +67 -18
  92. package/dist/storage/memory.js.map +1 -1
  93. package/dist/storage/opfs/leader.d.ts +28 -6
  94. package/dist/storage/opfs/leader.d.ts.map +1 -1
  95. package/dist/storage/opfs/leader.js +301 -34
  96. package/dist/storage/opfs/leader.js.map +1 -1
  97. package/dist/storage/opfs/rpc.d.ts.map +1 -1
  98. package/dist/storage/opfs/rpc.js +2 -1
  99. package/dist/storage/opfs/rpc.js.map +1 -1
  100. package/dist/storage/opfs/store.d.ts +34 -5
  101. package/dist/storage/opfs/store.d.ts.map +1 -1
  102. package/dist/storage/opfs/store.js +41 -0
  103. package/dist/storage/opfs/store.js.map +1 -1
  104. package/dist/storage/snapshot.d.ts +14 -0
  105. package/dist/storage/snapshot.d.ts.map +1 -1
  106. package/dist/storage/snapshot.js +44 -0
  107. package/dist/storage/snapshot.js.map +1 -1
  108. package/dist/storage/toolkit/index.d.ts +1 -1
  109. package/dist/storage/toolkit/index.d.ts.map +1 -1
  110. package/dist/storage/toolkit/index.js +1 -1
  111. package/dist/storage/toolkit/index.js.map +1 -1
  112. package/dist/storage/toolkit/record-core.d.ts +19 -5
  113. package/dist/storage/toolkit/record-core.d.ts.map +1 -1
  114. package/dist/storage/toolkit/record-core.js +353 -78
  115. package/dist/storage/toolkit/record-core.js.map +1 -1
  116. package/dist/storage/toolkit/wire.d.ts +15 -0
  117. package/dist/storage/toolkit/wire.d.ts.map +1 -1
  118. package/dist/storage/toolkit/wire.js +134 -0
  119. package/dist/storage/toolkit/wire.js.map +1 -1
  120. package/dist/storage/types.d.ts +213 -31
  121. package/dist/storage/types.d.ts.map +1 -1
  122. package/dist/storage/types.js +291 -20
  123. package/dist/storage/types.js.map +1 -1
  124. package/dist/testing/block-store-conformance.d.ts.map +1 -1
  125. package/dist/testing/block-store-conformance.js +153 -2
  126. package/dist/testing/block-store-conformance.js.map +1 -1
  127. package/dist/testing/index.d.ts +9 -0
  128. package/dist/testing/index.d.ts.map +1 -1
  129. package/dist/testing/index.js +23 -0
  130. package/dist/testing/index.js.map +1 -1
  131. package/dist/testing/simulator.d.ts +98 -0
  132. package/dist/testing/simulator.d.ts.map +1 -0
  133. package/dist/testing/simulator.js +560 -0
  134. package/dist/testing/simulator.js.map +1 -0
  135. package/dist/testing/sqllogictest.d.ts +90 -0
  136. package/dist/testing/sqllogictest.d.ts.map +1 -0
  137. package/dist/testing/sqllogictest.js +435 -0
  138. package/dist/testing/sqllogictest.js.map +1 -0
  139. package/dist/transactions/index.d.ts +18 -3
  140. package/dist/transactions/index.d.ts.map +1 -1
  141. package/dist/transactions/index.js +64 -8
  142. package/dist/transactions/index.js.map +1 -1
  143. package/package.json +5 -3
  144. package/postgres-feature-profile.json +223 -0
  145. 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
- const { ftsColumns: previousFts, triggers: previousTriggers, ...base } = record;
299
- const nextFts = update.ftsColumns === undefined ? previousFts : update.ftsColumns;
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 = `${FTS_BASE_PREFIX}${tableId}/${columnId}/`;
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 = input.chunks.length; ordinal < previousCount; ordinal += 1) {
380
- store.delete(`${chunkPrefix}${String(ordinal).padStart(6, "0")}`);
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 = `${FTS_BASE_PREFIX}${tableId}/${columnId}/`;
415
- const wantedOrdinals = (toc?.boundaries ?? []).flatMap((boundary, ordinal) => terms.some((term) => {
416
- const upper = term.prefix ? `${term.term}￿` : term.term;
417
- return term.term <= boundary.last && upper >= boundary.first;
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 segments = segmentValues.flat().map((value) => asSegmentRecord(value));
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 and keeps the membership cache;
1049
- // multi-entry commits (atomic write scopes) take the simpler merged path further down and
1050
- // drop the cache.
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 keyCacheValid = keyCache !== undefined &&
1055
- keyCache.tableId === uniqueKeyChanges?.tableId &&
1056
- keyCache.version === input.expectedManifestVersion;
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 state = await readChunkedUniqueKeys(catalog, tableId, unionTokens);
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
- if (work.addedTokens.length > 0 || work.removedTokens.length > 0) {
1345
- const indexValue = (await requestResult(catalog.get(uniqueKeyChunkIndexKey(work.tableId))));
1346
- const index = indexValue ?? { versions: [], hasBase: false };
1347
- catalog.put({ addedTokens: work.addedTokens, removedTokens: work.removedTokens }, uniqueKeyChunkKey(work.tableId, manifest.version));
1348
- catalog.put({
1349
- versions: [...index.versions, manifest.version],
1350
- hasBase: index.hasBase,
1351
- ...(index.partitions === undefined ? {} : { partitions: index.partitions }),
1352
- // Appending a tail chunk leaves the base alone, so its recorded size still holds.
1353
- ...(index.tokenCount === undefined ? {} : { tokenCount: index.tokenCount }),
1354
- }, uniqueKeyChunkIndexKey(work.tableId));
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
- if (invalidated !== undefined) {
1370
- catalog.put(structuredClone(invalidated), `${TABLE_ID_PREFIX}${tableId}`);
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
- catalog.put({ versions: [...(chunkIndex?.versions ?? []), manifest.version] }, indexKey);
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
- // Multi-entry commits take the merged path above; the single-table membership cache
1388
- // cannot describe them, so it drops and rebuilds on the next bulk load.
1389
- this.#uniqueKeyCache = undefined;
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
- version: manifest.version,
1405
- tableId: uniqueKeyChanges.tableId,
1406
- present: keyCachePlan.present,
1407
- chunks: keyCachePlan.chunks,
1408
- index: keyCachePlan.index,
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 = `${FTS_BASE_PREFIX}${record.id}/${columnId}/`;
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 (candidateManifestBlockIds.some((blockIds) => blockIds.has(id)))
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
- let proven = await visitObjectStoreSequentially(transaction.objectStore("transactions"), (value) => {
2577
- const record = asTransactionRecord(value);
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 (probeSegmentIds.has(id))
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 (probeSegmentIds.has(id))
3164
+ if (candidateSegments.has(id))
2724
3165
  directSegmentRoots.add(id);
2725
- if (job.outputSegmentId !== null && probeSegmentIds.has(job.outputSegmentId)) {
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
- for (const segment of relatedSegments.values()) {
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
- if (directSegmentRoots.has(segment.id) ||
2734
- activeOwnerTransactionIds.has(segment.transactionId) ||
2735
- (ids.length > 0 && ids.every((id) => directBlockRoots.has(id)))) {
2736
- if (candidateSegments.has(segment.id))
2737
- rootedSegmentIds.add(segment.id);
2738
- for (const id of ids)
2739
- if (candidateBlocks.has(id))
2740
- rootedBlockIds.add(id);
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 (directBlockRoots.has(id))
3237
+ if (await isDirectBlockRoot(id))
2745
3238
  rootedBlockIds.add(id);
2746
- for (const id of candidateSegmentIds)
2747
- if (directSegmentRoots.has(id))
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");