cross-tab-worker-databus 0.10.0 → 0.20.6

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 (50) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/README.md +1 -0
  3. package/README.zh.md +1 -0
  4. package/dist/centrifuge.js +1 -1
  5. package/dist/centrifuge.shared.worker.js +4 -2
  6. package/dist/centrifuge.shared.worker.js.map +2 -2
  7. package/dist/centrifuge.worker.js +4 -2
  8. package/dist/centrifuge.worker.js.map +2 -2
  9. package/dist/{chunk-ZOPNTR4E.js → chunk-LPS4XOK4.js} +177 -19
  10. package/dist/{chunk-ZOPNTR4E.js.map → chunk-LPS4XOK4.js.map} +2 -2
  11. package/dist/cjs/centrifuge.cjs +176 -18
  12. package/dist/cjs/centrifuge.cjs.map +2 -2
  13. package/dist/cjs/hooks.cjs +4 -1
  14. package/dist/cjs/hooks.cjs.map +2 -2
  15. package/dist/cjs/index.cjs +326 -67
  16. package/dist/cjs/index.cjs.map +2 -2
  17. package/dist/cjs/vue.cjs +7 -1
  18. package/dist/cjs/vue.cjs.map +2 -2
  19. package/dist/core/data-bus.d.ts +35 -0
  20. package/dist/core/data-bus.d.ts.map +1 -1
  21. package/dist/core/publication.d.ts.map +1 -1
  22. package/dist/core/replay-persistence.d.ts.map +1 -1
  23. package/dist/core/trace.d.ts +11 -1
  24. package/dist/core/trace.d.ts.map +1 -1
  25. package/dist/hooks.d.ts.map +1 -1
  26. package/dist/hooks.js +4 -1
  27. package/dist/hooks.js.map +2 -2
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +151 -50
  31. package/dist/index.js.map +2 -2
  32. package/dist/vue.d.ts.map +1 -1
  33. package/dist/vue.js +7 -1
  34. package/dist/vue.js.map +2 -2
  35. package/dist/websocket.d.ts.map +1 -1
  36. package/docs/README.md +1 -0
  37. package/docs/api.md +11 -4
  38. package/docs/architecture.md +4 -0
  39. package/docs/capabilities.md +2 -2
  40. package/docs/configuration.md +21 -1
  41. package/docs/release-checklist.md +20 -0
  42. package/docs/roadmap.md +144 -2
  43. package/docs/zh/README.md +1 -0
  44. package/docs/zh/api.md +11 -4
  45. package/docs/zh/architecture.md +4 -0
  46. package/docs/zh/capabilities.md +2 -2
  47. package/docs/zh/configuration.md +21 -1
  48. package/docs/zh/release-checklist.md +20 -0
  49. package/docs/zh/roadmap.md +132 -2
  50. package/package.json +2 -1
