@minnowdb/core 0.2.0 → 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 (146) 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 +3 -0
  15. package/dist/engine/client.d.ts.map +1 -1
  16. package/dist/engine/client.js +21 -10
  17. package/dist/engine/client.js.map +1 -1
  18. package/dist/engine/database.d.ts +71 -5
  19. package/dist/engine/database.d.ts.map +1 -1
  20. package/dist/engine/database.js +3619 -421
  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 +6 -2
  34. package/dist/engine/live.d.ts.map +1 -1
  35. package/dist/engine/live.js +10 -2
  36. package/dist/engine/live.js.map +1 -1
  37. package/dist/engine/optimizer.d.ts.map +1 -1
  38. package/dist/engine/optimizer.js +258 -23
  39. package/dist/engine/optimizer.js.map +1 -1
  40. package/dist/engine/query-cache.d.ts +1 -1
  41. package/dist/engine/query-cache.d.ts.map +1 -1
  42. package/dist/engine/query-cache.js +3 -1
  43. package/dist/engine/query-cache.js.map +1 -1
  44. package/dist/engine/query.d.ts +132 -22
  45. package/dist/engine/query.d.ts.map +1 -1
  46. package/dist/engine/query.js +1368 -246
  47. package/dist/engine/query.js.map +1 -1
  48. package/dist/engine/schema-wire.d.ts +6 -2
  49. package/dist/engine/schema-wire.d.ts.map +1 -1
  50. package/dist/engine/schema-wire.js +40 -33
  51. package/dist/engine/schema-wire.js.map +1 -1
  52. package/dist/engine/schema.d.ts +157 -76
  53. package/dist/engine/schema.d.ts.map +1 -1
  54. package/dist/engine/schema.js +548 -103
  55. package/dist/engine/schema.js.map +1 -1
  56. package/dist/engine/sort-keys.d.ts +4 -3
  57. package/dist/engine/sort-keys.d.ts.map +1 -1
  58. package/dist/engine/sort-keys.js +42 -27
  59. package/dist/engine/sort-keys.js.map +1 -1
  60. package/dist/engine/sql-domains.d.ts +26 -0
  61. package/dist/engine/sql-domains.d.ts.map +1 -0
  62. package/dist/engine/sql-domains.js +421 -0
  63. package/dist/engine/sql-domains.js.map +1 -0
  64. package/dist/engine/sql-driver.d.ts +19 -0
  65. package/dist/engine/sql-driver.d.ts.map +1 -0
  66. package/dist/engine/sql-driver.js +2 -0
  67. package/dist/engine/sql-driver.js.map +1 -0
  68. package/dist/engine/sql-json.d.ts +7 -8
  69. package/dist/engine/sql-json.d.ts.map +1 -1
  70. package/dist/engine/sql-json.js +28 -14
  71. package/dist/engine/sql-json.js.map +1 -1
  72. package/dist/engine/sql-semantics.d.ts +2 -0
  73. package/dist/engine/sql-semantics.d.ts.map +1 -1
  74. package/dist/engine/sql-semantics.js +69 -3
  75. package/dist/engine/sql-semantics.js.map +1 -1
  76. package/dist/engine/vector.d.ts +5 -1
  77. package/dist/engine/vector.d.ts.map +1 -1
  78. package/dist/engine/vector.js +433 -61
  79. package/dist/engine/vector.js.map +1 -1
  80. package/dist/engine/worker-host.d.ts +1 -0
  81. package/dist/engine/worker-host.d.ts.map +1 -1
  82. package/dist/engine/worker-host.js +8 -0
  83. package/dist/engine/worker-host.js.map +1 -1
  84. package/dist/plan/index.d.ts +3 -3
  85. package/dist/plan/index.js +3 -3
  86. package/dist/storage/indexeddb.d.ts +44 -5
  87. package/dist/storage/indexeddb.d.ts.map +1 -1
  88. package/dist/storage/indexeddb.js +738 -175
  89. package/dist/storage/indexeddb.js.map +1 -1
  90. package/dist/storage/memory.d.ts +27 -10
  91. package/dist/storage/memory.d.ts.map +1 -1
  92. package/dist/storage/memory.js +67 -18
  93. package/dist/storage/memory.js.map +1 -1
  94. package/dist/storage/opfs/leader.d.ts +28 -6
  95. package/dist/storage/opfs/leader.d.ts.map +1 -1
  96. package/dist/storage/opfs/leader.js +301 -34
  97. package/dist/storage/opfs/leader.js.map +1 -1
  98. package/dist/storage/opfs/rpc.d.ts.map +1 -1
  99. package/dist/storage/opfs/rpc.js +2 -1
  100. package/dist/storage/opfs/rpc.js.map +1 -1
  101. package/dist/storage/opfs/store.d.ts +34 -5
  102. package/dist/storage/opfs/store.d.ts.map +1 -1
  103. package/dist/storage/opfs/store.js +41 -0
  104. package/dist/storage/opfs/store.js.map +1 -1
  105. package/dist/storage/snapshot.d.ts +14 -0
  106. package/dist/storage/snapshot.d.ts.map +1 -1
  107. package/dist/storage/snapshot.js +44 -0
  108. package/dist/storage/snapshot.js.map +1 -1
  109. package/dist/storage/toolkit/index.d.ts +1 -1
  110. package/dist/storage/toolkit/index.d.ts.map +1 -1
  111. package/dist/storage/toolkit/index.js +1 -1
  112. package/dist/storage/toolkit/index.js.map +1 -1
  113. package/dist/storage/toolkit/record-core.d.ts +19 -5
  114. package/dist/storage/toolkit/record-core.d.ts.map +1 -1
  115. package/dist/storage/toolkit/record-core.js +353 -78
  116. package/dist/storage/toolkit/record-core.js.map +1 -1
  117. package/dist/storage/toolkit/wire.d.ts +15 -0
  118. package/dist/storage/toolkit/wire.d.ts.map +1 -1
  119. package/dist/storage/toolkit/wire.js +134 -0
  120. package/dist/storage/toolkit/wire.js.map +1 -1
  121. package/dist/storage/types.d.ts +213 -31
  122. package/dist/storage/types.d.ts.map +1 -1
  123. package/dist/storage/types.js +291 -20
  124. package/dist/storage/types.js.map +1 -1
  125. package/dist/testing/block-store-conformance.d.ts.map +1 -1
  126. package/dist/testing/block-store-conformance.js +153 -2
  127. package/dist/testing/block-store-conformance.js.map +1 -1
  128. package/dist/testing/index.d.ts +9 -0
  129. package/dist/testing/index.d.ts.map +1 -1
  130. package/dist/testing/index.js +23 -0
  131. package/dist/testing/index.js.map +1 -1
  132. package/dist/testing/simulator.d.ts +98 -0
  133. package/dist/testing/simulator.d.ts.map +1 -0
  134. package/dist/testing/simulator.js +560 -0
  135. package/dist/testing/simulator.js.map +1 -0
  136. package/dist/testing/sqllogictest.d.ts +90 -0
  137. package/dist/testing/sqllogictest.d.ts.map +1 -0
  138. package/dist/testing/sqllogictest.js +435 -0
  139. package/dist/testing/sqllogictest.js.map +1 -0
  140. package/dist/transactions/index.d.ts +18 -3
  141. package/dist/transactions/index.d.ts.map +1 -1
  142. package/dist/transactions/index.js +64 -8
  143. package/dist/transactions/index.js.map +1 -1
  144. package/package.json +5 -3
  145. package/postgres-feature-profile.json +223 -0
  146. package/sql-feature-matrix.json +179 -251
