@minnowdb/core 0.10.0 → 0.10.2

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 (37) hide show
  1. package/dist/engine/auto-store.d.ts +15 -3
  2. package/dist/engine/auto-store.js +48 -6
  3. package/dist/engine/client.js +23 -7
  4. package/dist/engine/database.js +347 -862
  5. package/dist/engine/index-terms.js +627 -0
  6. package/dist/engine/index.d.ts +1 -1
  7. package/dist/engine/index.js +2 -1
  8. package/dist/engine/live-maintenance.js +220 -0
  9. package/dist/engine/schema.js +2 -1
  10. package/dist/engine/sql-functions.js +3 -2
  11. package/dist/engine/sql-quote.js +6 -0
  12. package/dist/engine/vector.js +1 -3
  13. package/dist/engine/worker-host.js +2 -0
  14. package/dist/engine/worker-server.js +4 -5
  15. package/dist/engine/worker-store-auto.js +2 -2
  16. package/dist/engine/write-coordinator.js +21 -1
  17. package/dist/storage/indexeddb.d.ts +18 -0
  18. package/dist/storage/indexeddb.js +293 -211
  19. package/dist/storage/opfs/coordination-helpers.js +54 -0
  20. package/dist/storage/opfs/index.d.ts +1 -1
  21. package/dist/storage/opfs/index.js +3 -2
  22. package/dist/storage/opfs/leader.js +44 -5
  23. package/dist/storage/opfs/rpc.js +3 -1
  24. package/dist/storage/opfs/store.d.ts +12 -0
  25. package/dist/storage/opfs/store.js +59 -12
  26. package/dist/storage/toolkit/wal.js +16 -0
  27. package/dist/storage/toolkit/wire.js +3 -3
  28. package/dist/storage/types.d.ts +35 -4
  29. package/dist/storage/types.js +17 -4
  30. package/dist/testing/block-store-conformance.js +40 -1
  31. package/dist/testing/index.d.ts +1 -0
  32. package/dist/testing/index.js +9 -0
  33. package/dist/testing/interaction-simulator.d.ts +319 -0
  34. package/dist/testing/interaction-simulator.js +1631 -0
  35. package/dist/transactions/index.d.ts +7 -0
  36. package/dist/transactions/index.js +17 -3
  37. package/package.json +4 -1
@@ -0,0 +1,54 @@
1
+ import { OpfsBlockStore } from "./store.js";
2
+ function table(name) {
3
+ return {
4
+ id: `table-${name}`,
5
+ name,
6
+ columns: [{ id: "c1", name: "id", type: "number", nullable: false }],
7
+ managed: false,
8
+ revision: 0,
9
+ createdAt: "2026-08-19T00:00:00.000Z"
10
+ };
11
+ }
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ async function waitFor(condition, what, attempts = 800) {
16
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
17
+ if (await condition())
18
+ return;
19
+ await sleep(5);
20
+ }
21
+ throw new Error(`Timed out waiting for ${what}`);
22
+ }
23
+ function opener(shim, name, overrides = {}) {
24
+ return () => OpfsBlockStore.open({ name, root: shim.root, rpcTimeoutMs: 200, ...overrides });
25
+ }
26
+ async function outcome(work) {
27
+ return work.then(() => "ok", (error) => error instanceof Error ? error.name : String(error));
28
+ }
29
+ async function outcomeMessage(work) {
30
+ return work.then(() => "ok", (error) => error instanceof Error ? `${error.name}: ${error.message}` : String(error));
31
+ }
32
+ async function delaySyncHandleOpens(shim, matches, delayMs) {
33
+ const probe = await shim.root.getFileHandle("__delay-probe__", { create: true });
34
+ const prototype = Object.getPrototypeOf(probe);
35
+ const original = prototype.createSyncAccessHandle;
36
+ prototype.createSyncAccessHandle = async function patched() {
37
+ if (matches(this.name))
38
+ await sleep(delayMs);
39
+ return original.call(this);
40
+ };
41
+ await shim.root.removeEntry("__delay-probe__");
42
+ return () => {
43
+ prototype.createSyncAccessHandle = original;
44
+ };
45
+ }
46
+ export {
47
+ delaySyncHandleOpens,
48
+ opener,
49
+ outcome,
50
+ outcomeMessage,
51
+ sleep,
52
+ table,
53
+ waitFor
54
+ };
@@ -1,3 +1,3 @@
1
- export { OpfsBlockStore, deleteOpfsDatabase, type OpfsBlockStoreOptions } from "./store.js";
1
+ export { OpfsBlockStore, deleteOpfsDatabase, opfsDatabaseExists, type OpfsBlockStoreOptions, } from "./store.js";
2
2
  export { OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError, } from "../types.js";
3
3
  export { OpfsTree, type ReadFileOptions, type WriteFileOptions } from "./files.js";
@@ -1,4 +1,4 @@
1
- import { OpfsBlockStore, deleteOpfsDatabase } from "./store.js";
1
+ import { OpfsBlockStore, deleteOpfsDatabase, opfsDatabaseExists } from "./store.js";
2
2
  import { OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError } from "../types.js";
3
3
  import { OpfsTree } from "./files.js";