@@ -1091,7 +1091,9 @@ var DataBusTraceReporter = class {
1091
1091
  // Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
1092
1092
  latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
1093
1093
  latencySumMs = 0;
1094
- constructor(options, now = Date.now) {
1094
+ dedupAccepted = 0;
1095
+ dedupSuppressed = 0;
1096
+ constructor(options, now = options?.now ?? Date.now) {
1095
1097
  this.enabled = options?.enabled ?? false;
1096
1098
  this.mode = options?.mode ?? "all";
1097
1099
  this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
@@ -1166,6 +1168,13 @@ var DataBusTraceReporter = class {
1166
1168
  this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1167
1169
  this.latencySumMs += delayMs;
1168
1170
  }
1171
+ /** Record deduplication outcomes for the next metrics window. */
1172
+ recordDedupAccepted() {
1173
+ if (this.metricsActive) this.dedupAccepted += 1;
1174
+ }
1175
+ recordDedupSuppressed() {
1176
+ if (this.metricsActive) this.dedupSuppressed += 1;
1177
+ }
1169
1178
  /** True when metrics recording is active: enabled and mode includes metrics.
1170
1179
  * Extracted so the four record / flush methods share one guard expression
1171
1180
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
@@ -1179,7 +1188,7 @@ var DataBusTraceReporter = class {
1179
1188
  }
1180
1189
  flushNow() {
1181
1190
  const timestamp = this.now();
1182
- if (this.received > 0 || this.dispatched > 0) {
1191
+ if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1183
1192
  const samples = this.latencySamples;
1184
1193
  this.emit({
1185
1194
  type: "message_metrics",
@@ -1193,6 +1202,8 @@ var DataBusTraceReporter = class {
1193
1202
  dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1194
1203
  dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1195
1204
  dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1205
+ dedupAccepted: this.dedupAccepted,
1206
+ dedupSuppressed: this.dedupSuppressed,
1196
1207
  timestamp
1197
1208
  });
1198
1209
  this.resetMetrics();
@@ -1207,6 +1218,8 @@ var DataBusTraceReporter = class {
1207
1218
  this.receivedAt.clear();
1208
1219
  this.latencyBuckets.fill(0);
1209
1220
  this.latencySumMs = 0;
1221
+ this.dedupAccepted = 0;
1222
+ this.dedupSuppressed = 0;
1210
1223
  }
1211
1224
  emit(event) {
1212
1225
  try {
@@ -1242,6 +1255,12 @@ function roundMs(value) {
1242
1255
  // src/core/data-bus.ts
1243
1256
  var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1244
1257
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1258
+ var PersistenceRetryCancelledError = class extends Error {
1259
+ constructor() {
1260
+ super("Persistence retry cancelled by lifecycle transition.");
1261
+ this.name = "PersistenceRetryCancelledError";
1262
+ }
1263
+ };
1245
1264
  var CrossTabDataBus = class _CrossTabDataBus {
1246
1265
  transport;
1247
1266
  cluster;
@@ -1257,13 +1276,26 @@ var CrossTabDataBus = class _CrossTabDataBus {
1257
1276
  replayBuffers;
1258
1277
  replayMaxPerTopic;
1259
1278
  replayPersistence;
1279
+ replayRetentionMs;
1280
+ replayRetentionSweepMs;
1281
+ persistenceRetryMaxAttempts;
1282
+ persistenceRetryBackoffMs;
1283
+ persistenceRetryGeneration = 0;
1260
1284
  replayHydration;
1285
+ // Retention cleanup is coalesced so a burst of publications does not issue
1286
+ // one IndexedDB read/write transaction per message. The newest cutoff wins.
1287
+ replayRetentionCleanup = null;
1288
+ replayRetentionCutoff = null;
1289
+ replayRetentionTimer = null;
1261
1290
  initialConfig;
1262
1291
  hasInitialConfig;
1263
1292
  trace;
1264
1293
  dedupMaxEntries;
1265
1294
  dedupTtlMs;
1295
+ dedupSweepMs;
1296
+ dedupSweepTimer = null;
1266
1297
  dedupEnabled;
1298
+ now;
1267
1299
  seenMessageIds = /* @__PURE__ */ new Map();
1268
1300
  dedupSuppressed = 0;
1269
1301
  dedupAccepted = 0;
@@ -1304,14 +1336,32 @@ var CrossTabDataBus = class _CrossTabDataBus {
1304
1336
  this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1305
1337
  this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1306
1338
  this.replayPersistence = replay?.persistence ?? null;
1307
- this.replayHydration = this.hydrateReplay();
1339
+ this.replayRetentionMs = replay?.retentionMs;
1340
+ if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1341
+ throw new TypeError("replay.retentionMs must be a positive finite number.");
1342
+ }
1343
+ this.replayRetentionSweepMs = replay?.retentionSweepMs;
1344
+ if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1345
+ throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1346
+ }
1347
+ this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1348
+ this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1349
+ if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1350
+ throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1351
+ }
1352
+ if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1353
+ throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
1354
+ }
1308
1355
  const { autoStart, initialConfig, trace, transport, dedup, ...clusterOptions } = options;
1356
+ this.now = dedup?.now ?? Date.now;
1357
+ this.replayHydration = this.hydrateReplay();
1309
1358
  this.transport = transport;
1310
1359
  this.initialConfig = initialConfig;
1311
1360
  this.hasInitialConfig = "initialConfig" in options;
1312
1361
  this.trace = new DataBusTraceReporter(trace);
1313
1362
  this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1314
1363
  this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1364
+ this.dedupSweepMs = dedup?.sweepMs;
1315
1365
  this.dedupEnabled = dedup !== void 0;
1316
1366
  if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1317
1367
  throw new TypeError("dedup.maxEntries must be a positive safe integer.");
