@minnowdb/core 0.1.1 → 0.2.1

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 (119) hide show
  1. package/README.md +12 -9
  2. package/dist/engine/client.d.ts +10 -1
  3. package/dist/engine/client.d.ts.map +1 -1
  4. package/dist/engine/client.js +30 -5
  5. package/dist/engine/client.js.map +1 -1
  6. package/dist/engine/database.d.ts +55 -6
  7. package/dist/engine/database.d.ts.map +1 -1
  8. package/dist/engine/database.js +2689 -898
  9. package/dist/engine/database.js.map +1 -1
  10. package/dist/engine/live.d.ts +6 -2
  11. package/dist/engine/live.d.ts.map +1 -1
  12. package/dist/engine/live.js +35 -11
  13. package/dist/engine/live.js.map +1 -1
  14. package/dist/engine/query-cache.d.ts +13 -1
  15. package/dist/engine/query-cache.d.ts.map +1 -1
  16. package/dist/engine/query-cache.js +34 -5
  17. package/dist/engine/query-cache.js.map +1 -1
  18. package/dist/engine/query.d.ts +13 -1
  19. package/dist/engine/query.d.ts.map +1 -1
  20. package/dist/engine/query.js +73 -46
  21. package/dist/engine/query.js.map +1 -1
  22. package/dist/engine/result-wire.d.ts +68 -0
  23. package/dist/engine/result-wire.d.ts.map +1 -0
  24. package/dist/engine/result-wire.js +264 -0
  25. package/dist/engine/result-wire.js.map +1 -0
  26. package/dist/engine/sort-keys.d.ts +50 -12
  27. package/dist/engine/sort-keys.d.ts.map +1 -1
  28. package/dist/engine/sort-keys.js +371 -25
  29. package/dist/engine/sort-keys.js.map +1 -1
  30. package/dist/engine/vector.d.ts +11 -0
  31. package/dist/engine/vector.d.ts.map +1 -1
  32. package/dist/engine/vector.js +99 -203
  33. package/dist/engine/vector.js.map +1 -1
  34. package/dist/engine/worker-host.d.ts +17 -1
  35. package/dist/engine/worker-host.d.ts.map +1 -1
  36. package/dist/engine/worker-host.js +87 -13
  37. package/dist/engine/worker-host.js.map +1 -1
  38. package/dist/storage/index.d.ts +13 -0
  39. package/dist/storage/index.d.ts.map +1 -1
  40. package/dist/storage/index.js +13 -0
  41. package/dist/storage/index.js.map +1 -1
  42. package/dist/storage/indexeddb.d.ts +5 -1
  43. package/dist/storage/indexeddb.d.ts.map +1 -1
  44. package/dist/storage/indexeddb.js +484 -402
  45. package/dist/storage/indexeddb.js.map +1 -1
  46. package/dist/storage/memory.d.ts +24 -21
  47. package/dist/storage/memory.d.ts.map +1 -1
  48. package/dist/storage/memory.js +124 -1199
  49. package/dist/storage/memory.js.map +1 -1
  50. package/dist/storage/opfs/files.d.ts +64 -0
  51. package/dist/storage/opfs/files.d.ts.map +1 -0
  52. package/dist/storage/opfs/files.js +243 -0
  53. package/dist/storage/opfs/files.js.map +1 -0
  54. package/dist/storage/opfs/index.d.ts +3 -0
  55. package/dist/storage/opfs/index.d.ts.map +1 -0
  56. package/dist/storage/opfs/index.js +3 -0
  57. package/dist/storage/opfs/index.js.map +1 -0
  58. package/dist/storage/opfs/leader.d.ts +130 -0
  59. package/dist/storage/opfs/leader.d.ts.map +1 -0
  60. package/dist/storage/opfs/leader.js +1104 -0
  61. package/dist/storage/opfs/leader.js.map +1 -0
  62. package/dist/storage/opfs/rpc.d.ts +70 -0
  63. package/dist/storage/opfs/rpc.d.ts.map +1 -0
  64. package/dist/storage/opfs/rpc.js +58 -0
  65. package/dist/storage/opfs/rpc.js.map +1 -0
  66. package/dist/storage/opfs/store.d.ts +154 -0
  67. package/dist/storage/opfs/store.d.ts.map +1 -0
  68. package/dist/storage/opfs/store.js +958 -0
  69. package/dist/storage/opfs/store.js.map +1 -0
  70. package/dist/storage/toolkit/extents.d.ts +69 -0
  71. package/dist/storage/toolkit/extents.d.ts.map +1 -0
  72. package/dist/storage/toolkit/extents.js +175 -0
  73. package/dist/storage/toolkit/extents.js.map +1 -0
  74. package/dist/storage/toolkit/index.d.ts +30 -0
  75. package/dist/storage/toolkit/index.d.ts.map +1 -0
  76. package/dist/storage/toolkit/index.js +5 -0
  77. package/dist/storage/toolkit/index.js.map +1 -0
  78. package/dist/storage/toolkit/record-core.d.ts +252 -0
  79. package/dist/storage/toolkit/record-core.d.ts.map +1 -0
  80. package/dist/storage/toolkit/record-core.js +1670 -0
  81. package/dist/storage/toolkit/record-core.js.map +1 -0
  82. package/dist/storage/toolkit/sync-file.d.ts +29 -0
  83. package/dist/storage/toolkit/sync-file.d.ts.map +1 -0
  84. package/dist/storage/toolkit/sync-file.js +2 -0
  85. package/dist/storage/toolkit/sync-file.js.map +1 -0
  86. package/dist/storage/toolkit/wal.d.ts +22 -0
  87. package/dist/storage/toolkit/wal.d.ts.map +1 -0
  88. package/dist/storage/toolkit/wal.js +79 -0
  89. package/dist/storage/toolkit/wal.js.map +1 -0
  90. package/dist/storage/toolkit/wire.d.ts +25 -0
  91. package/dist/storage/toolkit/wire.d.ts.map +1 -0
  92. package/dist/storage/toolkit/wire.js +100 -0
  93. package/dist/storage/toolkit/wire.js.map +1 -0
  94. package/dist/storage/types.d.ts +343 -59
  95. package/dist/storage/types.d.ts.map +1 -1
  96. package/dist/storage/types.js +112 -11
  97. package/dist/storage/types.js.map +1 -1
  98. package/dist/testing/block-store-conformance.d.ts +51 -0
  99. package/dist/testing/block-store-conformance.d.ts.map +1 -0
  100. package/dist/testing/block-store-conformance.js +745 -0
  101. package/dist/testing/block-store-conformance.js.map +1 -0
  102. package/dist/testing/index.d.ts +3 -0
  103. package/dist/testing/index.d.ts.map +1 -1
  104. package/dist/testing/index.js +5 -0
  105. package/dist/testing/index.js.map +1 -1
  106. package/dist/testing/opfs-shim.d.ts +35 -0
  107. package/dist/testing/opfs-shim.d.ts.map +1 -0
  108. package/dist/testing/opfs-shim.js +295 -0
  109. package/dist/testing/opfs-shim.js.map +1 -0
  110. package/dist/transactions/index.d.ts +42 -1
  111. package/dist/transactions/index.d.ts.map +1 -1
  112. package/dist/transactions/index.js +219 -13
  113. package/dist/transactions/index.js.map +1 -1
  114. package/dist/worker-protocol/index.d.ts +2 -2
  115. package/dist/worker-protocol/index.d.ts.map +1 -1
  116. package/dist/worker-protocol/index.js +2 -1
  117. package/dist/worker-protocol/index.js.map +1 -1
  118. package/package.json +10 -2
  119. package/sql-feature-matrix.json +8 -0
