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

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 (71) 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-ZV4Y424B.js → backup-5S7K2Z35.js} +3 -3
  21. package/dist/{chunk-FFYA7J5H.js → chunk-CYO3YKFL.js} +4 -3
  22. package/dist/chunk-CYO3YKFL.js.map +1 -0
  23. package/dist/{chunk-6NKLDBTY.js → chunk-GSJ7EEUC.js} +2 -1
  24. package/dist/chunk-GSJ7EEUC.js.map +1 -0
  25. package/dist/{chunk-X57MWW35.js → chunk-HS6DBMST.js} +13 -10
  26. package/dist/chunk-HS6DBMST.js.map +1 -0
  27. package/dist/chunk-RJIEKEN4.js +751 -0
  28. package/dist/chunk-RJIEKEN4.js.map +1 -0
  29. package/dist/{chunk-QL2XVZRN.js → chunk-TUR7RGEP.js} +12 -1
  30. package/dist/{chunk-QL2XVZRN.js.map → chunk-TUR7RGEP.js.map} +1 -1
  31. package/dist/{chunk-IOEFNHHP.js → chunk-VDBJQ75K.js} +2 -2
  32. package/dist/chunk-VDBJQ75K.js.map +1 -0
  33. package/dist/{chunk-ZLWAYQEQ.js → chunk-YT7MRFCP.js} +36 -32
  34. package/dist/chunk-YT7MRFCP.js.map +1 -0
  35. package/dist/{export-audit-AJ7RORNK.js → export-audit-ODGQTQIK.js} +3 -3
  36. package/dist/index.cjs +313 -144
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.cts +51 -5
  39. package/dist/index.d.ts +51 -5
  40. package/dist/index.js +212 -59
  41. package/dist/index.js.map +1 -1
  42. package/dist/internal.cjs +46 -40
  43. package/dist/internal.cjs.map +1 -1
  44. package/dist/internal.d.cts +3 -3
  45. package/dist/internal.d.ts +3 -3
  46. package/dist/internal.js +3 -3
  47. package/dist/sqlite-wasm-channel-GGHA2BXU.js +12 -0
  48. package/dist/{sqlite-wasm-channel-DUDPclTE.d.cts → sqlite-wasm-channel-L6o3bWQK.d.cts} +1 -1
  49. package/dist/{sqlite-wasm-channel-DUDPclTE.d.ts → sqlite-wasm-channel-L6o3bWQK.d.ts} +1 -1
  50. package/dist/{types-5h4Hqcwc.d.ts → types-BOO9EPt1.d.ts} +1 -1
  51. package/dist/{types-k6AZgiH4.d.ts → types-BslDOTk0.d.cts} +26 -1
  52. package/dist/{types-k6AZgiH4.d.cts → types-BslDOTk0.d.ts} +26 -1
  53. package/dist/{types-DfPiPwKr.d.cts → types-C_GDavG-.d.cts} +1 -1
  54. package/package.json +2 -12
  55. package/dist/adapters/sqlite-wasm-shared-host.cjs +0 -68
  56. package/dist/adapters/sqlite-wasm-shared-host.cjs.map +0 -1
  57. package/dist/adapters/sqlite-wasm-shared-host.d.cts +0 -2
  58. package/dist/adapters/sqlite-wasm-shared-host.d.ts +0 -2
  59. package/dist/adapters/sqlite-wasm-shared-host.js +0 -66
  60. package/dist/adapters/sqlite-wasm-shared-host.js.map +0 -1
  61. package/dist/chunk-6NKLDBTY.js.map +0 -1
  62. package/dist/chunk-FFYA7J5H.js.map +0 -1
  63. package/dist/chunk-IOEFNHHP.js.map +0 -1
  64. package/dist/chunk-RBPVEAVM.js +0 -425
  65. package/dist/chunk-RBPVEAVM.js.map +0 -1
  66. package/dist/chunk-X57MWW35.js.map +0 -1
  67. package/dist/chunk-ZLWAYQEQ.js.map +0 -1
  68. package/dist/sqlite-wasm-channel-H43KEZLM.js +0 -12
  69. /package/dist/{backup-ZV4Y424B.js.map → backup-5S7K2Z35.js.map} +0 -0
  70. /package/dist/{export-audit-AJ7RORNK.js.map → export-audit-ODGQTQIK.js.map} +0 -0
  71. /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, PersistenceError;
