@korajs/store 1.0.0-beta.11 → 1.0.0-beta.13

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 (92) hide show
  1. package/README.md +29 -37
  2. package/dist/adapters/better-sqlite3.cjs +90 -4
  3. package/dist/adapters/better-sqlite3.cjs.map +1 -1
  4. package/dist/adapters/better-sqlite3.d.cts +21 -12
  5. package/dist/adapters/better-sqlite3.d.ts +21 -12
  6. package/dist/adapters/better-sqlite3.js +69 -5
  7. package/dist/adapters/better-sqlite3.js.map +1 -1
  8. package/dist/adapters/indexeddb.cjs +1316 -610
  9. package/dist/adapters/indexeddb.cjs.map +1 -1
  10. package/dist/adapters/indexeddb.d.cts +29 -3
  11. package/dist/adapters/indexeddb.d.ts +29 -3
  12. package/dist/adapters/indexeddb.js +247 -221
  13. package/dist/adapters/indexeddb.js.map +1 -1
  14. package/dist/adapters/sqlite-wasm-worker.cjs +803 -62
  15. package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
  16. package/dist/adapters/sqlite-wasm-worker.js +480 -62
  17. package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
  18. package/dist/adapters/sqlite-wasm.cjs +1032 -344
  19. package/dist/adapters/sqlite-wasm.cjs.map +1 -1
  20. package/dist/adapters/sqlite-wasm.d.cts +239 -18
  21. package/dist/adapters/sqlite-wasm.d.ts +239 -18
  22. package/dist/adapters/sqlite-wasm.js +111 -5
  23. package/dist/adapters/sqlite-wasm.js.map +1 -1
  24. package/dist/backup-4NUPDEMB.js +25 -0
  25. package/dist/chunk-32EDKWMI.js +876 -0
  26. package/dist/chunk-32EDKWMI.js.map +1 -0
  27. package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
  28. package/dist/chunk-A6GFH5IG.js +36 -0
  29. package/dist/chunk-A6GFH5IG.js.map +1 -0
  30. package/dist/chunk-DUKTWVZW.js +175 -0
  31. package/dist/chunk-DUKTWVZW.js.map +1 -0
  32. package/dist/chunk-HZTDBLDB.js +607 -0
  33. package/dist/chunk-HZTDBLDB.js.map +1 -0
  34. package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
  35. package/dist/chunk-J5AGXF3D.js.map +1 -0
  36. package/dist/chunk-MCQKEHOD.js +112 -0
  37. package/dist/chunk-MCQKEHOD.js.map +1 -0
  38. package/dist/chunk-P4V375FY.js +810 -0
  39. package/dist/chunk-P4V375FY.js.map +1 -0
  40. package/dist/chunk-QDUSJMFV.js +1095 -0
  41. package/dist/chunk-QDUSJMFV.js.map +1 -0
  42. package/dist/chunk-SJP2HTYZ.js +1003 -0
  43. package/dist/chunk-SJP2HTYZ.js.map +1 -0
  44. package/dist/chunk-W5F2LWG6.js +591 -0
  45. package/dist/chunk-W5F2LWG6.js.map +1 -0
  46. package/dist/chunk-Y5LRH7KI.js +5341 -0
  47. package/dist/chunk-Y5LRH7KI.js.map +1 -0
  48. package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
  49. package/dist/index.cjs +9354 -4157
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +1064 -108
  52. package/dist/index.d.ts +1064 -108
  53. package/dist/index.js +249 -3092
  54. package/dist/index.js.map +1 -1
  55. package/dist/internal.cjs +580 -264
  56. package/dist/internal.cjs.map +1 -1
  57. package/dist/internal.d.cts +15 -85
  58. package/dist/internal.d.ts +15 -85
  59. package/dist/internal.js +10 -8
  60. package/dist/restore-FR7XIKT2.js +299 -0
  61. package/dist/restore-FR7XIKT2.js.map +1 -0
  62. package/dist/sql-builder-C4u55jKW.d.cts +608 -0
  63. package/dist/sql-builder-CGckdBDL.d.ts +608 -0
  64. package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
  65. package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
  66. package/dist/sqlite-wasm-channel-MLHLKEED.js +12 -0
  67. package/dist/store-IMWKZLWB.js +16 -0
  68. package/dist/store-IMWKZLWB.js.map +1 -0
  69. package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
  70. package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
  71. package/package.json +3 -2
  72. package/dist/backup-5S7K2Z35.js +0 -14
  73. package/dist/chunk-GSJ7EEUC.js.map +0 -1
  74. package/dist/chunk-HS6DBMST.js +0 -383
  75. package/dist/chunk-HS6DBMST.js.map +0 -1
  76. package/dist/chunk-RJIEKEN4.js +0 -751
  77. package/dist/chunk-RJIEKEN4.js.map +0 -1
  78. package/dist/chunk-TUR7RGEP.js +0 -84
  79. package/dist/chunk-TUR7RGEP.js.map +0 -1
  80. package/dist/chunk-VDBJQ75K.js +0 -78
  81. package/dist/chunk-VDBJQ75K.js.map +0 -1
  82. package/dist/chunk-YT7MRFCP.js +0 -687
  83. package/dist/chunk-YT7MRFCP.js.map +0 -1
  84. package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
  85. package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
  86. package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
  87. package/dist/types-BOO9EPt1.d.ts +0 -170
  88. package/dist/types-C_GDavG-.d.cts +0 -170
  89. /package/dist/{backup-5S7K2Z35.js.map → backup-4NUPDEMB.js.map} +0 -0
  90. /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
  91. /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
  92. /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-MLHLKEED.js.map} +0 -0