@@ -0,0 +1,958 @@
1
+ import { validateId, validateTempRunPage, validateTempRunPageIdentity, } from "../toolkit/record-core.js";
2
+ import { OpfsTree, encodeSegment, isDomError } from "./files.js";
3
+ import { LOG_FORMAT_VERSION } from "../toolkit/wire.js";
4
+ import { OpfsLeader } from "./leader.js";
5
+ import { rehydrateStoreError, serializeStoreError, } from "./rpc.js";
6
+ /** How long a follower waits for a leader's answer before assuming it is gone. */
7
+ const RPC_TIMEOUT_MS = 1_000;
8
+ /** How long a ping waits for a leadership announcement. */
9
+ const DISCOVERY_WAIT_MS = 150;
10
+ /** Attempts across the discover → call → elect loop before an operation gives up. */
11
+ const DISPATCH_ATTEMPTS = 10;
12
+ /** A leader yields to a foreground bidder at most this often. */
13
+ const YIELD_COOLDOWN_MS = 3_000;
14
+ /** Results remembered for retried requests whose acknowledgement was lost. */
15
+ const DEDUPE_CACHE_SIZE = 512;
16
+ /** Channels into requesters' inboxes a leader keeps open between answers. */
17
+ const ANSWER_CHANNEL_CACHE_SIZE = 64;
18
+ /** Methods a follower may invoke on the leader. Temp-page IO is instance-local by design. */
19
+ const RPC_METHODS = new Set([
20
+ "addBlock",
21
+ "addBlocks",
22
+ "getBlock",
23
+ "getBlocks",
24
+ "removeBlock",
25
+ "listBlockIds",
26
+ "addTable",
27
+ "getTable",
28
+ "getTableByName",
29
+ "listTables",
30
+ "updateTable",
31
+ "removeTable",
32
+ "writeFtsBase",
33
+ "readFtsCandidates",
34
+ "addSegment",
35
+ "getSegment",
36
+ "listSegments",
37
+ "removeSegment",
38
+ "reserveRowIds",
39
+ "reserveAutoIncrement",
40
+ "getExistingUniqueKeys",
41
+ "getCurrentManifest",
42
+ "getCurrentManifestVersion",
43
+ "getCatalogProbe",
44
+ "getQueryCatalogState",
45
+ "getManifest",
46
+ "listManifests",
47
+ "listManifestPage",
48
+ "publishManifest",
49
+ "createTransaction",
50
+ "beginTransaction",
51
+ "getTransaction",
52
+ "getTransactions",
53
+ "listTransactions",
54
+ "listTransactionPage",
55
+ "updateTransaction",
56
+ "stageTransactionArtifacts",
57
+ "commitTransaction",
58
+ "writeTransaction",
59
+ "createLease",
60
+ "getLease",
61
+ "listLeases",
62
+ "renewLease",
63
+ "moveLease",
64
+ "removeLeaseIfExpired",
65
+ "removeLease",
66
+ "createCompactionJob",
67
+ "getCompactionJob",
68
+ "listCompactionJobs",
69
+ "listCompactionJobPage",
70
+ "updateCompactionJob",
71
+ "cancelCompactionJob",
72
+ "removeCompactionJob",
73
+ "createGarbageCollectionJob",
74
+ "getGarbageCollectionJob",
75
+ "listGarbageCollectionJobs",
76
+ "runGarbageCollectionStep",
77
+ "removePrunedManifestRecords",
78
+ "removeGarbageCollectionJob",
79
+ "createTempOwner",
80
+ "getTempOwner",
81
+ "renewTempOwner",
82
+ "removeTempOwnerIfExpired",
83
+ "removeTempOwner",
84
+ "listTempOwnerIdsPage",
85
+ "getLogicalStorageBytes",
86
+ "exportSnapshot",
87
+ "importSnapshot",
88
+ ]);
89
+ /**
90
+ * Reads are idempotent — a lost-acknowledgement retry can simply run again — so only
91
+ * mutations enter the dedupe cache, and read results (block bytes included) are never
92
+ * retained on the leader.
93
+ */
94
+ const READ_METHODS = new Set([
95
+ "getBlock",
96
+ "getBlocks",
97
+ "listBlockIds",
98
+ "getTable",
99
+ "getTableByName",
100
+ "listTables",
101
+ "readFtsCandidates",
102
+ "getSegment",
103
+ "listSegments",
104
+ "getExistingUniqueKeys",
105
+ "getCurrentManifest",
106
+ "getCurrentManifestVersion",
107
+ "getCatalogProbe",
108
+ "getQueryCatalogState",
109
+ "getManifest",
110
+ "listManifests",
111
+ "listManifestPage",
112
+ "getTransaction",
113
+ "getTransactions",
114
+ "listTransactions",
115
+ "listTransactionPage",
116
+ "getLease",
117
+ "listLeases",
118
+ "getCompactionJob",
119
+ "listCompactionJobs",
120
+ "listCompactionJobPage",
121
+ "getGarbageCollectionJob",
122
+ "listGarbageCollectionJobs",
123
+ "getTempOwner",
124
+ "listTempOwnerIdsPage",
125
+ "getLogicalStorageBytes",
126
+ "exportSnapshot",
127
+ ]);
128
+ /**
129
+ * The OPFS block store: one leader per database holds every file handle and does all storage
130
+ * work at held-handle speed; other connections are thin followers whose operations travel a
131
+ * `BroadcastChannel` to it.
132
+ *
133
+ * Two kinds of channel carry the protocol. One shared channel per database carries what every
134
+ * connection must hear: leader announcements, pings, bids, yields, releases. Every connection
135
+ * also owns an inbox — a channel named for its instance id — where the messages meant for it
136
+ * alone arrive: operations at the leader, results and busy notices at the requester. A block
137
+ * read's bytes are therefore structured-cloned once, into the tab that asked, rather than into
138
+ * every tab of the origin.
139
+ *
140
+ * Leadership is the write-ahead log's own exclusive sync-access handle — enforced by the
141
+ * browser against the actual resource, released the instant its holder dies. Elections are
142
+ * simply attempts to open it. Correctness never rides on a message: an operation is
143
+ * acknowledged only after the leader's WAL holds it, a lost message costs a retry (request
144
+ * ids deduplicate retries while the same leader serves), and a dead leader costs a failover
145
+ * in which the next acquirer replays checkpoint-plus-WAL. A retry that crosses a failover is
146
+ * the one case a duplicate can execute — the same "committed but the response was lost"
147
+ * class the IndexedDB store has — and it resolves the same way: compare-and-swap operations
148
+ * surface a conflict the engine's recovery already reconciles. The channel affects how fast
149
+ * multi-tab work moves, never whether it is right.
150
+ *
151
+ * `setForeground(true)` marks this connection as the one the user is looking at; a background
152
+ * leader yields to a foreground bidder, so the tab doing the work is normally the tab holding
153
+ * the microsecond-fast path.
154
+ */
155
+ export class OpfsBlockStore {
156
+ #tree;
157
+ #durability;
158
+ #checkpointEntries;
159
+ #rpcTimeoutMs;
160
+ #instanceId = crypto.randomUUID();
161
+ #channelName;
162
+ /** The database-wide channel: leadership traffic every connection listens to. */
163
+ #channel;
164
+ /** This connection's own channel: operations when leading, results when following. */
165
+ #inbox;
166
+ /** While leading: open channels into requesters' inboxes, by instance id. */
167
+ #answerChannels = new Map();
168
+ #leader;
169
+ #knownLeader;
170
+ #foreground = false;
171
+ #closed = false;
172
+ #lastYieldAt = 0;
173
+ #electing;
174
+ #pending = new Map();
175
+ #dedupe = new Map();
176
+ #reacquireTimer;
177
+ constructor(tree, options) {
178
+ this.#tree = tree;
179
+ this.#durability = options.durability ?? "relaxed";
180
+ this.#checkpointEntries = options.checkpointEntries;
181
+ this.#rpcTimeoutMs = options.rpcTimeoutMs ?? RPC_TIMEOUT_MS;
182
+ this.#channelName = `minnowdb-store:${options.name}`;
183
+ }
184
+ static async open(options) {
185
+ const tree = new OpfsTree(await resolveDatabaseRoot(options));
186
+ const store = new OpfsBlockStore(tree, options);
187
+ await store.#ensureFormatMarker();
188
+ if (typeof BroadcastChannel === "function") {
189
+ const onMessage = (event) => {
190
+ store.#onMessage(event.data);
191
+ };
192
+ const channel = new BroadcastChannel(store.#channelName);
193
+ channel.onmessage = onMessage;
194
+ const inbox = new BroadcastChannel(store.#inboxName(store.#instanceId));
195
+ inbox.onmessage = onMessage;
196
+ store.#channel = channel;
197
+ store.#inbox = inbox;
198
+ }
199
+ await store.#tryBecomeLeader();
200
+ return store;
201
+ }
202
+ // ---------------------------------------------------------------------------------------
203
+ // Leadership.
204
+ // ---------------------------------------------------------------------------------------
205
+ async #tryBecomeLeader() {
206
+ if (this.#closed)
207
+ return false;
208
+ if (this.#leader !== undefined)
209
+ return true;
210
+ if (this.#electing !== undefined)
211
+ return this.#electing;
212
+ const election = this.#elect();
213
+ this.#electing = election;
214
+ try {
215
+ return await election;
216
+ }
217
+ finally {
218
+ this.#electing = undefined;
219
+ }
220
+ }
221
+ async #elect() {
222
+ let wal;
223
+ try {
224
+ wal = await this.#tree.openHandle(["wal"], { create: true });
225
+ }
226
+ catch (error) {
227
+ if (isLockContention(error))
228
+ return false;
229
+ throw error;
230
+ }
231
+ let slotA;
232
+ let slotB;
233
+ try {
234
+ // A dying ex-leader releases all its handles at once, but a graceful demotion closes
235
+ // them in sequence; the brief retry covers the gap.
236
+ slotA = await this.#openWithRetry(["checkpoint-a"]);
237
+ slotB = await this.#openWithRetry(["checkpoint-b"]);
238
+ this.#leader = await OpfsLeader.recover(this.#tree, this.#durability === "strict", { wal, slotA, slotB }, this.#checkpointEntries);
239
+ }
240
+ catch (error) {
241
+ wal.close();
242
+ slotA?.close();
243
+ slotB?.close();
244
+ if (isLockContention(error))
245
+ return false;
246
+ throw error;
247
+ }
248
+ if (this.#closed) {
249
+ // close() ran while this election was in flight; a leader installed now would hold the
250
+ // browser's file lock with no owner to ever release it.
251
+ const leader = this.#leader;
252
+ this.#leader = undefined;
253
+ await leader.shutdown().catch(() => {
254
+ leader.crash();
255
+ });
256
+ return false;
257
+ }
258
+ this.#knownLeader = this.#instanceId;
259
+ this.#post({ kind: "leader", leaderId: this.#instanceId });
260
+ return true;
261
+ }
262
+ async #openWithRetry(path) {
263
+ for (let attempt = 0;; attempt += 1) {
264
+ try {
265
+ return await this.#tree.openHandle(path, { create: true });
266
+ }
267
+ catch (error) {
268
+ if (isLockContention(error) && attempt < 40) {
269
+ await sleep(5 + Math.random() * 10);
270
+ continue;
271
+ }
272
+ throw error;
273
+ }
274
+ }
275
+ }
276
+ /** Marks this connection as the one the user is looking at — a leadership preference. */
277
+ setForeground(foreground) {
278
+ this.#foreground = foreground;
279
+ if (foreground && this.#leader === undefined && !this.#closed) {
280
+ this.#post({ kind: "bid", bidderId: this.#instanceId, foreground: true });
281
+ }
282
+ }
283
+ async #yieldLeadership(to) {
284
+ const leader = this.#leader;
285
+ if (leader === undefined)
286
+ return;
287
+ this.#leader = undefined;
288
+ this.#knownLeader = undefined;
289
+ this.#lastYieldAt = Date.now();
290
+ try {
291
+ await leader.shutdown();
292
+ }
293
+ catch {
294
+ // Whatever failed, the handles must not outlive the leadership; crash-close is
295
+ // idempotent and releases them.
296
+ leader.crash();
297
+ }
298
+ // An open channel into a follower's inbox would hear the next leader's answers to it.
299
+ this.#closeAnswerChannels();
300
+ this.#post({ kind: "yield", to });
301
+ // If the bidder loses the race or vanishes, someone must still hold the database.
302
+ if (this.#reacquireTimer !== undefined)
303
+ clearTimeout(this.#reacquireTimer);
304
+ this.#reacquireTimer = setTimeout(() => {
305
+ if (this.#knownLeader === undefined && !this.#closed)
306
+ void this.#tryBecomeLeader();
307
+ }, 1_500);
308
+ }
309
+ #onMessage(message) {
310
+ if (this.#closed)
311
+ return;
312
+ switch (message.kind) {
313
+ case "op": {
314
+ if (this.#leader !== undefined)
315
+ void this.#serveOp(message);
316
+ return;
317
+ }
318
+ case "result": {
319
+ const pending = this.#pending.get(message.requestId);
320
+ if (pending === undefined)
321
+ return;
322
+ this.#pending.delete(message.requestId);
323
+ clearTimeout(pending.timer);
324
+ if (message.ok)
325
+ pending.resolve(message.value);
326
+ else
327
+ pending.reject(rehydrateStoreError(message.error));
328
+ return;
329
+ }
330
+ case "busy": {
331
+ const pending = this.#pending.get(message.requestId);
332
+ if (pending === undefined)
333
+ return;
334
+ clearTimeout(pending.timer);
335
+ pending.timer = setTimeout(() => {
336
+ this.#pending.delete(message.requestId);
337
+ pending.reject(RPC_TIMED_OUT);
338
+ }, this.#rpcTimeoutMs);
339
+ return;
340
+ }
341
+ case "leader": {
342
+ this.#knownLeader = message.leaderId;
343
+ if (this.#reacquireTimer !== undefined) {
344
+ clearTimeout(this.#reacquireTimer);
345
+ this.#reacquireTimer = undefined;
346
+ }
347
+ // A new leader never saw our in-flight requests; send those again (ids deduplicate).
348
+ // Requests already sitting with this same leader stay put — every ping triggers an
349
+ // announce, and blind re-sends would race their own in-flight executions.
350
+ for (const pending of this.#pending.values()) {
351
+ if (pending.sentTo === message.leaderId)
352
+ continue;
353
+ pending.sentTo = message.leaderId;
354
+ this.#send(message.leaderId, pending.message);
355
+ }
356
+ // The freshly announced leader may be background while we are what the user sees.
357
+ if (this.#foreground && this.#leader === undefined) {
358
+ this.#post({ kind: "bid", bidderId: this.#instanceId, foreground: true });
359
+ }
360
+ return;
361
+ }
362
+ case "ping": {
363
+ if (this.#leader !== undefined) {
364
+ this.#post({ kind: "leader", leaderId: this.#instanceId });
365
+ }
366
+ return;
367
+ }
368
+ case "bid": {
369
+ if (this.#leader !== undefined &&
370
+ !this.#foreground &&
371
+ message.foreground &&
372
+ message.bidderId !== this.#instanceId &&
373
+ Date.now() - this.#lastYieldAt > YIELD_COOLDOWN_MS) {
374
+ void this.#yieldLeadership(message.bidderId);
375
+ }
376
+ return;
377
+ }
378
+ case "yield": {
379
+ if (message.to === this.#instanceId)
380
+ void this.#tryBecomeLeader();
381
+ return;
382
+ }
383
+ case "released": {
384
+ if (this.#knownLeader === message.leaderId)
385
+ this.#knownLeader = undefined;
386
+ return;
387
+ }
388
+ }
389
+ }
390
+ async #serveOp(message) {
391
+ const leader = this.#leader;
392
+ if (leader === undefined || !RPC_METHODS.has(message.method))
393
+ return;
394
+ let outcome = this.#dedupe.get(message.requestId);
395
+ if (outcome === undefined) {
396
+ outcome = this.#executeServedOp(leader, message);
397
+ // Only mutations need at-most-once; reads simply run again on a retried request, and
398
+ // caching their results (block bytes included) would be pure memory waste.
399
+ if (!READ_METHODS.has(message.method)) {
400
+ this.#dedupe.set(message.requestId, outcome);
401
+ if (this.#dedupe.size > DEDUPE_CACHE_SIZE) {
402
+ const [oldest] = this.#dedupe.keys();
403
+ if (oldest !== undefined)
404
+ this.#dedupe.delete(oldest);
405
+ }
406
+ }
407
+ }
408
+ else {
409
+ // A duplicate of a request that is still running (or already remembered): attach to it
410
+ // rather than executing twice, and reset the caller's patience meanwhile — the original
411
+ // may legitimately take longer than one timeout (a snapshot import, a checkpoint pause).
412
+ this.#answer(message.from, { kind: "busy", requestId: message.requestId });
413
+ }
414
+ const settled = await outcome;
415
+ this.#answer(message.from, settled.ok
416
+ ? { kind: "result", requestId: message.requestId, ok: true, value: settled.value }
417
+ : {
418
+ kind: "result",
419
+ requestId: message.requestId,
420
+ ok: false,
421
+ error: settled.error ?? { name: "Error", message: "unknown" },
422
+ });
423
+ }
424
+ async #executeServedOp(leader, message) {
425
+ try {
426
+ const method = leader[message.method];
427
+ if (method === undefined) {
428
+ return { ok: false, error: { name: "Error", message: "Unknown store operation" } };
429
+ }
430
+ return { ok: true, value: await method.apply(leader, message.args) };
431
+ }
432
+ catch (error) {
433
+ return { ok: false, error: serializeStoreError(error) };
434
+ }
435
+ }
436
+ /** Posts on the shared channel: leadership traffic, heard by every connection. */
437
+ #post(message) {
438
+ this.#channel?.postMessage(message);
439
+ }
440
+ /**
441
+ * Posts an operation into the leader's inbox through a channel opened for this one message.
442
+ * Every follower posts into the same inbox, and a `BroadcastChannel` hears whatever any other
443
+ * object of its name posts — an outbound channel kept open would deliver every other
444
+ * follower's operation (block bytes included) to this tab. Opened and closed around a single
445
+ * message, nothing but the leader's inbox is ever listening on that name.
446
+ */
447
+ #send(leaderId, message) {
448
+ // Once the channels are torn down nothing may be posted.
449
+ if (this.#channel === undefined)
450
+ return;
451
+ this.#postOnce(leaderId, message);
452
+ }
453
+ /**
454
+ * Posts an answer into the requester's inbox. Only the leader posts there, so the leader
455
+ * keeps these channels open across requests and hears nothing through them. They close on
456
+ * demotion: an ex-leader's open channel would hear the next leader's answers to that tab.
457
+ */
458
+ #answer(requesterId, message) {
459
+ if (this.#channel === undefined)
460
+ return;
461
+ if (this.#leader === undefined) {
462
+ // An answer finishing after demotion; do not reopen a channel that demotion just closed.
463
+ this.#postOnce(requesterId, message);
464
+ return;
465
+ }
466
+ let outbound = this.#answerChannels.get(requesterId);
467
+ if (outbound === undefined) {
468
+ outbound = new BroadcastChannel(this.#inboxName(requesterId));
469
+ this.#answerChannels.set(requesterId, outbound);
470
+ if (this.#answerChannels.size > ANSWER_CHANNEL_CACHE_SIZE) {
471
+ // Followers leave without a goodbye; the oldest entry is the likeliest to be gone.
472
+ const [oldest] = this.#answerChannels;
473
+ if (oldest !== undefined) {
474
+ this.#answerChannels.delete(oldest[0]);
475
+ oldest[1].close();
476
+ }
477
+ }
478
+ }
479
+ outbound.postMessage(message);
480
+ }
481
+ #closeAnswerChannels() {
482
+ for (const outbound of this.#answerChannels.values())
483
+ outbound.close();
484
+ this.#answerChannels.clear();
485
+ }
486
+ /** Posts one message into an inbox through a channel that lives only for that message. */
487
+ #postOnce(instanceId, message) {
488
+ const outbound = new BroadcastChannel(this.#inboxName(instanceId));
489
+ outbound.postMessage(message);
490
+ outbound.close();
491
+ }
492
+ #inboxName(instanceId) {
493
+ return `${this.#channelName}:${instanceId}`;
494
+ }
495
+ /** Closes every channel; nothing is posted or delivered after this. */
496
+ #closeChannels() {
497
+ this.#channel?.close();
498
+ this.#channel = undefined;
499
+ this.#inbox?.close();
500
+ this.#inbox = undefined;
501
+ this.#closeAnswerChannels();
502
+ }
503
+ // ---------------------------------------------------------------------------------------
504
+ // Dispatch: local when leading, RPC when following, elect when leaderless.
505
+ // ---------------------------------------------------------------------------------------
506
+ #assertOpen() {
507
+ if (this.#closed)
508
+ throw new Error("This OPFS store connection is closed");
509
+ }
510
+ async #dispatch(method, args) {
511
+ this.#assertOpen();
512
+ const requestId = crypto.randomUUID();
513
+ for (let attempt = 0; attempt < DISPATCH_ATTEMPTS; attempt += 1) {
514
+ // Re-checked each attempt: the awaits below (elections, RPC round trips) give close()
515
+ // every opportunity to run.
516
+ this.#assertOpen();
517
+ const leader = this.#leader;
518
+ if (leader !== undefined) {
519
+ const bound = leader[method];
520
+ if (bound === undefined)
521
+ throw new Error(`Unknown store operation: ${method}`);
522
+ return bound.apply(leader, args);
523
+ }
524
+ if (this.#channel === undefined) {
525
+ if (await this.#tryBecomeLeader())
526
+ continue;
527
+ throw new Error("Another connection holds this OPFS database, and BroadcastChannel is unavailable to reach it");
528
+ }
529
+ if (!this.#leaderKnown()) {
530
+ this.#post({ kind: "ping" });
531
+ await sleep(DISCOVERY_WAIT_MS);
532
+ if (!this.#leaderKnown()) {
533
+ if (await this.#tryBecomeLeader())
534
+ continue;
535
+ await sleep(20 + Math.random() * 50);
536
+ continue;
537
+ }
538
+ }
539
+ try {
540
+ return await this.#rpc(requestId, method, args);
541
+ }
542
+ catch (error) {
543
+ if (error === RPC_TIMED_OUT) {
544
+ this.#knownLeader = undefined;
545
+ continue;
546
+ }
547
+ throw error;
548
+ }
549
+ }
550
+ throw new Error("The OPFS store could not reach or become a leader");
551
+ }
552
+ /** The channel can change #knownLeader between any two awaits; a method defeats narrowing. */
553
+ #leaderKnown() {
554
+ return this.#knownLeader !== undefined;
555
+ }
556
+ #rpc(requestId, method, args) {
557
+ return new Promise((resolve, reject) => {
558
+ const message = { kind: "op", requestId, from: this.#instanceId, method, args };
559
+ const timer = setTimeout(() => {
560
+ this.#pending.delete(requestId);
561
+ reject(RPC_TIMED_OUT);
562
+ }, this.#rpcTimeoutMs);
563
+ const leaderId = this.#knownLeader;
564
+ this.#pending.set(requestId, { message, sentTo: leaderId, resolve, reject, timer });
565
+ // The dispatch loop only gets here with a leader known; should it have slipped away in
566
+ // between, the timeout (or the next leader's announcement, which re-sends) takes over.
567
+ if (leaderId !== undefined)
568
+ this.#send(leaderId, message);
569
+ });
570
+ }
571
+ // ---------------------------------------------------------------------------------------
572
+ // The BlockStore surface.
573
+ // ---------------------------------------------------------------------------------------
574
+ async addBlock(id, bytes) {
575
+ await this.#dispatch("addBlock", [id, bytes]);
576
+ }
577
+ async addBlocks(blocks) {
578
+ await this.#dispatch("addBlocks", [blocks]);
579
+ }
580
+ async getBlock(id) {
581
+ return (await this.#dispatch("getBlock", [id]));
582
+ }
583
+ async getBlocks(ids) {
584
+ return (await this.#dispatch("getBlocks", [ids]));
585
+ }
586
+ async removeBlock(id) {
587
+ await this.#dispatch("removeBlock", [id]);
588
+ }
589
+ async listBlockIds() {
590
+ return (await this.#dispatch("listBlockIds", []));
591
+ }
592
+ async putTempRunPage(page) {
593
+ validateTempRunPage(page);
594
+ await this.#tree.writeFile(["temp", encodeSegment(page.ownerId), encodeSegment(page.runId), String(page.pageIndex)], page.bytes);
595
+ }
596
+ async putTempRunPages(pages) {
597
+ // Spill pages are instance-local files; the batch is a convenience, not a round-trip win.
598
+ for (const page of pages)
599
+ await this.putTempRunPage(page);
600
+ }
601
+ async getTempRunPage(ownerId, runId, pageIndex) {
602
+ validateTempRunPageIdentity(ownerId, runId, pageIndex);
603
+ return this.#tree.readFile([
604
+ "temp",
605
+ encodeSegment(ownerId),
606
+ encodeSegment(runId),
607
+ String(pageIndex),
608
+ ]);
609
+ }
610
+ async removeTempRun(ownerId, runId) {
611
+ validateTempRunPageIdentity(ownerId, runId, 0);
612
+ await this.#tree.deleteTree(["temp", encodeSegment(ownerId), encodeSegment(runId)]);
613
+ }
614
+ async removeTempOwner(ownerId) {
615
+ validateId(ownerId);
616
+ await this.#dispatch("removeTempOwner", [ownerId]);
617
+ await this.#tree.deleteTree(["temp", encodeSegment(ownerId)]);
618
+ }
619
+ async createTempOwner(record) {
620
+ await this.#dispatch("createTempOwner", [record]);
621
+ }
622
+ async getTempOwner(ownerId) {
623
+ return (await this.#dispatch("getTempOwner", [ownerId]));
624
+ }
625
+ async renewTempOwner(ownerId, expectedRevision, expiresAt) {
626
+ return (await this.#dispatch("renewTempOwner", [
627
+ ownerId,
628
+ expectedRevision,
629
+ expiresAt,
630
+ ]));
631
+ }
632
+ async removeTempOwnerIfExpired(ownerId, expiresAtCutoff) {
633
+ const removed = (await this.#dispatch("removeTempOwnerIfExpired", [
634
+ ownerId,
635
+ expiresAtCutoff,
636
+ ]));
637
+ if (removed)
638
+ await this.#tree.deleteTree(["temp", encodeSegment(ownerId)]);
639
+ return removed;
640
+ }
641
+ async listTempOwnerIdsPage(afterOwnerId, limit) {
642
+ return (await this.#dispatch("listTempOwnerIdsPage", [afterOwnerId, limit]));
643
+ }
644
+ async addTable(record) {
645
+ await this.#dispatch("addTable", [record]);
646
+ }
647
+ async getTable(id) {
648
+ return (await this.#dispatch("getTable", [id]));
649
+ }
650
+ async getTableByName(name) {
651
+ return (await this.#dispatch("getTableByName", [name]));
652
+ }
653
+ async listTables() {
654
+ return (await this.#dispatch("listTables", []));
655
+ }
656
+ async updateTable(id, expectedRevision, update) {
657
+ return (await this.#dispatch("updateTable", [id, expectedRevision, update]));
658
+ }
659
+ async removeTable(id, expectedRevision) {
660
+ await this.#dispatch("removeTable", [id, expectedRevision]);
661
+ }
662
+ async writeFtsBase(tableId, columnId, input) {
663
+ await this.#dispatch("writeFtsBase", [tableId, columnId, input]);
664
+ }
665
+ async readFtsCandidates(tableId, columnId, terms, upToVersion) {
666
+ return (await this.#dispatch("readFtsCandidates", [
667
+ tableId,
668
+ columnId,
669
+ terms,
670
+ upToVersion,
671
+ ]));
672
+ }
673
+ async addSegment(record) {
674
+ await this.#dispatch("addSegment", [record]);
675
+ }
676
+ async getSegment(id) {
677
+ return (await this.#dispatch("getSegment", [id]));
678
+ }
679
+ async listSegments(tableId) {
680
+ return (await this.#dispatch("listSegments", [tableId]));
681
+ }
682
+ async removeSegment(id) {
683
+ await this.#dispatch("removeSegment", [id]);
684
+ }
685
+ async reserveRowIds(tableId, count) {
686
+ return (await this.#dispatch("reserveRowIds", [tableId, count]));
687
+ }
688
+ async reserveAutoIncrement(tableId, columnId, count, atLeast) {
689
+ return (await this.#dispatch("reserveAutoIncrement", [
690
+ tableId,
691
+ columnId,
692
+ count,
693
+ atLeast,
694
+ ]));
695
+ }
696
+ async getExistingUniqueKeys(tableId, keyTokens) {
697
+ return (await this.#dispatch("getExistingUniqueKeys", [tableId, keyTokens]));
698
+ }
699
+ async getCurrentManifest() {
700
+ return (await this.#dispatch("getCurrentManifest", []));
701
+ }
702
+ async getCurrentManifestVersion() {
703
+ return (await this.#dispatch("getCurrentManifestVersion", []));
704
+ }
705
+ async getCatalogProbe() {
706
+ return (await this.#dispatch("getCatalogProbe", []));
707
+ }
708
+ async getQueryCatalogState(tableNames) {
709
+ return (await this.#dispatch("getQueryCatalogState", [tableNames]));
710
+ }
711
+ async getManifest(version) {
712
+ return (await this.#dispatch("getManifest", [version]));
713
+ }
714
+ async listManifests() {
715
+ return (await this.#dispatch("listManifests", []));
716
+ }
717
+ async listManifestPage(afterVersion, limit) {
718
+ return (await this.#dispatch("listManifestPage", [afterVersion, limit]));
719
+ }
720
+ async publishManifest(input) {
721
+ return (await this.#dispatch("publishManifest", [input]));
722
+ }
723
+ async createTransaction(record) {
724
+ await this.#dispatch("createTransaction", [record]);
725
+ }
726
+ async beginTransaction(input) {
727
+ return (await this.#dispatch("beginTransaction", [input]));
728
+ }
729
+ async getTransaction(id) {
730
+ return (await this.#dispatch("getTransaction", [id]));
731
+ }
732
+ async getTransactions(ids) {
733
+ return (await this.#dispatch("getTransactions", [ids]));
734
+ }
735
+ async listTransactions() {
736
+ return (await this.#dispatch("listTransactions", []));
737
+ }
738
+ async listTransactionPage(afterId, limit) {
739
+ return (await this.#dispatch("listTransactionPage", [afterId, limit]));
740
+ }
741
+ async updateTransaction(id, expectedRevision, update) {
742
+ return (await this.#dispatch("updateTransaction", [
743
+ id,
744
+ expectedRevision,
745
+ update,
746
+ ]));
747
+ }
748
+ async stageTransactionArtifacts(input) {
749
+ return (await this.#dispatch("stageTransactionArtifacts", [input]));
750
+ }
751
+ async commitTransaction(input) {
752
+ return (await this.#dispatch("commitTransaction", [input]));
753
+ }
754
+ async writeTransaction(input) {
755
+ return (await this.#dispatch("writeTransaction", [input]));
756
+ }
757
+ async createLease(record) {
758
+ await this.#dispatch("createLease", [record]);
759
+ }
760
+ async getLease(id) {
761
+ return (await this.#dispatch("getLease", [id]));
762
+ }
763
+ async listLeases() {
764
+ return (await this.#dispatch("listLeases", []));
765
+ }
766
+ async renewLease(id, expectedRevision, expiresAt) {
767
+ return (await this.#dispatch("renewLease", [id, expectedRevision, expiresAt]));
768
+ }
769
+ async moveLease(id, expectedRevision, manifestVersion, expiresAt) {
770
+ return (await this.#dispatch("moveLease", [
771
+ id,
772
+ expectedRevision,
773
+ manifestVersion,
774
+ expiresAt,
775
+ ]));
776
+ }
777
+ async removeLeaseIfExpired(id, expectedRevision, expiresAtCutoff) {
778
+ return (await this.#dispatch("removeLeaseIfExpired", [
779
+ id,
780
+ expectedRevision,
781
+ expiresAtCutoff,
782
+ ]));
783
+ }
784
+ async removeLease(id) {
785
+ await this.#dispatch("removeLease", [id]);
786
+ }
787
+ async createCompactionJob(record) {
788
+ await this.#dispatch("createCompactionJob", [record]);
789
+ }
790
+ async getCompactionJob(id) {
791
+ return (await this.#dispatch("getCompactionJob", [id]));
792
+ }
793
+ async listCompactionJobs(tableId) {
794
+ return (await this.#dispatch("listCompactionJobs", [tableId]));
795
+ }
796
+ async listCompactionJobPage(afterId, limit) {
797
+ return (await this.#dispatch("listCompactionJobPage", [afterId, limit]));
798
+ }
799
+ async updateCompactionJob(id, expectedRevision, update) {
800
+ return (await this.#dispatch("updateCompactionJob", [
801
+ id,
802
+ expectedRevision,
803
+ update,
804
+ ]));
805
+ }
806
+ async cancelCompactionJob(id, expectedRevision, cancelledAt) {
807
+ return (await this.#dispatch("cancelCompactionJob", [
808
+ id,
809
+ expectedRevision,
810
+ cancelledAt,
811
+ ]));
812
+ }
813
+ async removeCompactionJob(id) {
814
+ await this.#dispatch("removeCompactionJob", [id]);
815
+ }
816
+ async createGarbageCollectionJob(input) {
817
+ return (await this.#dispatch("createGarbageCollectionJob", [
818
+ input,
819
+ ]));
820
+ }
821
+ async getGarbageCollectionJob(id) {
822
+ return (await this.#dispatch("getGarbageCollectionJob", [id]));
823
+ }
824
+ async listGarbageCollectionJobs() {
825
+ return (await this.#dispatch("listGarbageCollectionJobs", []));
826
+ }
827
+ async runGarbageCollectionStep(input) {
828
+ return (await this.#dispatch("runGarbageCollectionStep", [
829
+ input,
830
+ ]));
831
+ }
832
+ async removeGarbageCollectionJob(id) {
833
+ await this.#dispatch("removeGarbageCollectionJob", [id]);
834
+ }
835
+ async removePrunedManifestRecords() {
836
+ return (await this.#dispatch("removePrunedManifestRecords", []));
837
+ }
838
+ async getLogicalStorageBytes() {
839
+ return (await this.#dispatch("getLogicalStorageBytes", []));
840
+ }
841
+ async exportSnapshot() {
842
+ return (await this.#dispatch("exportSnapshot", []));
843
+ }
844
+ async importSnapshot(snapshot, options = {}) {
845
+ const leader = this.#leader;
846
+ if (leader !== undefined) {
847
+ return leader.importSnapshot(snapshot, options);
848
+ }
849
+ // The progress callback cannot cross the channel; report the two ends locally.
850
+ const totalBytes = snapshot.blocks.reduce((total, block) => total + block.bytes.byteLength, 0);
851
+ options.onProgress?.({ phase: "blocks", writtenBytes: 0, totalBytes });
852
+ await this.#dispatch("importSnapshot", [snapshot, {}]);
853
+ options.onProgress?.({ phase: "done", writtenBytes: totalBytes, totalBytes });
854
+ }
855
+ close() {
856
+ if (this.#closed)
857
+ return;
858
+ this.#closed = true;
859
+ if (this.#reacquireTimer !== undefined)
860
+ clearTimeout(this.#reacquireTimer);
861
+ for (const pending of this.#pending.values()) {
862
+ clearTimeout(pending.timer);
863
+ pending.reject(new Error("This OPFS store connection is closed"));
864
+ }
865
+ this.#pending.clear();
866
+ const leader = this.#leader;
867
+ this.#leader = undefined;
868
+ if (leader !== undefined) {
869
+ void leader
870
+ .shutdown()
871
+ .catch(() => {
872
+ leader.crash();
873
+ })
874
+ .then(() => {
875
+ this.#post({ kind: "released", leaderId: this.#instanceId });
876
+ this.#closeChannels();
877
+ });
878
+ return;
879
+ }
880
+ this.#closeChannels();
881
+ }
882
+ /** Test-only: whether this connection currently holds the database's handles. */
883
+ _isLeaderForTests() {
884
+ return this.#leader !== undefined;
885
+ }
886
+ /** Test-only: the id that names this connection's inbox channel. */
887
+ _instanceIdForTests() {
888
+ return this.#instanceId;
889
+ }
890
+ /** Test-only: what tab death looks like — locks release, nothing flushes, no goodbyes. */
891
+ _crashForTests() {
892
+ this.#closed = true;
893
+ if (this.#reacquireTimer !== undefined)
894
+ clearTimeout(this.#reacquireTimer);
895
+ for (const pending of this.#pending.values())
896
+ clearTimeout(pending.timer);
897
+ this.#pending.clear();
898
+ this.#leader?.crash();
899
+ this.#leader = undefined;
900
+ this.#closeChannels();
901
+ }
902
+ async #ensureFormatMarker() {
903
+ const existing = await this.#tree.readFile(["format.json"], { lockedMeansAbsent: true });
904
+ if (existing !== undefined) {
905
+ try {
906
+ const parsed = JSON.parse(new TextDecoder().decode(existing));
907
+ if (typeof parsed.formatVersion === "number" &&
908
+ parsed.formatVersion !== LOG_FORMAT_VERSION) {
909
+ throw new Error(`This database uses OPFS layout version ${String(parsed.formatVersion)}; this build ` +
910
+ `reads version ${String(LOG_FORMAT_VERSION)}. Delete it with deleteOpfsDatabase() — ` +
911
+ `earlier layouts were experimental and carry no compatibility promise.`);
912
+ }
913
+ return;
914
+ }
915
+ catch (error) {
916
+ if (!(error instanceof SyntaxError))
917
+ throw error;
918
+ // A torn marker from a crashed first open: rewrite it below.
919
+ }
920
+ }
921
+ const bytes = new TextEncoder().encode(JSON.stringify({ formatVersion: LOG_FORMAT_VERSION }));
922
+ try {
923
+ await this.#tree.writeFile(["format.json"], bytes, { flush: true });
924
+ }
925
+ catch (error) {
926
+ // A concurrent open won the marker; theirs says the same thing.
927
+ if (!isLockContention(error))
928
+ throw error;
929
+ }
930
+ }
931
+ }
932
+ /** Removes every file of a database created by `OpfsBlockStore.open` under this name. */
933
+ export async function deleteOpfsDatabase(options) {
934
+ const root = options.root ?? (await navigator.storage.getDirectory());
935
+ try {
936
+ const namespace = await root.getDirectoryHandle("minnowdb");
937
+ await namespace.removeEntry(encodeSegment(options.name), { recursive: true });
938
+ }
939
+ catch (error) {
940
+ if (!isDomError(error, "NotFoundError"))
941
+ throw error;
942
+ }
943
+ }
944
+ const RPC_TIMED_OUT = new Error("The leader did not answer in time");
945
+ async function resolveDatabaseRoot(options) {
946
+ if (options.name.length === 0)
947
+ throw new TypeError("Database name cannot be empty");
948
+ const root = options.root ?? (await navigator.storage.getDirectory());
949
+ const namespace = await root.getDirectoryHandle("minnowdb", { create: true });
950
+ return namespace.getDirectoryHandle(encodeSegment(options.name), { create: true });
951
+ }
952
+ function isLockContention(error) {
953
+ return isDomError(error, "NoModificationAllowedError") || isDomError(error, "InvalidStateError");
954
+ }
955
+ function sleep(ms) {
956
+ return new Promise((resolve) => setTimeout(resolve, ms));
957
+ }
958
+ //# sourceMappingURL=store.js.map