24
+ var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError, PersistenceError;
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
  PersistenceError = class extends import_core.KoraError {
52
62
  constructor(message, context) {
53
63
  super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
@@ -190,6 +200,7 @@ __export(indexeddb_exports, {
190
200
  module.exports = __toCommonJS(indexeddb_exports);
191
201
 
192
202
  // src/adapters/indexeddb-adapter.ts
203
+ var import_core3 = require("@korajs/core");
193
204
  init_errors();
194
205
 
195
206
  // src/adapters/indexeddb-persistence-scheduler.ts
@@ -271,130 +282,86 @@ var IndexedDbPersistenceScheduler = class {
271
282
  var import_core2 = require("@korajs/core");
272
283
  init_errors();
273
284
 
274
- // src/multi-tab/shared-worker-bridge.ts
275
- init_errors();
276
- var SW_REQUEST = "kora-sw-request";
277
- var SW_RESPONSE = "kora-sw-response";
278
- var SharedWorkerClientBridge = class {
279
- constructor(sharedWorker, dbName, workerUrl, timeoutMs = 3e4) {
280
- this.dbName = dbName;
281
- this.workerUrl = workerUrl;
282
- this.timeoutMs = timeoutMs;
283
- this.port = sharedWorker.port;
284
- this.port.start();
285
- this.port.addEventListener("message", (event) => {
286
- const data = event.data;
287
- if (data?.type !== SW_RESPONSE) {
288
- return;
289
- }
290
- const entry = this.pending.get(data.requestId);
291
- if (entry) {
292
- this.pending.delete(data.requestId);
293
- entry.resolve(data.response);
294
- }
295
- });
296
- }
297
- dbName;
298
- workerUrl;
299
- port;
300
- pending = /* @__PURE__ */ new Map();
301
- timeoutMs;
302
- terminated = false;
303
- async send(request) {
304
- if (this.terminated) {
305
- return {
306
- id: request.id,
307
- type: "error",
308
- message: "SharedWorker bridge terminated",
309
- code: "BRIDGE_TERMINATED"
310
- };
311
- }
312
- const requestId = crypto.randomUUID();
313
- const msg = {
314
- type: SW_REQUEST,
315
- requestId,
316
- dbName: this.dbName,
317
- workerUrl: this.workerUrl,
318
- request
319
- };
320
- return new Promise((resolve, reject) => {
321
- const timer = setTimeout(() => {
322
- this.pending.delete(requestId);
323
- reject(new WorkerTimeoutError(`shared-worker:${request.type}`, this.timeoutMs));
324
- }, this.timeoutMs);
325
- this.pending.set(requestId, {
326
- resolve: (response) => {
327
- clearTimeout(timer);
328
- resolve(response);
329
- },
330
- reject: (error) => {
331
- clearTimeout(timer);
332
- reject(error);
333
- }
334
- });
335
- this.port.postMessage(msg);
336
- });
337
- }
338
- terminate() {
339
- if (this.terminated) {
340
- return;
341
- }
342
- this.terminated = true;
343
- this.port.close();
344
- for (const [, entry] of this.pending) {
345
- entry.reject(new Error("SharedWorker bridge terminated"));
346
- }
347
- this.pending.clear();
348
- }
349
- };
350
-
351
285
  // src/multi-tab/tab-storage.ts
352
286
  init_errors();
353
287
  var RPC_REQUEST = "kora-worker-request";
354
288
  var RPC_RESPONSE = "kora-worker-response";
355
- function isSharedWorkerStorageSupported() {
356
- return typeof globalThis !== "undefined" && typeof SharedWorker !== "undefined";
357
- }
289
+ var CLIENT_LEAVE = "kora-client-leave";
290
+ var LEADER_PING = "kora-leader-ping";
291
+ var LEADER_PONG = "kora-leader-pong";
292
+ var DEFAULT_LIVENESS_PROBE_MS = 2e3;
293
+ var DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS = 1e4;
358
294
  function isMultiTabStorageSupported() {
359
295
  return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
360
296
  }
361
- async function acquireTabStorageSession(dbName) {
297
+ async function acquireTabStorageSession(dbName, options) {
362
298
  const channelName = `kora-storage-${dbName}`;
299
+ const lockName = `kora-leader-${dbName}`;
363
300
  if (!isMultiTabStorageSupported()) {
364
301
  return { role: "leader", channelName };
365
302
  }
366
303
  return new Promise((resolve) => {
367
304
  let releaseHeld;
368
- void navigator.locks.request(
369
- `kora-leader-${dbName}`,
370
- { mode: "exclusive", ifAvailable: true },
371
- (lock) => {
372
- if (lock === null) {
373
- resolve({ role: "follower", channelName });
374
- return;
375
- }
376
- resolve({
377
- role: "leader",
378
- channelName,
379
- releaseLock: async () => {
380
- releaseHeld?.();
381
- }
382
- });
383
- return new Promise((release) => {
384
- releaseHeld = release;
385
- });
305
+ void navigator.locks.request(lockName, { mode: "exclusive", ifAvailable: true }, (lock) => {
306
+ if (lock === null) {
307
+ resolve(startFollowerSession(channelName, lockName, options));
308
+ return;
386
309
  }
387
- );
310
+ resolve({
311
+ role: "leader",
312
+ channelName,
313
+ releaseLock: async () => {
314
+ releaseHeld?.();
315
+ }
316
+ });
317
+ return new Promise((release) => {
318
+ releaseHeld = release;
319
+ });
320
+ });
321
+ });
322
+ }
323
+ function startFollowerSession(channelName, lockName, options) {
324
+ let promoted = false;
325
+ let releasePromotedLock;
326
+ const abort = new AbortController();
327
+ void navigator.locks.request(lockName, { mode: "exclusive", signal: abort.signal }, () => {
328
+ promoted = true;
329
+ options?.onPromote?.();
330
+ return new Promise((release) => {
331
+ releasePromotedLock = release;
332
+ });
333
+ }).catch(() => {
388
334
  });
335
+ return {
336
+ role: "follower",
337
+ channelName,
338
+ cancelPromotionWatch: () => {
339
+ if (promoted) {
340
+ releasePromotedLock?.();
341
+ } else {
342
+ abort.abort();
343
+ }
344
+ }
345
+ };
389
346
  }
390
347
  function startLeaderRpcRelay(channelName, bridge) {
391
348
  const channel = new BroadcastChannel(channelName);
392
349
  const onMessage = (event) => {
393
350
  const data = event.data;
351
+ if (data?.type === LEADER_PING) {
352
+ channel.postMessage({ type: LEADER_PONG });
353
+ return;
354
+ }
355
+ if (data?.type === CLIENT_LEAVE) {
356
+ if (hasReclaimClient(bridge)) {
357
+ bridge.reclaimClient(data.clientId, "client-left");
358
+ }
359
+ return;
360
+ }
394
361
  if (data?.type !== RPC_REQUEST) {
395
362
  return;
396
363
  }
397
- void bridge.send(data.request).then((response) => {
364
+ void bridge.send(data.request, data.clientId).then((response) => {
398
365
  const msg = {
399
366
  type: RPC_RESPONSE,
400
367
  requestId: data.requestId,
@@ -424,12 +391,21 @@ function startLeaderRpcRelay(channelName, bridge) {
424
391
  }
425
392
  var FollowerBroadcastBridge = class {
426
393
  channel;
394
+ clientId = createClientId();
395
+ onPageHide = () => {
396
+ this.leaveLeader();
397
+ };
427
398
  pending = /* @__PURE__ */ new Map();
428
399
  timeoutMs;
400
+ livenessProbeMs;
429
401
  terminated = false;
430
- constructor(channelName, timeoutMs = 3e4) {
402
+ constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
431
403
  this.timeoutMs = timeoutMs;
404
+ this.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
432
405
  this.channel = new BroadcastChannel(channelName);
406
+ if (typeof addEventListener === "function") {
407
+ addEventListener("pagehide", this.onPageHide);
408
+ }
433
409
  this.channel.addEventListener("message", (event) => {
434
410
  const data = event.data;
435
411
  if (data?.type !== RPC_RESPONSE) {
@@ -442,6 +418,48 @@ var FollowerBroadcastBridge = class {
442
418
  }
443
419
  });
444
420
  }
421
+ /**
422
+ * Readiness handshake: resolves `true` as soon as a live leader answers a ping,
423
+ * or `false` if none answers within the budget. The adapter calls this before
424
+ * its first RPC so a follower created before a leader relay is live retries the
425
+ * handshake instead of firing into the void and waiting out the full timeout.
426
+ */
427
+ async waitForLeader(timeoutMs = 3e3, attempts = 3) {
428
+ const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
429
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
430
+ if (this.terminated) {
431
+ return false;
432
+ }
433
+ if (await this.pingLeader(perAttempt)) {
434
+ return true;
435
+ }
436
+ }
437
+ return false;
438
+ }
439
+ /** Sends one liveness ping and resolves whether a leader answered in time. */
440
+ pingLeader(timeoutMs) {
441
+ if (this.terminated) {
442
+ return Promise.resolve(false);
443
+ }
444
+ return new Promise((resolve) => {
445
+ const onPong = (event) => {
446
+ if (event.data?.type === LEADER_PONG) {
447
+ cleanup();
448
+ resolve(true);
449
+ }
450
+ };
451
+ const cleanup = () => {
452
+ clearTimeout(timer);
453
+ this.channel.removeEventListener("message", onPong);
454
+ };
455
+ const timer = setTimeout(() => {
456
+ cleanup();
457
+ resolve(false);
458
+ }, timeoutMs);
459
+ this.channel.addEventListener("message", onPong);
460
+ this.channel.postMessage({ type: LEADER_PING });
461
+ });
462
+ }
445
463
  async send(request) {
446
464
  if (this.terminated) {
447
465
  return {
@@ -451,22 +469,33 @@ var FollowerBroadcastBridge = class {
451
469
  code: "BRIDGE_TERMINATED"
452
470
  };
453
471
  }
454
- const requestId = crypto.randomUUID();
455
- const msg = { type: RPC_REQUEST, requestId, request };
472
+ const requestId = createClientId();
473
+ const msg = {
474
+ type: RPC_REQUEST,
475
+ requestId,
476
+ clientId: this.clientId,
477
+ request
478
+ };
456
479
  return new Promise((resolve, reject) => {
457
- const timer = setTimeout(() => {
480
+ const settle = (fn) => {
481
+ clearTimeout(timer);
482
+ clearTimeout(probe);
458
483
  this.pending.delete(requestId);
459
- reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs));
484
+ fn();
485
+ };
486
+ const timer = setTimeout(() => {
487
+ settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
460
488
  }, this.timeoutMs);
489
+ const probe = setTimeout(() => {
490
+ void this.pingLeader(this.livenessProbeMs).then((alive) => {
491
+ if (!alive && this.pending.has(requestId)) {
492
+ settle(() => reject(new NoLeaderError(`follower-rpc:${request.type}`)));
493
+ }
494
+ });
495
+ }, this.livenessProbeMs);
461
496
  this.pending.set(requestId, {
462
- resolve: (response) => {
463
- clearTimeout(timer);
464
- resolve(response);
465
- },
466
- reject: (error) => {
467
- clearTimeout(timer);
468
- reject(error);
469
- }
497
+ resolve: (response) => settle(() => resolve(response)),
498
+ reject: (error) => settle(() => reject(error))
470
499
  });
471
500
  this.channel.postMessage(msg);
472
501
  });
@@ -476,16 +505,213 @@ var FollowerBroadcastBridge = class {
476
505
  return;
477
506
  }
478
507
  this.terminated = true;
508
+ this.leaveLeader();
509
+ if (typeof removeEventListener === "function") {
510
+ removeEventListener("pagehide", this.onPageHide);
511
+ }
479
512
  this.channel.close();
480
513
  for (const [, entry] of this.pending) {
481
514
  entry.reject(new Error("Follower bridge terminated"));
482
515
  }
483
516
  this.pending.clear();
484
517
  }
518
+ leaveLeader() {
519
+ try {
520
+ const message = {
521
+ type: CLIENT_LEAVE,
522
+ clientId: this.clientId
523
+ };
524
+ this.channel.postMessage(message);
525
+ } catch {
526
+ }
527
+ }
485
528
  };
529
+ var TransactionSerializingWorkerBridge = class {
530
+ inner;
531
+ leaderClientId = createClientId();
532
+ transactionIdleTimeoutMs;
533
+ queue = [];
534
+ activeTransactionClient = null;
535
+ abortedClients = /* @__PURE__ */ new Set();
536
+ transactionIdleTimer = null;
537
+ reclaiming = false;
538
+ processing = false;
539
+ terminated = false;
540
+ constructor(inner, transactionIdleTimeoutMs = DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS) {
541
+ this.inner = inner;
542
+ this.transactionIdleTimeoutMs = transactionIdleTimeoutMs;
543
+ }
544
+ send(request, clientId = this.leaderClientId) {
545
+ if (this.terminated) {
546
+ return Promise.resolve({
547
+ id: request.id,
548
+ type: "error",
549
+ message: "Worker has been terminated",
550
+ code: "WORKER_TERMINATED"
551
+ });
552
+ }
553
+ if (this.abortedClients.has(clientId)) {
554
+ if (request.type === "begin") {
555
+ this.abortedClients.delete(clientId);
556
+ } else {
557
+ return Promise.resolve({
558
+ id: request.id,
559
+ type: "error",
560
+ message: "Previous transaction was aborted because the client stopped sending requests.",
561
+ code: "TRANSACTION_ABORTED"
562
+ });
563
+ }
564
+ }
565
+ return new Promise((resolve, reject) => {
566
+ this.queue.push({ clientId, request, resolve, reject });
567
+ void this.processQueue();
568
+ });
569
+ }
570
+ terminate() {
571
+ if (this.terminated) {
572
+ return;
573
+ }
574
+ this.terminated = true;
575
+ this.clearTransactionIdleTimer();
576
+ this.inner.terminate();
577
+ const pending = this.queue.splice(0);
578
+ for (const entry of pending) {
579
+ entry.reject(new Error("Worker terminated"));
580
+ }
581
+ }
582
+ reclaimClient(clientId, reason) {
583
+ if (this.terminated) {
584
+ return;
585
+ }
586
+ void this.reclaimClientNow(clientId, reason);
587
+ }
588
+ async processQueue() {
589
+ if (this.processing || this.reclaiming) {
590
+ return;
591
+ }
592
+ this.processing = true;
593
+ try {
594
+ while (!this.terminated) {
595
+ const index = this.nextRunnableIndex();
596
+ if (index === -1) {
597
+ return;
598
+ }
599
+ const [entry] = this.queue.splice(index, 1);
600
+ if (!entry) {
601
+ return;
602
+ }
603
+ try {
604
+ const response = await this.inner.send(entry.request);
605
+ this.recordTransactionState(entry, response);
606
+ entry.resolve(response);
607
+ } catch (error) {
608
+ this.resetFailedTransaction(entry);
609
+ entry.reject(error instanceof Error ? error : new Error(String(error)));
610
+ }
611
+ }
612
+ } finally {
613
+ this.processing = false;
614
+ if (!this.terminated && !this.reclaiming && this.nextRunnableIndex() !== -1) {
615
+ void this.processQueue();
616
+ }
617
+ }
618
+ }
619
+ nextRunnableIndex() {
620
+ if (this.queue.length === 0) {
621
+ return -1;
622
+ }
623
+ if (this.activeTransactionClient === null) {
624
+ return 0;
625
+ }
626
+ return this.queue.findIndex((entry) => entry.clientId === this.activeTransactionClient);
627
+ }
628
+ recordTransactionState(entry, response) {
629
+ if (response.type === "error") {
630
+ this.resetFailedTransaction(entry);
631
+ return;
632
+ }
633
+ if (entry.request.type === "begin") {
634
+ this.activeTransactionClient = entry.clientId;
635
+ this.armTransactionIdleTimer(entry.clientId);
636
+ return;
637
+ }
638
+ if (this.activeTransactionClient === entry.clientId) {
639
+ this.armTransactionIdleTimer(entry.clientId);
640
+ }
641
+ if (this.activeTransactionClient === entry.clientId && (entry.request.type === "commit" || entry.request.type === "rollback")) {
642
+ this.activeTransactionClient = null;
643
+ this.clearTransactionIdleTimer();
644
+ }
645
+ }
646
+ resetFailedTransaction(entry) {
647
+ if (this.activeTransactionClient === entry.clientId) {
648
+ this.activeTransactionClient = null;
649
+ this.clearTransactionIdleTimer();
650
+ }
651
+ }
652
+ armTransactionIdleTimer(clientId) {
653
+ this.clearTransactionIdleTimer();
654
+ this.transactionIdleTimer = setTimeout(() => {
655
+ void this.reclaimClientNow(clientId, "transaction-idle-timeout");
656
+ }, this.transactionIdleTimeoutMs);
657
+ }
658
+ clearTransactionIdleTimer() {
659
+ if (this.transactionIdleTimer) {
660
+ clearTimeout(this.transactionIdleTimer);
661
+ this.transactionIdleTimer = null;
662
+ }
663
+ }
664
+ async reclaimClientNow(clientId, reason) {
665
+ if (this.reclaiming || this.activeTransactionClient !== clientId) {
666
+ return;
667
+ }
668
+ this.reclaiming = true;
669
+ this.clearTransactionIdleTimer();
670
+ this.activeTransactionClient = null;
671
+ this.abortedClients.add(clientId);
672
+ this.rejectQueuedClientRequests(clientId, reason);
673
+ try {
674
+ const response = await this.inner.send({ id: 0, type: "rollback" });
675
+ if (response.type === "error") {
676
+ }
677
+ } catch {
678
+ } finally {
679
+ this.reclaiming = false;
680
+ if (!this.terminated && this.nextRunnableIndex() !== -1) {
681
+ void this.processQueue();
682
+ }
683
+ }
684
+ }
685
+ rejectQueuedClientRequests(clientId, reason) {
686
+ const keep = [];
687
+ for (const entry of this.queue) {
688
+ if (entry.clientId === clientId) {
689
+ entry.resolve({
690
+ id: entry.request.id,
691
+ type: "error",
692
+ message: `Transaction client was reclaimed: ${reason}`,
693
+ code: "TRANSACTION_ABORTED"
694
+ });
695
+ } else {
696
+ keep.push(entry);
697
+ }
698
+ }
699
+ this.queue = keep;
700
+ }
701
+ };
702
+ function hasReclaimClient(bridge) {
703
+ return typeof bridge.reclaimClient === "function";
704
+ }
705
+ function createClientId() {
706
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
707
+ return crypto.randomUUID();
708
+ }
709
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
710
+ }
486
711
 
487
712
  // src/adapters/sqlite-wasm-adapter.ts
488
713
  init_sqlite_wasm_channel();
714
+ var warnedSharedWorkerDeprecated = false;
489
715
  var SqliteWasmAdapter = class {
490
716
  bridge = null;
491
717
  opened = false;
@@ -495,43 +721,76 @@ var SqliteWasmAdapter = class {
495
721
  sharedWorkerUrl;
496
722
  workerResponseTimeoutMs;
497
723
  dbName;
724
+ emitter;
498
725
  tabSession = null;
499
- sharedWorker = null;
726
+ emitNonPersistentDiagnostic;
727
+ storageOpenState = null;
728
+ /** Retained so a follower promoted to leader can re-open its own worker. */
729
+ schema = null;
730
+ promoting = false;
500
731
  constructor(options = {}) {
501
732
  this.injectedBridge = options.bridge;
502
733
  this.workerUrl = options.workerUrl;
503
734
  this.sharedWorkerUrl = options.sharedWorkerUrl;
504
735
  this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
505
736
  this.dbName = options.dbName ?? "kora-db";
737
+ this.emitter = options.emitter;
738
+ this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
506
739
  }
507
740
  async open(schema) {
508
741
  if (this.opened) return;
742
+ const ddlStatements = (0, import_core2.generateFullDDL)(schema);
509
743
  if (this.injectedBridge) {
510
744
  this.bridge = this.injectedBridge;
511
- } else if (this.sharedWorkerUrl && this.workerUrl && isSharedWorkerStorageSupported()) {
512
- const workerUrlString = typeof this.workerUrl === "string" ? this.workerUrl : this.workerUrl.href;
513
- const sharedUrl = typeof this.sharedWorkerUrl === "string" ? this.sharedWorkerUrl : this.sharedWorkerUrl.href;
514
- this.sharedWorker = new SharedWorker(sharedUrl, { name: `kora-sw-${this.dbName}` });
515
- this.bridge = new SharedWorkerClientBridge(this.sharedWorker, this.dbName, workerUrlString);
516
- } else if (this.workerUrl) {
517
- this.tabSession = await acquireTabStorageSession(this.dbName);
518
- const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
519
- if (this.tabSession.role === "leader") {
520
- const workerBridge = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs);
521
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
522
- this.bridge = workerBridge;
523
- } else {
524
- this.bridge = new FollowerBroadcastBridge(
525
- this.tabSession.channelName,
526
- this.workerResponseTimeoutMs
527
- );
745
+ const response = await this.openCurrentBridge(ddlStatements);
746
+ this.reportStorageMode(response.data);
747
+ this.opened = true;
748
+ return;
749
+ }
750
+ if (this.workerUrl) {
751
+ this.warnSharedWorkerDeprecated();
752
+ await this.openDurableWorkerBridge(schema);
753
+ const response = await this.openCurrentBridge(ddlStatements);
754
+ this.reportStorageMode(response.data);
755
+ this.opened = true;
756
+ return;
757
+ }
758
+ throw new AdapterError(
759
+ 'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
760
+ );
761
+ }
762
+ async openDurableWorkerBridge(schema) {
763
+ if (!this.workerUrl) {
764
+ throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
765
+ }
766
+ this.schema = schema;
767
+ this.tabSession = await acquireTabStorageSession(this.dbName, {
768
+ onPromote: () => {
769
+ void this.promoteToLeader();
528
770
  }
529
- } else {
530
- throw new AdapterError(
531
- 'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
771
+ });
772
+ const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
773
+ if (this.tabSession.role === "leader") {
774
+ const workerBridge = new TransactionSerializingWorkerBridge(
775
+ new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
532
776
  );
777
+ this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
778
+ this.bridge = workerBridge;
779
+ return;
533
780
  }
534
- const ddlStatements = (0, import_core2.generateFullDDL)(schema);
781
+ const followerBridge = new FollowerBroadcastBridge(
782
+ this.tabSession.channelName,
783
+ this.workerResponseTimeoutMs
784
+ );
785
+ this.bridge = followerBridge;
786
+ this.emitter?.emit({
787
+ type: "store:db-name-collision",
788
+ dbName: this.dbName,
789
+ 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.`
790
+ });
791
+ await followerBridge.waitForLeader();
792
+ }
793
+ async openCurrentBridge(ddlStatements) {
535
794
  const response = await this.sendRequest({
536
795
  id: 0,
537
796
  type: "open",
@@ -544,7 +803,40 @@ var SqliteWasmAdapter = class {
544
803
  dbName: this.dbName
545
804
  });
546
805
  }
547
- this.opened = true;
806
+ return response;
807
+ }
808
+ /**
809
+ * Surface a `store:opfs-unavailable` diagnostic when the worker reported that
810
+ * persistence silently degraded to an in-memory database, so a data-loss
811
+ * condition is observable instead of failing silently. A persistent open (or a
812
+ * bridge that does not report a mode, e.g. the Node mock) emits nothing.
813
+ */
814
+ reportStorageMode(data) {
815
+ this.storageOpenState = parseStorageOpenState(data);
816
+ if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
817
+ return;
818
+ }
819
+ if (this.storageOpenState.persistent === false) {
820
+ const reason = this.storageOpenState.fallbackReason ?? "unsupported";
821
+ this.emitter.emit({
822
+ type: "store:opfs-unavailable",
823
+ dbName: this.dbName,
824
+ reason,
825
+ message: `OPFS persistence is unavailable (${reason}) for database "${this.dbName}"; the store is running in memory and data will not survive a reload.`
826
+ });
827
+ }
828
+ }
829
+ getStorageOpenState() {
830
+ return this.storageOpenState;
831
+ }
832
+ warnSharedWorkerDeprecated() {
833
+ if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
834
+ return;
835
+ }
836
+ warnedSharedWorkerDeprecated = true;
837
+ console.warn(
838
+ "[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."
839
+ );
548
840
  }
549
841
  async close() {
550
842
  if (!this.bridge) return;
@@ -552,16 +844,42 @@ var SqliteWasmAdapter = class {
552
844
  await this.sendRequest({ id: 0, type: "close" });
553
845
  } finally {
554
846
  this.tabSession?.stopRelay?.();
847
+ this.tabSession?.cancelPromotionWatch?.();
555
848
  if (this.tabSession?.releaseLock) {
556
849
  await this.tabSession.releaseLock();
557
850
  }
558
851
  this.tabSession = null;
559
- this.sharedWorker = null;
560
852
  this.bridge.terminate();
561
853
  this.bridge = null;
562
854
  this.opened = false;
563
855
  }
564
856
  }
857
+ /**
858
+ * Promotes a follower to leader after the previous leader released the storage
859
+ * lock (its tab closed or crashed). The lock is only granted once the old leader
860
+ * is gone, so opening our own worker here is safe from the OPFS single-writer
861
+ * rule. In-flight follower RPCs are rejected and left to the caller / reactive
862
+ * layer to retry against the new leader.
863
+ */
864
+ async promoteToLeader() {
865
+ if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
866
+ return;
867
+ }
868
+ this.promoting = true;
869
+ const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
870
+ const workerBridge = new TransactionSerializingWorkerBridge(
871
+ new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
872
+ );
873
+ const previousBridge = this.bridge;
874
+ this.bridge = workerBridge;
875
+ if (this.tabSession) {
876
+ this.tabSession.role = "leader";
877
+ this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
878
+ }
879
+ previousBridge?.terminate();
880
+ const ddlStatements = (0, import_core2.generateFullDDL)(this.schema);
881
+ await this.sendRequest({ id: 0, type: "open", ddlStatements, dbName: this.dbName });
882
+ }
565
883
  async execute(sql, params) {
566
884
  this.guardOpen();
567
885
  const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
@@ -649,7 +967,10 @@ var SqliteWasmAdapter = class {
649
967
  this.guardOpen();
650
968
  const response = await this.sendRequest({ id: 0, type: "export" });
651
969
  if (response.type === "error") {
652
- throw new AdapterError(`Export failed: ${response.message}`);
970
+ throw new AdapterError(`Export failed: ${response.message}`, {
971
+ code: response.code,
972
+ context: response.context
973
+ });
653
974
  }
654
975
  return response.data;
655
976
  }
@@ -682,6 +1003,20 @@ var SqliteWasmAdapter = class {
682
1003
  }
683
1004
  }
684
1005
  };
1006
+ function parseStorageOpenState(data) {
1007
+ if (typeof data !== "object" || data === null) {
1008
+ return null;
1009
+ }
1010
+ const mode = data;
1011
+ if (typeof mode.persistent !== "boolean") {
1012
+ return null;
1013
+ }
1014
+ return {
1015
+ persistent: mode.persistent,
1016
+ mode: mode.persistent ? "opfs" : "memory",
1017
+ ...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {}
1018
+ };
1019
+ }
685
1020
 
686
1021
  // src/adapters/sqlite-wasm-persistence.ts
687
1022
  init_errors();
@@ -816,6 +1151,24 @@ async function loadDumpFromIndexedDB(dbName) {
816
1151
  idb.close();
817
1152
  }
818
1153
  }
1154
+ async function deleteSnapshotFromIndexedDB(dbName) {
1155
+ const idb = await openIdb();
1156
+ try {
1157
+ await new Promise((resolve, reject) => {
1158
+ const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
1159
+ const store = tx.objectStore(IDB_STORE_NAME);
1160
+ store.delete(dbName);
1161
+ tx.oncomplete = () => resolve();
1162
+ tx.onerror = () => reject(
1163
+ new PersistenceError(`Failed to delete database snapshot "${dbName}" from IndexedDB`, {
1164
+ dbName
1165
+ })
1166
+ );
1167
+ });
1168
+ } finally {
1169
+ idb.close();
1170
+ }
1171
+ }
819
1172
 
820
1173
  // src/adapters/indexeddb-adapter.ts
821
1174
  var IndexedDbAdapter = class {
@@ -823,6 +1176,7 @@ var IndexedDbAdapter = class {
823
1176
  dbName;
824
1177
  emitter;
825
1178
  scheduler;
1179
+ storageOpenState = null;
826
1180
  constructor(options = {}) {
827
1181
  this.dbName = options.dbName ?? "kora-db";
828
1182
  this.emitter = options.emitter;
@@ -840,8 +1194,12 @@ var IndexedDbAdapter = class {
840
1194
  }
841
1195
  async open(schema) {
842
1196
  await this.inner.open(schema);
1197
+ this.storageOpenState = { persistent: true, mode: "indexeddb" };
843
1198
  const persisted = await loadFromIndexedDB(this.dbName);
844
- if (!persisted) return;
1199
+ if (!persisted) {
1200
+ await this.restoreFromDumpFallback();
1201
+ return;
1202
+ }
845
1203
  try {
846
1204
  await this.inner.importDatabase(persisted);
847
1205
  } catch {
@@ -868,6 +1226,9 @@ var IndexedDbAdapter = class {
868
1226
  await this.inner.migrate(from, to, migration);
869
1227
  this.scheduler.schedule();
870
1228
  }
1229
+ getStorageOpenState() {
1230
+ return this.storageOpenState;
1231
+ }
871
1232
  /**
872
1233
  * Force an immediate snapshot write to IndexedDB (skips debounce).
873
1234
  * Useful before tab unload or in tests.
@@ -876,10 +1237,17 @@ var IndexedDbAdapter = class {
876
1237
  await this.scheduler.flushNow();
877
1238
  }
878
1239
  async writeSnapshot() {
879
- const data = await this.inner.exportDatabase();
880
- await saveToIndexedDB(this.dbName, data);
881
1240
  const dump = await this.exportDump();
882
1241
  await saveDumpToIndexedDB(this.dbName, dump);
1242
+ try {
1243
+ const data = await this.inner.exportDatabase();
1244
+ await saveToIndexedDB(this.dbName, data);
1245
+ } catch (error) {
1246
+ if (!isUnsupportedWorkerExport(error)) {
1247
+ throw error;
1248
+ }
1249
+ await deleteSnapshotFromIndexedDB(this.dbName);
1250
+ }
883
1251
  }
884
1252
  handlePersistenceError(error) {
885
1253
  const message = error instanceof Error ? error.message : "IndexedDB persistence failed";
@@ -904,7 +1272,7 @@ var IndexedDbAdapter = class {
904
1272
  if (!dump) return;
905
1273
  for (const table of dump.tables) {
906
1274
  const name = ensureSafeIdentifier(table.name);
907
- await this.inner.execute(`DELETE FROM ${name}`);
1275
+ await this.inner.execute(`DELETE FROM ${(0, import_core3.quoteIdent)(name)}`);
908
1276
  if (table.rows.length === 0) continue;
909
1277
  for (const row of table.rows) {
910
1278
  const columns = table.columns.filter(
@@ -912,10 +1280,10 @@ var IndexedDbAdapter = class {
912
1280
  );
913
1281
  if (columns.length === 0) continue;
914
1282
  const placeholders = columns.map(() => "?").join(", ");
915
- const quotedColumns = columns.map((column) => ensureSafeIdentifier(column)).join(", ");
1283
+ const quotedColumns = columns.map((column) => (0, import_core3.quoteIdent)(ensureSafeIdentifier(column))).join(", ");
916
1284
  const values = columns.map((column) => row[column]);
917
1285
  await this.inner.execute(
918
- `INSERT INTO ${name} (${quotedColumns}) VALUES (${placeholders})`,
1286
+ `INSERT INTO ${(0, import_core3.quoteIdent)(name)} (${quotedColumns}) VALUES (${placeholders})`,
919
1287
  values
920
1288
  );
921
1289
  }
@@ -928,9 +1296,13 @@ var IndexedDbAdapter = class {
928
1296
  const tables = [];
929
1297
  for (const tableRow of tableRows) {
930
1298
  const tableName = ensureSafeIdentifier(tableRow.name);
931
- const columns = await this.inner.query(`PRAGMA table_info(${tableName})`);
1299
+ const columns = await this.inner.query(
1300
+ `PRAGMA table_info(${(0, import_core3.quoteIdent)(tableName)})`
1301
+ );
932
1302
  const columnNames = columns.map((column) => column.name);
933
- const rows = await this.inner.query(`SELECT * FROM ${tableName}`);
1303
+ const rows = await this.inner.query(
1304
+ `SELECT * FROM ${(0, import_core3.quoteIdent)(tableName)}`
1305
+ );
934
1306
  tables.push({
935
1307
  name: tableName,
936
1308
  columns: columnNames,
@@ -949,6 +1321,9 @@ function ensureSafeIdentifier(identifier) {
949
1321
  }
950
1322
  return identifier;
951
1323
  }
1324
+ function isUnsupportedWorkerExport(error) {
1325
+ return error instanceof AdapterError && error.context?.code === "EXPORT_NOT_SUPPORTED";
1326
+ }
952
1327
  // Annotate the CommonJS export names for ESM import in node:
953
1328
  0 && (module.exports = {
954
1329
  IndexedDbAdapter