@@ -21,7 +21,7 @@ var __copyProps = (to, from, except, desc) => {
21
21
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
22
22
 
23
23
  // src/errors.ts
24
- var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError;
24
+ var import_core, AdapterError, StorageDurabilityError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError, LeaderUnresponsiveError, BridgeTerminatedError, RequestAbortedError, StorageInUseError, UnsyncedDataError, PersistenceError;
25
25
  var init_errors = __esm({
26
26
  "src/errors.ts"() {
27
27
  "use strict";
@@ -32,6 +32,16 @@ var init_errors = __esm({
32
32
  this.name = "AdapterError";
33
33
  }
34
34
  };
35
+ StorageDurabilityError = class extends import_core.KoraError {
36
+ constructor(dbName, phase, reason) {
37
+ super(
38
+ `Database "${dbName}" has no durable storage (${reason} during ${phase}); writes are refused so they are not silently lost on reload. Close other tabs or apps using this origin's storage and reload, or set allowNonDurable: true to accept in-memory storage.`,
39
+ "STORAGE_DURABILITY_LOST",
40
+ { dbName, phase, reason }
41
+ );
42
+ this.name = "StorageDurabilityError";
43
+ }
44
+ };
35
45
  StoreNotOpenError = class extends import_core.KoraError {
36
46
  constructor() {
37
47
  super("Store is not open. Call store.open() before performing operations.", "STORE_NOT_OPEN");
@@ -49,13 +59,66 @@ var init_errors = __esm({
49
59
  }
50
60
  };
51
61
  NoLeaderError = class extends import_core.KoraError {
52
- constructor(operation) {
62
+ constructor(operation, message = `No live leader tab is answering multi-tab storage RPC for operation "${operation}"`, code = "NO_LEADER", context = {}) {
63
+ super(message, code, { operation, ...context });
64
+ this.name = "NoLeaderError";
65
+ }
66
+ };
67
+ LeaderUnresponsiveError = class extends NoLeaderError {
68
+ constructor(operation, silentMs, requestId) {
53
69
  super(
54
- `No live leader tab is answering multi-tab storage RPC for operation "${operation}"`,
55
- "NO_LEADER",
56
- { operation }
70
+ operation,
71
+ `The leader tab stopped responding (no heartbeat for ${silentMs}ms) during "${operation}". The request may or may not have been applied; retry it with the same requestId and the leader de-duplicates it.`,
72
+ "LEADER_UNRESPONSIVE",
73
+ { silentMs, requestId, retriable: true }
57
74
  );
58
- this.name = "NoLeaderError";
75
+ this.name = "LeaderUnresponsiveError";
76
+ }
77
+ };
78
+ BridgeTerminatedError = class extends import_core.KoraError {
79
+ constructor(operation, reason) {
80
+ super(
81
+ `Storage request "${operation}" was interrupted (${reason}); retry it against the new storage leader.`,
82
+ "BRIDGE_TERMINATED",
83
+ { operation, reason, retriable: true }
84
+ );
85
+ this.name = "BridgeTerminatedError";
86
+ }
87
+ };
88
+ RequestAbortedError = class extends import_core.KoraError {
89
+ constructor(operation, requestId) {
90
+ super(
91
+ `Storage request "${operation}" was aborted by the caller. A write may already have reached the leader; retry with the same requestId to stay idempotent.`,
92
+ "REQUEST_ABORTED",
93
+ { operation, requestId }
94
+ );
95
+ this.name = "RequestAbortedError";
96
+ }
97
+ };
98
+ StorageInUseError = class extends import_core.KoraError {
99
+ constructor(dbName) {
100
+ super(
101
+ `Database "${dbName}" is open in a tab on this origin; close it before deleting it.`,
102
+ "STORAGE_IN_USE",
103
+ { dbName }
104
+ );
105
+ this.name = "StorageInUseError";
106
+ }
107
+ };
108
+ UnsyncedDataError = class extends import_core.KoraError {
109
+ constructor(dbName) {
110
+ super(
111
+ `Database "${dbName}" has unsynced operations; deleting it would lose them. Sync first, or pass { force: true } to discard them deliberately.`,
112
+ "UNSYNCED_DATA",
113
+ { dbName }
114
+ );
115
+ this.name = "UnsyncedDataError";
116
+ }
117
+ };
118
+ PersistenceError = class extends import_core.KoraError {
119
+ constructor(message, context) {
120
+ super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
121
+ this.name = "PersistenceError";
59
122
  }
60
123
  };
61
124
  }
@@ -124,16 +187,21 @@ var init_sqlite_wasm_channel = __esm({
124
187
  /**
125
188
  * @param workerUrl - URL to the sqlite-wasm-worker script
126
189
  * @param timeoutMs - Timeout for worker responses in milliseconds (default: 30000)
190
+ * @param options - Status event listener
127
191
  */
128
- constructor(workerUrl, timeoutMs = 3e4) {
192
+ constructor(workerUrl, timeoutMs = 3e4, options = {}) {
129
193
  this.timeoutMs = timeoutMs;
130
194
  this.worker = new Worker(workerUrl, { type: "module" });
131
195
  this.worker.onmessage = (event) => {
132
- const response = event.data;
133
- const entry = this.pending.get(response.id);
196
+ const message = event.data;
197
+ if (message.type === "event") {
198
+ options.onEvent?.(message.event);
199
+ return;
200
+ }
201
+ const entry = this.pending.get(message.id);
134
202
  if (entry) {
135
- this.pending.delete(response.id);
136
- entry.resolve(response);
203
+ this.pending.delete(message.id);
204
+ entry.resolve(message);
137
205
  }
138
206
  };
139
207
  this.worker.onerror = (event) => {
@@ -144,7 +212,17 @@ var init_sqlite_wasm_channel = __esm({
144
212
  }
145
213
  };
146
214
  }
147
- async send(request) {
215
+ /** True once {@link terminate} ran. */
216
+ isTerminated() {
217
+ return this.terminated;
218
+ }
219
+ /** Post a request without awaiting a response (fire-and-forget control messages). */
220
+ post(request, clientId) {
221
+ if (this.terminated) return;
222
+ const message = { ...request, id: this.nextId++, clientId };
223
+ this.worker.postMessage(message);
224
+ }
225
+ async send(request, clientId, options = {}) {
148
226
  if (this.terminated) {
149
227
  return {
150
228
  id: request.id,
@@ -154,23 +232,40 @@ var init_sqlite_wasm_channel = __esm({
154
232
  };
155
233
  }
156
234
  const id = this.nextId++;
157
- const req = { ...request, id };
235
+ const message = { ...request, id, clientId };
236
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
237
+ const signal = options.signal;
238
+ if (signal?.aborted) {
239
+ throw new RequestAbortedError(request.type, request.requestId ?? String(id));
240
+ }
158
241
  return new Promise((resolve, reject) => {
159
- const timer = setTimeout(() => {
242
+ const timer = Number.isFinite(timeoutMs) ? setTimeout(() => {
243
+ this.pending.delete(id);
244
+ cleanup();
245
+ reject(new WorkerTimeoutError(message.type, timeoutMs));
246
+ }, timeoutMs) : void 0;
247
+ const onAbort = () => {
160
248
  this.pending.delete(id);
161
- reject(new WorkerTimeoutError(req.type, this.timeoutMs));
162
- }, this.timeoutMs);
249
+ cleanup();
250
+ reject(new RequestAbortedError(request.type, request.requestId ?? String(id)));
251
+ };
252
+ const cleanup = () => {
253
+ if (timer !== void 0) clearTimeout(timer);
254
+ signal?.removeEventListener("abort", onAbort);
255
+ };
256
+ signal?.addEventListener("abort", onAbort, { once: true });
163
257
  this.pending.set(id, {
258
+ type: request.type,
164
259
  resolve: (response) => {
165
- clearTimeout(timer);
260
+ cleanup();
166
261
  resolve(response);
167
262
  },
168
263
  reject: (error) => {
169
- clearTimeout(timer);
264
+ cleanup();
170
265
  reject(error);
171
266
  }
172
267
  });
173
- this.worker.postMessage(req);
268
+ this.worker.postMessage(message);
174
269
  });
175
270
  }
176
271
  terminate() {
@@ -179,7 +274,7 @@ var init_sqlite_wasm_channel = __esm({
179
274
  this.worker.terminate();
180
275
  for (const [id, entry] of this.pending) {
181
276
  this.pending.delete(id);
182
- entry.reject(new Error("Worker terminated"));
277
+ entry.reject(new BridgeTerminatedError(entry.type, "worker terminated"));
183
278
  }
184
279
  }
185
280
  };
@@ -191,14 +286,43 @@ var sqlite_wasm_exports = {};
191
286
  __export(sqlite_wasm_exports, {
192
287
  Mutex: () => Mutex,
193
288
  SqliteWasmAdapter: () => SqliteWasmAdapter,
194
- WebWorkerBridge: () => WebWorkerBridge
289
+ WebWorkerBridge: () => WebWorkerBridge,
290
+ deleteLocalDatabase: () => deleteLocalDatabase,
291
+ listLocalDatabases: () => listLocalDatabases,
292
+ opfsPoolNameFor: () => opfsPoolNameFor
195
293
  });
196
294
  module.exports = __toCommonJS(sqlite_wasm_exports);
197
295
 
198
296
  // src/adapters/sqlite-wasm-adapter.ts
199
- var import_core2 = require("@korajs/core");
297
+ var import_core3 = require("@korajs/core");
200
298
  init_errors();
201
299
 
300
+ // src/adapters/opfs-names.ts
301
+ var OPFS_POOL_PREFIX = "kora-opfs-";
302
+ var MAX_POOL_SLUG_LENGTH = 48;
303
+ function fnv1a32Hex(value) {
304
+ let hash = 2166136261;
305
+ for (let i = 0; i < value.length; i += 1) {
306
+ hash ^= value.charCodeAt(i);
307
+ hash = Math.imul(hash, 16777619);
308
+ }
309
+ return (hash >>> 0).toString(16).padStart(8, "0");
310
+ }
311
+ function opfsPoolNameFor(dbName) {
312
+ const sanitized = dbName.replace(/[^a-zA-Z0-9_-]/g, "_");
313
+ const slug = sanitized.slice(0, MAX_POOL_SLUG_LENGTH);
314
+ if (slug === dbName) {
315
+ return `${OPFS_POOL_PREFIX}${slug}`;
316
+ }
317
+ return `${OPFS_POOL_PREFIX}${slug}-${fnv1a32Hex(dbName)}`;
318
+ }
319
+ function leaderLockName(dbName) {
320
+ return `kora-leader-${dbName}`;
321
+ }
322
+ function storageChannelName(dbName) {
323
+ return `kora-storage-${dbName}`;
324
+ }
325
+
202
326
  // src/multi-tab/tab-storage.ts
203
327
  init_errors();
204
328
  var RPC_REQUEST = "kora-worker-request";
@@ -206,14 +330,16 @@ var RPC_RESPONSE = "kora-worker-response";
206
330
  var CLIENT_LEAVE = "kora-client-leave";
207
331
  var LEADER_PING = "kora-leader-ping";
208
332
  var LEADER_PONG = "kora-leader-pong";
209
- var DEFAULT_LIVENESS_PROBE_MS = 2e3;
210
- var DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS = 1e4;
333
+ var LEADER_HEARTBEAT = "kora-leader-heartbeat";
334
+ var DEFAULT_HEARTBEAT_MS = 1e3;
335
+ var MISSED_BEATS_UNRESPONSIVE = 3;
336
+ var MISSED_BEATS_ABSENT = 2;
211
337
  function isMultiTabStorageSupported() {
212
338
  return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
213
339
  }
214
340
  async function acquireTabStorageSession(dbName, options) {
215
- const channelName = `kora-storage-${dbName}`;
216
- const lockName = `kora-leader-${dbName}`;
341
+ const channelName = storageChannelName(dbName);
342
+ const lockName = leaderLockName(dbName);
217
343
  if (!isMultiTabStorageSupported()) {
218
344
  return { role: "leader", channelName };
219
345
  }
@@ -261,51 +387,6 @@ function startFollowerSession(channelName, lockName, options) {
261
387
  }
262
388
  };
263
389
  }
264
- function startLeaderRpcRelay(channelName, bridge) {
265
- const channel = new BroadcastChannel(channelName);
266
- const onMessage = (event) => {
267
- const data = event.data;
268
- if (data?.type === LEADER_PING) {
269
- channel.postMessage({ type: LEADER_PONG });
270
- return;
271
- }
272
- if (data?.type === CLIENT_LEAVE) {
273
- if (hasReclaimClient(bridge)) {
274
- bridge.reclaimClient(data.clientId, "client-left");
275
- }
276
- return;
277
- }
278
- if (data?.type !== RPC_REQUEST) {
279
- return;
280
- }
281
- void bridge.send(data.request, data.clientId).then((response) => {
282
- const msg = {
283
- type: RPC_RESPONSE,
284
- requestId: data.requestId,
285
- response
286
- };
287
- channel.postMessage(msg);
288
- }).catch((error) => {
289
- const message = error instanceof Error ? error.message : "Worker RPC failed";
290
- const msg = {
291
- type: RPC_RESPONSE,
292
- requestId: data.requestId,
293
- response: {
294
- id: data.request.id,
295
- type: "error",
296
- message,
297
- code: "LEADER_RPC_ERROR"
298
- }
299
- };
300
- channel.postMessage(msg);
301
- });
302
- };
303
- channel.addEventListener("message", onMessage);
304
- return () => {
305
- channel.removeEventListener("message", onMessage);
306
- channel.close();
307
- };
308
- }
309
390
  var FollowerBroadcastBridge = class {
310
391
  channel;
311
392
  clientId = createClientId();
@@ -314,17 +395,28 @@ var FollowerBroadcastBridge = class {
314
395
  };
315
396
  pending = /* @__PURE__ */ new Map();
316
397
  timeoutMs;
317
- livenessProbeMs;
398
+ heartbeatMs;
318
399
  terminated = false;
319
- constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
400
+ lastHeardAt = 0;
401
+ heardSinceWatch = false;
402
+ watchdog;
403
+ /**
404
+ * @param channelName - Storage channel of the database
405
+ * @param timeoutMs - Hard ceiling per request (default 30s)
406
+ * @param heartbeatMs - Expected leader heartbeat interval, also the watchdog tick
407
+ */
408
+ constructor(channelName, timeoutMs = 3e4, heartbeatMs = DEFAULT_HEARTBEAT_MS) {
320
409
  this.timeoutMs = timeoutMs;
321
- this.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
410
+ this.heartbeatMs = Math.max(10, Math.min(heartbeatMs, timeoutMs));
322
411
  this.channel = new BroadcastChannel(channelName);
323
412
  if (typeof addEventListener === "function") {
324
413
  addEventListener("pagehide", this.onPageHide);
325
414
  }
326
415
  this.channel.addEventListener("message", (event) => {
327
416
  const data = event.data;
417
+ if (data?.type === LEADER_HEARTBEAT || data?.type === LEADER_PONG || data?.type === RPC_RESPONSE) {
418
+ this.markHeard();
419
+ }
328
420
  if (data?.type !== RPC_RESPONSE) {
329
421
  return;
330
422
  }
@@ -339,7 +431,7 @@ var FollowerBroadcastBridge = class {
339
431
  * Readiness handshake: resolves `true` as soon as a live leader answers a ping,
340
432
  * or `false` if none answers within the budget. The adapter calls this before
341
433
  * its first RPC so a follower created before a leader relay is live retries the
342
- * handshake instead of firing into the void and waiting out the full timeout.
434
+ * handshake instead of firing into the void.
343
435
  */
344
436
  async waitForLeader(timeoutMs = 3e3, attempts = 3) {
345
437
  const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
@@ -377,7 +469,7 @@ var FollowerBroadcastBridge = class {
377
469
  this.channel.postMessage({ type: LEADER_PING });
378
470
  });
379
471
  }
380
- async send(request) {
472
+ async send(request, _clientId, options = {}) {
381
473
  if (this.terminated) {
382
474
  return {
383
475
  id: request.id,
@@ -386,34 +478,42 @@ var FollowerBroadcastBridge = class {
386
478
  code: "BRIDGE_TERMINATED"
387
479
  };
388
480
  }
389
- const requestId = createClientId();
481
+ const requestId = request.requestId ?? createClientId();
482
+ const signal = options.signal;
483
+ if (signal?.aborted) {
484
+ throw new RequestAbortedError(request.type, requestId);
485
+ }
390
486
  const msg = {
391
487
  type: RPC_REQUEST,
392
488
  requestId,
393
489
  clientId: this.clientId,
394
- request
490
+ request: { ...request, requestId }
395
491
  };
492
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
396
493
  return new Promise((resolve, reject) => {
494
+ let timer;
495
+ const onAbort = () => {
496
+ settle(() => reject(new RequestAbortedError(request.type, requestId)));
497
+ };
397
498
  const settle = (fn) => {
398
- clearTimeout(timer);
399
- clearTimeout(probe);
499
+ if (timer !== void 0) clearTimeout(timer);
500
+ signal?.removeEventListener("abort", onAbort);
400
501
  this.pending.delete(requestId);
502
+ this.stopWatchdogIfIdle();
401
503
  fn();
402
504
  };
403
- const timer = setTimeout(() => {
404
- settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
405
- }, this.timeoutMs);
406
- const probe = setTimeout(() => {
407
- void this.pingLeader(this.livenessProbeMs).then((alive) => {
408
- if (!alive && this.pending.has(requestId)) {
409
- settle(() => reject(new NoLeaderError(`follower-rpc:${request.type}`)));
410
- }
411
- });
412
- }, this.livenessProbeMs);
505
+ if (Number.isFinite(timeoutMs)) {
506
+ timer = setTimeout(() => {
507
+ settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, timeoutMs)));
508
+ }, timeoutMs);
509
+ }
510
+ signal?.addEventListener("abort", onAbort, { once: true });
413
511
  this.pending.set(requestId, {
512
+ type: request.type,
414
513
  resolve: (response) => settle(() => resolve(response)),
415
514
  reject: (error) => settle(() => reject(error))
416
515
  });
516
+ this.startWatchdog();
417
517
  this.channel.postMessage(msg);
418
518
  });
419
519
  }
@@ -427,10 +527,51 @@ var FollowerBroadcastBridge = class {
427
527
  removeEventListener("pagehide", this.onPageHide);
428
528
  }
429
529
  this.channel.close();
430
- for (const [, entry] of this.pending) {
431
- entry.reject(new Error("Follower bridge terminated"));
530
+ for (const [, entry] of [...this.pending]) {
531
+ entry.reject(new BridgeTerminatedError(entry.type, "follower bridge terminated"));
432
532
  }
433
533
  this.pending.clear();
534
+ this.stopWatchdog();
535
+ }
536
+ markHeard() {
537
+ this.lastHeardAt = Date.now();
538
+ this.heardSinceWatch = true;
539
+ }
540
+ startWatchdog() {
541
+ if (this.watchdog !== void 0) return;
542
+ const now = Date.now();
543
+ this.heardSinceWatch = now - this.lastHeardAt < this.heartbeatMs * 2;
544
+ if (!this.heardSinceWatch) this.lastHeardAt = now;
545
+ this.watchdog = setInterval(() => this.checkLiveness(), this.heartbeatMs);
546
+ }
547
+ stopWatchdogIfIdle() {
548
+ if (this.pending.size === 0) this.stopWatchdog();
549
+ }
550
+ stopWatchdog() {
551
+ if (this.watchdog !== void 0) {
552
+ clearInterval(this.watchdog);
553
+ this.watchdog = void 0;
554
+ }
555
+ }
556
+ checkLiveness() {
557
+ if (this.pending.size === 0) {
558
+ this.stopWatchdog();
559
+ return;
560
+ }
561
+ const silentMs = Date.now() - this.lastHeardAt;
562
+ const heard = this.heardSinceWatch;
563
+ const limit = this.heartbeatMs * (heard ? MISSED_BEATS_UNRESPONSIVE : MISSED_BEATS_ABSENT);
564
+ if (silentMs >= limit) {
565
+ for (const [requestId, entry] of [...this.pending]) {
566
+ entry.reject(
567
+ heard ? new LeaderUnresponsiveError(`follower-rpc:${entry.type}`, silentMs, requestId) : new NoLeaderError(`follower-rpc:${entry.type}`)
568
+ );
569
+ }
570
+ return;
571
+ }
572
+ if (silentMs >= this.heartbeatMs) {
573
+ this.channel.postMessage({ type: LEADER_PING });
574
+ }
434
575
  }
435
576
  leaveLeader() {
436
577
  try {
@@ -443,194 +584,213 @@ var FollowerBroadcastBridge = class {
443
584
  }
444
585
  }
445
586
  };
446
- var TransactionSerializingWorkerBridge = class {
447
- inner;
448
- leaderClientId = createClientId();
449
- transactionIdleTimeoutMs;
450
- queue = [];
451
- activeTransactionClient = null;
452
- abortedClients = /* @__PURE__ */ new Set();
453
- transactionIdleTimer = null;
454
- reclaiming = false;
455
- processing = false;
456
- terminated = false;
457
- constructor(inner, transactionIdleTimeoutMs = DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS) {
458
- this.inner = inner;
459
- this.transactionIdleTimeoutMs = transactionIdleTimeoutMs;
587
+ function createClientId() {
588
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
589
+ return crypto.randomUUID();
460
590
  }
461
- send(request, clientId = this.leaderClientId) {
462
- if (this.terminated) {
463
- return Promise.resolve({
464
- id: request.id,
465
- type: "error",
466
- message: "Worker has been terminated",
467
- code: "WORKER_TERMINATED"
468
- });
469
- }
470
- if (this.abortedClients.has(clientId)) {
471
- if (request.type === "begin") {
472
- this.abortedClients.delete(clientId);
473
- } else {
474
- return Promise.resolve({
475
- id: request.id,
476
- type: "error",
477
- message: "Previous transaction was aborted because the client stopped sending requests.",
478
- code: "TRANSACTION_ABORTED"
479
- });
480
- }
481
- }
482
- return new Promise((resolve, reject) => {
483
- this.queue.push({ clientId, request, resolve, reject });
484
- void this.processQueue();
591
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
592
+ }
593
+
594
+ // src/adapters/database-dump.ts
595
+ var import_core2 = require("@korajs/core");
596
+ init_errors();
597
+ function ensureSafeIdentifier(identifier) {
598
+ if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
599
+ throw new PersistenceError(`Unsafe SQL identifier: ${identifier}`, {
600
+ code: "UNSAFE_IDENTIFIER",
601
+ identifier
485
602
  });
486
603
  }
487
- terminate() {
488
- if (this.terminated) {
489
- return;
490
- }
491
- this.terminated = true;
492
- this.clearTransactionIdleTimer();
493
- this.inner.terminate();
494
- const pending = this.queue.splice(0);
495
- for (const entry of pending) {
496
- entry.reject(new Error("Worker terminated"));
604
+ return identifier;
605
+ }
606
+ function restoreDumpStatements(dump, _createMissingTables = true) {
607
+ const statements = [];
608
+ for (const table of dump.tables) {
609
+ const name = (0, import_core2.quoteIdent)(ensureSafeIdentifier(table.name));
610
+ const create = createTableIfNotExists(table.name, table.sql);
611
+ if (create) {
612
+ statements.push({ sql: create });
613
+ } else {
614
+ const columns = table.columns.map((column) => (0, import_core2.quoteIdent)(ensureSafeIdentifier(column)));
615
+ if (columns.length === 0) continue;
616
+ statements.push({ sql: `CREATE TABLE IF NOT EXISTS ${name} (${columns.join(", ")})` });
497
617
  }
498
- }
499
- reclaimClient(clientId, reason) {
500
- if (this.terminated) {
501
- return;
618
+ statements.push({ sql: `DELETE FROM ${name}` });
619
+ for (const row of table.rows) {
620
+ const columns = table.columns.filter(
621
+ (column) => Object.prototype.hasOwnProperty.call(row, column)
622
+ );
623
+ if (columns.length === 0) continue;
624
+ const quotedColumns = columns.map((column) => (0, import_core2.quoteIdent)(ensureSafeIdentifier(column))).join(", ");
625
+ statements.push({
626
+ sql: `INSERT INTO ${name} (${quotedColumns}) VALUES (${columns.map(() => "?").join(", ")})`,
627
+ params: columns.map((column) => row[column])
628
+ });
502
629
  }
503
- void this.reclaimClientNow(clientId, reason);
504
630
  }
505
- async processQueue() {
506
- if (this.processing || this.reclaiming) {
507
- return;
508
- }
509
- this.processing = true;
510
- try {
511
- while (!this.terminated) {
512
- const index = this.nextRunnableIndex();
513
- if (index === -1) {
514
- return;
515
- }
516
- const [entry] = this.queue.splice(index, 1);
517
- if (!entry) {
518
- return;
519
- }
520
- try {
521
- const response = await this.inner.send(entry.request);
522
- this.recordTransactionState(entry, response);
523
- entry.resolve(response);
524
- } catch (error) {
525
- this.resetFailedTransaction(entry);
526
- entry.reject(error instanceof Error ? error : new Error(String(error)));
527
- }
528
- }
529
- } finally {
530
- this.processing = false;
531
- if (!this.terminated && !this.reclaiming && this.nextRunnableIndex() !== -1) {
532
- void this.processQueue();
631
+ return statements;
632
+ }
633
+ function createTableIfNotExists(tableName, sql) {
634
+ if (typeof sql !== "string" || sql.includes(";")) return null;
635
+ const match = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?("?)([A-Za-z0-9_]+)\1\s*\(/i.exec(
636
+ sql
637
+ );
638
+ if (!match || match[2] !== tableName) return null;
639
+ return `CREATE TABLE IF NOT EXISTS ${(0, import_core2.quoteIdent)(tableName)} ${sql.slice(match[0].length - 1)}`;
640
+ }
641
+
642
+ // src/adapters/sqlite-wasm-adapter.ts
643
+ init_sqlite_wasm_channel();
644
+
645
+ // src/adapters/sqlite-wasm-persistence.ts
646
+ init_errors();
647
+ var IDB_DATABASE_NAME = "kora-persistence";
648
+ var IDB_STORE_NAME = "databases";
649
+ var IDB_VERSION = 1;
650
+ var DUMP_SUFFIX = "::dump";
651
+ function openIdb() {
652
+ return new Promise((resolve, reject) => {
653
+ const request = indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
654
+ request.onupgradeneeded = () => {
655
+ const db = request.result;
656
+ if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
657
+ db.createObjectStore(IDB_STORE_NAME);
533
658
  }
534
- }
535
- }
536
- nextRunnableIndex() {
537
- if (this.queue.length === 0) {
538
- return -1;
539
- }
540
- if (this.activeTransactionClient === null) {
541
- return 0;
542
- }
543
- return this.queue.findIndex((entry) => entry.clientId === this.activeTransactionClient);
544
- }
545
- recordTransactionState(entry, response) {
546
- if (response.type === "error") {
547
- this.resetFailedTransaction(entry);
548
- return;
549
- }
550
- if (entry.request.type === "begin") {
551
- this.activeTransactionClient = entry.clientId;
552
- this.armTransactionIdleTimer(entry.clientId);
553
- return;
554
- }
555
- if (this.activeTransactionClient === entry.clientId) {
556
- this.armTransactionIdleTimer(entry.clientId);
557
- }
558
- if (this.activeTransactionClient === entry.clientId && (entry.request.type === "commit" || entry.request.type === "rollback")) {
559
- this.activeTransactionClient = null;
560
- this.clearTransactionIdleTimer();
561
- }
562
- }
563
- resetFailedTransaction(entry) {
564
- if (this.activeTransactionClient === entry.clientId) {
565
- this.activeTransactionClient = null;
566
- this.clearTransactionIdleTimer();
567
- }
568
- }
569
- armTransactionIdleTimer(clientId) {
570
- this.clearTransactionIdleTimer();
571
- this.transactionIdleTimer = setTimeout(() => {
572
- void this.reclaimClientNow(clientId, "transaction-idle-timeout");
573
- }, this.transactionIdleTimeoutMs);
659
+ };
660
+ request.onsuccess = () => resolve(request.result);
661
+ request.onerror = () => reject(
662
+ new PersistenceError(
663
+ `Failed to open IndexedDB: ${request.error?.message ?? "unknown error"}`,
664
+ { database: IDB_DATABASE_NAME }
665
+ )
666
+ );
667
+ });
668
+ }
669
+ async function loadDumpFromIndexedDB(dbName) {
670
+ const idb = await openIdb();
671
+ try {
672
+ return await new Promise((resolve, reject) => {
673
+ const tx = idb.transaction(IDB_STORE_NAME, "readonly");
674
+ const store = tx.objectStore(IDB_STORE_NAME);
675
+ const request = store.get(`${dbName}${DUMP_SUFFIX}`);
676
+ request.onsuccess = () => {
677
+ resolve(request.result ?? null);
678
+ };
679
+ request.onerror = () => reject(
680
+ new PersistenceError(`Failed to load dump for database "${dbName}" from IndexedDB`, {
681
+ dbName
682
+ })
683
+ );
684
+ });
685
+ } finally {
686
+ idb.close();
574
687
  }
575
- clearTransactionIdleTimer() {
576
- if (this.transactionIdleTimer) {
577
- clearTimeout(this.transactionIdleTimer);
578
- this.transactionIdleTimer = null;
579
- }
688
+ }
689
+ async function deleteFromIndexedDB(dbName) {
690
+ const idb = await openIdb();
691
+ try {
692
+ await new Promise((resolve, reject) => {
693
+ const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
694
+ const store = tx.objectStore(IDB_STORE_NAME);
695
+ store.delete(dbName);
696
+ store.delete(`${dbName}${DUMP_SUFFIX}`);
697
+ tx.oncomplete = () => resolve();
698
+ tx.onerror = () => reject(
699
+ new PersistenceError(`Failed to delete database "${dbName}" from IndexedDB`, { dbName })
700
+ );
701
+ });
702
+ } finally {
703
+ idb.close();
580
704
  }
581
- async reclaimClientNow(clientId, reason) {
582
- if (this.reclaiming || this.activeTransactionClient !== clientId) {
583
- return;
584
- }
585
- this.reclaiming = true;
586
- this.clearTransactionIdleTimer();
587
- this.activeTransactionClient = null;
588
- this.abortedClients.add(clientId);
589
- this.rejectQueuedClientRequests(clientId, reason);
590
- try {
591
- const response = await this.inner.send({ id: 0, type: "rollback" });
592
- if (response.type === "error") {
593
- }
594
- } catch {
595
- } finally {
596
- this.reclaiming = false;
597
- if (!this.terminated && this.nextRunnableIndex() !== -1) {
598
- void this.processQueue();
705
+ }
706
+
707
+ // src/adapters/storage-manifest.ts
708
+ var MANIFEST_DB_NAME = "kora-manifest";
709
+ var MANIFEST_STORE = "databases";
710
+ var MANIFEST_VERSION = 1;
711
+ function isManifestAvailable() {
712
+ return typeof indexedDB !== "undefined";
713
+ }
714
+ function openManifest() {
715
+ return new Promise((resolve, reject) => {
716
+ const request = indexedDB.open(MANIFEST_DB_NAME, MANIFEST_VERSION);
717
+ request.onupgradeneeded = () => {
718
+ const db = request.result;
719
+ if (!db.objectStoreNames.contains(MANIFEST_STORE)) {
720
+ db.createObjectStore(MANIFEST_STORE, { keyPath: "dbName" });
599
721
  }
600
- }
601
- }
602
- rejectQueuedClientRequests(clientId, reason) {
603
- const keep = [];
604
- for (const entry of this.queue) {
605
- if (entry.clientId === clientId) {
606
- entry.resolve({
607
- id: entry.request.id,
608
- type: "error",
609
- message: `Transaction client was reclaimed: ${reason}`,
610
- code: "TRANSACTION_ABORTED"
611
- });
612
- } else {
613
- keep.push(entry);
722
+ };
723
+ request.onsuccess = () => resolve(request.result);
724
+ request.onerror = () => reject(request.error ?? new Error("Failed to open the Kora storage manifest"));
725
+ });
726
+ }
727
+ async function withStore(mode, run) {
728
+ const db = await openManifest();
729
+ try {
730
+ return await new Promise((resolve, reject) => {
731
+ const tx = db.transaction(MANIFEST_STORE, mode);
732
+ const request = run(tx.objectStore(MANIFEST_STORE));
733
+ let result;
734
+ if (request) {
735
+ request.onsuccess = () => {
736
+ result = request.result;
737
+ };
614
738
  }
615
- }
616
- this.queue = keep;
739
+ tx.oncomplete = () => resolve(result);
740
+ tx.onerror = () => reject(tx.error ?? new Error("Kora storage manifest transaction failed"));
741
+ tx.onabort = () => reject(tx.error ?? new Error("Kora storage manifest transaction aborted"));
742
+ });
743
+ } finally {
744
+ db.close();
617
745
  }
618
- };
619
- function hasReclaimClient(bridge) {
620
- return typeof bridge.reclaimClient === "function";
621
746
  }
622
- function createClientId() {
623
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
624
- return crypto.randomUUID();
625
- }
626
- return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
747
+ function isRecord(value) {
748
+ if (typeof value !== "object" || value === null) return false;
749
+ const record = value;
750
+ return typeof record.dbName === "string" && (record.backend === "opfs" || record.backend === "indexeddb");
751
+ }
752
+ async function readManifestRecord(dbName) {
753
+ if (!isManifestAvailable()) return null;
754
+ const value = await withStore("readonly", (store) => store.get(dbName));
755
+ return isRecord(value) ? value : null;
756
+ }
757
+ async function listManifestRecords() {
758
+ if (!isManifestAvailable()) return [];
759
+ const values = await withStore("readonly", (store) => store.getAll());
760
+ return (values ?? []).filter(isRecord).sort((a, b) => a.dbName < b.dbName ? -1 : 1);
761
+ }
762
+ async function recordDatabase(entry) {
763
+ if (!isManifestAvailable()) return;
764
+ const existing = await readManifestRecord(entry.dbName);
765
+ const now = Date.now();
766
+ const record = {
767
+ dbName: entry.dbName,
768
+ backend: entry.backend,
769
+ ...entry.poolName ? { poolName: entry.poolName } : {},
770
+ createdAt: existing?.createdAt ?? now,
771
+ lastOpenedAt: now
772
+ };
773
+ await withStore("readwrite", (store) => store.put(record));
774
+ }
775
+ async function deleteManifestRecord(dbName) {
776
+ if (!isManifestAvailable()) return;
777
+ await withStore("readwrite", (store) => store.delete(dbName));
627
778
  }
628
779
 
629
780
  // src/adapters/sqlite-wasm-adapter.ts
630
- init_sqlite_wasm_channel();
631
781
  var warnedSharedWorkerDeprecated = false;
782
+ function createGate() {
783
+ let open = () => {
784
+ };
785
+ const promise = new Promise((resolve) => {
786
+ open = resolve;
787
+ });
788
+ return { promise, open };
789
+ }
632
790
  var SqliteWasmAdapter = class {
633
791
  bridge = null;
792
+ /** The dedicated worker this tab runs while it is the database's leader. */
793
+ ownWorker = null;
634
794
  opened = false;
635
795
  mutex = new Mutex();
636
796
  injectedBridge;
@@ -645,6 +805,19 @@ var SqliteWasmAdapter = class {
645
805
  /** Retained so a follower promoted to leader can re-open its own worker. */
646
806
  schema = null;
647
807
  promoting = false;
808
+ closing = false;
809
+ suspended = false;
810
+ /** Requests wait here while leadership changes hands (promotion, resume). */
811
+ gate = null;
812
+ removeLifecycleListeners = null;
813
+ allowNonDurable;
814
+ deferOpenDurabilityCheck;
815
+ releaseOnFreeze;
816
+ storage;
817
+ skipBackendRecord;
818
+ /** Set when this adapter lost (or never had) durable storage; writes are refused. */
819
+ durabilityLoss = null;
820
+ onLeaderWorkerOpened;
648
821
  constructor(options = {}) {
649
822
  this.injectedBridge = options.bridge;
650
823
  this.workerUrl = options.workerUrl;
@@ -653,50 +826,72 @@ var SqliteWasmAdapter = class {
653
826
  this.dbName = options.dbName ?? "kora-db";
654
827
  this.emitter = options.emitter;
655
828
  this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
829
+ this.allowNonDurable = options.allowNonDurable ?? false;
830
+ this.deferOpenDurabilityCheck = options.deferOpenDurabilityCheck ?? false;
831
+ this.releaseOnFreeze = options.releaseOnFreeze ?? true;
832
+ this.storage = options.storage ?? "opfs";
833
+ this.skipBackendRecord = options.skipBackendRecord ?? false;
834
+ this.onLeaderWorkerOpened = options.onLeaderWorkerOpened;
656
835
  }
657
836
  async open(schema) {
658
837
  if (this.opened) return;
659
- const ddlStatements = (0, import_core2.generateFullDDL)(schema);
838
+ const ddlStatements = (0, import_core3.generateFullDDL)(schema);
660
839
  if (this.injectedBridge) {
661
840
  this.bridge = this.injectedBridge;
662
841
  const response = await this.openCurrentBridge(ddlStatements);
663
842
  this.reportStorageMode(response.data);
843
+ this.enforceDurability("open");
664
844
  this.opened = true;
665
845
  return;
666
846
  }
667
847
  if (this.workerUrl) {
668
848
  this.warnSharedWorkerDeprecated();
669
- await this.openDurableWorkerBridge(schema);
670
- const response = await this.openCurrentBridge(ddlStatements);
671
- this.reportStorageMode(response.data);
672
- this.opened = true;
849
+ this.schema = schema;
850
+ try {
851
+ const response = await this.attachAndOpen(ddlStatements);
852
+ this.reportStorageMode(response.data);
853
+ this.enforceDurability("open");
854
+ await this.afterLeaderOpen(response.data);
855
+ this.opened = true;
856
+ } catch (error) {
857
+ await this.teardown();
858
+ throw error;
859
+ }
673
860
  return;
674
861
  }
675
862
  throw new AdapterError(
676
863
  'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
677
864
  );
678
865
  }
679
- async openDurableWorkerBridge(schema) {
680
- if (!this.workerUrl) {
866
+ /**
867
+ * Join the database as leader (own worker) or follower (RPC to the leader's
868
+ * worker), then open it. The open waits, without a timeout, while another
869
+ * holder still has the storage; the worker reports that as a blocking state.
870
+ */
871
+ async attachAndOpen(ddlStatements) {
872
+ const workerUrl = this.workerUrl;
873
+ if (!workerUrl) {
681
874
  throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
682
875
  }
683
- this.schema = schema;
684
- this.tabSession = await acquireTabStorageSession(this.dbName, {
876
+ const session = await acquireTabStorageSession(this.dbName, {
685
877
  onPromote: () => {
686
878
  void this.promoteToLeader();
687
879
  }
688
880
  });
689
- const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
690
- if (this.tabSession.role === "leader") {
691
- const workerBridge = new TransactionSerializingWorkerBridge(
692
- new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
693
- );
694
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
695
- this.bridge = workerBridge;
696
- return;
881
+ this.tabSession = session;
882
+ if (session.role === "leader") {
883
+ const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
884
+ const worker = new WebWorkerBridge2(workerUrl, this.workerResponseTimeoutMs, {
885
+ onEvent: (event) => this.onWorkerEvent(event)
886
+ });
887
+ this.ownWorker = worker;
888
+ this.bridge = worker;
889
+ const response = await this.openLeaderWorker(worker, ddlStatements);
890
+ this.installLifecycleListeners();
891
+ return response;
697
892
  }
698
893
  const followerBridge = new FollowerBroadcastBridge(
699
- this.tabSession.channelName,
894
+ session.channelName,
700
895
  this.workerResponseTimeoutMs
701
896
  );
702
897
  this.bridge = followerBridge;
@@ -706,22 +901,152 @@ var SqliteWasmAdapter = class {
706
901
  message: `Another runtime on this origin is already using database "${this.dbName}"; this runtime is sharing it as a follower. If these are separate apps, give each a distinct store name.`
707
902
  });
708
903
  await followerBridge.waitForLeader();
904
+ this.installLifecycleListeners();
905
+ return this.checkedOpen(followerBridge, ddlStatements);
709
906
  }
710
- async openCurrentBridge(ddlStatements) {
711
- const response = await this.sendRequest({
712
- id: 0,
713
- type: "open",
714
- ddlStatements,
715
- dbName: this.dbName
716
- });
907
+ /** Open the database in this tab's own worker and start serving followers. */
908
+ async openLeaderWorker(worker, ddlStatements) {
909
+ const hook = this.onLeaderWorkerOpened;
910
+ if (!hook) {
911
+ const pending = this.checkedOpen(worker, ddlStatements);
912
+ worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
913
+ return pending;
914
+ }
915
+ const response = await this.checkedOpen(worker, ddlStatements);
916
+ await runWorkerTransaction(worker, hook);
917
+ worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
918
+ return response;
919
+ }
920
+ /**
921
+ * Whether this runtime owns the database's worker: the storage leader in a browser,
922
+ * or any adapter with an injected bridge (tests, Node). A follower tab relays every
923
+ * request to the leader's worker (STORE-6).
924
+ */
925
+ isLeader() {
926
+ if (this.injectedBridge) return true;
927
+ return this.tabSession?.role === "leader";
928
+ }
929
+ async checkedOpen(bridge, ddlStatements) {
930
+ const response = await bridge.send(
931
+ {
932
+ id: 0,
933
+ type: "open",
934
+ ddlStatements,
935
+ dbName: this.dbName,
936
+ ...this.storage === "memory" ? { storage: "memory" } : {}
937
+ },
938
+ void 0,
939
+ { timeoutMs: Number.POSITIVE_INFINITY }
940
+ );
717
941
  if (response.type === "error") {
718
942
  throw new AdapterError(`Failed to open database: ${response.message}`, {
719
943
  code: response.code,
720
- dbName: this.dbName
944
+ dbName: this.dbName,
945
+ ...response.context ?? {}
721
946
  });
722
947
  }
723
948
  return response;
724
949
  }
950
+ async openCurrentBridge(ddlStatements) {
951
+ const bridge = this.bridge;
952
+ if (!bridge) throw new StoreNotOpenError();
953
+ return this.checkedOpen(bridge, ddlStatements);
954
+ }
955
+ onWorkerEvent(event) {
956
+ if (!this.emitter) return;
957
+ const holder = event.resource === "legacy-pool" ? "the shared OPFS pool used by older Kora versions (a tab running an older version of this app may still be open)" : "another tab or worker that is still releasing it";
958
+ if (event.kind === "storage-blocked") {
959
+ this.emitter.emit({
960
+ type: "store:storage-blocked",
961
+ dbName: this.dbName,
962
+ resource: event.resource,
963
+ state: "waiting",
964
+ message: `Database "${this.dbName}" is waiting for ${holder}. Close other tabs of this app if this persists; Kora will not fall back to non-durable storage.`
965
+ });
966
+ return;
967
+ }
968
+ this.emitter.emit({
969
+ type: "store:storage-blocked",
970
+ dbName: this.dbName,
971
+ resource: event.resource,
972
+ state: "resolved",
973
+ waitedMs: event.waitedMs,
974
+ message: `Database "${this.dbName}" obtained its storage after waiting ${event.waitedMs}ms.`
975
+ });
976
+ }
977
+ /**
978
+ * Leader-only bookkeeping after a durable open: report a legacy-pool
979
+ * migration, move an IndexedDB-held copy into OPFS explicitly (never two
980
+ * disjoint copies), and record OPFS as this database's backend.
981
+ */
982
+ async afterLeaderOpen(data) {
983
+ if (!this.ownWorker || this.skipBackendRecord || this.storageOpenState?.persistent !== true) {
984
+ return;
985
+ }
986
+ const info = typeof data === "object" && data !== null ? data : {};
987
+ if (info.migratedFromLegacy === true) {
988
+ this.emitter?.emit({
989
+ type: "store:storage-migrated",
990
+ dbName: this.dbName,
991
+ from: "legacy-opfs-pool",
992
+ to: "opfs",
993
+ message: `Database "${this.dbName}" moved from the shared pre-1.0 OPFS pool into its own pool.`
994
+ });
995
+ }
996
+ if (!isManifestAvailable()) return;
997
+ let recordedBackend = null;
998
+ try {
999
+ recordedBackend = (await readManifestRecord(this.dbName))?.backend ?? null;
1000
+ } catch (error) {
1001
+ console.warn(`[kora] Could not read the storage manifest for "${this.dbName}":`, error);
1002
+ }
1003
+ if (recordedBackend === "indexeddb") {
1004
+ const dump = await loadDumpFromIndexedDB(this.dbName);
1005
+ if (dump) {
1006
+ await this.applyDumpThroughWorker(this.ownWorker, dump);
1007
+ }
1008
+ this.emitter?.emit({
1009
+ type: "store:storage-migrated",
1010
+ dbName: this.dbName,
1011
+ from: "indexeddb",
1012
+ to: "opfs",
1013
+ message: `Database "${this.dbName}" moved from IndexedDB into OPFS.`
1014
+ });
1015
+ }
1016
+ try {
1017
+ await recordDatabase({
1018
+ dbName: this.dbName,
1019
+ backend: "opfs",
1020
+ ...this.storageOpenState.poolName ? { poolName: this.storageOpenState.poolName } : {}
1021
+ });
1022
+ if (recordedBackend === "indexeddb") {
1023
+ await deleteFromIndexedDB(this.dbName);
1024
+ }
1025
+ } catch (error) {
1026
+ console.warn(`[kora] Could not update the storage manifest for "${this.dbName}":`, error);
1027
+ }
1028
+ }
1029
+ async applyDumpThroughWorker(worker, dump) {
1030
+ const check = async (request, what) => {
1031
+ const response = await worker.send(request);
1032
+ if (response.type === "error") {
1033
+ throw new AdapterError(`${what} failed: ${response.message}`, { dbName: this.dbName });
1034
+ }
1035
+ };
1036
+ await check({ id: 0, type: "begin" }, "BEGIN IndexedDB import");
1037
+ try {
1038
+ for (const statement of restoreDumpStatements(dump)) {
1039
+ await check(
1040
+ { id: 0, type: "execute", sql: statement.sql, params: statement.params },
1041
+ "IndexedDB import"
1042
+ );
1043
+ }
1044
+ await check({ id: 0, type: "commit" }, "COMMIT IndexedDB import");
1045
+ } catch (error) {
1046
+ await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
1047
+ throw error;
1048
+ }
1049
+ }
725
1050
  /**
726
1051
  * Surface a `store:opfs-unavailable` diagnostic when the worker reported that
727
1052
  * persistence silently degraded to an in-memory database, so a data-loss
@@ -733,7 +1058,7 @@ var SqliteWasmAdapter = class {
733
1058
  if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
734
1059
  return;
735
1060
  }
736
- if (this.storageOpenState.persistent === false) {
1061
+ if (this.storageOpenState.persistent === false && this.storage === "opfs") {
737
1062
  const reason = this.storageOpenState.fallbackReason ?? "unsupported";
738
1063
  this.emitter.emit({
739
1064
  type: "store:opfs-unavailable",
@@ -746,6 +1071,42 @@ var SqliteWasmAdapter = class {
746
1071
  getStorageOpenState() {
747
1072
  return this.storageOpenState;
748
1073
  }
1074
+ /**
1075
+ * Durable or loud, never silent (NEW-STORE-6). When the open or a promotion
1076
+ * ended on non-persistent storage and the app did not opt into that, emit the
1077
+ * blocking `store:durability-lost` event and refuse every later write. A
1078
+ * bridge that reports no mode (the Node mock) is not judged.
1079
+ */
1080
+ enforceDurability(phase, failure) {
1081
+ if (this.allowNonDurable) return;
1082
+ if (phase === "open" && this.deferOpenDurabilityCheck && !failure) return;
1083
+ let reason;
1084
+ if (failure) {
1085
+ reason = failure.reason;
1086
+ } else if (this.storageOpenState?.persistent === false) {
1087
+ reason = this.storageOpenState.fallbackReason ?? "unsupported";
1088
+ } else {
1089
+ this.durabilityLoss = null;
1090
+ return;
1091
+ }
1092
+ this.durabilityLoss = { phase, reason };
1093
+ this.emitter?.emit({
1094
+ type: "store:durability-lost",
1095
+ dbName: this.dbName,
1096
+ phase,
1097
+ reason,
1098
+ message: `Database "${this.dbName}" has no durable storage (${reason} during ${phase}); writes are refused until the app reloads with durable storage.${failure ? ` ${failure.message}` : ""}`
1099
+ });
1100
+ }
1101
+ guardDurableWrite() {
1102
+ if (this.durabilityLoss) {
1103
+ throw new StorageDurabilityError(
1104
+ this.dbName,
1105
+ this.durabilityLoss.phase,
1106
+ this.durabilityLoss.reason
1107
+ );
1108
+ }
1109
+ }
749
1110
  warnSharedWorkerDeprecated() {
750
1111
  if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
751
1112
  return;
@@ -755,71 +1116,255 @@ var SqliteWasmAdapter = class {
755
1116
  "[kora] sharedWorkerUrl is deprecated and ignored: SharedWorker-hosted SQLite cannot use OPFS and is never durable. Kora uses the durable dedicated-worker leader/follower path for multi-tab storage."
756
1117
  );
757
1118
  }
1119
+ /**
1120
+ * Close the database. A leader closes the database file and releases the OPFS
1121
+ * pool inside its worker, terminates the worker, and only then releases the
1122
+ * tab leader lock, so the next leader never races a still-running worker for
1123
+ * the file handles (NEW-STORE-10). A follower just leaves.
1124
+ */
758
1125
  async close() {
759
- if (!this.bridge) return;
1126
+ if (!this.bridge && !this.tabSession) return;
1127
+ this.closing = true;
760
1128
  try {
761
- await this.sendRequest({ id: 0, type: "close" });
1129
+ if (this.injectedBridge && this.bridge) {
1130
+ await this.bridge.send({ id: 0, type: "close" });
1131
+ } else if (this.ownWorker && !this.ownWorker.isTerminated()) {
1132
+ const response = await this.ownWorker.send({ id: 0, type: "close" }, void 0, { timeoutMs: 5e3 }).catch((error) => ({
1133
+ id: 0,
1134
+ type: "error",
1135
+ message: error instanceof Error ? error.message : String(error),
1136
+ code: "CLOSE_FAILED"
1137
+ }));
1138
+ if (response.type === "error") {
1139
+ console.warn(`[kora] Closing "${this.dbName}" in its worker failed:`, response.message);
1140
+ }
1141
+ }
762
1142
  } finally {
763
- this.tabSession?.stopRelay?.();
764
- this.tabSession?.cancelPromotionWatch?.();
765
- if (this.tabSession?.releaseLock) {
766
- await this.tabSession.releaseLock();
1143
+ await this.teardown();
1144
+ this.closing = false;
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Release everything this adapter holds, in ownership order: lifecycle
1149
+ * listeners, then the worker (which holds the OPFS pool and its Web Lock),
1150
+ * then the tab leader lock or queued promotion request.
1151
+ */
1152
+ async teardown() {
1153
+ this.removeLifecycleListeners?.();
1154
+ this.removeLifecycleListeners = null;
1155
+ const session = this.tabSession;
1156
+ this.tabSession = null;
1157
+ session?.stopRelay?.();
1158
+ this.bridge?.terminate();
1159
+ this.bridge = null;
1160
+ this.ownWorker = null;
1161
+ if (session) {
1162
+ if (session.releaseLock) {
1163
+ await session.releaseLock();
767
1164
  }
768
- this.tabSession = null;
769
- this.bridge.terminate();
770
- this.bridge = null;
771
- this.opened = false;
1165
+ session.cancelPromotionWatch?.();
772
1166
  }
1167
+ this.opened = false;
1168
+ this.suspended = false;
1169
+ const gate = this.gate;
1170
+ this.gate = null;
1171
+ gate?.open();
773
1172
  }
774
1173
  /**
775
1174
  * Promotes a follower to leader after the previous leader released the storage
776
- * lock (its tab closed or crashed). The lock is only granted once the old leader
777
- * is gone, so opening our own worker here is safe from the OPFS single-writer
778
- * rule. In-flight follower RPCs are rejected and left to the caller / reactive
779
- * layer to retry against the new leader.
1175
+ * lock (its tab closed, crashed, or suspended on freeze). The new worker waits
1176
+ * on the pool's Web Lock and handles, so it never races the previous worker.
1177
+ * Requests issued during the hand-off wait for it; requests that were in
1178
+ * flight to the old leader fail with a retriable `BridgeTerminatedError`.
780
1179
  */
781
1180
  async promoteToLeader() {
782
- if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
1181
+ if (this.promoting || this.closing || !this.opened || this.workerUrl === void 0 || this.schema === null) {
783
1182
  return;
784
1183
  }
785
1184
  this.promoting = true;
786
- const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
787
- const workerBridge = new TransactionSerializingWorkerBridge(
788
- new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
789
- );
790
- const previousBridge = this.bridge;
791
- this.bridge = workerBridge;
792
- if (this.tabSession) {
793
- this.tabSession.role = "leader";
794
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
1185
+ const gate = createGate();
1186
+ this.gate = gate;
1187
+ try {
1188
+ const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
1189
+ if (this.closing || !this.opened) return;
1190
+ const worker = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs, {
1191
+ onEvent: (event) => this.onWorkerEvent(event)
1192
+ });
1193
+ const previousBridge = this.bridge;
1194
+ this.bridge = worker;
1195
+ this.ownWorker = worker;
1196
+ if (this.tabSession) {
1197
+ this.tabSession.role = "leader";
1198
+ }
1199
+ previousBridge?.terminate();
1200
+ const ddlStatements = (0, import_core3.generateFullDDL)(this.schema);
1201
+ let response;
1202
+ try {
1203
+ response = await this.openLeaderWorker(worker, ddlStatements);
1204
+ } catch (error) {
1205
+ this.storageOpenState = { persistent: false, mode: "memory" };
1206
+ this.enforceDurability("promotion", {
1207
+ reason: "open-failed",
1208
+ message: error instanceof Error ? error.message : String(error)
1209
+ });
1210
+ return;
1211
+ }
1212
+ this.storageOpenState = parseStorageOpenState(response.data) ?? this.storageOpenState;
1213
+ this.enforceDurability("promotion");
1214
+ try {
1215
+ await this.afterLeaderOpen(response.data);
1216
+ } catch (error) {
1217
+ this.enforceDurability("promotion", {
1218
+ reason: "open-failed",
1219
+ message: error instanceof Error ? error.message : String(error)
1220
+ });
1221
+ }
1222
+ } finally {
1223
+ this.promoting = false;
1224
+ if (this.gate === gate) this.gate = null;
1225
+ gate.open();
795
1226
  }
796
- previousBridge?.terminate();
797
- const ddlStatements = (0, import_core2.generateFullDDL)(this.schema);
798
- await this.sendRequest({ id: 0, type: "open", ddlStatements, dbName: this.dbName });
799
1227
  }
800
- async execute(sql, params) {
1228
+ /**
1229
+ * Page Lifecycle: a frozen or bfcached page cannot run, so it must not keep
1230
+ * other tabs waiting on its storage. Leaders terminate their worker (freeing
1231
+ * the OPFS pool) and then release the leader lock, so a visible tab is
1232
+ * promoted; followers withdraw their queued lock request so a frozen tab is
1233
+ * never granted leadership it cannot use. Never uses Web Locks `steal`.
1234
+ */
1235
+ installLifecycleListeners() {
1236
+ if (!this.releaseOnFreeze || this.removeLifecycleListeners) return;
1237
+ if (typeof addEventListener !== "function" || typeof document === "undefined") return;
1238
+ const onFreeze = () => this.suspend();
1239
+ const onPageHide = () => this.suspend();
1240
+ const onResume = () => {
1241
+ void this.resume();
1242
+ };
1243
+ const onPageShow = (event) => {
1244
+ if (event.persisted) void this.resume();
1245
+ };
1246
+ document.addEventListener("freeze", onFreeze);
1247
+ document.addEventListener("resume", onResume);
1248
+ addEventListener("pagehide", onPageHide);
1249
+ addEventListener("pageshow", onPageShow);
1250
+ this.removeLifecycleListeners = () => {
1251
+ document.removeEventListener("freeze", onFreeze);
1252
+ document.removeEventListener("resume", onResume);
1253
+ removeEventListener("pagehide", onPageHide);
1254
+ removeEventListener("pageshow", onPageShow);
1255
+ };
1256
+ }
1257
+ /** Synchronous by necessity: a `freeze` handler gets no further turns. */
1258
+ suspend() {
1259
+ if (!this.opened || this.suspended || this.closing || this.promoting) return;
1260
+ this.suspended = true;
1261
+ this.gate = createGate();
1262
+ const session = this.tabSession;
1263
+ this.tabSession = null;
1264
+ this.bridge?.terminate();
1265
+ this.bridge = null;
1266
+ this.ownWorker = null;
1267
+ if (session) {
1268
+ void session.releaseLock?.();
1269
+ session.cancelPromotionWatch?.();
1270
+ }
1271
+ }
1272
+ async resume() {
1273
+ if (!this.suspended || this.closing || this.schema === null) return;
1274
+ const gate = this.gate;
1275
+ try {
1276
+ const response = await this.attachAndOpen((0, import_core3.generateFullDDL)(this.schema));
1277
+ this.reportStorageMode(response.data);
1278
+ this.enforceDurability("promotion");
1279
+ await this.afterLeaderOpen(response.data);
1280
+ } catch (error) {
1281
+ this.enforceDurability("promotion", {
1282
+ reason: "open-failed",
1283
+ message: error instanceof Error ? error.message : String(error)
1284
+ });
1285
+ } finally {
1286
+ this.suspended = false;
1287
+ if (this.gate === gate) this.gate = null;
1288
+ gate?.open();
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Non-transactional write. Takes the same mutex as {@link transaction}, so it
1293
+ * never lands inside this tab's open transaction and is never rolled back
1294
+ * with it (STORE-8). Inside a transaction callback, use the `tx` handle.
1295
+ */
1296
+ async execute(sql, params, options) {
801
1297
  this.guardOpen();
802
- const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
803
- if (response.type === "error") {
804
- throw new AdapterError(`Execute failed: ${response.message}`, { sql, params });
1298
+ this.guardDurableWrite();
1299
+ const release = await this.mutex.acquire();
1300
+ try {
1301
+ const response = await this.sendRequest(
1302
+ {
1303
+ id: 0,
1304
+ type: "execute",
1305
+ sql,
1306
+ params,
1307
+ ...options?.requestId ? { requestId: options.requestId } : {}
1308
+ },
1309
+ options?.signal ? { signal: options.signal } : void 0
1310
+ );
1311
+ if (response.type === "error") {
1312
+ throw new AdapterError(`Execute failed: ${response.message}`, { sql, params });
1313
+ }
1314
+ } finally {
1315
+ release();
805
1316
  }
806
1317
  }
807
- async query(sql, params) {
1318
+ /**
1319
+ * Non-transactional read. Waits for this tab's open transaction to finish, so
1320
+ * it never observes uncommitted (possibly rolled-back) rows (STORE-8).
1321
+ */
1322
+ async query(sql, params, options) {
808
1323
  this.guardOpen();
809
- const response = await this.sendRequest({ id: 0, type: "query", sql, params });
810
- if (response.type === "error") {
811
- throw new AdapterError(`Query failed: ${response.message}`, { sql, params });
1324
+ const release = await this.mutex.acquire();
1325
+ try {
1326
+ const response = await this.sendRequest(
1327
+ {
1328
+ id: 0,
1329
+ type: "query",
1330
+ sql,
1331
+ params,
1332
+ ...options?.requestId ? { requestId: options.requestId } : {}
1333
+ },
1334
+ options?.signal ? { signal: options.signal } : void 0
1335
+ );
1336
+ if (response.type === "error") {
1337
+ throw new AdapterError(`Query failed: ${response.message}`, { sql, params });
1338
+ }
1339
+ return response.data ?? [];
1340
+ } finally {
1341
+ release();
812
1342
  }
813
- return response.data ?? [];
814
1343
  }
815
1344
  async transaction(fn) {
816
1345
  this.guardOpen();
1346
+ this.guardDurableWrite();
817
1347
  const release = await this.mutex.acquire();
1348
+ let txBridge = null;
1349
+ const sendInSpan = async (request) => {
1350
+ if (txBridge === null || this.bridge !== txBridge) {
1351
+ throw new BridgeTerminatedError(request.type, "storage leader changed during transaction");
1352
+ }
1353
+ return txBridge.send(request);
1354
+ };
818
1355
  try {
819
- await this.sendChecked({ id: 0, type: "begin" }, "BEGIN transaction");
1356
+ while (this.gate) {
1357
+ await this.gate.promise;
1358
+ }
1359
+ txBridge = this.bridge;
1360
+ if (!txBridge) throw new StoreNotOpenError();
1361
+ const begin = await sendInSpan({ id: 0, type: "begin" });
1362
+ if (begin.type === "error") {
1363
+ throw new AdapterError(`BEGIN transaction failed: ${begin.message}`);
1364
+ }
820
1365
  const tx = {
821
1366
  execute: async (sql, params) => {
822
- const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
1367
+ const response = await sendInSpan({ id: 0, type: "execute", sql, params });
823
1368
  if (response.type === "error") {
824
1369
  throw new AdapterError(`Transaction execute failed: ${response.message}`, {
825
1370
  sql,
@@ -828,7 +1373,7 @@ var SqliteWasmAdapter = class {
828
1373
  }
829
1374
  },
830
1375
  query: async (sql, params) => {
831
- const response = await this.sendRequest({ id: 0, type: "query", sql, params });
1376
+ const response = await sendInSpan({ id: 0, type: "query", sql, params });
832
1377
  if (response.type === "error") {
833
1378
  throw new AdapterError(`Transaction query failed: ${response.message}`, { sql, params });
834
1379
  }
@@ -836,10 +1381,15 @@ var SqliteWasmAdapter = class {
836
1381
  }
837
1382
  };
838
1383
  await fn(tx);
839
- await this.sendChecked({ id: 0, type: "commit" }, "COMMIT transaction");
1384
+ const commit = await sendInSpan({ id: 0, type: "commit" });
1385
+ if (commit.type === "error") {
1386
+ throw new AdapterError(`COMMIT transaction failed: ${commit.message}`);
1387
+ }
840
1388
  } catch (error) {
841
1389
  try {
842
- await this.sendRequest({ id: 0, type: "rollback" });
1390
+ if (txBridge && this.bridge === txBridge) {
1391
+ await txBridge.send({ id: 0, type: "rollback" });
1392
+ }
843
1393
  } catch {
844
1394
  }
845
1395
  throw error;
@@ -849,6 +1399,7 @@ var SqliteWasmAdapter = class {
849
1399
  }
850
1400
  async migrate(from, to, migration) {
851
1401
  this.guardOpen();
1402
+ this.guardDurableWrite();
852
1403
  const release = await this.mutex.acquire();
853
1404
  try {
854
1405
  await this.sendChecked({ id: 0, type: "begin" }, "BEGIN migration");
@@ -882,36 +1433,50 @@ var SqliteWasmAdapter = class {
882
1433
  */
883
1434
  async exportDatabase() {
884
1435
  this.guardOpen();
885
- const response = await this.sendRequest({ id: 0, type: "export" });
886
- if (response.type === "error") {
887
- throw new AdapterError(`Export failed: ${response.message}`, {
888
- code: response.code,
889
- context: response.context
890
- });
1436
+ const release = await this.mutex.acquire();
1437
+ try {
1438
+ const response = await this.sendRequest({ id: 0, type: "export" });
1439
+ if (response.type === "error") {
1440
+ throw new AdapterError(`Export failed: ${response.message}`, {
1441
+ code: response.code,
1442
+ context: response.context
1443
+ });
1444
+ }
1445
+ return response.data;
1446
+ } finally {
1447
+ release();
891
1448
  }
892
- return response.data;
893
1449
  }
894
1450
  /**
895
1451
  * Import a serialized database snapshot.
896
1452
  */
897
1453
  async importDatabase(data) {
898
1454
  this.guardOpen();
899
- const response = await this.sendRequest({ id: 0, type: "import", data });
900
- if (response.type === "error") {
901
- throw new AdapterError(`Import failed: ${response.message}`);
1455
+ this.guardDurableWrite();
1456
+ const release = await this.mutex.acquire();
1457
+ try {
1458
+ const response = await this.sendRequest({ id: 0, type: "import", data });
1459
+ if (response.type === "error") {
1460
+ throw new AdapterError(`Import failed: ${response.message}`);
1461
+ }
1462
+ } finally {
1463
+ release();
902
1464
  }
903
1465
  }
904
1466
  guardOpen() {
905
- if (!this.opened || !this.bridge) {
1467
+ if (!this.opened || !this.bridge && !this.gate) {
906
1468
  throw new StoreNotOpenError();
907
1469
  }
908
1470
  }
909
- async sendRequest(request) {
1471
+ async sendRequest(request, options) {
1472
+ while (this.gate) {
1473
+ await this.gate.promise;
1474
+ }
910
1475
  const bridge = this.bridge;
911
1476
  if (!bridge) {
912
1477
  throw new StoreNotOpenError();
913
1478
  }
914
- return bridge.send(request);
1479
+ return bridge.send(request, void 0, options);
915
1480
  }
916
1481
  async sendChecked(request, description) {
917
1482
  const response = await this.sendRequest(request);
@@ -920,6 +1485,34 @@ var SqliteWasmAdapter = class {
920
1485
  }
921
1486
  }
922
1487
  };
1488
+ async function runWorkerTransaction(worker, fn) {
1489
+ const send = async (request, what) => {
1490
+ const response = await worker.send(request);
1491
+ if (response.type === "error") {
1492
+ throw new AdapterError(`${what} failed: ${response.message}`, {
1493
+ code: response.code,
1494
+ ...request.type === "execute" || request.type === "query" ? { sql: request.sql } : {}
1495
+ });
1496
+ }
1497
+ return response;
1498
+ };
1499
+ await send({ id: 0, type: "begin" }, "BEGIN");
1500
+ try {
1501
+ await fn({
1502
+ execute: async (sql, params) => {
1503
+ await send({ id: 0, type: "execute", sql, params }, "Leader open execute");
1504
+ },
1505
+ query: async (sql, params) => {
1506
+ const response = await send({ id: 0, type: "query", sql, params }, "Leader open query");
1507
+ return response.data ?? [];
1508
+ }
1509
+ });
1510
+ await send({ id: 0, type: "commit" }, "COMMIT");
1511
+ } catch (error) {
1512
+ await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
1513
+ throw error;
1514
+ }
1515
+ }
923
1516
  function parseStorageOpenState(data) {
924
1517
  if (typeof data !== "object" || data === null) {
925
1518
  return null;
@@ -931,16 +1524,111 @@ function parseStorageOpenState(data) {
931
1524
  return {
932
1525
  persistent: mode.persistent,
933
1526
  mode: mode.persistent ? "opfs" : "memory",
934
- ...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {}
1527
+ ...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {},
1528
+ ...typeof mode.poolName === "string" ? { poolName: mode.poolName } : {},
1529
+ ...typeof mode.journalMode === "string" ? { journalMode: mode.journalMode } : {}
935
1530
  };
936
1531
  }
937
1532
 
938
1533
  // src/adapters/sqlite-wasm.ts
939
1534
  init_sqlite_wasm_channel();
1535
+
1536
+ // src/adapters/local-databases.ts
1537
+ init_errors();
1538
+ async function listLocalDatabases() {
1539
+ return listManifestRecords();
1540
+ }
1541
+ async function withExclusiveDatabase(dbName, run) {
1542
+ const locks = typeof navigator !== "undefined" ? navigator.locks : void 0;
1543
+ if (!locks || typeof locks.request !== "function") {
1544
+ return run();
1545
+ }
1546
+ let ran = false;
1547
+ let result;
1548
+ await locks.request(
1549
+ leaderLockName(dbName),
1550
+ { mode: "exclusive", ifAvailable: true },
1551
+ async (lock) => {
1552
+ if (lock === null) return;
1553
+ ran = true;
1554
+ result = await run();
1555
+ }
1556
+ );
1557
+ if (!ran) {
1558
+ throw new StorageInUseError(dbName);
1559
+ }
1560
+ return result;
1561
+ }
1562
+ async function deleteLocalDatabase(dbName, options) {
1563
+ const record = await readManifestRecord(dbName);
1564
+ const backend = record?.backend ?? "opfs";
1565
+ return withExclusiveDatabase(dbName, async () => {
1566
+ const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
1567
+ const worker = new WebWorkerBridge2(options.workerUrl, options.workerResponseTimeoutMs ?? 3e4);
1568
+ const send = async (request) => worker.send(request);
1569
+ const reader = {
1570
+ dbName,
1571
+ query: async (sql, params) => {
1572
+ const response = await send({ id: 0, type: "query", sql, params });
1573
+ if (response.type === "error") {
1574
+ throw new AdapterError(`Query failed: ${response.message}`, { dbName, sql });
1575
+ }
1576
+ return response.data ?? [];
1577
+ }
1578
+ };
1579
+ try {
1580
+ let exists;
1581
+ if (backend === "opfs") {
1582
+ const opened = await worker.send(
1583
+ { id: 0, type: "open", ddlStatements: [], dbName, mustExist: true },
1584
+ void 0,
1585
+ { timeoutMs: Number.POSITIVE_INFINITY }
1586
+ );
1587
+ if (opened.type === "error" && opened.code !== "NOT_FOUND") {
1588
+ throw new AdapterError(`Could not open "${dbName}" for deletion: ${opened.message}`, {
1589
+ dbName,
1590
+ code: opened.code
1591
+ });
1592
+ }
1593
+ exists = opened.type === "success";
1594
+ } else {
1595
+ const dump = await loadDumpFromIndexedDB(dbName);
1596
+ exists = dump !== null;
1597
+ if (dump) {
1598
+ await send({ id: 0, type: "open", ddlStatements: [], dbName, storage: "memory" });
1599
+ for (const statement of restoreDumpStatements(dump, true)) {
1600
+ await send({ id: 0, type: "execute", sql: statement.sql, params: statement.params });
1601
+ }
1602
+ }
1603
+ }
1604
+ if (exists && !options.force && options.hasUnsyncedOperations) {
1605
+ if (await options.hasUnsyncedOperations(reader)) {
1606
+ throw new UnsyncedDataError(dbName);
1607
+ }
1608
+ }
1609
+ if (backend === "opfs" && exists) {
1610
+ const destroyed = await send({ id: 0, type: "destroy" });
1611
+ if (destroyed.type === "error") {
1612
+ throw new AdapterError(`Could not delete "${dbName}": ${destroyed.message}`, { dbName });
1613
+ }
1614
+ }
1615
+ if (backend === "indexeddb") {
1616
+ await deleteFromIndexedDB(dbName);
1617
+ }
1618
+ await deleteManifestRecord(dbName);
1619
+ return exists || record !== null;
1620
+ } finally {
1621
+ worker.terminate();
1622
+ }
1623
+ });
1624
+ }
940
1625
  // Annotate the CommonJS export names for ESM import in node:
941
1626
  0 && (module.exports = {
942
1627
  Mutex,
943
1628
  SqliteWasmAdapter,
944
- WebWorkerBridge
1629
+ WebWorkerBridge,
1630
+ deleteLocalDatabase,
1631
+ listLocalDatabases,
1632
+ opfsPoolNameFor
945
1633
  });
946
1634
  //# sourceMappingURL=sqlite-wasm.cjs.map