@korajs/store 1.0.0-beta.0 → 1.0.0-beta.10

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 (81) hide show
  1. package/dist/adapters/better-sqlite3.cjs.map +1 -1
  2. package/dist/adapters/better-sqlite3.d.cts +1 -1
  3. package/dist/adapters/better-sqlite3.d.ts +1 -1
  4. package/dist/adapters/better-sqlite3.js +1 -1
  5. package/dist/adapters/indexeddb.cjs +523 -148
  6. package/dist/adapters/indexeddb.cjs.map +1 -1
  7. package/dist/adapters/indexeddb.d.cts +4 -2
  8. package/dist/adapters/indexeddb.d.ts +4 -2
  9. package/dist/adapters/indexeddb.js +55 -11
  10. package/dist/adapters/indexeddb.js.map +1 -1
  11. package/dist/adapters/sqlite-wasm-worker.cjs +207 -187
  12. package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
  13. package/dist/adapters/sqlite-wasm-worker.js +208 -188
  14. package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
  15. package/dist/adapters/sqlite-wasm.cjs +474 -140
  16. package/dist/adapters/sqlite-wasm.cjs.map +1 -1
  17. package/dist/adapters/sqlite-wasm.d.cts +45 -8
  18. package/dist/adapters/sqlite-wasm.d.ts +45 -8
  19. package/dist/adapters/sqlite-wasm.js +3 -3
  20. package/dist/{backup-5XZ7THNJ.js → backup-5S7K2Z35.js} +3 -3
  21. package/dist/blob-fs.cjs +166 -0
  22. package/dist/blob-fs.cjs.map +1 -0
  23. package/dist/blob-fs.d.cts +29 -0
  24. package/dist/blob-fs.d.ts +29 -0
  25. package/dist/blob-fs.js +128 -0
  26. package/dist/blob-fs.js.map +1 -0
  27. package/dist/{chunk-EIVXWF4B.js → chunk-CYO3YKFL.js} +4 -3
  28. package/dist/chunk-CYO3YKFL.js.map +1 -0
  29. package/dist/{chunk-ETDJEH2I.js → chunk-GSJ7EEUC.js} +12 -1
  30. package/dist/chunk-GSJ7EEUC.js.map +1 -0
  31. package/dist/{chunk-5R4QMY3Z.js → chunk-HS6DBMST.js} +13 -10
  32. package/dist/chunk-HS6DBMST.js.map +1 -0
  33. package/dist/chunk-RJIEKEN4.js +751 -0
  34. package/dist/chunk-RJIEKEN4.js.map +1 -0
  35. package/dist/{chunk-QL2XVZRN.js → chunk-TUR7RGEP.js} +12 -1
  36. package/dist/{chunk-QL2XVZRN.js.map → chunk-TUR7RGEP.js.map} +1 -1
  37. package/dist/{chunk-IOEFNHHP.js → chunk-VDBJQ75K.js} +2 -2
  38. package/dist/chunk-VDBJQ75K.js.map +1 -0
  39. package/dist/chunk-W4MTTNEZ.js +55 -0
  40. package/dist/chunk-W4MTTNEZ.js.map +1 -0
  41. package/dist/{chunk-OUQDC5Z6.js → chunk-YT7MRFCP.js} +55 -35
  42. package/dist/chunk-YT7MRFCP.js.map +1 -0
  43. package/dist/content-addressed-blob-store-Dcj3SB9r.d.cts +60 -0
  44. package/dist/content-addressed-blob-store-Dcj3SB9r.d.ts +60 -0
  45. package/dist/{export-audit-VPGQWNR6.js → export-audit-ODGQTQIK.js} +3 -3
  46. package/dist/index.cjs +923 -131
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +443 -6
  49. package/dist/index.d.ts +443 -6
  50. package/dist/index.js +737 -49
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal.cjs +69 -43
  53. package/dist/internal.cjs.map +1 -1
  54. package/dist/internal.d.cts +3 -3
  55. package/dist/internal.d.ts +3 -3
  56. package/dist/internal.js +3 -3
  57. package/dist/sqlite-wasm-channel-GGHA2BXU.js +12 -0
  58. package/dist/{sqlite-wasm-channel-DUDPclTE.d.cts → sqlite-wasm-channel-L6o3bWQK.d.cts} +1 -1
  59. package/dist/{sqlite-wasm-channel-DUDPclTE.d.ts → sqlite-wasm-channel-L6o3bWQK.d.ts} +1 -1
  60. package/dist/{types-CyjXwrjW.d.cts → types-BOO9EPt1.d.ts} +10 -3
  61. package/dist/{types-CJOVKxLw.d.ts → types-BslDOTk0.d.cts} +32 -2
  62. package/dist/{types-CJOVKxLw.d.cts → types-BslDOTk0.d.ts} +32 -2
  63. package/dist/{types-DAxCladl.d.ts → types-C_GDavG-.d.cts} +10 -3
  64. package/package.json +13 -12
  65. package/dist/adapters/sqlite-wasm-shared-host.cjs +0 -68
  66. package/dist/adapters/sqlite-wasm-shared-host.cjs.map +0 -1
  67. package/dist/adapters/sqlite-wasm-shared-host.d.cts +0 -2
  68. package/dist/adapters/sqlite-wasm-shared-host.d.ts +0 -2
  69. package/dist/adapters/sqlite-wasm-shared-host.js +0 -66
  70. package/dist/adapters/sqlite-wasm-shared-host.js.map +0 -1
  71. package/dist/chunk-5R4QMY3Z.js.map +0 -1
  72. package/dist/chunk-EIVXWF4B.js.map +0 -1
  73. package/dist/chunk-ETDJEH2I.js.map +0 -1
  74. package/dist/chunk-IOEFNHHP.js.map +0 -1
  75. package/dist/chunk-OUQDC5Z6.js.map +0 -1
  76. package/dist/chunk-RBPVEAVM.js +0 -425
  77. package/dist/chunk-RBPVEAVM.js.map +0 -1
  78. package/dist/sqlite-wasm-channel-H43KEZLM.js +0 -12
  79. /package/dist/{backup-5XZ7THNJ.js.map → backup-5S7K2Z35.js.map} +0 -0
  80. /package/dist/{export-audit-VPGQWNR6.js.map → export-audit-ODGQTQIK.js.map} +0 -0
  81. /package/dist/{sqlite-wasm-channel-H43KEZLM.js.map → sqlite-wasm-channel-GGHA2BXU.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;
24
+ var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError;
25
25
  var init_errors = __esm({
26
26
  "src/errors.ts"() {
27
27
  "use strict";
@@ -48,6 +48,16 @@ var init_errors = __esm({
48
48
  this.name = "WorkerTimeoutError";
49
49
  }
50
50
  };
51
+ NoLeaderError = class extends import_core.KoraError {
52
+ constructor(operation) {
53
+ super(
54
+ `No live leader tab is answering multi-tab storage RPC for operation "${operation}"`,
55
+ "NO_LEADER",
56
+ { operation }
57
+ );
58
+ this.name = "NoLeaderError";
59
+ }
60
+ };
51
61
  }
52
62
  });
53
63
 
@@ -189,130 +199,86 @@ module.exports = __toCommonJS(sqlite_wasm_exports);
189
199
  var import_core2 = require("@korajs/core");
190
200
  init_errors();
191
201
 
192
- // src/multi-tab/shared-worker-bridge.ts
193
- init_errors();
194
- var SW_REQUEST = "kora-sw-request";
195
- var SW_RESPONSE = "kora-sw-response";
196
- var SharedWorkerClientBridge = class {
197
- constructor(sharedWorker, dbName, workerUrl, timeoutMs = 3e4) {
198
- this.dbName = dbName;
199
- this.workerUrl = workerUrl;
200
- this.timeoutMs = timeoutMs;
201
- this.port = sharedWorker.port;
202
- this.port.start();
203
- this.port.addEventListener("message", (event) => {
204
- const data = event.data;
205
- if (data?.type !== SW_RESPONSE) {
206
- return;
207
- }
208
- const entry = this.pending.get(data.requestId);
209
- if (entry) {
210
- this.pending.delete(data.requestId);
211
- entry.resolve(data.response);
212
- }
213
- });
214
- }
215
- dbName;
216
- workerUrl;
217
- port;
218
- pending = /* @__PURE__ */ new Map();
219
- timeoutMs;
220
- terminated = false;
221
- async send(request) {
222
- if (this.terminated) {
223
- return {
224
- id: request.id,
225
- type: "error",
226
- message: "SharedWorker bridge terminated",
227
- code: "BRIDGE_TERMINATED"
228
- };
229
- }
230
- const requestId = crypto.randomUUID();
231
- const msg = {
232
- type: SW_REQUEST,
233
- requestId,
234
- dbName: this.dbName,
235
- workerUrl: this.workerUrl,
236
- request
237
- };
238
- return new Promise((resolve, reject) => {
239
- const timer = setTimeout(() => {
240
- this.pending.delete(requestId);
241
- reject(new WorkerTimeoutError(`shared-worker:${request.type}`, this.timeoutMs));
242
- }, this.timeoutMs);
243
- this.pending.set(requestId, {
244
- resolve: (response) => {
245
- clearTimeout(timer);
246
- resolve(response);
247
- },
248
- reject: (error) => {
249
- clearTimeout(timer);
250
- reject(error);
251
- }
252
- });
253
- this.port.postMessage(msg);
254
- });
255
- }
256
- terminate() {
257
- if (this.terminated) {
258
- return;
259
- }
260
- this.terminated = true;
261
- this.port.close();
262
- for (const [, entry] of this.pending) {
263
- entry.reject(new Error("SharedWorker bridge terminated"));
264
- }
265
- this.pending.clear();
266
- }
267
- };
268
-
269
202
  // src/multi-tab/tab-storage.ts
270
203
  init_errors();
271
204
  var RPC_REQUEST = "kora-worker-request";
272
205
  var RPC_RESPONSE = "kora-worker-response";
273
- function isSharedWorkerStorageSupported() {
274
- return typeof globalThis !== "undefined" && typeof SharedWorker !== "undefined";
275
- }
206
+ var CLIENT_LEAVE = "kora-client-leave";
207
+ var LEADER_PING = "kora-leader-ping";
208
+ var LEADER_PONG = "kora-leader-pong";
209
+ var DEFAULT_LIVENESS_PROBE_MS = 2e3;
210
+ var DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS = 1e4;
276
211
  function isMultiTabStorageSupported() {
277
212
  return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
278
213
  }
279
- async function acquireTabStorageSession(dbName) {
214
+ async function acquireTabStorageSession(dbName, options) {
280
215
  const channelName = `kora-storage-${dbName}`;
216
+ const lockName = `kora-leader-${dbName}`;
281
217
  if (!isMultiTabStorageSupported()) {
282
218
  return { role: "leader", channelName };
283
219
  }
284
220
  return new Promise((resolve) => {
285
221
  let releaseHeld;
286
- void navigator.locks.request(
287
- `kora-leader-${dbName}`,
288
- { mode: "exclusive", ifAvailable: true },
289
- (lock) => {
290
- if (lock === null) {
291
- resolve({ role: "follower", channelName });
292
- return;
293
- }
294
- resolve({
295
- role: "leader",
296
- channelName,
297
- releaseLock: async () => {
298
- releaseHeld?.();
299
- }
300
- });
301
- return new Promise((release) => {
302
- releaseHeld = release;
303
- });
222
+ void navigator.locks.request(lockName, { mode: "exclusive", ifAvailable: true }, (lock) => {
223
+ if (lock === null) {
224
+ resolve(startFollowerSession(channelName, lockName, options));
225
+ return;
304
226
  }
305
- );
227
+ resolve({
228
+ role: "leader",
229
+ channelName,
230
+ releaseLock: async () => {
231
+ releaseHeld?.();
232
+ }
233
+ });
234
+ return new Promise((release) => {
235
+ releaseHeld = release;
236
+ });
237
+ });
306
238
  });
307
239
  }
240
+ function startFollowerSession(channelName, lockName, options) {
241
+ let promoted = false;
242
+ let releasePromotedLock;
243
+ const abort = new AbortController();
244
+ void navigator.locks.request(lockName, { mode: "exclusive", signal: abort.signal }, () => {
245
+ promoted = true;
246
+ options?.onPromote?.();
247
+ return new Promise((release) => {
248
+ releasePromotedLock = release;
249
+ });
250
+ }).catch(() => {
251
+ });
252
+ return {
253
+ role: "follower",
254
+ channelName,
255
+ cancelPromotionWatch: () => {
256
+ if (promoted) {
257
+ releasePromotedLock?.();
258
+ } else {
259
+ abort.abort();
260
+ }
261
+ }
262
+ };
263
+ }
308
264
  function startLeaderRpcRelay(channelName, bridge) {
309
265
  const channel = new BroadcastChannel(channelName);
310
266
  const onMessage = (event) => {
311
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
+ }
312
278
  if (data?.type !== RPC_REQUEST) {
313
279
  return;
314
280
  }
315
- void bridge.send(data.request).then((response) => {
281
+ void bridge.send(data.request, data.clientId).then((response) => {
316
282
  const msg = {
317
283
  type: RPC_RESPONSE,
318
284
  requestId: data.requestId,
@@ -342,12 +308,21 @@ function startLeaderRpcRelay(channelName, bridge) {
342
308
  }
343
309
  var FollowerBroadcastBridge = class {
344
310
  channel;
311
+ clientId = createClientId();
312
+ onPageHide = () => {
313
+ this.leaveLeader();
314
+ };
345
315
  pending = /* @__PURE__ */ new Map();
346
316
  timeoutMs;
317
+ livenessProbeMs;
347
318
  terminated = false;
348
- constructor(channelName, timeoutMs = 3e4) {
319
+ constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
349
320
  this.timeoutMs = timeoutMs;
321
+ this.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
350
322
  this.channel = new BroadcastChannel(channelName);
323
+ if (typeof addEventListener === "function") {
324
+ addEventListener("pagehide", this.onPageHide);
325
+ }
351
326
  this.channel.addEventListener("message", (event) => {
352
327
  const data = event.data;
353
328
  if (data?.type !== RPC_RESPONSE) {
@@ -360,6 +335,48 @@ var FollowerBroadcastBridge = class {
360
335
  }
361
336
  });
362
337
  }
338
+ /**
339
+ * Readiness handshake: resolves `true` as soon as a live leader answers a ping,
340
+ * or `false` if none answers within the budget. The adapter calls this before
341
+ * 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.
343
+ */
344
+ async waitForLeader(timeoutMs = 3e3, attempts = 3) {
345
+ const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
346
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
347
+ if (this.terminated) {
348
+ return false;
349
+ }
350
+ if (await this.pingLeader(perAttempt)) {
351
+ return true;
352
+ }
353
+ }
354
+ return false;
355
+ }
356
+ /** Sends one liveness ping and resolves whether a leader answered in time. */
357
+ pingLeader(timeoutMs) {
358
+ if (this.terminated) {
359
+ return Promise.resolve(false);
360
+ }
361
+ return new Promise((resolve) => {
362
+ const onPong = (event) => {
363
+ if (event.data?.type === LEADER_PONG) {
364
+ cleanup();
365
+ resolve(true);
366
+ }
367
+ };
368
+ const cleanup = () => {
369
+ clearTimeout(timer);
370
+ this.channel.removeEventListener("message", onPong);
371
+ };
372
+ const timer = setTimeout(() => {
373
+ cleanup();
374
+ resolve(false);
375
+ }, timeoutMs);
376
+ this.channel.addEventListener("message", onPong);
377
+ this.channel.postMessage({ type: LEADER_PING });
378
+ });
379
+ }
363
380
  async send(request) {
364
381
  if (this.terminated) {
365
382
  return {
@@ -369,22 +386,33 @@ var FollowerBroadcastBridge = class {
369
386
  code: "BRIDGE_TERMINATED"
370
387
  };
371
388
  }
372
- const requestId = crypto.randomUUID();
373
- const msg = { type: RPC_REQUEST, requestId, request };
389
+ const requestId = createClientId();
390
+ const msg = {
391
+ type: RPC_REQUEST,
392
+ requestId,
393
+ clientId: this.clientId,
394
+ request
395
+ };
374
396
  return new Promise((resolve, reject) => {
375
- const timer = setTimeout(() => {
397
+ const settle = (fn) => {
398
+ clearTimeout(timer);
399
+ clearTimeout(probe);
376
400
  this.pending.delete(requestId);
377
- reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs));
401
+ fn();
402
+ };
403
+ const timer = setTimeout(() => {
404
+ settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
378
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);
379
413
  this.pending.set(requestId, {
380
- resolve: (response) => {
381
- clearTimeout(timer);
382
- resolve(response);
383
- },
384
- reject: (error) => {
385
- clearTimeout(timer);
386
- reject(error);
387
- }
414
+ resolve: (response) => settle(() => resolve(response)),
415
+ reject: (error) => settle(() => reject(error))
388
416
  });
389
417
  this.channel.postMessage(msg);
390
418
  });
@@ -394,16 +422,213 @@ var FollowerBroadcastBridge = class {
394
422
  return;
395
423
  }
396
424
  this.terminated = true;
425
+ this.leaveLeader();
426
+ if (typeof removeEventListener === "function") {
427
+ removeEventListener("pagehide", this.onPageHide);
428
+ }
397
429
  this.channel.close();
398
430
  for (const [, entry] of this.pending) {
399
431
  entry.reject(new Error("Follower bridge terminated"));
400
432
  }
401
433
  this.pending.clear();
402
434
  }
435
+ leaveLeader() {
436
+ try {
437
+ const message = {
438
+ type: CLIENT_LEAVE,
439
+ clientId: this.clientId
440
+ };
441
+ this.channel.postMessage(message);
442
+ } catch {
443
+ }
444
+ }
403
445
  };
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;
460
+ }
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();
485
+ });
486
+ }
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"));
497
+ }
498
+ }
499
+ reclaimClient(clientId, reason) {
500
+ if (this.terminated) {
501
+ return;
502
+ }
503
+ void this.reclaimClientNow(clientId, reason);
504
+ }
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();
533
+ }
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);
574
+ }
575
+ clearTransactionIdleTimer() {
576
+ if (this.transactionIdleTimer) {
577
+ clearTimeout(this.transactionIdleTimer);
578
+ this.transactionIdleTimer = null;
579
+ }
580
+ }
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();
599
+ }
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);
614
+ }
615
+ }
616
+ this.queue = keep;
617
+ }
618
+ };
619
+ function hasReclaimClient(bridge) {
620
+ return typeof bridge.reclaimClient === "function";
621
+ }
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)}`;
627
+ }
404
628
 
405
629
  // src/adapters/sqlite-wasm-adapter.ts
406
630
  init_sqlite_wasm_channel();
631
+ var warnedSharedWorkerDeprecated = false;
407
632
  var SqliteWasmAdapter = class {
408
633
  bridge = null;
409
634
  opened = false;
@@ -413,43 +638,76 @@ var SqliteWasmAdapter = class {
413
638
  sharedWorkerUrl;
414
639
  workerResponseTimeoutMs;
415
640
  dbName;
641
+ emitter;
416
642
  tabSession = null;
417
- sharedWorker = null;
643
+ emitNonPersistentDiagnostic;
644
+ storageOpenState = null;
645
+ /** Retained so a follower promoted to leader can re-open its own worker. */
646
+ schema = null;
647
+ promoting = false;
418
648
  constructor(options = {}) {
419
649
  this.injectedBridge = options.bridge;
420
650
  this.workerUrl = options.workerUrl;
421
651
  this.sharedWorkerUrl = options.sharedWorkerUrl;
422
652
  this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
423
653
  this.dbName = options.dbName ?? "kora-db";
654
+ this.emitter = options.emitter;
655
+ this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
424
656
  }
425
657
  async open(schema) {
426
658
  if (this.opened) return;
659
+ const ddlStatements = (0, import_core2.generateFullDDL)(schema);
427
660
  if (this.injectedBridge) {
428
661
  this.bridge = this.injectedBridge;
429
- } else if (this.sharedWorkerUrl && this.workerUrl && isSharedWorkerStorageSupported()) {
430
- const workerUrlString = typeof this.workerUrl === "string" ? this.workerUrl : this.workerUrl.href;
431
- const sharedUrl = typeof this.sharedWorkerUrl === "string" ? this.sharedWorkerUrl : this.sharedWorkerUrl.href;
432
- this.sharedWorker = new SharedWorker(sharedUrl, { name: `kora-sw-${this.dbName}` });
433
- this.bridge = new SharedWorkerClientBridge(this.sharedWorker, this.dbName, workerUrlString);
434
- } else if (this.workerUrl) {
435
- this.tabSession = await acquireTabStorageSession(this.dbName);
436
- const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
437
- if (this.tabSession.role === "leader") {
438
- const workerBridge = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs);
439
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
440
- this.bridge = workerBridge;
441
- } else {
442
- this.bridge = new FollowerBroadcastBridge(
443
- this.tabSession.channelName,
444
- this.workerResponseTimeoutMs
445
- );
662
+ const response = await this.openCurrentBridge(ddlStatements);
663
+ this.reportStorageMode(response.data);
664
+ this.opened = true;
665
+ return;
666
+ }
667
+ if (this.workerUrl) {
668
+ this.warnSharedWorkerDeprecated();
669
+ await this.openDurableWorkerBridge(schema);
670
+ const response = await this.openCurrentBridge(ddlStatements);
671
+ this.reportStorageMode(response.data);
672
+ this.opened = true;
673
+ return;
674
+ }
675
+ throw new AdapterError(
676
+ '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
+ );
678
+ }
679
+ async openDurableWorkerBridge(schema) {
680
+ if (!this.workerUrl) {
681
+ throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
682
+ }
683
+ this.schema = schema;
684
+ this.tabSession = await acquireTabStorageSession(this.dbName, {
685
+ onPromote: () => {
686
+ void this.promoteToLeader();
446
687
  }
447
- } else {
448
- throw new AdapterError(
449
- 'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
688
+ });
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)
450
693
  );
694
+ this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
695
+ this.bridge = workerBridge;
696
+ return;
451
697
  }
452
- const ddlStatements = (0, import_core2.generateFullDDL)(schema);
698
+ const followerBridge = new FollowerBroadcastBridge(
699
+ this.tabSession.channelName,
700
+ this.workerResponseTimeoutMs
701
+ );
702
+ this.bridge = followerBridge;
703
+ this.emitter?.emit({
704
+ type: "store:db-name-collision",
705
+ dbName: this.dbName,
706
+ 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
+ });
708
+ await followerBridge.waitForLeader();
709
+ }
710
+ async openCurrentBridge(ddlStatements) {
453
711
  const response = await this.sendRequest({
454
712
  id: 0,
455
713
  type: "open",
@@ -462,7 +720,40 @@ var SqliteWasmAdapter = class {
462
720
  dbName: this.dbName
463
721
  });
464
722
  }
465
- this.opened = true;
723
+ return response;
724
+ }
725
+ /**
726
+ * Surface a `store:opfs-unavailable` diagnostic when the worker reported that
727
+ * persistence silently degraded to an in-memory database, so a data-loss
728
+ * condition is observable instead of failing silently. A persistent open (or a
729
+ * bridge that does not report a mode, e.g. the Node mock) emits nothing.
730
+ */
731
+ reportStorageMode(data) {
732
+ this.storageOpenState = parseStorageOpenState(data);
733
+ if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
734
+ return;
735
+ }
736
+ if (this.storageOpenState.persistent === false) {
737
+ const reason = this.storageOpenState.fallbackReason ?? "unsupported";
738
+ this.emitter.emit({
739
+ type: "store:opfs-unavailable",
740
+ dbName: this.dbName,
741
+ reason,
742
+ message: `OPFS persistence is unavailable (${reason}) for database "${this.dbName}"; the store is running in memory and data will not survive a reload.`
743
+ });
744
+ }
745
+ }
746
+ getStorageOpenState() {
747
+ return this.storageOpenState;
748
+ }
749
+ warnSharedWorkerDeprecated() {
750
+ if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
751
+ return;
752
+ }
753
+ warnedSharedWorkerDeprecated = true;
754
+ console.warn(
755
+ "[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
+ );
466
757
  }
467
758
  async close() {
468
759
  if (!this.bridge) return;
@@ -470,16 +761,42 @@ var SqliteWasmAdapter = class {
470
761
  await this.sendRequest({ id: 0, type: "close" });
471
762
  } finally {
472
763
  this.tabSession?.stopRelay?.();
764
+ this.tabSession?.cancelPromotionWatch?.();
473
765
  if (this.tabSession?.releaseLock) {
474
766
  await this.tabSession.releaseLock();
475
767
  }
476
768
  this.tabSession = null;
477
- this.sharedWorker = null;
478
769
  this.bridge.terminate();
479
770
  this.bridge = null;
480
771
  this.opened = false;
481
772
  }
482
773
  }
774
+ /**
775
+ * 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.
780
+ */
781
+ async promoteToLeader() {
782
+ if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
783
+ return;
784
+ }
785
+ 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);
795
+ }
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
+ }
483
800
  async execute(sql, params) {
484
801
  this.guardOpen();
485
802
  const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
@@ -567,7 +884,10 @@ var SqliteWasmAdapter = class {
567
884
  this.guardOpen();
568
885
  const response = await this.sendRequest({ id: 0, type: "export" });
569
886
  if (response.type === "error") {
570
- throw new AdapterError(`Export failed: ${response.message}`);
887
+ throw new AdapterError(`Export failed: ${response.message}`, {
888
+ code: response.code,
889
+ context: response.context
890
+ });
571
891
  }
572
892
  return response.data;
573
893
  }
@@ -600,6 +920,20 @@ var SqliteWasmAdapter = class {
600
920
  }
601
921
  }
602
922
  };
923
+ function parseStorageOpenState(data) {
924
+ if (typeof data !== "object" || data === null) {
925
+ return null;
926
+ }
927
+ const mode = data;
928
+ if (typeof mode.persistent !== "boolean") {
929
+ return null;
930
+ }
931
+ return {
932
+ persistent: mode.persistent,
933
+ mode: mode.persistent ? "opfs" : "memory",
934
+ ...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {}
935
+ };
936
+ }
603
937
 
604
938
  // src/adapters/sqlite-wasm.ts
605
939
  init_sqlite_wasm_channel();