cross-tab-worker-databus 0.20.85 → 0.20.86

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.
@@ -1855,6 +1855,36 @@ function roundMs(value) {
1855
1855
  return Math.round(value * 10) / 10;
1856
1856
  }
1857
1857
 
1858
+ // src/core/replay-pruning.ts
1859
+ function pruneReplayHistory(messages, options) {
1860
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1861
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1862
+ if (!ageEnabled) {
1863
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1864
+ }
1865
+ const cutoff = now - retentionMs;
1866
+ let hasExpired = false;
1867
+ let timestamplessCount = 0;
1868
+ for (const message of messages) {
1869
+ if (message.timestamp === void 0) timestamplessCount += 1;
1870
+ else if (message.timestamp < cutoff) hasExpired = true;
1871
+ }
1872
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1873
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1874
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1875
+ }
1876
+ if (timestamplessCount <= maxPerTopic) return pruned;
1877
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1878
+ pruned = pruned.filter((message) => {
1879
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1880
+ timestamplessToDrop -= 1;
1881
+ return false;
1882
+ }
1883
+ return true;
1884
+ });
1885
+ return pruned;
1886
+ }
1887
+
1858
1888
  // src/core/replay-manager.ts
1859
1889
  var PersistenceRetryCancelledError = class extends Error {
1860
1890
  constructor() {
@@ -1916,17 +1946,15 @@ var ReplayManager = class {
1916
1946
  this.buffers.set(message.topic, buffer);
1917
1947
  }
1918
1948
  buffer.push(message);
1919
- const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1920
- if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1921
- while (buffer.length > this.maxPerTopic) buffer.shift();
1922
- }
1923
- if (ageBounded) {
1924
- const cutoff = this.now() - this.retentionMs;
1925
- while (buffer.length > 0) {
1926
- const first = buffer[0];
1927
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1928
- buffer.shift();
1929
- }
1949
+ const pruned = pruneReplayHistory(buffer, {
1950
+ maxPerTopic: this.maxPerTopic,
1951
+ pruneStrategy: this.pruneStrategy,
1952
+ retentionMs: this.retentionMs,
1953
+ now: this.now()
1954
+ });
1955
+ if (pruned !== buffer) {
1956
+ buffer = pruned;
1957
+ this.buffers.set(message.topic, buffer);
1930
1958
  }
1931
1959
  if (!this.persistence) return;
1932
1960
  if (this.persistence.appendBatch) {
@@ -2107,14 +2135,24 @@ var ReplayManager = class {
2107
2135
  if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2108
2136
  await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2109
2137
  }
2110
- for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2138
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2139
+ for (const message of loaded) {
2111
2140
  let buffer = this.buffers.get(message.topic);
2112
2141
  if (!buffer) {
2113
2142
  buffer = [];
2114
2143
  this.buffers.set(message.topic, buffer);
2115
2144
  }
2116
2145
  buffer.push(message);
2117
- if (buffer.length > this.maxPerTopic) buffer.shift();
2146
+ }
2147
+ const hydrationNow = this.now();
2148
+ for (const [topic, buffer] of this.buffers) {
2149
+ const pruned = pruneReplayHistory(buffer, {
2150
+ maxPerTopic: this.maxPerTopic,
2151
+ pruneStrategy: this.pruneStrategy,
2152
+ retentionMs: this.retentionMs,
2153
+ now: hydrationNow
2154
+ });
2155
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2118
2156
  }
2119
2157
  } catch (error) {
2120
2158
  this.onPersistenceError(error);
@@ -2291,7 +2329,7 @@ var DedupManager = class {
2291
2329
  };
2292
2330
 
2293
2331
  // src/core/version.ts
2294
- var SDK_VERSION = true ? "0.20.85" : "";
2332
+ var SDK_VERSION = true ? "0.20.86" : "";
2295
2333
 
2296
2334
  // src/core/data-bus.ts
2297
2335
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2329,6 +2367,24 @@ var CrossTabDataBus = class {
2329
2367
  // Gate that serialises start/stop/suspend/resume — only one lifecycle
2330
2368
  // transition at a time. Resets to null once the operation settles.
2331
2369
  startPromise = null;
2370
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2371
+ // start() received while stopping chains a fresh start after it.
2372
+ stopPromise = null;
2373
+ // A start() requested while an explicit stop() is still settling. Kept
2374
+ // separate from startPromise because stop()'s finally block clears the
2375
+ // ordinary lifecycle gate before the queued start is allowed to run.
2376
+ queuedStart = null;
2377
+ // Lazy readiness view of queuedStart. start() keeps its documented
2378
+ // resolve-on-cancellation contract, while ready() must reject when the
2379
+ // queued intent was superseded by a later stop().
2380
+ queuedStartReady = null;
2381
+ queuedStartReadyToken = 0;
2382
+ // The queued continuation is chained to the stop promise and cannot be
2383
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2384
+ // current intent by recording its token; a subsequent start() issues a
2385
+ // higher token so the latest lifecycle request still wins.
2386
+ queuedStartToken = 0;
2387
+ canceledQueuedStartToken = 0;
2332
2388
  // Timestamp of the last automatic transport recovery attempt.
2333
2389
  // Used to avoid a tight retry loop when the transport fails repeatedly.
2334
2390
  lastRecoveryAt = 0;
@@ -2351,6 +2407,9 @@ var CrossTabDataBus = class {
2351
2407
  // surfaces a failure while later opens and automatic recovery wait for the
2352
2408
  // stop to settle.
2353
2409
  pendingStop = null;
2410
+ // Ownership token for asynchronous transport opens. Every lifecycle
2411
+ // transition invalidates callbacks and failure cleanup from older opens.
2412
+ lifecycleEpoch = 0;
2354
2413
  // Minimum interval in ms between automatic recovery attempts.
2355
2414
  recoveryCooldownMs;
2356
2415
  recoveryMaxAttempts;
@@ -2463,37 +2522,51 @@ var CrossTabDataBus = class {
2463
2522
  * Start the DataBus with the given transport config.
2464
2523
  *
2465
2524
  * The first call starts the cluster and opens the transport. Concurrent calls
2466
- * during an in-flight start return the same promise. Once the operation
2467
- * settles (success or failure) the promise gate is cleared so a subsequent
2468
- * start() or resumeTransport() can open a fresh lifecycle.
2525
+ * during an in-flight open return the same promise. A call received while an
2526
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2527
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2528
+ * intent wins. Once an operation settles (success or failure) its promise
2529
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2530
+ * fresh lifecycle.
2469
2531
  */
2470
2532
  start(config) {
2471
- if (this.startPromise) return this.startPromise;
2472
- if (this.started) return Promise.resolve();
2533
+ if (this.queuedStart) return this.queuedStart;
2534
+ if (this.stopping) return this.queueStartAfterStop(config);
2535
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2536
+ if (this.started) {
2537
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2538
+ if (!transportDown) return Promise.resolve();
2539
+ this.activeConfig = config;
2540
+ this.resetFailureState();
2541
+ return this.reopenTransport();
2542
+ }
2473
2543
  this.started = true;
2474
2544
  this.stopping = false;
2475
2545
  this.suspended = false;
2476
2546
  this.activeConfig = config;
2477
- this.lastError = null;
2478
- this.lastFailure = null;
2479
- this.persistenceFailureCount = 0;
2480
- this.persistenceLastFailureAt = null;
2481
- this.persistenceLastErrorMessage = null;
2547
+ this.resetFailureState();
2482
2548
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2483
2549
  this.trace.start();
2484
2550
  this.startDedupSweep();
2485
2551
  this.replayManager.start();
2486
2552
  this.updateStatus(WORKER_STATUS.CONNECTING);
2487
2553
  this.cluster.start();
2488
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2554
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2555
+ const opening = this.openTransport(
2556
+ config,
2557
+ this.pendingStop ?? Promise.resolve(),
2558
+ true,
2559
+ lifecycleEpoch
2560
+ );
2489
2561
  this.startPromise = opening;
2490
2562
  for (const topic of this.topicHandlers.keys()) {
2491
2563
  this.cluster.subscribe(topic);
2492
2564
  }
2493
2565
  void opening.then(
2494
2566
  () => {
2567
+ if (this.startPromise !== opening) return;
2495
2568
  this.emitCoordinationTrace();
2496
- if (this.startPromise === opening) this.startPromise = null;
2569
+ this.startPromise = null;
2497
2570
  },
2498
2571
  () => {
2499
2572
  if (this.startPromise === opening) this.startPromise = null;
@@ -2501,24 +2574,80 @@ var CrossTabDataBus = class {
2501
2574
  );
2502
2575
  return opening;
2503
2576
  }
2577
+ /** Return a cancellation-aware readiness view of the current queued start. */
2578
+ getQueuedStartReady() {
2579
+ const queued = this.queuedStart;
2580
+ if (!queued) {
2581
+ return Promise.reject(new Error("No queued start is in flight."));
2582
+ }
2583
+ const token = this.queuedStartToken;
2584
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2585
+ return this.queuedStartReady;
2586
+ }
2587
+ this.queuedStartReadyToken = token;
2588
+ this.queuedStartReady = queued.then(() => {
2589
+ if (token <= this.canceledQueuedStartToken) {
2590
+ throw new Error(
2591
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2592
+ );
2593
+ }
2594
+ if (!this.started || !this.transportReady) {
2595
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2596
+ }
2597
+ });
2598
+ return this.queuedStartReady;
2599
+ }
2600
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2601
+ queueStartAfterStop(config) {
2602
+ if (this.queuedStart) return this.queuedStart;
2603
+ const stop = this.stopPromise ?? Promise.resolve();
2604
+ const token = ++this.queuedStartToken;
2605
+ const queued = stop.catch(() => void 0).then(() => {
2606
+ if (this.queuedStart === queued) this.queuedStart = null;
2607
+ if (token <= this.canceledQueuedStartToken) return;
2608
+ return this.start(config);
2609
+ });
2610
+ this.queuedStart = queued;
2611
+ return queued;
2612
+ }
2613
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2614
+ resetFailureState() {
2615
+ this.lastError = null;
2616
+ this.lastErrorAt = null;
2617
+ this.lastFailure = null;
2618
+ this.persistenceFailureCount = 0;
2619
+ this.persistenceLastFailureAt = null;
2620
+ this.persistenceLastErrorMessage = null;
2621
+ this.recoveryAttempt = 0;
2622
+ this.recoveryExhausted = false;
2623
+ this.lastRecoveryAt = 0;
2624
+ }
2504
2625
  /**
2505
2626
  * Open the transport, chained after `before` to ensure lifecycle ordering.
2506
2627
  * When `stopClusterOnFailure` is true (initial start), a transport failure
2507
2628
  * tears down the cluster as well.
2508
2629
  */
2509
- openTransport(config, before, stopClusterOnFailure) {
2630
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
2510
2631
  this.transportReady = false;
2511
2632
  const chainedPendingStop = this.pendingStop;
2633
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2512
2634
  return before.catch(() => void 0).then(() => {
2513
- if (this.stopping || this.suspended) return;
2635
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2514
2636
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2515
2637
  return Promise.resolve(
2516
2638
  this.transport.start(config, {
2517
- onMessage: (message) => this.handleTransportMessage(message),
2518
- onStatus: (status) => this.updateStatus(status),
2519
- onError: (error) => this.reportError(error)
2639
+ onMessage: (message) => {
2640
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2641
+ },
2642
+ onStatus: (status) => {
2643
+ if (isCurrentLifecycle()) this.updateStatus(status);
2644
+ },
2645
+ onError: (error) => {
2646
+ if (isCurrentLifecycle()) this.reportError(error);
2647
+ }
2520
2648
  })
2521
2649
  ).then(() => {
2650
+ if (!isCurrentLifecycle()) return;
2522
2651
  if (this.status === WORKER_STATUS.ERROR) {
2523
2652
  throw new Error("Transport failed during startup.");
2524
2653
  }
@@ -2529,6 +2658,7 @@ var CrossTabDataBus = class {
2529
2658
  }
2530
2659
  });
2531
2660
  }).catch((error) => {
2661
+ if (!isCurrentLifecycle()) throw error;
2532
2662
  if (stopClusterOnFailure) this.started = false;
2533
2663
  if (!this.pendingStop) {
2534
2664
  this.pendingStop = this.createStopPromise();
@@ -2543,16 +2673,26 @@ var CrossTabDataBus = class {
2543
2673
  this.cluster.stop();
2544
2674
  this.stopping = false;
2545
2675
  }
2546
- this.startPromise = null;
2547
2676
  throw error;
2548
2677
  });
2549
2678
  }
2550
2679
  /**
2551
2680
  * Await the DataBus to be fully started (lazy init when using initialConfig).
2552
2681
  * Returns a rejected promise when the transport has failed and no start is in
2553
- * flight — the caller can retry by calling start() or ready() again.
2682
+ * flight — the caller can retry by calling start() or ready() again. While an
2683
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2684
+ * it; false readiness during teardown is never reported.
2554
2685
  */
2555
2686
  ready() {
2687
+ if (this.queuedStart) return this.getQueuedStartReady();
2688
+ if (this.stopping) {
2689
+ return Promise.reject(new Error(
2690
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2691
+ ));
2692
+ }
2693
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2694
+ return Promise.reject(this.lastError);
2695
+ }
2556
2696
  try {
2557
2697
  this.ensureStarted();
2558
2698
  } catch (error) {
@@ -2568,9 +2708,18 @@ var CrossTabDataBus = class {
2568
2708
  /**
2569
2709
  * Register a handler for `topic`. The handler fires on every publication
2570
2710
  * delivered to this tab, regardless of which tab published it. Returns an
2571
- * unsubscribe function for convenience.
2711
+ * unsubscribe function for convenience. During an explicit stop() the
2712
+ * registration is rejected through onError and a no-op cleanup is returned,
2713
+ * so a late subscriber cannot leak into a future restart.
2572
2714
  */
2573
2715
  subscribe(topic, handler, options) {
2716
+ if (this.stopping) {
2717
+ this.reportError(new Error(
2718
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2719
+ ));
2720
+ return () => {
2721
+ };
2722
+ }
2574
2723
  this.ensureStarted();
2575
2724
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
2576
2725
  const wasUnused = handlers.size === 0;
@@ -2624,6 +2773,7 @@ var CrossTabDataBus = class {
2624
2773
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
2625
2774
  publish(topic, data, options) {
2626
2775
  this.ensureStarted();
2776
+ if (this.rejectPublishDuringStop("publish")) return;
2627
2777
  if (!this.cluster.publish(topic, data, options)) {
2628
2778
  this.reportError(
2629
2779
  new Error("Failed to send the publish control message to the owning worker.")
@@ -2640,6 +2790,7 @@ var CrossTabDataBus = class {
2640
2790
  publishBatch(topic, items) {
2641
2791
  this.ensureStarted();
2642
2792
  if (items.length === 0) return;
2793
+ if (this.rejectPublishDuringStop("publishBatch")) return;
2643
2794
  if (items.length === 1) {
2644
2795
  const first = items[0];
2645
2796
  this.publish(topic, first.data, first.options);
@@ -2768,10 +2919,33 @@ var CrossTabDataBus = class {
2768
2919
  }
2769
2920
  /**
2770
2921
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
2771
- * and close the transport. Idempotent.
2922
+ * and close the transport. Concurrent and repeated calls share the in-flight
2923
+ * stop promise. A start() received while stopping runs after this completes,
2924
+ * unless another stop() arrives first and cancels that queued restart.
2772
2925
  */
2773
- async stop() {
2774
- if (!this.started) return;
2926
+ stop() {
2927
+ if (this.queuedStart) {
2928
+ this.canceledQueuedStartToken = this.queuedStartToken;
2929
+ this.queuedStart = null;
2930
+ }
2931
+ if (this.stopPromise) return this.stopPromise;
2932
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2933
+ return Promise.resolve();
2934
+ }
2935
+ const stopPromise = this.performStop();
2936
+ this.stopPromise = stopPromise;
2937
+ void stopPromise.then(
2938
+ () => {
2939
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2940
+ },
2941
+ () => {
2942
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2943
+ }
2944
+ );
2945
+ return stopPromise;
2946
+ }
2947
+ async performStop() {
2948
+ this.lifecycleEpoch += 1;
2775
2949
  this.stopping = true;
2776
2950
  this.replayManager.suspend();
2777
2951
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
@@ -2963,6 +3137,7 @@ var CrossTabDataBus = class {
2963
3137
  */
2964
3138
  suspendTransport() {
2965
3139
  if (this.stopping) return;
3140
+ this.lifecycleEpoch += 1;
2966
3141
  this.suspended = true;
2967
3142
  this.transportReady = false;
2968
3143
  this.transportSubscribedTopics.clear();
@@ -3001,19 +3176,23 @@ var CrossTabDataBus = class {
3001
3176
  this.started = true;
3002
3177
  this.suspended = false;
3003
3178
  this.updateStatus(WORKER_STATUS.CONNECTING);
3179
+ const lifecycleEpoch = ++this.lifecycleEpoch;
3004
3180
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3005
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3181
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3006
3182
  this.startPromise = opening;
3007
3183
  void opening.then(
3008
3184
  () => {
3185
+ if (this.startPromise === opening) this.startPromise = null;
3186
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3009
3187
  if (traceAttempt !== void 0) {
3010
3188
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
3011
3189
  this.recoveryAttempt = 0;
3012
3190
  this.recoveryExhausted = false;
3013
3191
  }
3014
- if (this.startPromise === opening) this.startPromise = null;
3015
3192
  },
3016
3193
  () => {
3194
+ if (this.startPromise === opening) this.startPromise = null;
3195
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3017
3196
  if (traceAttempt !== void 0) {
3018
3197
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
3019
3198
  }
@@ -3047,6 +3226,19 @@ var CrossTabDataBus = class {
3047
3226
  return operation();
3048
3227
  }).catch((error) => this.reportError(error));
3049
3228
  }
3229
+ /**
3230
+ * Publications started after teardown begins cannot reach any transport.
3231
+ * Surface that as a normal asynchronous API failure instead of letting
3232
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3233
+ * and are filtered by the caller before this check.
3234
+ */
3235
+ rejectPublishDuringStop(operation) {
3236
+ if (!this.stopping) return false;
3237
+ this.reportError(new Error(
3238
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3239
+ ));
3240
+ return true;
3241
+ }
3050
3242
  /**
3051
3243
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
3052
3244
  * Called automatically by subscribe/publish/ready when autoStart is true.
@@ -3152,35 +3344,29 @@ function createIndexedDbReplayPersistence(options) {
3152
3344
  grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
3153
3345
  }
3154
3346
  let hasError = false;
3347
+ const fail = (error) => {
3348
+ if (hasError) return;
3349
+ hasError = true;
3350
+ invalidate(db);
3351
+ reject(error);
3352
+ };
3155
3353
  for (const [topic, topicMessages] of grouped) {
3156
3354
  const request = store.get(topic);
3157
3355
  request.onsuccess = () => {
3158
3356
  if (hasError) return;
3159
- let history = (request.result?.messages ?? []).concat(topicMessages);
3160
- const ageBounded = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
3161
- if (ageBounded) {
3162
- const cutoff = Date.now() - retentionMs;
3163
- history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
3164
- }
3165
- if (pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) history = history.slice(-maxPerTopic);
3357
+ const history = pruneReplayHistory(
3358
+ (request.result?.messages ?? []).concat(topicMessages),
3359
+ { maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }
3360
+ );
3166
3361
  store.put({ topic, messages: history });
3167
3362
  };
3168
- request.onerror = () => {
3169
- if (hasError) return;
3170
- hasError = true;
3171
- invalidate(db);
3172
- reject(request.error ?? new Error("Failed to read replay history."));
3173
- };
3363
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3174
3364
  }
3175
3365
  transaction.oncomplete = () => {
3176
3366
  if (!hasError) resolve();
3177
3367
  };
3178
- transaction.onerror = () => {
3179
- if (hasError) return;
3180
- hasError = true;
3181
- invalidate(db);
3182
- reject(transaction.error ?? new Error("Failed to persist replay history."));
3183
- };
3368
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3369
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3184
3370
  });
3185
3371
  })();
3186
3372
  const open = () => {
@@ -3208,19 +3394,34 @@ function createIndexedDbReplayPersistence(options) {
3208
3394
  async load() {
3209
3395
  const db = await open();
3210
3396
  return new Promise((resolve, reject) => {
3397
+ let transaction;
3211
3398
  let request;
3212
3399
  try {
3213
- request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
3400
+ transaction = db.transaction(storeName, "readonly");
3401
+ request = transaction.objectStore(storeName).getAll();
3214
3402
  } catch (error) {
3215
3403
  invalidate(db);
3216
3404
  reject(error);
3217
3405
  return;
3218
3406
  }
3219
- request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
3220
- request.onerror = () => {
3407
+ let settled = false;
3408
+ const fail = (error) => {
3409
+ if (settled) return;
3410
+ settled = true;
3221
3411
  invalidate(db);
3222
- reject(request.error ?? new Error("Failed to load replay history."));
3412
+ reject(error);
3413
+ };
3414
+ let records = [];
3415
+ request.onsuccess = () => {
3416
+ records = request.result;
3223
3417
  };
3418
+ request.onerror = () => fail(request.error ?? new Error("Failed to load replay history."));
3419
+ transaction.oncomplete = () => {
3420
+ if (settled) return;
3421
+ settled = true;
3422
+ resolve(records.flatMap((record) => record.messages));
3423
+ };
3424
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to load replay history."));
3224
3425
  });
3225
3426
  },
3226
3427
  append(message) {
@@ -3244,12 +3445,20 @@ function createIndexedDbReplayPersistence(options) {
3244
3445
  reject(error);
3245
3446
  return;
3246
3447
  }
3247
- transaction.objectStore(storeName).clear();
3248
- transaction.oncomplete = () => resolve();
3249
- transaction.onerror = () => {
3448
+ let settled = false;
3449
+ const fail = (error) => {
3450
+ if (settled) return;
3451
+ settled = true;
3250
3452
  invalidate(db);
3251
- reject(transaction.error ?? new Error("Failed to clear replay history."));
3453
+ reject(error);
3454
+ };
3455
+ transaction.objectStore(storeName).clear();
3456
+ transaction.oncomplete = () => {
3457
+ settled = true;
3458
+ resolve();
3252
3459
  };
3460
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3461
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3253
3462
  });
3254
3463
  })()
3255
3464
  });
@@ -3268,12 +3477,20 @@ function createIndexedDbReplayPersistence(options) {
3268
3477
  reject(error);
3269
3478
  return;
3270
3479
  }
3271
- transaction.objectStore(storeName).delete(topic);
3272
- transaction.oncomplete = () => resolve();
3273
- transaction.onerror = () => {
3480
+ let settled = false;
3481
+ const fail = (error) => {
3482
+ if (settled) return;
3483
+ settled = true;
3274
3484
  invalidate(db);
3275
- reject(transaction.error ?? new Error("Failed to clear topic replay history."));
3485
+ reject(error);
3276
3486
  };
3487
+ transaction.objectStore(storeName).delete(topic);
3488
+ transaction.oncomplete = () => {
3489
+ settled = true;
3490
+ resolve();
3491
+ };
3492
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3493
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3277
3494
  });
3278
3495
  })()
3279
3496
  });
@@ -3292,6 +3509,13 @@ function createIndexedDbReplayPersistence(options) {
3292
3509
  reject(error);
3293
3510
  return;
3294
3511
  }
3512
+ let settled = false;
3513
+ const fail = (error) => {
3514
+ if (settled) return;
3515
+ settled = true;
3516
+ invalidate(db);
3517
+ reject(error);
3518
+ };
3295
3519
  const store = transaction.objectStore(storeName);
3296
3520
  const request = store.getAll();
3297
3521
  request.onsuccess = () => {
@@ -3301,15 +3525,13 @@ function createIndexedDbReplayPersistence(options) {
3301
3525
  else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3302
3526
  }
3303
3527
  };
3304
- request.onerror = () => {
3305
- invalidate(db);
3306
- reject(request.error ?? new Error("Failed to read replay history."));
3307
- };
3308
- transaction.oncomplete = () => resolve();
3309
- transaction.onerror = () => {
3310
- invalidate(db);
3311
- reject(transaction.error ?? new Error("Failed to prune replay history."));
3528
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3529
+ transaction.oncomplete = () => {
3530
+ settled = true;
3531
+ resolve();
3312
3532
  };
3533
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3534
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3313
3535
  });
3314
3536
  })()
3315
3537
  });