@@ -1319,6 +1369,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1319
1369
  if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1320
1370
  throw new TypeError("dedup.ttlMs must be a positive finite number.");
1321
1371
  }
1372
+ if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1373
+ throw new TypeError("dedup.sweepMs must be a positive finite number.");
1374
+ }
1322
1375
  this.cluster = new WorkerClusterRuntime({
1323
1376
  ...clusterOptions,
1324
1377
  handlers: {
@@ -1359,11 +1412,16 @@ var CrossTabDataBus = class _CrossTabDataBus {
1359
1412
  onSuspend: () => {
1360
1413
  if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1361
1414
  this.trace.pause();
1415
+ this.persistenceRetryGeneration += 1;
1416
+ this.stopDedupSweep();
1417
+ this.stopReplayRetentionSweep();
1362
1418
  this.suspendTransport();
1363
1419
  },
1364
1420
  onResume: () => {
1365
1421
  this.trace.event({ type: "lifecycle", action: "resume" });
1366
1422
  this.trace.start();
1423
+ this.startDedupSweep();
1424
+ this.startReplayRetentionSweep();
1367
1425
  this.resumeTransport();
1368
1426
  },
1369
1427
  onDiagnostic: (event) => {
@@ -1391,6 +1449,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1391
1449
  this.lastError = null;
1392
1450
  this.trace.event({ type: "lifecycle", action: "start" });
1393
1451
  this.trace.start();
1452
+ this.startDedupSweep();
1453
+ this.startReplayRetentionSweep();
1394
1454
  this.updateStatus("connecting");
1395
1455
  this.cluster.start();
1396
1456
  const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
@@ -1515,7 +1575,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1515
1575
  this.topicHandlers.delete(topic);
1516
1576
  this.replayBuffers?.delete(topic);
1517
1577
  if (this.replayPersistence?.clearTopic) {
1518
- void this.replayPersistence.clearTopic(topic).catch((error) => this.reportError(error));
1578
+ void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1519
1579
  }
1520
1580
  this.cluster.unsubscribe(topic);
1521
1581
  }
@@ -1524,9 +1584,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1524
1584
  this.replayBuffers?.clear();
1525
1585
  if (this.replayPersistence?.clear) {
1526
1586
  try {
1527
- await this.replayPersistence.clear();
1587
+ await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1528
1588
  } catch (error) {
1529
- this.reportError(error);
1589
+ this.reportPersistenceError(error);
1530
1590
  throw error;
1531
1591
  }
1532
1592
  }
@@ -1536,9 +1596,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1536
1596
  this.replayBuffers?.delete(topic);
1537
1597
  if (this.replayPersistence?.clearTopic) {
1538
1598
  try {
1539
- await this.replayPersistence.clearTopic(topic);
1599
+ await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1540
1600
  } catch (error) {
1541
- this.reportError(error);
1601
+ this.reportPersistenceError(error);
1542
1602
  throw error;
1543
1603
  }
1544
1604
  }
@@ -1548,12 +1608,19 @@ var CrossTabDataBus = class _CrossTabDataBus {
1548
1608
  if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1549
1609
  if (this.replayBuffers) {
1550
1610
  for (const [topic, messages] of this.replayBuffers) {
1551
- const kept = messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
1611
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1552
1612
  if (kept.length) this.replayBuffers.set(topic, kept);
1553
1613
  else this.replayBuffers.delete(topic);
1554
1614
  }
1555
1615
  }
1556
- if (this.replayPersistence?.clearBefore) await this.replayPersistence.clearBefore(timestamp);
1616
+ if (this.replayPersistence?.clearBefore) {
1617
+ try {
1618
+ await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1619
+ } catch (error) {
1620
+ this.reportPersistenceError(error);
1621
+ throw error;
1622
+ }
1623
+ }
1557
1624
  }
1558
1625
  /** Return bounded deduplication counters for diagnostics and health checks. */
1559
1626
  getDedupStats() {
@@ -1611,8 +1678,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1611
1678
  async stop() {
1612
1679
  if (!this.started) return;
1613
1680
  this.stopping = true;
1681
+ this.persistenceRetryGeneration += 1;
1614
1682
  this.trace.event({ type: "lifecycle", action: "stop" });
1615
1683
  this.trace.stop();
1684
+ this.stopDedupSweep();
1685
+ this.stopReplayRetentionSweep();
1616
1686
  this.topicHandlers.clear();
1617
1687
  this.replayBuffers?.clear();
1618
1688
  this.cluster.stop();
@@ -1623,7 +1693,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1623
1693
  else await this.transport.stop();
1624
1694
  } finally {
1625
1695
  this.transportSubscribedTopics.clear();
1626
- this.seenMessageIds.clear();
1696
+ this.resetDedup();
1627
1697
  this.started = false;
1628
1698
  this.stopping = false;
1629
1699
  this.suspended = false;
@@ -1656,17 +1726,19 @@ var CrossTabDataBus = class _CrossTabDataBus {
1656
1726
  }
1657
1727
  isDuplicate(message) {
1658
1728
  if (!this.dedupEnabled || !message.messageId) return false;
1659
- const now = Date.now();
1729
+ const now = this.now();
1660
1730
  for (const [id, timestamp] of this.seenMessageIds) {
1661
1731
  if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
1662
1732
  }
1663
1733
  if (this.seenMessageIds.has(message.messageId)) {
1664
1734
  this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
1665
1735
  this.dedupSuppressed += 1;
1736
+ this.trace.recordDedupSuppressed();
1666
1737
  return true;
1667
1738
  }
1668
1739
  this.seenMessageIds.set(message.messageId, now);
1669
1740
  this.dedupAccepted += 1;
1741
+ this.trace.recordDedupAccepted();
1670
1742
  while (this.seenMessageIds.size > this.dedupMaxEntries) {
1671
1743
  const oldest = this.seenMessageIds.keys().next().value;
1672
1744
  if (oldest === void 0) break;
@@ -1674,6 +1746,20 @@ var CrossTabDataBus = class _CrossTabDataBus {
1674
1746
  }
1675
1747
  return false;
1676
1748
  }
1749
+ startDedupSweep() {
1750
+ if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
1751
+ this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
1752
+ }
1753
+ stopDedupSweep() {
1754
+ if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
1755
+ this.dedupSweepTimer = null;
1756
+ }
1757
+ pruneExpiredDedup() {
1758
+ const cutoff = this.now() - this.dedupTtlMs;
1759
+ for (const [id, timestamp] of this.seenMessageIds) {
1760
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
1761
+ }
1762
+ }
1677
1763
  /** Deliver a message to every local handler registered for its topic,
1678
1764
  * plus every handler registered with a wildcard subscription that matches
1679
1765
  * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
@@ -1700,7 +1786,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1700
1786
  buffer.push(storedMessage);
1701
1787
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1702
1788
  if (this.replayPersistence) {
1703
- void this.replayPersistence.append(storedMessage).catch((error) => this.reportError(error));
1789
+ void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
1790
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1791
+ this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
1792
+ }
1704
1793
  }
1705
1794
  }
1706
1795
  async hydrateReplay() {
@@ -1708,7 +1797,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1708
1797
  return;
1709
1798
  }
1710
1799
  try {
1711
- for (const message of await this.replayPersistence.load()) {
1800
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1801
+ await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
1802
+ }
1803
+ for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
1712
1804
  let buffer = this.replayBuffers.get(message.topic);
1713
1805
  if (!buffer) {
1714
1806
  buffer = [];
@@ -1718,7 +1810,66 @@ var CrossTabDataBus = class _CrossTabDataBus {
1718
1810
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1719
1811
  }
1720
1812
  } catch (error) {
1721
- this.reportError(error);
1813
+ this.reportPersistenceError(error);
1814
+ }
1815
+ }
1816
+ scheduleReplayRetentionCleanup(cutoff) {
1817
+ if (!this.replayPersistence?.clearBefore) return;
1818
+ if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
1819
+ this.replayRetentionCutoff = cutoff;
1820
+ }
1821
+ if (this.replayRetentionCleanup) return;
1822
+ this.replayRetentionCleanup = (async () => {
1823
+ while (this.replayRetentionCutoff !== null) {
1824
+ const nextCutoff = this.replayRetentionCutoff;
1825
+ this.replayRetentionCutoff = null;
1826
+ try {
1827
+ await this.replayPersistence.clearBefore(nextCutoff);
1828
+ } catch (error) {
1829
+ this.reportPersistenceError(error);
1830
+ }
1831
+ }
1832
+ })().finally(() => {
1833
+ this.replayRetentionCleanup = null;
1834
+ if (this.replayRetentionCutoff !== null) {
1835
+ this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
1836
+ }
1837
+ });
1838
+ }
1839
+ startReplayRetentionSweep() {
1840
+ if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
1841
+ this.replayRetentionTimer = setInterval(() => {
1842
+ this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
1843
+ }, this.replayRetentionSweepMs);
1844
+ }
1845
+ stopReplayRetentionSweep() {
1846
+ if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
1847
+ this.replayRetentionTimer = null;
1848
+ }
1849
+ async withPersistenceRetry(persistenceOperation, operation) {
1850
+ const generation = this.persistenceRetryGeneration;
1851
+ let attempt = 0;
1852
+ let delay = this.persistenceRetryBackoffMs;
1853
+ while (true) {
1854
+ attempt += 1;
1855
+ try {
1856
+ if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
1857
+ return await operation();
1858
+ } catch (error) {
1859
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
1860
+ throw new PersistenceRetryCancelledError();
1861
+ }
1862
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
1863
+ this.trace.event({
1864
+ type: "reliability",
1865
+ operation: "persistence_retry",
1866
+ persistenceOperation,
1867
+ attempt
1868
+ });
1869
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
1870
+ if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
1871
+ delay = Math.min(delay * 2, 1600);
1872
+ }
1722
1873
  }
1723
1874
  }
1724
1875
  /** Deliver buffered history to a newly-registered handler. For an exact
@@ -1756,7 +1907,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1756
1907
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
1757
1908
  }
1758
1909
  if (status === "error" && this.started && !this.stopping) {
1759
- const now = Date.now();
1910
+ const now = this.now();
1760
1911
  if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
1761
1912
  this.lastRecoveryAt = now;
1762
1913
  this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: 1 });
@@ -1773,6 +1924,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1773
1924
  this.trace.event({ type: "error", source: "transport" });
1774
1925
  this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1775
1926
  }
1927
+ reportPersistenceError(error) {
1928
+ if (error instanceof PersistenceRetryCancelledError) return;
1929
+ this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
1930
+ this.reportError(error);
1931
+ }
1776
1932
  traceSubscription(action, topic) {
1777
1933
  this.trace.event({
1778
1934
  type: "subscription",
@@ -1926,74 +2082,165 @@ function createIndexedDbReplayPersistence(options) {
1926
2082
  throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
1927
2083
  }
1928
2084
  let dbPromise = null;
2085
+ const invalidate = (db) => {
2086
+ if (dbPromise) {
2087
+ void dbPromise.then((current) => {
2088
+ if (current === db) {
2089
+ current.close();
2090
+ dbPromise = null;
2091
+ }
2092
+ }, () => void 0);
2093
+ }
2094
+ };
2095
+ let mutationQueue = Promise.resolve();
2096
+ const serializeMutation = (mutation) => {
2097
+ const next = mutationQueue.then(mutation, mutation);
2098
+ mutationQueue = next.catch(() => void 0);
2099
+ return next;
2100
+ };
1929
2101
  const open = () => {
1930
2102
  if (dbPromise) return dbPromise;
1931
- dbPromise = new Promise((resolve, reject) => {
2103
+ const pending = new Promise((resolve, reject) => {
1932
2104
  const request = indexedDb.open(dbName, 1);
1933
2105
  request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
1934
- request.onsuccess = () => resolve(request.result);
2106
+ request.onsuccess = () => {
2107
+ const db = request.result;
2108
+ db.onversionchange = () => {
2109
+ db.close();
2110
+ if (dbPromise) dbPromise = null;
2111
+ };
2112
+ resolve(db);
2113
+ };
1935
2114
  request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
1936
2115
  });
1937
- return dbPromise;
2116
+ dbPromise = pending;
2117
+ void pending.catch(() => {
2118
+ if (dbPromise === pending) dbPromise = null;
2119
+ });
2120
+ return pending;
1938
2121
  };
1939
2122
  return {
1940
2123
  async load() {
1941
2124
  const db = await open();
1942
2125
  return new Promise((resolve, reject) => {
1943
- const request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
2126
+ let request;
2127
+ try {
2128
+ request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
2129
+ } catch (error) {
2130
+ invalidate(db);
2131
+ reject(error);
2132
+ return;
2133
+ }
1944
2134
  request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
1945
- request.onerror = () => reject(request.error ?? new Error("Failed to load replay history."));
2135
+ request.onerror = () => {
2136
+ invalidate(db);
2137
+ reject(request.error ?? new Error("Failed to load replay history."));
2138
+ };
1946
2139
  });
1947
2140
  },
1948
- async append(message) {
1949
- const db = await open();
1950
- await new Promise((resolve, reject) => {
1951
- const transaction = db.transaction(storeName, "readwrite");
1952
- const store = transaction.objectStore(storeName);
1953
- const request = store.get(message.topic);
1954
- request.onsuccess = () => {
1955
- const messages = (request.result?.messages ?? []).concat(message).slice(-maxPerTopic);
1956
- store.put({ topic: message.topic, messages });
1957
- };
1958
- request.onerror = () => reject(request.error ?? new Error("Failed to read replay history."));
1959
- transaction.oncomplete = () => resolve();
1960
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to persist replay history."));
2141
+ append(message) {
2142
+ return serializeMutation(async () => {
2143
+ const db = await open();
2144
+ await new Promise((resolve, reject) => {
2145
+ let transaction;
2146
+ try {
2147
+ transaction = db.transaction(storeName, "readwrite");
2148
+ } catch (error) {
2149
+ invalidate(db);
2150
+ reject(error);
2151
+ return;
2152
+ }
2153
+ const store = transaction.objectStore(storeName);
2154
+ const request = store.get(message.topic);
2155
+ request.onsuccess = () => {
2156
+ const messages = (request.result?.messages ?? []).concat(message).slice(-maxPerTopic);
2157
+ store.put({ topic: message.topic, messages });
2158
+ };
2159
+ request.onerror = () => {
2160
+ invalidate(db);
2161
+ reject(request.error ?? new Error("Failed to read replay history."));
2162
+ };
2163
+ transaction.oncomplete = () => resolve();
2164
+ transaction.onerror = () => {
2165
+ invalidate(db);
2166
+ reject(transaction.error ?? new Error("Failed to persist replay history."));
2167
+ };
2168
+ });
1961
2169
  });
1962
2170
  },
1963
- async clear() {
1964
- const db = await open();
1965
- await new Promise((resolve, reject) => {
1966
- const transaction = db.transaction(storeName, "readwrite");
1967
- transaction.objectStore(storeName).clear();
1968
- transaction.oncomplete = () => resolve();
1969
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to clear replay history."));
2171
+ clear() {
2172
+ return serializeMutation(async () => {
2173
+ const db = await open();
2174
+ await new Promise((resolve, reject) => {
2175
+ let transaction;
2176
+ try {
2177
+ transaction = db.transaction(storeName, "readwrite");
2178
+ } catch (error) {
2179
+ invalidate(db);
2180
+ reject(error);
2181
+ return;
2182
+ }
2183
+ transaction.objectStore(storeName).clear();
2184
+ transaction.oncomplete = () => resolve();
2185
+ transaction.onerror = () => {
2186
+ invalidate(db);
2187
+ reject(transaction.error ?? new Error("Failed to clear replay history."));
2188
+ };
2189
+ });
1970
2190
  });
1971
2191
  },
1972
- async clearTopic(topic) {
1973
- const db = await open();
1974
- await new Promise((resolve, reject) => {
1975
- const transaction = db.transaction(storeName, "readwrite");
1976
- transaction.objectStore(storeName).delete(topic);
1977
- transaction.oncomplete = () => resolve();
1978
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2192
+ clearTopic(topic) {
2193
+ return serializeMutation(async () => {
2194
+ const db = await open();
2195
+ await new Promise((resolve, reject) => {
2196
+ let transaction;
2197
+ try {
2198
+ transaction = db.transaction(storeName, "readwrite");
2199
+ } catch (error) {
2200
+ invalidate(db);
2201
+ reject(error);
2202
+ return;
2203
+ }
2204
+ transaction.objectStore(storeName).delete(topic);
2205
+ transaction.oncomplete = () => resolve();
2206
+ transaction.onerror = () => {
2207
+ invalidate(db);
2208
+ reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2209
+ };
2210
+ });
1979
2211
  });
1980
2212
  },
1981
- async clearBefore(timestamp) {
1982
- const db = await open();
1983
- await new Promise((resolve, reject) => {
1984
- const transaction = db.transaction(storeName, "readwrite");
1985
- const store = transaction.objectStore(storeName);
1986
- const request = store.getAll();
1987
- request.onsuccess = () => {
1988
- for (const record of request.result) {
1989
- const messages = record.messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
1990
- if (messages.length === 0) store.delete(record.topic);
1991
- else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
2213
+ clearBefore(timestamp) {
2214
+ return serializeMutation(async () => {
2215
+ const db = await open();
2216
+ await new Promise((resolve, reject) => {
2217
+ let transaction;
2218
+ try {
2219
+ transaction = db.transaction(storeName, "readwrite");
2220
+ } catch (error) {
2221
+ invalidate(db);
2222
+ reject(error);
2223
+ return;
1992
2224
  }
1993
- };
1994
- request.onerror = () => reject(request.error ?? new Error("Failed to read replay history."));
1995
- transaction.oncomplete = () => resolve();
1996
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to prune replay history."));
2225
+ const store = transaction.objectStore(storeName);
2226
+ const request = store.getAll();
2227
+ request.onsuccess = () => {
2228
+ for (const record of request.result) {
2229
+ const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2230
+ if (messages.length === 0) store.delete(record.topic);
2231
+ else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
2232
+ }
2233
+ };
2234
+ request.onerror = () => {
2235
+ invalidate(db);
2236
+ reject(request.error ?? new Error("Failed to read replay history."));
2237
+ };
2238
+ transaction.oncomplete = () => resolve();
2239
+ transaction.onerror = () => {
2240
+ invalidate(db);
2241
+ reject(transaction.error ?? new Error("Failed to prune replay history."));
2242
+ };
2243
+ });
1997
2244
  });
1998
2245
  }
1999
2246
  };
@@ -2011,11 +2258,13 @@ function parseDataBusPublication(value, fallbackTopic) {
2011
2258
  if (!topic) return null;
2012
2259
  const hasMetadataEnvelope = fallbackTopic !== void 0 && Object.prototype.hasOwnProperty.call(publication, "data") && (typeof publication.messageId === "string" || typeof publication.timestamp === "number");
2013
2260
  const data = nested || fallbackTopic === void 0 || hasMetadataEnvelope ? publication.data : value;
2261
+ const messageId = typeof publication.messageId === "string" && publication.messageId.length > 0 ? publication.messageId : void 0;
2262
+ const timestamp = typeof publication.timestamp === "number" && Number.isFinite(publication.timestamp) ? publication.timestamp : void 0;
2014
2263
  return {
2015
2264
  topic,
2016
2265
  data,
2017
- ...typeof publication.messageId === "string" ? { messageId: publication.messageId } : {},
2018
- ...typeof publication.timestamp === "number" ? { timestamp: publication.timestamp } : {}
2266
+ ...messageId === void 0 ? {} : { messageId },
2267
+ ...timestamp === void 0 ? {} : { timestamp }
2019
2268
  };
2020
2269
  }
2021
2270
 
@@ -2051,7 +2300,9 @@ var WebSocketTransport = class {
2051
2300
  };
2052
2301
  socket.onclose = () => handlers.onStatus("disconnected");
2053
2302
  socket.onerror = () => handlers.onStatus("error");
2054
- socket.onmessage = (event) => this.handleMessage(event.data);
2303
+ socket.onmessage = (event) => {
2304
+ void this.handleMessage(event.data);
2305
+ };
2055
2306
  this.socket = socket;
2056
2307
  }
2057
2308
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
@@ -2127,7 +2378,15 @@ var WebSocketTransport = class {
2127
2378
  /** Parse a server frame. Only objects carrying a string `topic` are
2128
2379
  * publications; malformed JSON and unknown shapes are ignored so a chatty
2129
2380
  * server cannot crash the message path. */
2130
- handleMessage(raw) {
2381
+ async handleMessage(raw) {
2382
+ if (typeof Blob !== "undefined" && raw instanceof Blob) {
2383
+ try {
2384
+ await this.handleMessage(await raw.arrayBuffer());
2385
+ } catch (error) {
2386
+ this.handlers?.onError(error);
2387
+ }
2388
+ return;
2389
+ }
2131
2390
  let parsed;
2132
2391
  if (raw instanceof ArrayBuffer) {
2133
2392
  const bytes = new Uint8Array(raw);