4
4
  export {
@@ -7,5 +7,6 @@ export {
7
7
  OpfsDatabaseInUseError,
8
8
  OpfsTree,
9
9
  OpfsUncertainOutcomeError,
10
- deleteOpfsDatabase
10
+ deleteOpfsDatabase,
11
+ opfsDatabaseExists
11
12
  };
@@ -85,6 +85,36 @@ const LOGGED_BODY_BUILDERS = {
85
85
  removeGarbageCollectionJob: ([id]) => ({ op: "removeGarbageCollectionJob", id }),
86
86
  removePrunedManifestRecords: ([maxItems]) => ({ op: "removePrunedManifestRecords", maxItems })
87
87
  };
88
+ const STATE_SHRINKING_OPS = /* @__PURE__ */ new Set([
89
+ "rollbackTransactionArtifacts",
90
+ "abortTransactionIfExpired",
91
+ "removeLease",
92
+ "removeLeaseIfExpired",
93
+ "removeTempRun",
94
+ "removeTempOwner",
95
+ "removeTempOwnerIfExpired",
96
+ "removeCompactionJob",
97
+ "cancelCompactionJob",
98
+ "removeGarbageCollectionJob",
99
+ "removePrunedManifestRecords",
100
+ "removeAbortedSegment",
101
+ "abortFtsBaseBuild",
102
+ "abortUniqueKeyBuild",
103
+ "cancelSnapshotFrameImport",
104
+ "closeSnapshotFrameExport",
105
+ "garbageCollectionStep"
106
+ ]);
107
+ function shrinksLoggedState(body) {
108
+ if (body.op === "updateTransaction")
109
+ return body.update.status === "aborted";
110
+ return STATE_SHRINKING_OPS.has(body.op);
111
+ }
112
+ class OpfsLeaderClosedError extends Error {
113
+ name = "OpfsLeaderClosedError";
114
+ constructor() {
115
+ super("This OPFS store connection is closed");
116
+ }
117
+ }
88
118
  class OpfsLeader {
89
119
  #tree;
90
120
  #strict;
@@ -425,7 +455,7 @@ class OpfsLeader {
425
455
  #run(work) {
426
456
  const result = this.#chain.then(async () => {
427
457
  if (this.#closed)
428
- throw new Error("This OPFS store connection is closed");
458
+ throw new OpfsLeaderClosedError();
429
459
  if (this.#poisoned)
430
460
  await this.#loadFromDisk();
431
461
  return work();
@@ -470,11 +500,13 @@ class OpfsLeader {
470
500
  return this.#servedLedger.get(key);
471
501
  }
472
502
  async completeServed(key, result) {
473
- const entry = this.#servedLedger.get(key);
474
- if (entry === void 0 || entry.settled)
503
+ if (!this.#servedLedger.has(key))
475
504
  return;
476
505
  const bytes = estimateRpcValueBytes(result);
477
506
  await this.#run(() => {
507
+ const entry = this.#servedLedger.get(key);
508
+ if (entry === void 0 || entry.settled)
509
+ return;
478
510
  if (bytes > this.#servedLedgerResultBytes) {
479
511
  this.#appendFrame({ op: "servedResult", key, withheld: true }, false);
480
512
  entry.settled = true;
@@ -499,7 +531,13 @@ class OpfsLeader {
499
531
  }
500
532
  #logged(body) {
501
533
  if (this.#wal.byteLength >= MAX_OPFS_WAL_BYTES - 64 * 1024 * 1024) {
502
- this.checkpointNow();
534
+ try {
535
+ this.checkpointNow();
536
+ } catch (error) {
537
+ if (!shrinksLoggedState(body) || this.#wal.byteLength >= MAX_OPFS_WAL_BYTES - 16 * 1024 * 1024) {
538
+ throw error;
539
+ }
540
+ }
503
541
  }
504
542
  if (this.#strict) {
505
543
  try {
@@ -1569,7 +1607,7 @@ class OpfsLeader {
1569
1607
  };
1570
1608
  const bytes = encodeSyncCheckpoint(state);
1571
1609
  if (bytes.byteLength > MAX_OPFS_CHECKPOINT_BYTES) {
1572
- throw new Error(`OPFS checkpoint exceeds its ${String(MAX_OPFS_CHECKPOINT_BYTES)} byte limit: ` + String(bytes.byteLength));
1610
+ throw new StorageResourceLimitError("checkpoint byte", bytes.byteLength, MAX_OPFS_CHECKPOINT_BYTES);
1573
1611
  }
1574
1612
  const slotIndex = this.#newestSlot === 0 ? 1 : 0;
1575
1613
  const mirrorIndex = slotIndex === 0 ? 1 : 0;
@@ -4802,5 +4840,6 @@ export {
4802
4840
  MAX_OPFS_CHECKPOINT_BYTES,
4803
4841
  MAX_OPFS_WAL_BYTES,
4804
4842
  OpfsLeader,
4843
+ OpfsLeaderClosedError,
4805
4844
  assertBlockReadBatchByteLimit
4806
4845
  };
@@ -1,4 +1,4 @@
1
- import { BlockReadBatchTooLargeError, CompactionBacklogError, CompactionJobConflictError, ConnectionLostError, GarbageCollectionJobConflictError, IndexedDbSchemaUpgradeBlockedError, LeaseConflictError, LeaseExpiredError, LeaseOwnerConflictError, OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError, PostingBuildConflictError, SchemaConflictError, SnapshotImportConflictError, SnapshotManifestMissingError, StorageCorruptionError, StorageFormatVersionError, StorageResourceLimitError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueIndexCoverageError, UniqueKeyBuildConflictError, UniqueKeyConflictError, UnknownOutcomeError, WriteConflictError } from "../types.js";
1
+ import { BlockReadBatchTooLargeError, CompactionBacklogError, CompactionJobConflictError, ConnectionLostError, GarbageCollectionJobConflictError, IndexedDbSchemaUpgradeBlockedError, LeaseConflictError, LeaseExpiredError, LeaseOwnerConflictError, OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError, PostingBuildConflictError, SchemaConflictError, SnapshotImportConflictError, SnapshotManifestMissingError, StorageCorruptionError, StorageUnresponsiveError, StorageFormatVersionError, StorageResourceLimitError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueIndexCoverageError, UniqueKeyBuildConflictError, UniqueKeyConflictError, UnknownOutcomeError, WriteConflictError } from "../types.js";
2
2
  import { dateIsoString } from "../../date-value.js";
3
3
  import { MAX_SERIALIZED_CAUSE_DEPTH, rehydrateError, serializeError } from "../../worker-protocol/index.js";
4
4
  const MAX_OPFS_RPC_STACK_CHARACTERS = 64 * 1024;
@@ -109,6 +109,7 @@ function parseStoreRpcMessage(value, knownMethods) {
109
109
  case "state":
110
110
  return exactKeys(record, ["kind", "leaderId", "foreground"]) && boundedRpcString(record.leaderId) && typeof record.foreground === "boolean" ? record : void 0;
111
111
  case "leader":
112
+ case "wait":
112
113
  case "released":
113
114
  return exactKeys(record, ["kind", "leaderId"]) && boundedRpcString(record.leaderId) ? record : void 0;
114
115
  case "ping":
@@ -202,6 +203,7 @@ const errorRegistry = new Map([
202
203
  PostingBuildConflictError,
203
204
  StorageCorruptionError,
204
205
  StorageFormatVersionError,
206
+ StorageUnresponsiveError,
205
207
  OpfsCoordinationError,
206
208
  OpfsDatabaseInUseError,
207
209
  OpfsUncertainOutcomeError,
@@ -27,6 +27,8 @@ export interface OpfsBlockStoreOptions {
27
27
  hiddenIdleReleaseMs?: number;
28
28
  /** @internal Test seam: how long an ex-leader stays out of elections (default 1500ms). */
29
29
  handoverGraceMs?: number;
30
+ /** @internal Test seam: how long an operation looks for a leader (default 10000ms). */
31
+ dispatchBudgetMs?: number;
30
32
  /**
31
33
  * Hears failures no operation reports: a background checkpoint or cleanup that failed, an
32
34
  * election or handover that threw, a served request that could not be answered. The worker
@@ -108,3 +110,13 @@ export declare function deleteOpfsDatabase(options: {
108
110
  name: string;
109
111
  root?: FileSystemDirectoryHandle;
110
112
  }): Promise<void>;
113
+ /**
114
+ * Whether a database directory of this name exists in the origin's private file system (or
115
+ * under `root`). Nothing is created: an `auto` descriptor asks this before choosing a store for
116
+ * a name it has no record of, so a database an explicit `opfs` descriptor created is found
117
+ * rather than shadowed by an empty one.
118
+ */
119
+ export declare function opfsDatabaseExists(options: {
120
+ name: string;
121
+ root?: FileSystemDirectoryHandle;
122
+ }): Promise<boolean>;
@@ -2,11 +2,11 @@ import { assertStorageBulkReadItems, assertTempRunPageBatchLimits, OpfsCoordinat
2
2
  import { validateTempRunPage, validateTempRunPageIdentity } from "../toolkit/record-core.js";
3
3
  import { OpfsTree, encodeSegment, isDomError } from "./files.js";
4
4
  import { LOG_FORMAT_VERSION } from "../toolkit/wire.js";
5
- import { OpfsLeader } from "./leader.js";
5
+ import { OpfsLeader, OpfsLeaderClosedError } from "./leader.js";
6
6
  import { rehydrateStoreError, estimateRpcValueBytes, fingerprintStoreRequest, MAX_OPFS_RPC_HOLD_MS, MAX_OPFS_RPC_MESSAGE_BYTES, parseStoreRpcMessage, serializeStoreError } from "./rpc.js";
7
7
  const RPC_TIMEOUT_MS = 1e3;
8
8
  const DISCOVERY_WAIT_MS = 150;
9
- const DISPATCH_ATTEMPTS = 10;
9
+ const DISPATCH_BUDGET_MS = 1e4;
10
10
  const YIELD_COOLDOWN_MS = 3e3;
11
11
  const HANDOVER_GRACE_MS = 1500;
12
12
  const HIDDEN_IDLE_RELEASE_MS = 15e3;
@@ -185,6 +185,7 @@ class OpfsBlockStore {
185
185
  #yieldCooldownMs;
186
186
  #hiddenIdleReleaseMs;
187
187
  #handoverGraceMs;
188
+ #dispatchBudgetMs;
188
189
  #onDiagnostic;
189
190
  #instanceId = crypto.randomUUID();
190
191
  #channelName;
@@ -197,6 +198,8 @@ class OpfsBlockStore {
197
198
  #closed = false;
198
199
  #lastYieldAt = 0;
199
200
  #electing;
201
+ #recovering = false;
202
+ #waitHeardAt = 0;
200
203
  #pending = /* @__PURE__ */ new Map();
201
204
  #inFlightMutations = /* @__PURE__ */ new Map();
202
205
  #settledMutations = /* @__PURE__ */ new Map();
@@ -230,6 +233,7 @@ class OpfsBlockStore {
230
233
  this.#yieldCooldownMs = options.yieldCooldownMs ?? YIELD_COOLDOWN_MS;
231
234
  this.#hiddenIdleReleaseMs = options.hiddenIdleReleaseMs ?? HIDDEN_IDLE_RELEASE_MS;
232
235
  this.#handoverGraceMs = options.handoverGraceMs ?? HANDOVER_GRACE_MS;
236
+ this.#dispatchBudgetMs = options.dispatchBudgetMs ?? DISPATCH_BUDGET_MS;
233
237
  this.#onDiagnostic = options.onDiagnostic;
234
238
  this.#channelName = `minnowdb-store:${options.name}`;
235
239
  this.liveQueryChannelName = `minnowdb-live:opfs:${options.name}`;
@@ -311,7 +315,9 @@ class OpfsBlockStore {
311
315
  }
312
316
  let slotA;
313
317
  let slotB;
318
+ this.#recovering = true;
314
319
  try {
320
+ this.#post({ kind: "wait", leaderId: this.#instanceId });
315
321
  slotA = await this.#openWithRetry(["checkpoint-a"]);
316
322
  slotB = await this.#openWithRetry(["checkpoint-b"]);
317
323
  this.#leader = await OpfsLeader.recover(this.#tree, this.#durability === "strict", { wal, slotA, slotB }, this.#checkpointEntries, this.#cleanupLimitBytes, this.#onDiagnostic, this.#servedLedgerAgeMs, this.#servedLedgerResultBytes);
@@ -322,6 +328,8 @@ class OpfsBlockStore {
322
328
  if (isLockContention(error))
323
329
  return false;
324
330
  throw error;
331
+ } finally {
332
+ this.#recovering = false;
325
333
  }
326
334
  if (this.#closed) {
327
335
  const leader = this.#leader;
@@ -409,7 +417,7 @@ class OpfsBlockStore {
409
417
  return;
410
418
  this.#leader = void 0;
411
419
  this.#knownLeader = void 0;
412
- const shutdown = leader.shutdown().catch((error) => {
420
+ const shutdown = this.#shutdownAfterMutations(leader).catch((error) => {
413
421
  this.#diagnostic(error, "opfs idle release shutdown");
414
422
  leader.crash();
415
423
  });
@@ -494,7 +502,7 @@ class OpfsBlockStore {
494
502
  this.#leader = void 0;
495
503
  this.#knownLeader = void 0;
496
504
  this.#lastYieldAt = Date.now();
497
- const shutdown = leader.shutdown().catch((error) => {
505
+ const shutdown = this.#shutdownAfterMutations(leader).catch((error) => {
498
506
  this.#diagnostic(error, "opfs yield shutdown");
499
507
  leader.crash();
500
508
  });
@@ -516,6 +524,14 @@ class OpfsBlockStore {
516
524
  }, this.#handoverGraceMs);
517
525
  }
518
526
  #yielding;
527
+ #shutdownAfterMutations(leader) {
528
+ return this.#withMutationTurn(() => leader.shutdown());
529
+ }
530
+ async #servedDrained() {
531
+ const deadline = Date.now() + DECLINE_AFTER_CLOSE_MS;
532
+ while (this.#served.size > 0 && Date.now() < deadline)
533
+ await sleep(5);
534
+ }
519
535
  #onMessage(message) {
520
536
  if (this.#closed) {
521
537
  if (message.kind === "op")
@@ -645,9 +661,16 @@ class OpfsBlockStore {
645
661
  case "ping": {
646
662
  if (this.#leader !== void 0) {
647
663
  this.#post({ kind: "leader", leaderId: this.#instanceId });
664
+ } else if (this.#recovering || this.#yielding !== void 0) {
665
+ this.#post({ kind: "wait", leaderId: this.#instanceId });
648
666
  }
649
667
  return;
650
668
  }
669
+ case "wait": {
670
+ if (message.leaderId !== this.#instanceId)
671
+ this.#waitHeardAt = Date.now();
672
+ return;
673
+ }
651
674
  case "bid": {
652
675
  if (message.foreground && message.bidderId !== this.#instanceId) {
653
676
  this.#considerBid(message.bidderId);
@@ -1036,7 +1059,7 @@ class OpfsBlockStore {
1036
1059
  let sentRemotely = false;
1037
1060
  let mayHaveRun = false;
1038
1061
  this.#lastActivityAt = sentAt;
1039
- for (let attempt = 0; attempt < DISPATCH_ATTEMPTS; attempt += 1) {
1062
+ while (Date.now() - Math.max(sentAt, this.#waitHeardAt) < this.#dispatchBudgetMs) {
1040
1063
  this.#assertOpen();
1041
1064
  const leader = this.#leader;
1042
1065
  if (leader !== void 0) {
@@ -1058,7 +1081,16 @@ class OpfsBlockStore {
1058
1081
  if (sentAt < leader.servedCoverageSince)
1059
1082
  throw new OpfsUncertainOutcomeError(method);
1060
1083
  }
1061
- return await this.#withMutationTurn(() => bound.apply(leader, args));
1084
+ return await this.#withMutationTurn(() => {
1085
+ if (leader.isClosed() || !this.#leads(leader))
1086
+ throw new OpfsLeaderClosedError();
1087
+ return bound.apply(leader, args);
1088
+ });
1089
+ } catch (error) {
1090
+ if (!this.#leads(leader) && (isRead || error instanceof OpfsLeaderClosedError)) {
1091
+ continue;
1092
+ }
1093
+ throw error;
1062
1094
  } finally {
1063
1095
  this.#lastActivityAt = Date.now();
1064
1096
  }
@@ -1187,10 +1219,11 @@ class OpfsBlockStore {
1187
1219
  const leader = this.#leader;
1188
1220
  this.#leader = void 0;
1189
1221
  if (leader !== void 0) {
1190
- void leader.shutdown().catch((error) => {
1222
+ void this.#shutdownAfterMutations(leader).catch((error) => {
1191
1223
  this.#diagnostic(error, "opfs close shutdown");
1192
1224
  leader.crash();
1193
- }).then(() => {
1225
+ }).then(() => this.#servedDrained()).then(() => {
1226
+ this.#stopKeepalive();
1194
1227
  this.#post({ kind: "released", leaderId: this.#instanceId });
1195
1228
  this.#closeChannels();
1196
1229
  this.#releaseWhenHandlesClose();
@@ -1198,13 +1231,15 @@ class OpfsBlockStore {
1198
1231
  return;
1199
1232
  }
1200
1233
  if (this.#yielding !== void 0) {
1201
- void this.#yielding.then(() => {
1234
+ void this.#yielding.then(() => this.#servedDrained()).then(() => {
1235
+ this.#stopKeepalive();
1202
1236
  this.#post({ kind: "released", leaderId: this.#instanceId });
1203
1237
  this.#closeChannels();
1204
1238
  this.#releaseWhenHandlesClose();
1205
1239
  });
1206
1240
  return;
1207
1241
  }
1242
+ this.#stopKeepalive();
1208
1243
  this.#closeChannels();
1209
1244
  this.#releaseWhenHandlesClose();
1210
1245
  }
@@ -1212,10 +1247,8 @@ class OpfsBlockStore {
1212
1247
  if (this.#reacquireTimer !== void 0)
1213
1248
  clearTimeout(this.#reacquireTimer);
1214
1249
  this.#reacquireTimer = void 0;
1215
- this.#stopKeepalive();
1216
1250
  this.#clearDeferredBid();
1217
1251
  this.#clearHiddenIdleTimer();
1218
- this.#served.clear();
1219
1252
  }
1220
1253
  #releaseWhenHandlesClose() {
1221
1254
  const release = this.#releaseConnectionLock;
@@ -1275,6 +1308,8 @@ class OpfsBlockStore {
1275
1308
  _crashForTests() {
1276
1309
  this.#closed = true;
1277
1310
  this.#clearCoordinationTimers();
1311
+ this.#stopKeepalive();
1312
+ this.#served.clear();
1278
1313
  for (const pending of this.#pending.values())
1279
1314
  clearTimeout(pending.timer);
1280
1315
  this.#pending.clear();
@@ -1423,6 +1458,17 @@ async function resolveDatabaseRoot(options) {
1423
1458
  const namespace = await root.getDirectoryHandle("minnowdb", { create: true });
1424
1459
  return namespace.getDirectoryHandle(encodedName, { create: true });
1425
1460
  }
1461
+ async function opfsDatabaseExists(options) {
1462
+ const encodedName = encodeSegment(validateStorageDatabaseName(options.name));
1463
+ try {
1464
+ const root = options.root ?? await navigator.storage.getDirectory();
1465
+ const namespace = await root.getDirectoryHandle("minnowdb");
1466
+ await namespace.getDirectoryHandle(encodedName);
1467
+ return true;
1468
+ } catch {
1469
+ return false;
1470
+ }
1471
+ }
1426
1472
  function isLockContention(error) {
1427
1473
  return isDomError(error, "NoModificationAllowedError") || isDomError(error, "InvalidStateError");
1428
1474
  }
@@ -1431,5 +1477,6 @@ function sleep(ms) {
1431
1477
  }
1432
1478
  export {
1433
1479
  OpfsBlockStore,
1434
- deleteOpfsDatabase
1480
+ deleteOpfsDatabase,
1481
+ opfsDatabaseExists
1435
1482
  };
@@ -79,6 +79,8 @@ function* iterateWalFrames(handle) {
79
79
  while (offset + FRAME_HEADER_BYTES <= size) {
80
80
  readFully(handle, header, offset, "reading a WAL frame header for recovery");
81
81
  if (headerView.getUint32(0, true) !== FRAME_MAGIC) {
82
+ if (isZeroFilled(handle, offset, size))
83
+ break;
82
84
  throw new Error(`WAL frame marker mismatch at offset ${String(offset)}`);
83
85
  }
84
86
  const length = headerView.getUint32(4, true);
@@ -92,12 +94,26 @@ function* iterateWalFrames(handle) {
92
94
  const payloadBytes = new Uint8Array(length);
93
95
  readFully(handle, payloadBytes, offset + FRAME_HEADER_BYTES, "reading a WAL frame payload for recovery");
94
96
  if (crc32(payloadBytes) !== checksum) {
97
+ if (payloadBytes.every((byte) => byte === 0) && isZeroFilled(handle, end, size))
98
+ break;
95
99
  throw new Error(`WAL frame checksum mismatch at offset ${String(offset)}`);
96
100
  }
97
101
  yield { payload: decodeRecordJson(payloadBytes), frameEnd: end };
98
102
  offset = end;
99
103
  }
100
104
  }
105
+ function isZeroFilled(handle, offset, size) {
106
+ const chunk = new Uint8Array(Math.min(64 * 1024, Math.max(0, size - offset)));
107
+ for (let at = offset; at < size; at += chunk.byteLength) {
108
+ const length = Math.min(chunk.byteLength, size - at);
109
+ const window = length === chunk.byteLength ? chunk : chunk.subarray(0, length);
110
+ readFully(handle, window, at, "reading a WAL tail for recovery");
111
+ for (let index = 0; index < length; index += 1)
112
+ if (window[index] !== 0)
113
+ return false;
114
+ }
115
+ return true;
116
+ }
101
117
  export {
102
118
  MAX_WAL_FRAME_BYTES,
103
119
  WalWriter,
@@ -58,15 +58,15 @@ function decodeEnvelope(magic, bytes) {
58
58
  }
59
59
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
60
60
  const version = view.getUint32(8, true);
61
- if (version !== LOG_FORMAT_VERSION) {
62
- throw new StorageFormatVersionError("opfs", `envelope/${magic}`, version, LOG_FORMAT_VERSION, version < LOG_FORMAT_VERSION ? "older" : "newer");
63
- }
64
61
  const payloadLength = view.getUint32(12, true);
65
62
  if (bytes.byteLength !== ENVELOPE_HEADER_BYTES + payloadLength)
66
63
  return void 0;
67
64
  const payload = bytes.subarray(ENVELOPE_HEADER_BYTES, ENVELOPE_HEADER_BYTES + payloadLength);
68
65
  if (crc32(payload) !== view.getUint32(16, true))
69
66
  return void 0;
67
+ if (version !== LOG_FORMAT_VERSION) {
68
+ throw new StorageFormatVersionError("opfs", `envelope/${magic}`, version, LOG_FORMAT_VERSION, version < LOG_FORMAT_VERSION ? "older" : "newer");
69
+ }
70
70
  return payload;
71
71
  }
72
72
  const CHUNK_MAGIC = "MNWCHNK1";
@@ -254,8 +254,16 @@ export interface SecondaryIndexRecord {
254
254
  unique?: true;
255
255
  /** The membership set has been seeded and must be enforced, independent of postings state. */
256
256
  uniqueEnforced?: true;
257
- /** Prefix-free composite encoding used by every v1 secondary index. */
258
- termEncoding: "tuple-v1";
257
+ /**
258
+ * Prefix-free composite term encoding.
259
+ *
260
+ * `tuple-v1` encodes non-null components only: a row with a NULL in any indexed column has no
261
+ * posting at all, so a prefix lookup through the index cannot see it. `tuple-v2` keeps every
262
+ * non-null component byte-identical and adds a one-character marker for a NULL component, so a
263
+ * row with a NULL trailing component is named under its non-null prefix. Both stay readable;
264
+ * only `tuple-v2` may serve a prefix lookup whose unconstrained trailing columns are nullable.
265
+ */
266
+ termEncoding: "tuple-v1" | "tuple-v2";
259
267
  storage: "postings-v1";
260
268
  storageColumnId: string;
261
269
  locator: "row-id" | "key-hash-v1";
@@ -502,11 +510,11 @@ export declare const MAX_PINNED_RETIRED_BYTES: number;
502
510
  export declare const MAX_RETIRED_HISTORY_BYTES: number;
503
511
  /** A durable resource family reached its fixed corruption/growth safety ceiling. */
504
512
  export declare class StorageResourceLimitError extends Error {
505
- readonly resource: "lease" | "temp owner" | "temp run" | "temp page" | "temporary run total" | "temporary page total" | "temp owner byte" | "temporary byte" | "compaction job" | "garbage collection job" | "unique-key build" | "full-text build" | "secondary-index build" | "accelerator build byte" | "accelerator build entry" | "transaction" | "staged artifact byte" | "staged block" | "staged segment" | "catalog record" | "catalog byte" | "manifest record" | "manifest byte" | "segment record" | "segment byte" | "terminal transaction" | "terminal compaction job" | "completed garbage collection job" | "pinned manifest version lag" | "pinned retired block" | "pinned retired byte" | "snapshot accelerator byte" | "snapshot accelerator entry" | "retired history byte";
513
+ readonly resource: "lease" | "temp owner" | "temp run" | "temp page" | "temporary run total" | "temporary page total" | "temp owner byte" | "temporary byte" | "compaction job" | "garbage collection job" | "unique-key build" | "full-text build" | "secondary-index build" | "accelerator build byte" | "accelerator build entry" | "transaction" | "staged artifact byte" | "staged block" | "staged segment" | "catalog record" | "catalog byte" | "manifest record" | "manifest byte" | "segment record" | "segment byte" | "terminal transaction" | "terminal compaction job" | "completed garbage collection job" | "pinned manifest version lag" | "pinned retired block" | "pinned retired byte" | "snapshot accelerator byte" | "snapshot accelerator entry" | "retired history byte" | "checkpoint byte";
506
514
  readonly count: number;
507
515
  readonly limit: number;
508
516
  readonly name = "StorageResourceLimitError";
509
- constructor(resource: "lease" | "temp owner" | "temp run" | "temp page" | "temporary run total" | "temporary page total" | "temp owner byte" | "temporary byte" | "compaction job" | "garbage collection job" | "unique-key build" | "full-text build" | "secondary-index build" | "accelerator build byte" | "accelerator build entry" | "transaction" | "staged artifact byte" | "staged block" | "staged segment" | "catalog record" | "catalog byte" | "manifest record" | "manifest byte" | "segment record" | "segment byte" | "terminal transaction" | "terminal compaction job" | "completed garbage collection job" | "pinned manifest version lag" | "pinned retired block" | "pinned retired byte" | "snapshot accelerator byte" | "snapshot accelerator entry" | "retired history byte", count: number, limit: number);
517
+ constructor(resource: "lease" | "temp owner" | "temp run" | "temp page" | "temporary run total" | "temporary page total" | "temp owner byte" | "temporary byte" | "compaction job" | "garbage collection job" | "unique-key build" | "full-text build" | "secondary-index build" | "accelerator build byte" | "accelerator build entry" | "transaction" | "staged artifact byte" | "staged block" | "staged segment" | "catalog record" | "catalog byte" | "manifest record" | "manifest byte" | "segment record" | "segment byte" | "terminal transaction" | "terminal compaction job" | "completed garbage collection job" | "pinned manifest version lag" | "pinned retired block" | "pinned retired byte" | "snapshot accelerator byte" | "snapshot accelerator entry" | "retired history byte" | "checkpoint byte", count: number, limit: number);
510
518
  }
511
519
  export interface LeaseRecord {
512
520
  id: string;
@@ -1361,6 +1369,29 @@ export declare class UnknownOutcomeError extends Error {
1361
1369
  export declare class ConnectionLostError extends Error {
1362
1370
  readonly name: string;
1363
1371
  }
1372
+ /**
1373
+ * The store stopped answering: the whole connection went the adapter's unresponsive deadline
1374
+ * without a single storage event while it had work outstanding. The remedy is to reload the
1375
+ * page, not to retry — the connection cannot recover, and a fresh one in the same page may be
1376
+ * wedged by the same cause.
1377
+ *
1378
+ * The known cause is a worker terminated with a write in flight. WebKit keeps the dead worker's
1379
+ * IndexedDB connection, and its unfinished transaction, registered until the owning document
1380
+ * goes away, and while it is registered every connection to that database blocks — including
1381
+ * connections opened afterwards in other tabs. A store cannot unwedge the browser, so it bounds
1382
+ * the wait and says so instead of hanging.
1383
+ *
1384
+ * It extends `UnknownOutcomeError` because a write that stalled may still be committed by the
1385
+ * browser once the wedge clears, and carries a `ConnectionLostError` cause so `classifyError`
1386
+ * reports the connection as unusable.
1387
+ */
1388
+ export declare class StorageUnresponsiveError extends UnknownOutcomeError {
1389
+ readonly backend: string;
1390
+ readonly databaseName: string;
1391
+ readonly waitedMs: number;
1392
+ readonly name = "StorageUnresponsiveError";
1393
+ constructor(backend: string, databaseName: string, waitedMs: number);
1394
+ }
1364
1395
  /**
1365
1396
  * A remote OPFS leader may have committed a mutation whose acknowledgement was lost.
1366
1397
  * Reconcile stable identities or revisions before retrying the named operation.
@@ -226,7 +226,7 @@ function secondaryIndexWriteContractChanged(previous, next) {
226
226
  if (rightEntry?.[0] !== id)
227
227
  return true;
228
228
  const right = rightEntry[1];
229
- return left.name !== right.name || left.columnId !== right.columnId || left.columnIds.length !== right.columnIds.length || left.columnIds.some((columnId, index) => columnId !== right.columnIds[index]) || left.directions.length !== right.directions.length || left.directions.some((direction, index) => direction !== right.directions[index]) || left.unique !== right.unique || left.uniqueEnforced !== right.uniqueEnforced || left.storageColumnId !== right.storageColumnId || left.locator !== right.locator;
229
+ return left.name !== right.name || left.columnId !== right.columnId || left.columnIds.length !== right.columnIds.length || left.columnIds.some((columnId, index) => columnId !== right.columnIds[index]) || left.directions.length !== right.directions.length || left.directions.some((direction, index) => direction !== right.directions[index]) || left.termEncoding !== right.termEncoding || left.unique !== right.unique || left.uniqueEnforced !== right.uniqueEnforced || left.storageColumnId !== right.storageColumnId || left.locator !== right.locator;
230
230
  });
231
231
  }
232
232
  function validateTableForeignKey(childTable, key, parentTable) {
@@ -461,7 +461,7 @@ function validateSecondaryIndexes(record) {
461
461
  }
462
462
  const directions = index.directions;
463
463
  const termEncoding = index.termEncoding;
464
- if (directions.length !== indexedColumnIds.length || directions.some((direction) => direction !== "asc" && direction !== "desc") || termEncoding !== "tuple-v1") {
464
+ if (directions.length !== indexedColumnIds.length || directions.some((direction) => direction !== "asc" && direction !== "desc") || termEncoding !== "tuple-v1" && termEncoding !== "tuple-v2") {
465
465
  throw new TypeError(`Secondary index ${index.name} has invalid key metadata`);
466
466
  }
467
467
  if (index.uniqueEnforced === true && index.unique !== true) {
@@ -993,6 +993,18 @@ class UnknownOutcomeError extends Error {
993
993
  class ConnectionLostError extends Error {
994
994
  name = "ConnectionLostError";
995
995
  }
996
+ class StorageUnresponsiveError extends UnknownOutcomeError {
997
+ backend;
998
+ databaseName;
999
+ waitedMs;
1000
+ name = "StorageUnresponsiveError";
1001
+ constructor(backend, databaseName, waitedMs) {
1002
+ super(`The ${backend} database ${databaseName} answered nothing for ${String(waitedMs)}ms and is treated as unresponsive; reload the page to open it again`, { cause: new ConnectionLostError(`The ${backend} connection stopped answering`) });
1003
+ this.backend = backend;
1004
+ this.databaseName = databaseName;
1005
+ this.waitedMs = waitedMs;
1006
+ }
1007
+ }
996
1008
  class OpfsUncertainOutcomeError extends UnknownOutcomeError {
997
1009
  method;
998
1010
  name = "OpfsUncertainOutcomeError";
@@ -2428,7 +2440,7 @@ function normalizeMergeCompactionRewritePlan(value) {
2428
2440
  for (let index = 1; index < sourceSegments.length; index += 1) {
2429
2441
  const previous = sourceSegments[index - 1];
2430
2442
  const current = sourceSegments[index];
2431
- if (previous !== void 0 && current !== void 0 && compareMergeSourceSegments(previous, current) >= 0) {
2443
+ if (previous !== void 0 && current !== void 0 && compareMergeSourceSegments(previous, current) > 0) {
2432
2444
  throw new TypeError("Merge source segments must use canonical logical order");
2433
2445
  }
2434
2446
  }
@@ -2739,7 +2751,7 @@ function normalizeRowIdSpans(value, totalRows, rowIdStart, rowIdEndExclusive, la
2739
2751
  return spans;
2740
2752
  }
2741
2753
  function compareMergeSourceSegments(left, right) {
2742
- return left.logicalOrder - right.logicalOrder || left.committedVersion - right.committedVersion || left.segmentId.localeCompare(right.segmentId);
2754
+ return left.logicalOrder - right.logicalOrder || left.committedVersion - right.committedVersion;
2743
2755
  }
2744
2756
  function normalizeCompactionOutputCursor(value, plan) {
2745
2757
  if (plan.kind === "copy-v1") {
@@ -3065,6 +3077,7 @@ export {
3065
3077
  StorageCorruptionError,
3066
3078
  StorageFormatVersionError,
3067
3079
  StorageResourceLimitError,
3080
+ StorageUnresponsiveError,
3068
3081
  TableInUseError,
3069
3082
  TableRecordConflictError,
3070
3083
  TempOwnerConflictError,
@@ -1,4 +1,4 @@
1
- import { CompactionJobConflictError, LeaseConflictError, LeaseOwnerConflictError, MAX_LEVEL_ZERO_SEGMENTS, MAX_MANIFEST_BLOCK_PRESENCE_IDS, MAX_STORAGE_BULK_READ_ITEMS, PostingBuildConflictError, SnapshotManifestMissingError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, secondaryUniqueKeyNamespace, WriteConflictError } from "../storage/types.js";
1
+ import { CompactionJobConflictError, GarbageCollectionJobConflictError, LeaseConflictError, LeaseOwnerConflictError, MAX_LEVEL_ZERO_SEGMENTS, MAX_MANIFEST_BLOCK_PRESENCE_IDS, MAX_STORAGE_BULK_READ_ITEMS, PostingBuildConflictError, SnapshotManifestMissingError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyConflictError, secondaryUniqueKeyNamespace, WriteConflictError } from "../storage/types.js";
2
2
  async function runBlockStoreConformance(target) {
3
3
  for (const conformanceCase of blockStoreConformanceCases()) {
4
4
  try {
@@ -768,6 +768,36 @@ function blockStoreConformanceCases() {
768
768
  limit: 1
769
769
  });
770
770
  checkEqual(retiredBeforeCollection.records.map((record) => record.blockId), [first.blockId], "retired provenance must remain independently pageable before collection");
771
+ const discovering = await store.createGarbageCollectionJob({
772
+ id: "gc-discovering",
773
+ candidateManifestVersions: [],
774
+ candidateSegmentIds: [],
775
+ candidateBlockIds: [],
776
+ leaseCutoff: LATER,
777
+ createdAt: LATER,
778
+ discovery: {
779
+ phase: "manifests",
780
+ currentManifestVersion: null,
781
+ retainAboveVersion: 0,
782
+ retainAfter: 0,
783
+ maxPlanningItems: 16,
784
+ manifestCursor: null,
785
+ segmentCursor: null,
786
+ transactionCursor: null,
787
+ compactionCursor: null,
788
+ visitedRecords: 0,
789
+ resumePhase: null,
790
+ postManifestPhase: null,
791
+ artifactCursor: null
792
+ }
793
+ });
794
+ const discovered = await store.updateGarbageCollectionPlanning({
795
+ jobId: discovering.id,
796
+ expectedRevision: discovering.revision,
797
+ discovery: { ...discovering.discovery ?? {}, phase: "complete" },
798
+ updatedAt: LATER
799
+ });
800
+ check(discovered.state === "completed", "a discovery that found nothing must complete its job");
771
801
  const job = await store.createGarbageCollectionJob({
772
802
  id: "gc-1",
773
803
  candidateManifestVersions: [first.version],
@@ -778,6 +808,15 @@ function blockStoreConformanceCases() {
778
808
  });
779
809
  await checkThrows(() => store.removeGarbageCollectionJob(job.id), Error, "removing a non-completed garbage-collection job");
780
810
  check((await store.getGarbageCollectionJob(job.id))?.state === "planned", "a refused garbage-collection job removal mutated the record");
811
+ await checkThrows(() => store.createGarbageCollectionJob({
812
+ id: "gc-2",
813
+ candidateManifestVersions: [first.version],
814
+ candidateSegmentIds: [],
815
+ candidateBlockIds: [first.blockId],
816
+ leaseCutoff: LATER,
817
+ createdAt: LATER
818
+ }), GarbageCollectionJobConflictError, "a second active garbage-collection job");
819
+ check(await store.getGarbageCollectionJob("gc-2") === void 0, "a refused second garbage-collection job left a record behind");
781
820
  const step = await store.runGarbageCollectionStep({
782
821
  jobId: job.id,
783
822
  expectedRevision: job.revision,