@@ -1,5 +1,5 @@
1
- import { CompactionJobConflictError, createManifest, createGarbageCollectionJobRecord, advanceGarbageCollectionJobRecord, collectFtsCandidates, invalidateUncoveredFtsColumns, GarbageCollectionJobConflictError, LeaseConflictError, SnapshotManifestMissingError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, normalizeCompactionJobRecord, normalizeSegmentRecord, updateCompactionJobRecord, updateTransactionRecord, WriteConflictError, } from "../types.js";
2
- import { selectLiveRecords } from "../snapshot.js";
1
+ import { CompactionJobConflictError, createManifest, createGarbageCollectionJobRecord, advanceGarbageCollectionJobRecord, collectFtsCandidates, collectFtsPostings, activePostingStorageColumnIds, invalidateUncoveredFtsColumns, invalidateUncoveredSecondaryIndexes, GarbageCollectionJobConflictError, LeaseConflictError, SnapshotManifestMissingError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, UniqueIndexCoverageError, normalizeCompactionJobRecord, normalizeSegmentRecord, updateCompactionJobRecord, updateTransactionRecord, validateTableColumns, validateSecondaryIndexes, secondaryIndexColumnIds, secondaryUniqueKeyNamespace, WriteConflictError, } from "../types.js";
2
+ import { selectLiveRecords, validateSnapshotCatalog, } from "../snapshot.js";
3
3
  /**
4
4
  * The synchronous record-state machine behind `MemoryBlockStore` and `OpfsBlockStore`, offered
5
5
  * to adapter authors as the semantics half of a `BlockStore`: every record family the contract
@@ -92,23 +92,47 @@ export class RecordCore {
92
92
  */
