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.
@@ -1857,6 +1857,36 @@ function roundMs(value) {
1857
1857
  return Math.round(value * 10) / 10;
1858
1858
  }
1859
1859
 
1860
+ // src/core/replay-pruning.ts
1861
+ function pruneReplayHistory(messages, options) {
1862
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1863
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1864
+ if (!ageEnabled) {
1865
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1866
+ }
1867
+ const cutoff = now - retentionMs;
1868
+ let hasExpired = false;
1869
+ let timestamplessCount = 0;
1870
+ for (const message of messages) {
1871
+ if (message.timestamp === void 0) timestamplessCount += 1;
1872
+ else if (message.timestamp < cutoff) hasExpired = true;
1873
+ }
1874
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1875
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1876
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1877
+ }
1878
+ if (timestamplessCount <= maxPerTopic) return pruned;
1879
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1880
+ pruned = pruned.filter((message) => {
1881
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1882
+ timestamplessToDrop -= 1;
1883
+ return false;
1884
+ }
1885
+ return true;
1886
+ });
1887
+ return pruned;
1888
+ }
1889
+
1860
1890
  // src/core/replay-manager.ts
1861
1891
  var PersistenceRetryCancelledError = class extends Error {
1862
1892
  constructor() {
@@ -1918,17 +1948,15 @@ var ReplayManager = class {
1918
1948
  this.buffers.set(message.topic, buffer);
1919
1949
  }
1920
1950
  buffer.push(message);
1921
- const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1922
- if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1923
- while (buffer.length > this.maxPerTopic) buffer.shift();
1924
- }
1925
- if (ageBounded) {
1926
- const cutoff = this.now() - this.retentionMs;
1927
- while (buffer.length > 0) {
1928
- const first = buffer[0];
1929
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1930
- buffer.shift();
1931
- }
1951
+ const pruned = pruneReplayHistory(buffer, {
1952
+ maxPerTopic: this.maxPerTopic,
1953
+ pruneStrategy: this.pruneStrategy,
1954
+ retentionMs: this.retentionMs,
1955
+ now: this.now()
1956
+ });
1957
+ if (pruned !== buffer) {
1958
+ buffer = pruned;
1959
+ this.buffers.set(message.topic, buffer);
1932
1960
  }
1933
1961
  if (!this.persistence) return;
1934
1962
  if (this.persistence.appendBatch) {
@@ -2109,14 +2137,24 @@ var ReplayManager = class {
2109
2137
  if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2110
2138
  await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2111
2139
  }
2112
- for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2140
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2141
+ for (const message of loaded) {
2113
2142
  let buffer = this.buffers.get(message.topic);
2114
2143
  if (!buffer) {
2115
2144
  buffer = [];
2116
2145
  this.buffers.set(message.topic, buffer);
2117
2146
  }
2118
2147
  buffer.push(message);
2119
- if (buffer.length > this.maxPerTopic) buffer.shift();
2148
+ }
2149
+ const hydrationNow = this.now();
2150
+ for (const [topic, buffer] of this.buffers) {
2151
+ const pruned = pruneReplayHistory(buffer, {
2152
+ maxPerTopic: this.maxPerTopic,
2153
+ pruneStrategy: this.pruneStrategy,
2154
+ retentionMs: this.retentionMs,
2155
+ now: hydrationNow
2156
+ });
2157
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2120
2158
  }
2121
2159
  } catch (error) {
2122
2160
  this.onPersistenceError(error);
@@ -2293,7 +2331,7 @@ var DedupManager = class {
2293
2331
  };
2294
2332
 
2295
2333
  // src/core/version.ts
2296
- var SDK_VERSION = true ? "0.20.85" : "";
2334
+ var SDK_VERSION = true ? "0.20.86" : "";
2297
2335
 
2298
2336
  // src/core/data-bus.ts
2299
2337
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2331,6 +2369,24 @@ var CrossTabDataBus = class {
2331
2369
  // Gate that serialises start/stop/suspend/resume — only one lifecycle
2332
2370
  // transition at a time. Resets to null once the operation settles.
2333
2371
  startPromise = null;
2372
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2373
+ // start() received while stopping chains a fresh start after it.
2374
+ stopPromise = null;
2375
+ // A start() requested while an explicit stop() is still settling. Kept
2376
+ // separate from startPromise because stop()'s finally block clears the
2377
+ // ordinary lifecycle gate before the queued start is allowed to run.
2378
+ queuedStart = null;
2379
+ // Lazy readiness view of queuedStart. start() keeps its documented
2380
+ // resolve-on-cancellation contract, while ready() must reject when the
2381
+ // queued intent was superseded by a later stop().
2382
+ queuedStartReady = null;
2383
+ queuedStartReadyToken = 0;
2384
+ // The queued continuation is chained to the stop promise and cannot be
2385
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2386
+ // current intent by recording its token; a subsequent start() issues a
2387
+ // higher token so the latest lifecycle request still wins.
2388
+ queuedStartToken = 0;
2389
+ canceledQueuedStartToken = 0;
2334
2390
  // Timestamp of the last automatic transport recovery attempt.
2335
2391
  // Used to avoid a tight retry loop when the transport fails repeatedly.
2336
2392
  lastRecoveryAt = 0;
@@ -2353,6 +2409,9 @@ var CrossTabDataBus = class {
2353
2409
  // surfaces a failure while later opens and automatic recovery wait for the
2354
2410
  // stop to settle.
2355
2411
  pendingStop = null;
2412
+ // Ownership token for asynchronous transport opens. Every lifecycle
2413
+ // transition invalidates callbacks and failure cleanup from older opens.
2414
+ lifecycleEpoch = 0;
2356
2415
  // Minimum interval in ms between automatic recovery attempts.
2357
2416
  recoveryCooldownMs;
2358
2417
  recoveryMaxAttempts;
@@ -2465,37 +2524,51 @@ var CrossTabDataBus = class {
2465
2524
  * Start the DataBus with the given transport config.
2466
2525
  *
2467
2526
  * The first call starts the cluster and opens the transport. Concurrent calls
2468
- * during an in-flight start return the same promise. Once the operation
2469
- * settles (success or failure) the promise gate is cleared so a subsequent
2470
- * start() or resumeTransport() can open a fresh lifecycle.
2527
+ * during an in-flight open return the same promise. A call received while an
2528
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2529
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2530
+ * intent wins. Once an operation settles (success or failure) its promise
2531
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2532
+ * fresh lifecycle.
2471
2533
  */
2472
2534
  start(config) {
2473
- if (this.startPromise) return this.startPromise;
2474
- if (this.started) return Promise.resolve();
2535
+ if (this.queuedStart) return this.queuedStart;
2536
+ if (this.stopping) return this.queueStartAfterStop(config);
2537
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2538
+ if (this.started) {
2539
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2540
+ if (!transportDown) return Promise.resolve();
2541
+ this.activeConfig = config;
2542
+ this.resetFailureState();
2543
+ return this.reopenTransport();
2544
+ }
2475
2545
  this.started = true;
2476
2546
  this.stopping = false;
2477
2547
  this.suspended = false;
2478
2548
  this.activeConfig = config;
2479
- this.lastError = null;
2480
- this.lastFailure = null;
2481
- this.persistenceFailureCount = 0;
2482
- this.persistenceLastFailureAt = null;
2483
- this.persistenceLastErrorMessage = null;
2549
+ this.resetFailureState();
2484
2550
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2485
2551
  this.trace.start();
2486
2552
  this.startDedupSweep();
2487
2553
  this.replayManager.start();
2488
2554
  this.updateStatus(WORKER_STATUS.CONNECTING);
2489
2555
  this.cluster.start();
2490
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2556
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2557
+ const opening = this.openTransport(
2558
+ config,
2559
+ this.pendingStop ?? Promise.resolve(),
2560
+ true,
2561
+ lifecycleEpoch
2562
+ );
2491
2563
  this.startPromise = opening;
2492
2564
  for (const topic of this.topicHandlers.keys()) {
2493
2565
  this.cluster.subscribe(topic);
2494
2566
  }
2495
2567
  void opening.then(
2496
2568
  () => {
2569
+ if (this.startPromise !== opening) return;
2497
2570
  this.emitCoordinationTrace();
2498
- if (this.startPromise === opening) this.startPromise = null;
2571
+ this.startPromise = null;
2499
2572
  },
2500
2573
  () => {
2501
2574
  if (this.startPromise === opening) this.startPromise = null;
@@ -2503,24 +2576,80 @@ var CrossTabDataBus = class {
2503
2576
  );
2504
2577
  return opening;
2505
2578
  }
2579
+ /** Return a cancellation-aware readiness view of the current queued start. */
2580
+ getQueuedStartReady() {
2581
+ const queued = this.queuedStart;
2582
+ if (!queued) {
2583
+ return Promise.reject(new Error("No queued start is in flight."));
2584
+ }
2585
+ const token = this.queuedStartToken;
2586
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2587
+ return this.queuedStartReady;
2588
+ }
2589
+ this.queuedStartReadyToken = token;
2590
+ this.queuedStartReady = queued.then(() => {
2591
+ if (token <= this.canceledQueuedStartToken) {
2592
+ throw new Error(
2593
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2594
+ );
2595
+ }
2596
+ if (!this.started || !this.transportReady) {
2597
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2598
+ }
2599
+ });
2600
+ return this.queuedStartReady;
2601
+ }
2602
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2603
+ queueStartAfterStop(config) {
2604
+ if (this.queuedStart) return this.queuedStart;
2605
+ const stop = this.stopPromise ?? Promise.resolve();
2606
+ const token = ++this.queuedStartToken;
2607
+ const queued = stop.catch(() => void 0).then(() => {
2608
+ if (this.queuedStart === queued) this.queuedStart = null;
2609
+ if (token <= this.canceledQueuedStartToken) return;
2610
+ return this.start(config);
2611
+ });
2612
+ this.queuedStart = queued;
2613
+ return queued;
2614
+ }
2615
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2616
+ resetFailureState() {
2617
+ this.lastError = null;
2618
+ this.lastErrorAt = null;
2619
+ this.lastFailure = null;
2620
+ this.persistenceFailureCount = 0;
2621
+ this.persistenceLastFailureAt = null;
2622
+ this.persistenceLastErrorMessage = null;
2623
+ this.recoveryAttempt = 0;
2624
+ this.recoveryExhausted = false;
2625
+ this.lastRecoveryAt = 0;
2626
+ }
2506
2627
  /**
2507
2628
  * Open the transport, chained after `before` to ensure lifecycle ordering.
2508
2629
  * When `stopClusterOnFailure` is true (initial start), a transport failure
2509
2630
  * tears down the cluster as well.
2510
2631
  */
2511
- openTransport(config, before, stopClusterOnFailure) {
2632
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
2512
2633
  this.transportReady = false;
2513
2634
  const chainedPendingStop = this.pendingStop;
2635
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2514
2636
  return before.catch(() => void 0).then(() => {
2515
- if (this.stopping || this.suspended) return;
2637
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2516
2638
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2517
2639
  return Promise.resolve(
2518
2640
  this.transport.start(config, {
2519
- onMessage: (message) => this.handleTransportMessage(message),
2520
- onStatus: (status) => this.updateStatus(status),
2521
- onError: (error) => this.reportError(error)
2641
+ onMessage: (message) => {
2642
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2643
+ },
2644
+ onStatus: (status) => {
2645
+ if (isCurrentLifecycle()) this.updateStatus(status);
2646
+ },
2647
+ onError: (error) => {
2648
+ if (isCurrentLifecycle()) this.reportError(error);
2649
+ }
2522
2650
  })
2523
2651
  ).then(() => {
2652
+ if (!isCurrentLifecycle()) return;
2524
2653
  if (this.status === WORKER_STATUS.ERROR) {
2525
2654
  throw new Error("Transport failed during startup.");
2526
2655
  }
@@ -2531,6 +2660,7 @@ var CrossTabDataBus = class {
2531
2660
  }
2532
2661
  });
2533
2662
  }).catch((error) => {
2663
+ if (!isCurrentLifecycle()) throw error;
2534
2664
  if (stopClusterOnFailure) this.started = false;
2535
2665
  if (!this.pendingStop) {
2536
2666
  this.pendingStop = this.createStopPromise();
@@ -2545,16 +2675,26 @@ var CrossTabDataBus = class {
2545
2675
  this.cluster.stop();
2546
2676
  this.stopping = false;
2547
2677
  }
2548
- this.startPromise = null;
2549
2678
  throw error;
2550
2679
  });
2551
2680
  }
2552
2681
  /**
2553
2682
  * Await the DataBus to be fully started (lazy init when using initialConfig).
2554
2683
  * Returns a rejected promise when the transport has failed and no start is in
2555
- * flight — the caller can retry by calling start() or ready() again.
2684
+ * flight — the caller can retry by calling start() or ready() again. While an
2685
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2686
+ * it; false readiness during teardown is never reported.
2556
2687
  */
2557
2688
  ready() {
2689
+ if (this.queuedStart) return this.getQueuedStartReady();
2690
+ if (this.stopping) {
2691
+ return Promise.reject(new Error(
2692
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2693
+ ));
2694
+ }
2695
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2696
+ return Promise.reject(this.lastError);
2697
+ }
2558
2698
  try {
2559
2699
  this.ensureStarted();
2560
2700
  } catch (error) {
@@ -2570,9 +2710,18 @@ var CrossTabDataBus = class {
2570
2710
  /**
2571
2711
  * Register a handler for `topic`. The handler fires on every publication
2572
2712
  * delivered to this tab, regardless of which tab published it. Returns an
2573
- * unsubscribe function for convenience.
2713
+ * unsubscribe function for convenience. During an explicit stop() the
2714
+ * registration is rejected through onError and a no-op cleanup is returned,
2715
+ * so a late subscriber cannot leak into a future restart.
2574
2716
  */
2575
2717
  subscribe(topic, handler, options) {
2718
+ if (this.stopping) {
2719
+ this.reportError(new Error(
2720
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2721
+ ));
2722
+ return () => {
2723
+ };
2724
+ }
2576
2725
  this.ensureStarted();
2577
2726
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
2578
2727
  const wasUnused = handlers.size === 0;
@@ -2626,6 +2775,7 @@ var CrossTabDataBus = class {
2626
2775
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
2627
2776
  publish(topic, data, options) {
2628
2777
  this.ensureStarted();
2778
+ if (this.rejectPublishDuringStop("publish")) return;
2629
2779
  if (!this.cluster.publish(topic, data, options)) {
2630
2780
  this.reportError(
2631
2781
  new Error("Failed to send the publish control message to the owning worker.")
@@ -2642,6 +2792,7 @@ var CrossTabDataBus = class {
2642
2792
  publishBatch(topic, items) {
2643
2793
  this.ensureStarted();
2644
2794
  if (items.length === 0) return;
2795
+ if (this.rejectPublishDuringStop("publishBatch")) return;
2645
2796
  if (items.length === 1) {
2646
2797
  const first = items[0];
2647
2798
  this.publish(topic, first.data, first.options);
@@ -2770,10 +2921,33 @@ var CrossTabDataBus = class {
2770
2921
  }
2771
2922
  /**
2772
2923
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
2773
- * and close the transport. Idempotent.
2924
+ * and close the transport. Concurrent and repeated calls share the in-flight
2925
+ * stop promise. A start() received while stopping runs after this completes,
2926
+ * unless another stop() arrives first and cancels that queued restart.
2774
2927
  */
2775
- async stop() {
2776
- if (!this.started) return;
2928
+ stop() {
2929
+ if (this.queuedStart) {
2930
+ this.canceledQueuedStartToken = this.queuedStartToken;
2931
+ this.queuedStart = null;
2932
+ }
2933
+ if (this.stopPromise) return this.stopPromise;
2934
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2935
+ return Promise.resolve();
2936
+ }
2937
+ const stopPromise = this.performStop();
2938
+ this.stopPromise = stopPromise;
2939
+ void stopPromise.then(
2940
+ () => {
2941
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2942
+ },
2943
+ () => {
2944
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2945
+ }
2946
+ );
2947
+ return stopPromise;
2948
+ }
2949
+ async performStop() {
2950
+ this.lifecycleEpoch += 1;
2777
2951
  this.stopping = true;
2778
2952
  this.replayManager.suspend();
2779
2953
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
@@ -2965,6 +3139,7 @@ var CrossTabDataBus = class {
2965
3139
  */
2966
3140
  suspendTransport() {
2967
3141
  if (this.stopping) return;
3142
+ this.lifecycleEpoch += 1;
2968
3143
  this.suspended = true;
2969
3144
  this.transportReady = false;
2970
3145
  this.transportSubscribedTopics.clear();
@@ -3003,19 +3178,23 @@ var CrossTabDataBus = class {
3003
3178
  this.started = true;
3004
3179
  this.suspended = false;
3005
3180
  this.updateStatus(WORKER_STATUS.CONNECTING);
3181
+ const lifecycleEpoch = ++this.lifecycleEpoch;
3006
3182
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3007
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3183
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3008
3184
  this.startPromise = opening;
3009
3185
  void opening.then(
3010
3186
  () => {
3187
+ if (this.startPromise === opening) this.startPromise = null;
3188
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3011
3189
  if (traceAttempt !== void 0) {
3012
3190
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
3013
3191
  this.recoveryAttempt = 0;
3014
3192
  this.recoveryExhausted = false;
3015
3193
  }
3016
- if (this.startPromise === opening) this.startPromise = null;
3017
3194
  },
3018
3195
  () => {
3196
+ if (this.startPromise === opening) this.startPromise = null;
3197
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3019
3198
  if (traceAttempt !== void 0) {
3020
3199
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
3021
3200
  }
@@ -3049,6 +3228,19 @@ var CrossTabDataBus = class {
3049
3228
  return operation();
3050
3229
  }).catch((error) => this.reportError(error));
3051
3230
  }
3231
+ /**
3232
+ * Publications started after teardown begins cannot reach any transport.
3233
+ * Surface that as a normal asynchronous API failure instead of letting
3234
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3235
+ * and are filtered by the caller before this check.
3236
+ */
3237
+ rejectPublishDuringStop(operation) {
3238
+ if (!this.stopping) return false;
3239
+ this.reportError(new Error(
3240
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3241
+ ));
3242
+ return true;
3243
+ }
3052
3244
  /**
3053
3245
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
3054
3246
  * Called automatically by subscribe/publish/ready when autoStart is true.