93
93
  listTempOwnerIdsPage(afterOwnerId, limit, extraOwnerIds) {
94
94
  validatePageLimit(limit);
95
- const ownerIds = new Set(this.#tempOwners.keys());
95
+ const records = [];
96
+ const add = (ownerId) => {
97
+ if (afterOwnerId !== null && ownerId <= afterOwnerId)
98
+ return;
99
+ const index = records.findIndex((existing) => existing >= ownerId);
100
+ if (records[index] === ownerId)
101
+ return;
102
+ if (index < 0)
103
+ records.push(ownerId);
104
+ else
105
+ records.splice(index, 0, ownerId);
106
+ if (records.length > limit)
107
+ records.pop();
108
+ };
109
+ for (const ownerId of this.#tempOwners.keys())
110
+ add(ownerId);
96
111
  for (const ownerId of extraOwnerIds)
97
- ownerIds.add(ownerId);
98
- const sorted = [...ownerIds]
99
- .filter((ownerId) => afterOwnerId === null || ownerId > afterOwnerId)
100
- .sort();
101
- const records = sorted.slice(0, limit);
112
+ add(ownerId);
102
113
  return {
103
114
  records,
104
- nextCursor: sorted.length > limit ? (records[records.length - 1] ?? null) : null,
115
+ nextCursor: records.length === limit ? (records.at(-1) ?? null) : null,
105
116
  };
106
117
  }
107
118
  addTable(record) {
119
+ validateTableColumns(record.columns);
120
+ validateSecondaryIndexes(record);
108
121
  if (this.#tables.has(record.id))
109
122
  throw new Error(`Table already exists: ${record.id}`);
110
123
  if (this.#tableIdsByName.has(record.name))
111
124
  throw new Error(`Table name already exists: ${record.name}`);
125
+ const newIndexNames = new Set();
126
+ for (const index of Object.values(record.secondaryIndexes ?? {})) {
127
+ if (newIndexNames.has(index.name))
128
+ throw new TypeError(`Index already exists: ${index.name}`);
129
+ newIndexNames.add(index.name);
130
+ for (const table of this.#tables.values()) {
131
+ if (Object.values(table.secondaryIndexes ?? {}).some((existing) => existing.name === index.name)) {
132
+ throw new TypeError(`Index already exists: ${index.name}`);
133
+ }
134
+ }
135
+ }
112
136
  this.#tables.set(record.id, structuredClone(record));
113
137
  this.#tableIdsByName.set(record.name, record.id);
114
138
  this.#catalogEpoch += 1;
@@ -123,10 +147,50 @@ export class RecordCore {
123
147
  if (record === undefined || actualRevision !== expectedRevision) {
124
148
  throw new TableRecordConflictError(id, expectedRevision, actualRevision);
125
149
  }
150
+ if (update.expectedManifestVersion !== undefined &&
151
+ (this.getCurrentManifestVersion() ?? null) !== update.expectedManifestVersion.value) {
152
+ throw new WriteConflictError(update.expectedManifestVersion.value, this.getCurrentManifestVersion());
153
+ }
154
+ let uniqueSeed;
155
+ if (update.uniqueKeySeed !== undefined) {
156
+ uniqueSeed = new Set();
157
+ for (const token of update.uniqueKeySeed.keyTokens) {
158
+ if (uniqueSeed.has(token))
159
+ throw new UniqueKeyConflictError(update.uniqueKeySeed.namespaceId, token);
160
+ uniqueSeed.add(token);
161
+ }
162
+ }
126
163
  if (update.columns !== undefined)
127
164
  validateTableColumns(update.columns);
128
- const { ftsColumns: previousFts, triggers: previousTriggers, ...base } = record;
129
- const nextFts = update.ftsColumns === undefined ? previousFts : update.ftsColumns;
165
+ const { ftsColumns: previousFts, secondaryIndexes: previousSecondary, triggers: previousTriggers, ...base } = record;
166
+ let nextFts = update.ftsColumns === undefined ? previousFts : update.ftsColumns;
167
+ let nextSecondary = update.secondaryIndexes === undefined ? previousSecondary : update.secondaryIndexes;
168
+ const retainedColumnIds = update.columns === undefined
169
+ ? undefined
170
+ : new Set(update.columns.map(({ id: columnId }) => columnId));
171
+ if (nextFts !== null && nextFts !== undefined && retainedColumnIds !== undefined) {
172
+ nextFts = Object.fromEntries(Object.entries(nextFts).filter(([columnId]) => retainedColumnIds.has(columnId)));
173
+ if (Object.keys(nextFts).length === 0)
174
+ nextFts = null;
175
+ }
176
+ if (nextSecondary !== null && nextSecondary !== undefined && retainedColumnIds !== undefined) {
177
+ nextSecondary = Object.fromEntries(Object.entries(nextSecondary).filter(([, index]) => secondaryIndexColumnIds(index).every((columnId) => retainedColumnIds.has(columnId))));
178
+ if (Object.keys(nextSecondary).length === 0)
179
+ nextSecondary = null;
180
+ }
181
+ const nextIndexNames = new Set();
182
+ for (const index of Object.values(nextSecondary ?? {})) {
183
+ if (nextIndexNames.has(index.name))
184
+ throw new TypeError(`Index already exists: ${index.name}`);
185
+ nextIndexNames.add(index.name);
186
+ for (const [otherTableId, table] of this.#tables) {
187
+ if (otherTableId === id)
188
+ continue;
189
+ if (Object.values(table.secondaryIndexes ?? {}).some((existing) => existing.name === index.name)) {
190
+ throw new TypeError(`Index already exists: ${index.name}`);
191
+ }
192
+ }
193
+ }
130
194
  const nextTriggers = update.triggers === undefined ? previousTriggers : update.triggers;
131
195
  const updated = {
132
196
  ...base,
@@ -134,11 +198,44 @@ export class RecordCore {
134
198
  ...(nextFts === null || nextFts === undefined
135
199
  ? {}
136
200
  : { ftsColumns: structuredClone(nextFts) }),
201
+ ...(nextSecondary === null || nextSecondary === undefined
202
+ ? {}
203
+ : { secondaryIndexes: structuredClone(nextSecondary) }),
137
204
  ...(nextTriggers === null || nextTriggers === undefined
138
205
  ? {}
139
206
  : { triggers: structuredClone(nextTriggers) }),
140
207
  revision: expectedRevision + 1,
141
208
  };
209
+ validateSecondaryIndexes(updated);
210
+ if (update.uniqueKeySeed !== undefined) {
211
+ const ownsSeed = Object.entries(updated.secondaryIndexes ?? {}).some(([indexId, index]) => index.unique === true &&
212
+ index.uniqueEnforced === true &&
213
+ index.state === "ready" &&
214
+ secondaryUniqueKeyNamespace(updated.id, indexId) === update.uniqueKeySeed?.namespaceId);
215
+ if (!ownsSeed) {
216
+ throw new TypeError("UNIQUE-index seed does not belong to a ready catalog index");
217
+ }
218
+ }
219
+ if (retainedColumnIds !== undefined) {
220
+ for (const column of record.columns) {
221
+ if (!retainedColumnIds.has(column.id))
222
+ this.removeFtsColumn(record.id, column.id);
223
+ }
224
+ }
225
+ const retainedIndexStorage = new Set(Object.values(nextSecondary ?? {}).map((index) => index.storageColumnId));
226
+ for (const index of Object.values(previousSecondary ?? {})) {
227
+ if (!retainedIndexStorage.has(index.storageColumnId)) {
228
+ this.removeFtsColumn(record.id, index.storageColumnId);
229
+ }
230
+ }
231
+ for (const [indexId, previous] of Object.entries(previousSecondary ?? {})) {
232
+ if (previous.unique === true && nextSecondary?.[indexId]?.unique !== true) {
233
+ this.#uniqueKeys.delete(secondaryUniqueKeyNamespace(record.id, indexId));
234
+ }
235
+ }
236
+ if (update.uniqueKeySeed !== undefined) {
237
+ this.#uniqueKeys.set(update.uniqueKeySeed.namespaceId, uniqueSeed ?? new Set());
238
+ }
142
239
  this.#tables.set(id, updated);
143
240
  this.#catalogEpoch += 1;
144
241
  return structuredClone(updated);
@@ -167,6 +264,11 @@ export class RecordCore {
167
264
  this.#nextAutoIncrement.delete(key);
168
265
  }
169
266
  this.#uniqueKeys.delete(id);
267
+ const secondaryUniquePrefix = `${id}\u0000secondary-index\u0000`;
268
+ for (const namespaceId of [...this.#uniqueKeys.keys()]) {
269
+ if (namespaceId.startsWith(secondaryUniquePrefix))
270
+ this.#uniqueKeys.delete(namespaceId);
271
+ }
170
272
  this.#nextRowIds.delete(id);
171
273
  this.#tableIdsByName.delete(record.name);
172
274
  this.#tables.delete(id);
@@ -183,6 +285,11 @@ export class RecordCore {
183
285
  }
184
286
  }
185
287
  }
288
+ removeFtsColumn(tableId, columnId) {
289
+ const key = `${tableId}/${columnId}`;
290
+ this.#ftsBases.delete(key);
291
+ this.#ftsDeltas.delete(key);
292
+ }
186
293
  readFtsCandidates(tableId, columnId, terms, upToVersion) {
187
294
  const key = `${tableId}/${columnId}`;
188
295
  const base = this.#ftsBases.get(key);
@@ -203,6 +310,7 @@ export class RecordCore {
203
310
  deltaChunkCount,
204
311
  totalTokens,
205
312
  coversVersion: base?.coversVersion ?? -1,
313
+ hasBase: base !== undefined,
206
314
  };
207
315
  }
208
316
  /**
@@ -210,8 +318,9 @@ export class RecordCore {
210
318
  * segments to a table with an active index but no delta for one of its columns flips that
211
319
  * column to "invalid" so a rebuild self-heals instead of the data commit failing.
212
320
  */
213
- #applyFtsChanges(pendingSegments, changeList, version) {
321
+ #applyFtsChanges(pendingSegments, changeList, version, logicallyChangedTableIds) {
214
322
  const changedTableIds = new Set(pendingSegments.map((segment) => segment.tableId));
323
+ const scalarChangedTableIds = logicallyChangedTableIds === undefined ? changedTableIds : new Set(logicallyChangedTableIds);
215
324
  for (const tableId of changedTableIds) {
216
325
  const record = this.#tables.get(tableId);
217
326
  if (record === undefined)
@@ -219,14 +328,26 @@ export class RecordCore {
219
328
  const forTable = (changeList ?? []).find((entry) => entry.tableId === tableId);
220
329
  const covered = new Set(forTable?.columns.map((column) => column.columnId) ?? []);
221
330
  const invalidated = invalidateUncoveredFtsColumns(record, covered);
222
- if (invalidated !== undefined)
331
+ const withFts = invalidated ?? record;
332
+ const withSecondary = scalarChangedTableIds.has(tableId)
333
+ ? invalidateUncoveredSecondaryIndexes(withFts, covered)
334
+ : undefined;
335
+ if (withSecondary !== undefined)
336
+ this.#tables.set(record.id, withSecondary);
337
+ else if (invalidated !== undefined)
223
338
  this.#tables.set(record.id, invalidated);
224
339
  }
225
340
  for (const changes of changeList ?? [])
226
341
  this.#applyFtsEntry(changes, version);
227
342
  }
228
343
  #applyFtsEntry(changes, version) {
344
+ const table = this.#tables.get(changes.tableId);
345
+ if (table === undefined)
346
+ return;
347
+ const active = activePostingStorageColumnIds(table);
229
348
  for (const column of changes.columns) {
349
+ if (!active.has(column.columnId))
350
+ continue;
230
351
  const key = `${changes.tableId}/${column.columnId}`;
231
352
  const deltas = this.#ftsDeltas.get(key) ??
232
353
  new Map();
@@ -256,6 +377,17 @@ export class RecordCore {
256
377
  }
257
378
  return { chunkLists, deltaChunkCount, deltaTokens };
258
379
  }
380
+ readFtsPostings(tableId, columnId, upToVersion) {
381
+ const base = this.#ftsBases.get(`${tableId}/${columnId}`);
382
+ const coversVersion = base?.coversVersion ?? -1;
383
+ const delta = this.readFtsDeltas(tableId, columnId, coversVersion, upToVersion);
384
+ return {
385
+ postings: collectFtsPostings([...(base?.chunks ?? []), ...delta.chunkLists]),
386
+ deltaChunkCount: delta.deltaChunkCount,
387
+ coversVersion,
388
+ hasBase: base !== undefined,
389
+ };
390
+ }
259
391
  /** The delta-pruning half of `writeFtsBase`, for stores that keep the base itself outside. */
260
392
  pruneFtsDeltas(tableId, columnId, coversVersion) {
261
393
  const deltas = this.#ftsDeltas.get(`${tableId}/${columnId}`);
@@ -316,6 +448,11 @@ export class RecordCore {
316
448
  .sort((left, right) => left.id.localeCompare(right.id))
317
449
  .map((record) => normalizeSegmentRecord(record));
318
450
  }
451
+ listSegmentPage(afterId, limit) {
452
+ validatePageLimit(limit);
453
+ const records = boundedRecordPage(this.#segments.values(), afterId, limit, (record) => record.id).map((record) => normalizeSegmentRecord(record));
454
+ return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
455
+ }
319
456
  removeSegment(id) {
320
457
  this.#segments.delete(id);
321
458
  }
@@ -354,16 +491,24 @@ export class RecordCore {
354
491
  getQueryCatalogState(tableNames) {
355
492
  const tables = tableNames.map((name) => this.getTableByName(name));
356
493
  const foundTableIds = new Set(tables.filter((table) => table !== undefined).map((table) => table.id));
357
- // Filter raw records first: normalizing and sorting every segment in the database to
358
- // keep a handful is the wrong order of operations on a hot path.
494
+ const currentManifest = this.#currentVersion === null ? undefined : this.#manifests.get(this.#currentVersion);
495
+ const visibleBlockIds = new Set(currentManifest?.blockIds ?? []);
496
+ // Filter raw records first: normalizing and sorting every segment in the database to keep a
497
+ // handful is the wrong order of operations on a hot path. This read is anchored to the
498
+ // current manifest version it returns, so segments already superseded from that manifest are
499
+ // historical records, not query inputs. Explicit version reads use listSegments instead.
359
500
  const segments = [...this.#segments.values()]
360
- .filter((record) => foundTableIds.has(record.tableId))
501
+ .filter((record) => foundTableIds.has(record.tableId) &&
502
+ Object.values(record.columnBlockIds)
503
+ .flat()
504
+ .every((blockId) => visibleBlockIds.has(blockId)))
361
505
  .sort((left, right) => left.id.localeCompare(right.id))
362
506
  .map((record) => normalizeSegmentRecord(record));
363
507
  const transactionIds = [...new Set(segments.map((segment) => segment.transactionId))];
364
508
  const transactions = this.getTransactions(transactionIds).filter((record) => record !== undefined);
365
509
  return {
366
510
  manifestVersion: this.#currentVersion,
511
+ manifestBlockIds: [...visibleBlockIds].sort(),
367
512
  tables,
368
513
  segments,
369
514
  transactions,
@@ -385,11 +530,7 @@ export class RecordCore {
385
530
  }
386
531
  listManifestPage(afterVersion, limit) {
387
532
  validatePageLimit(limit);
388
- const records = [...this.#manifests.values()]
389
- .filter((manifest) => afterVersion === null || manifest.version > afterVersion)
390
- .sort((left, right) => left.version - right.version)
391
- .slice(0, limit)
392
- .map((manifest) => structuredClone(manifest));
533
+ const records = boundedRecordPage(this.#manifests.values(), afterVersion, limit, (manifest) => manifest.version).map((manifest) => structuredClone(manifest));
393
534
  return {
394
535
  records,
395
536
  nextCursor: records.length === limit ? (records.at(-1)?.version ?? null) : null,
@@ -487,11 +628,7 @@ export class RecordCore {
487
628
  }
488
629
  listTransactionPage(afterId, limit) {
489
630
  validatePageLimit(limit);
490
- const records = [...this.#transactions.values()]
491
- .filter((record) => afterId === null || record.id > afterId)
492
- .sort((left, right) => left.id.localeCompare(right.id))
493
- .slice(0, limit)
494
- .map((record) => structuredClone(record));
631
+ const records = boundedRecordPage(this.#transactions.values(), afterId, limit, (record) => record.id).map((record) => structuredClone(record));
495
632
  return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
496
633
  }
497
634
  updateTransaction(id, expectedRevision, update) {
@@ -672,6 +809,22 @@ export class RecordCore {
672
809
  return segment;
673
810
  });
674
811
  const uniqueKeyEntries = input.uniqueKeyChanges ?? [];
812
+ const coveredUniqueNamespaces = new Set(uniqueKeyEntries.map((entry) => entry.tableId));
813
+ const changedTableIds = new Set([
814
+ ...(input.changedTableIds ?? []),
815
+ ...pendingSegments.map((segment) => segment.tableId),
816
+ ]);
817
+ for (const tableId of changedTableIds) {
818
+ const changedTable = this.#tables.get(tableId);
819
+ if (changedTable === undefined)
820
+ continue;
821
+ for (const [indexId, index] of Object.entries(changedTable.secondaryIndexes ?? {})) {
822
+ if (index.uniqueEnforced === true &&
823
+ !coveredUniqueNamespaces.has(secondaryUniqueKeyNamespace(tableId, indexId))) {
824
+ throw new UniqueIndexCoverageError(tableId, index.name);
825
+ }
826
+ }
827
+ }
675
828
  // Entries apply in operation order over per-table deltas against the live membership, so
676
829
  // a scope that inserts the same key twice conflicts exactly like two separate commits
677
830
  // would. A delta rather than a working copy of the table's whole key set: the copy made
@@ -741,12 +894,20 @@ export class RecordCore {
741
894
  for (const token of delta.added)
742
895
  tokens.add(token);
743
896
  }
744
- this.#applyFtsChanges(pendingSegments, plan.ftsChanges, manifest.version);
897
+ this.#applyFtsChanges(pendingSegments, plan.ftsChanges, manifest.version, manifest.changedTableIds);
745
898
  this.#catalogEpoch += 1;
746
899
  // Match the IndexedDB store's observable commit shape: the summary without blockIds.
747
900
  const { blockIds: _resolved, ...summary } = manifest;
748
901
  void _resolved;
749
- return structuredClone(summary);
902
+ const ftsDeltaCounts = (plan.ftsChanges ?? []).flatMap((changes) => changes.columns.map((column) => ({
903
+ tableId: changes.tableId,
904
+ columnId: column.columnId,
905
+ count: this.#ftsDeltas.get(`${changes.tableId}/${column.columnId}`)?.size ?? 0,
906
+ })));
907
+ return structuredClone({
908
+ ...summary,
909
+ ...(ftsDeltaCounts.length === 0 ? {} : { ftsDeltaCounts }),
910
+ });
750
911
  }
751
912
  createLease(record) {
752
913
  validateLeaseExpiration(record.expiresAt);
@@ -822,11 +983,7 @@ export class RecordCore {
822
983
  }
823
984
  listCompactionJobPage(afterId, limit) {
824
985
  validatePageLimit(limit);
825
- const records = [...this.#compactionJobs.values()]
826
- .filter((record) => afterId === null || record.id > afterId)
827
- .sort((left, right) => left.id.localeCompare(right.id))
828
- .slice(0, limit)
829
- .map((record) => structuredClone(record));
986
+ const records = boundedRecordPage(this.#compactionJobs.values(), afterId, limit, (record) => record.id).map((record) => structuredClone(record));
830
987
  return { records, nextCursor: records.length === limit ? (records.at(-1)?.id ?? null) : null };
831
988
  }
832
989
  updateCompactionJob(id, expectedRevision, update) {
@@ -960,7 +1117,7 @@ export class RecordCore {
960
1117
  segmentIndex + segmentIdsToExamine.length === current.candidateSegmentIds.length
961
1118
  ? current.candidateBlockIds.slice(current.cursor.blockIndex, current.cursor.blockIndex + blockCapacity)
962
1119
  : [];
963
- const roots = collectBoundedPhysicalRoots(segmentIdsToExamine, blockIdsToExamine, this.#manifests.values(), prunedManifestVersionSet, this.#segments, this.#transactions, this.#compactionJobs.values());
1120
+ const roots = collectBoundedPhysicalRoots(segmentIdsToExamine, blockIdsToExamine, this.#manifests, prunedManifestVersionSet, this.#segments, this.#transactions, this.#compactionJobs);
964
1121
  while (remaining > 0 &&
965
1122
  manifestIndex === current.candidateManifestVersions.length &&
966
1123
  segmentIndex < current.candidateSegmentIds.length) {
@@ -1156,10 +1313,23 @@ export class RecordCore {
1156
1313
  ftsColumns[columnId] = { ...state, state: "invalid" };
1157
1314
  }
1158
1315
  }
1316
+ const secondaryIndexes = { ...(table.secondaryIndexes ?? {}) };
1317
+ for (const [indexId, state] of Object.entries(secondaryIndexes)) {
1318
+ const base = this.#ftsBases.get(`${table.id}/${state.storageColumnId}`);
1319
+ if (state.state === "ready" && base?.coversVersion === version) {
1320
+ fts.push({ columnId: state.storageColumnId, ...structuredClone(base) });
1321
+ }
1322
+ else {
1323
+ const { buildId: _abandonedBuild, ...invalid } = state;
1324
+ void _abandonedBuild;
1325
+ secondaryIndexes[indexId] = { ...invalid, state: "invalid" };
1326
+ }
1327
+ }
1159
1328
  return {
1160
1329
  record: {
1161
1330
  ...structuredClone(table),
1162
1331
  ...(Object.keys(ftsColumns).length === 0 ? {} : { ftsColumns }),
1332
+ ...(Object.keys(secondaryIndexes).length === 0 ? {} : { secondaryIndexes }),
1163
1333
  },
1164
1334
  nextRowId: this.#nextRowIds.get(table.id) ?? 1n,
1165
1335
  autoIncrement: table.columns.flatMap((column) => {
@@ -1167,6 +1337,17 @@ export class RecordCore {
1167
1337
  return next === undefined ? [] : [{ columnId: column.id, next }];
1168
1338
  }),
1169
1339
  uniqueKeyTokens: [...(this.#uniqueKeys.get(table.id) ?? [])],
1340
+ secondaryUniqueKeys: Object.entries(table.secondaryIndexes ?? {}).flatMap(([indexId, index]) => index.uniqueEnforced === true
1341
+ ? [
1342
+ {
1343
+ indexId,
1344
+ keyTokens: [
1345
+ ...(this.#uniqueKeys.get(secondaryUniqueKeyNamespace(table.id, indexId)) ??
1346
+ []),
1347
+ ],
1348
+ },
1349
+ ]
1350
+ : []),
1170
1351
  fts,
1171
1352
  };
1172
1353
  });
@@ -1190,12 +1371,22 @@ export class RecordCore {
1190
1371
  this.loadSnapshot(snapshot, storeBlockBytes);
1191
1372
  }
1192
1373
  loadSnapshot(snapshot, storeBlockBytes) {
1374
+ validateSnapshotCatalog(snapshot.tables);
1193
1375
  for (const block of snapshot.blocks)
1194
1376
  storeBlockBytes(block.id, block.bytes);
1195
1377
  this.loadSnapshotRecords({ ...snapshot, blocks: undefined }, snapshot.blocks.map((block) => block.id));
1196
1378
  }
1197
1379
  /** The record half of a snapshot load; block bytes are the caller's, ids theirs to name. */
1198
1380
  loadSnapshotRecords(snapshot, blockIds) {
1381
+ validateSnapshotCatalog(snapshot.tables);
1382
+ const indexNames = new Set();
1383
+ for (const table of snapshot.tables) {
1384
+ for (const index of Object.values(table.record.secondaryIndexes ?? {})) {
1385
+ if (indexNames.has(index.name))
1386
+ throw new TypeError(`Index already exists: ${index.name}`);
1387
+ indexNames.add(index.name);
1388
+ }
1389
+ }
1199
1390
  for (const segment of snapshot.segments) {
1200
1391
  this.#segments.set(segment.id, normalizeSegmentRecord(segment));
1201
1392
  }
@@ -1213,6 +1404,13 @@ export class RecordCore {
1213
1404
  if (table.uniqueKeyTokens.length > 0) {
1214
1405
  this.#uniqueKeys.set(record.id, new Set(table.uniqueKeyTokens));
1215
1406
  }
1407
+ for (const entry of table.secondaryUniqueKeys ?? []) {
1408
+ const index = record.secondaryIndexes?.[entry.indexId];
1409
+ if (index?.unique !== true) {
1410
+ throw new TypeError(`Snapshot has membership for an unknown UNIQUE index: ${entry.indexId}`);
1411
+ }
1412
+ this.#uniqueKeys.set(secondaryUniqueKeyNamespace(record.id, entry.indexId), new Set(entry.keyTokens));
1413
+ }
1216
1414
  for (const index of table.fts) {
1217
1415
  this.#ftsBases.set(`${record.id}/${index.columnId}`, {
1218
1416
  coversVersion: index.coversVersion,
@@ -1266,6 +1464,16 @@ export class RecordCore {
1266
1464
  /** Replaces the whole record state with a dump's content. */
1267
1465
  load(state) {
1268
1466
  const cloned = structuredClone(state);
1467
+ const indexNames = new Set();
1468
+ for (const table of cloned.tables) {
1469
+ validateTableColumns(table.columns);
1470
+ validateSecondaryIndexes(table);
1471
+ for (const index of Object.values(table.secondaryIndexes ?? {})) {
1472
+ if (indexNames.has(index.name))
1473
+ throw new TypeError(`Index already exists: ${index.name}`);
1474
+ indexNames.add(index.name);
1475
+ }
1476
+ }
1269
1477
  for (const map of [
1270
1478
  this.#manifests,
1271
1479
  this.#transactions,
@@ -1435,8 +1643,12 @@ function assertGarbageCollectionCandidateProvenance(job, manifests, segments, tr
1435
1643
  }
1436
1644
  }
1437
1645
  const blockHasProvenance = (id) => {
1438
- for (const version of job.candidateManifestVersions) {
1439
- if (manifests.get(version)?.blockIds.includes(id))
1646
+ // A prior collection pass may already have pruned the manifest that names a leftover block.
1647
+ // Its persisted record is still provenance until the block is reclaimed; requiring that
1648
+ // already-pruned version to appear in candidateManifestVersions is impossible because that
1649
+ // list contains only manifests this job will newly prune.
1650
+ for (const manifest of manifests.values()) {
1651
+ if (manifest.blockIds.includes(id))
1440
1652
  return true;
1441
1653
  }
1442
1654
  for (const transaction of transactions.values()) {
@@ -1456,20 +1668,10 @@ function assertGarbageCollectionCandidateProvenance(job, manifests, segments, tr
1456
1668
  throw new Error(`Garbage collection block candidate has no persisted provenance: ${unprovenBlockId}`);
1457
1669
  }
1458
1670
  const unprovenSegmentId = job.candidateSegmentIds.find((id) => {
1459
- for (const transaction of transactions.values()) {
1460
- if (transaction.status === "aborted" && transaction.pendingSegmentIds.includes(id)) {
1461
- return false;
1462
- }
1463
- }
1464
- for (const compaction of compactionJobs.values()) {
1465
- if (isTerminalCompactionJob(compaction) &&
1466
- (compaction.sourceSegmentIds.includes(id) || compaction.outputSegmentId === id)) {
1467
- return false;
1468
- }
1469
- }
1470
- const segment = segments.get(id);
1471
- const blockIds = segment === undefined ? [] : segmentBlockIds(segment);
1472
- return blockIds.length === 0 || !blockIds.every(blockHasProvenance);
1671
+ // The persisted segment record is sufficient provenance for nominating that exact id.
1672
+ // Its blocks may already have been reclaimed by an earlier bounded pass, and terminal
1673
+ // compaction records are intentionally aged out, so neither is a durable discovery source.
1674
+ return !segments.has(id);
1473
1675
  });
1474
1676
  if (unprovenSegmentId !== undefined) {
1475
1677
  throw new Error(`Garbage collection segment candidate has no persisted provenance: ${unprovenSegmentId}`);
@@ -1510,29 +1712,25 @@ function collectBoundedPhysicalRoots(candidateSegmentIds, candidateBlockIds, man
1510
1712
  const candidateBlocks = new Set(candidateBlockIds);
1511
1713
  const probeBlockIds = new Set(candidateBlockIds);
1512
1714
  const probeSegmentIds = new Set(candidateSegmentIds);
1513
- const relatedSegments = new Map();
1514
- let dependencyOverflow = false;
1715
+ // First discover a bounded working set. A very wide segment can cross the cache ceiling; its
1716
+ // remaining IDs are checked lazily below instead of retaining the whole batch forever.
1515
1717
  for (const segment of segments.values()) {
1516
1718
  const ids = segmentBlockIds(segment);
1517
1719
  if (!candidateSegmentIds.includes(segment.id) && !ids.some((id) => candidateBlocks.has(id))) {
1518
1720
  continue;
1519
1721
  }
1520
- const newBlockIds = new Set(ids.filter((id) => !probeBlockIds.has(id)));
1521
- if (relatedSegments.size >= MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES ||
1522
- probeBlockIds.size + newBlockIds.size > MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES) {
1523
- dependencyOverflow = true;
1524
- break;
1722
+ if (probeSegmentIds.size < MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES) {
1723
+ probeSegmentIds.add(segment.id);
1724
+ }
1725
+ for (const id of ids) {
1726
+ if (probeBlockIds.size >= MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES)
1727
+ break;
1728
+ probeBlockIds.add(id);
1525
1729
  }
1526
- relatedSegments.set(segment.id, segment);
1527
- probeSegmentIds.add(segment.id);
1528
- ids.forEach((id) => probeBlockIds.add(id));
1529
- }
1530
- if (dependencyOverflow) {
1531
- return { blockIds: new Set(candidateBlockIds), segmentIds: new Set(candidateSegmentIds) };
1532
1730
  }
1533
1731
  const directBlockRoots = new Set();
1534
1732
  const directSegmentRoots = new Set();
1535
- for (const manifest of manifests) {
1733
+ for (const manifest of manifests.values()) {
1536
1734
  if (manifest.prunedAt !== undefined || newlyPrunedVersions.has(manifest.version))
1537
1735
  continue;
1538
1736
  for (const id of manifest.blockIds)
@@ -1549,7 +1747,7 @@ function collectBoundedPhysicalRoots(candidateSegmentIds, candidateBlockIds, man
1549
1747
  if (probeSegmentIds.has(id))
1550
1748
  directSegmentRoots.add(id);
1551
1749
  }
1552
- for (const job of compactionJobs) {
1750
+ for (const job of compactionJobs.values()) {
1553
1751
  if (isTerminalCompactionJob(job))
1554
1752
  continue;
1555
1753
  for (const id of job.sourceBlockIds)
@@ -1565,13 +1763,82 @@ function collectBoundedPhysicalRoots(candidateSegmentIds, candidateBlockIds, man
1565
1763
  directSegmentRoots.add(job.outputSegmentId);
1566
1764
  }
1567
1765
  }
1766
+ const uncachedBlockRoots = new Map();
1767
+ const isDirectBlockRoot = (id) => {
1768
+ if (probeBlockIds.has(id))
1769
+ return directBlockRoots.has(id);
1770
+ const cached = uncachedBlockRoots.get(id);
1771
+ if (cached !== undefined)
1772
+ return cached;
1773
+ let rooted = false;
1774
+ for (const manifest of manifests.values()) {
1775
+ if (manifest.prunedAt === undefined &&
1776
+ !newlyPrunedVersions.has(manifest.version) &&
1777
+ manifest.blockIds.includes(id)) {
1778
+ rooted = true;
1779
+ break;
1780
+ }
1781
+ }
1782
+ if (!rooted) {
1783
+ for (const transaction of transactions.values()) {
1784
+ if (transaction.status === "active" && transaction.pendingBlockIds.includes(id)) {
1785
+ rooted = true;
1786
+ break;
1787
+ }
1788
+ }
1789
+ }
1790
+ if (!rooted) {
1791
+ for (const job of compactionJobs.values()) {
1792
+ if (!isTerminalCompactionJob(job) &&
1793
+ (job.sourceBlockIds.includes(id) || job.outputBlockIds.includes(id))) {
1794
+ rooted = true;
1795
+ break;
1796
+ }
1797
+ }
1798
+ }
1799
+ if (uncachedBlockRoots.size < MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES) {
1800
+ uncachedBlockRoots.set(id, rooted);
1801
+ }
1802
+ return rooted;
1803
+ };
1804
+ const uncachedSegmentRoots = new Map();
1805
+ const isDirectSegmentRoot = (id) => {
1806
+ if (probeSegmentIds.has(id))
1807
+ return directSegmentRoots.has(id);
1808
+ const cached = uncachedSegmentRoots.get(id);
1809
+ if (cached !== undefined)
1810
+ return cached;
1811
+ let rooted = false;
1812
+ for (const transaction of transactions.values()) {
1813
+ if (transaction.status === "active" && transaction.pendingSegmentIds.includes(id)) {
1814
+ rooted = true;
1815
+ break;
1816
+ }
1817
+ }
1818
+ if (!rooted) {
1819
+ for (const job of compactionJobs.values()) {
1820
+ if (!isTerminalCompactionJob(job) &&
1821
+ (job.sourceSegmentIds.includes(id) || job.outputSegmentId === id)) {
1822
+ rooted = true;
1823
+ break;
1824
+ }
1825
+ }
1826
+ }
1827
+ if (uncachedSegmentRoots.size < MAX_GARBAGE_COLLECTION_ROOT_DEPENDENCIES) {
1828
+ uncachedSegmentRoots.set(id, rooted);
1829
+ }
1830
+ return rooted;
1831
+ };
1568
1832
  const rootedBlockIds = new Set();
1569
1833
  const rootedSegmentIds = new Set();
1570
- for (const segment of relatedSegments.values()) {
1834
+ for (const segment of segments.values()) {
1571
1835
  const ids = segmentBlockIds(segment);
1572
- if (directSegmentRoots.has(segment.id) ||
1836
+ if (!candidateSegmentIds.includes(segment.id) && !ids.some((id) => candidateBlocks.has(id))) {
1837
+ continue;
1838
+ }
1839
+ if (isDirectSegmentRoot(segment.id) ||
1573
1840
  transactions.get(segment.transactionId)?.status === "active" ||
1574
- (ids.length > 0 && ids.every((id) => directBlockRoots.has(id)))) {
1841
+ (ids.length > 0 && ids.every(isDirectBlockRoot))) {
1575
1842
  if (candidateSegmentIds.includes(segment.id))
1576
1843
  rootedSegmentIds.add(segment.id);
1577
1844
  for (const id of ids)
@@ -1580,10 +1847,10 @@ function collectBoundedPhysicalRoots(candidateSegmentIds, candidateBlockIds, man
1580
1847
  }
1581
1848
  }
1582
1849
  for (const id of candidateBlockIds)
1583
- if (directBlockRoots.has(id))
1850
+ if (isDirectBlockRoot(id))
1584
1851
  rootedBlockIds.add(id);
1585
1852
  for (const id of candidateSegmentIds)
1586
- if (directSegmentRoots.has(id))
1853
+ if (isDirectSegmentRoot(id))
1587
1854
  rootedSegmentIds.add(id);
1588
1855
  return { blockIds: rootedBlockIds, segmentIds: rootedSegmentIds };
1589
1856
  }
@@ -1608,14 +1875,22 @@ function validatePageLimit(limit) {
1608
1875
  throw new RangeError("Storage page limit must be a positive whole number");
1609
1876
  }
1610
1877
  }
1611
- function validateTableColumns(columns) {
1612
- if (columns.length === 0)
1613
- throw new TypeError("A table needs at least one column");
1614
- const ids = new Set(columns.map(({ id }) => id));
1615
- const names = new Set(columns.map(({ name }) => name));
1616
- if (ids.size !== columns.length || names.size !== columns.length) {
1617
- throw new TypeError("Table columns must have unique IDs and names");
1618
- }
1878
+ /** Selects the next sorted page without copying or sorting the complete backing map. */
1879
+ function boundedRecordPage(values, after, limit, keyOf) {
1880
+ const selected = [];
1881
+ for (const value of values) {
1882
+ const key = keyOf(value);
1883
+ if (after !== null && key <= after)
1884
+ continue;
1885
+ const index = selected.findIndex((entry) => entry.key >= key);
1886
+ if (index < 0)
1887
+ selected.push({ key, value });
1888
+ else
1889
+ selected.splice(index, 0, { key, value });
1890
+ if (selected.length > limit)
1891
+ selected.pop();
1892
+ }
1893
+ return selected.map(({ value }) => value);
1619
1894
  }
1620
1895
  function validateTempOwnerRecord(record) {
1621
1896
  validateId(record.ownerId);