cross-tab-worker-databus 0.21.24 → 0.21.26

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,48 @@
1
+ ## [0.21.26] - 2026-09-25
2
+
3
+ One false sentence about who cancels a persistence retry, corrected everywhere it had been written — found by an audit pass whose own scheduling step was wrong, which is the part worth reading.
4
+
5
+ ### Fixed
6
+
7
+ - **`retryGeneration` has exactly one writer, and it is not `stop()`.** Four comments in `replay-manager.ts` described the cancellation generation as bumped "on suspend/stop". This class defines *both* of those methods, and only `suspend()` increments the field — `stop()` clears the retention-sweep timer and cancels nothing in flight. A maintainer auditing "what supersedes an in-flight retry" who read `stop()` first would have found no bump and had to conclude either that the comment was right and the code was broken, or that the pair meant something else. `ReplayManager` appears nowhere in the barrel, so no consumer can reach the wrong model; the class's own callers can, and `stop()`'s only caller in `src/` is `suspend()`.
8
+ - **The half that was true is now stated at the level it is true at.** `CrossTabDataBus` has no `suspend()` method at all: it reaches the bump from two sites, the cluster's `onSuspend` handler and `beginStop()`. So a hidden *tab* and a stopped *bus* both do cancel their queued durable appends — through `ReplayManager.suspend()` — and that is exactly why the shorthand stayed plausible: the test that covers the behavior is named "cancels a pending persistence retry when the bus stops", and it passes whatever this class's `stop()` does. Nor was the sentence a stale remnant of code that once behaved that way — checked against `0cb8572`, the commit that split this file out, `stop()` has never touched the field; the pair has been false since the day it was written.
9
+ - **One corrected claim about the corrected claims, caught before it shipped.** The pass began with a scan that decided which zero-count branch arms still lacked a verdict at their site by reading the line above each one. It reported 21 unvisited legs on a ledger of 46 in which **every** arm already carries one, because those verdicts run five to fifteen lines and state the measurement that closes the arm. Reading the reported sites with a window is what turned the pass into a one-defect pass instead of a re-do of closed work.
10
+
11
+ ### Tests
12
+
13
+ - **None added, and that is the honest classification.** Nothing here moves behavior: the diff is comment lines only (`git diff -U0 src/` with every non-comment line filtered out returns nothing), the suite is unchanged at 936 tests, the perf gates at 5/5, and the branch-arm ledger at **46 zero-count arms of 1963** with aggregate 99.02 / 97.65 / 99.27 / 99.69 — byte-identical to `0.21.25`'s. A test asserting that `ReplayManager.stop()` cancels nothing would characterize an internal method with one caller rather than pin a contract, and this repository's rule is to name that difference instead of banking the green.
14
+
15
+ ### Documentation
16
+
17
+ - **`AGENTS.md` gained the scoping rule.** A scan is a probe, so it needs the control the probe rules already demand: run it on a site you know is answered and check it reports "present" before its output becomes a task list — a scan that cannot distinguish *absent* from *not looked at* schedules rework over closed ground, and that rework looks like progress. The second half of the rule is the pair-shorthand itself: when a comment names two methods as doing the same thing, check each name in the class that owns the thing, because the pair can be accurate about callers and false about callees while every test passes.
18
+ - **The shipped surface for a comment is wider than the declarations.** Verified rather than assumed: the corrected sentences appear in `dist/core/replay-manager.d.ts`, in the ESM chunk `dist/chunk-WKOCDMCH.js`, and in both CJS bundles (`dist/cjs/index.cjs`, `dist/cjs/centrifuge.cjs`) — esbuild preserves comments in the JS, and all of those paths are in `package.json` `files`.
19
+
20
+ ### Compatibility
21
+
22
+ Comment-only. No export, frame, option, default or storage key moved; `verify:compat`, `verify:types` and `verify:pack` pass against `v0.21.25`.
23
+
24
+ ## [0.21.25] - 2026-09-25
25
+
26
+ A publication's fan-out set is now fixed when its delivery begins, which closes a shape that did not terminate: a handler that registers a fresh closure for its own topic made the message that triggered the registration keep delivering to the handlers it created.
27
+
28
+ ### Fixed
29
+
30
+ - **`dispatch()` collects before it invokes.** Handler lists are held in a `Set` per topic, and `Set` iteration visits entries appended after the cursor while skipping entries deleted before it, so both directions of mutation were observable inside one delivery: a handler that subscribed another one for the same topic received the message that caused the subscription, and a handler that unsubscribed a later one cut it out of a delivery already in flight (measured: a two-handler topic delivered `['first']`). The first half does not terminate and did not need a hostile application to reach — the ordinary "re-arm this one-shot handler on every message" pattern, which registers a *new* closure each time, ran **500 handlers for a single publication** (that number is the probe's own ceiling, not a bound the library imposed) and the next publication delivered to **999**, because the registrations persist. Re-subscribing the *same* function reference is a no-op by `Set` identity, which is why the shape survived every prior pass over this file: the first probe written for it used a named function and measured one invocation.
31
+ - **What the contract is now.** Both passes — the exact-topic handlers and the handlers of every matching wildcard pattern — are collected before the first handler runs, so a message is delivered to the subscribers that existed when its delivery began, and a late `unsubscribe()` takes effect on the next publication instead of mid-message.
32
+
33
+ ### Tests
34
+
35
+ - **Three cases, and each one's failure message is the measurement.** `delivers a publication to the handlers that existed when its delivery began` fails as `expected 500 to be 1` against the previous code, `keeps a handler in a delivery that another handler already started` as `expected [ 'first' ] to deeply equal [ 'first', 'second' ]`, and `does not deliver a publication to a wildcard subscription made during it` as `expected [ 'exact', 'pattern' ] to deeply equal [ 'exact' ]`. The first is written with an explicit ceiling so a regression reports a count rather than hanging the runner. All three pass against the fix, and the 933 tests that already existed are unchanged by it — no prior test depended on mutating the fan-out mid-delivery.
36
+ - **The hot path was sized, not assumed.** The five `tests/perf-gate.test.ts` gates cover hashing, owner selection and pattern matching, not dispatch, so the snapshot was measured directly: best-of-seven over 200k iterations, **9-23 ns per dispatch** for 1-20 handlers and **+15 ns** for a matching wildcard pattern holding two handlers, against a shipped per-message budget in the tens of microseconds. Coverage did not move — 46 zero-count branch arms of 1963, aggregate 99.02 / 97.65 / 99.27 / 99.69 — because both arms of every branch added here were already exercised. Tests 933 → 936.
37
+
38
+ ### Documentation
39
+
40
+ - **`docs/api.md` and its Chinese mirror each gained one bullet** stating the delivery-set contract in the `subscribe()` list, so the rule is visible where a consumer reads it rather than only in the source.
41
+
42
+ ### Compatibility
43
+
44
+ Delivery to a handler registered *during* a dispatch, and cancellation of a handler unsubscribed *during* one, both change: those handlers no longer see the in-flight message, and that handler does. A consumer relying on either needed the pattern this fix stops (a self-growing fan-out), and no shipped default, frame, storage key, export or option moved. `verify:compat`, `verify:types` and `verify:pack` pass against `v0.21.24`.
45
+
1
46
  ## [0.21.24] - 2026-09-25
2
47
 
3
48
  Two lifecycle defects that the coverage ledger handed over as a by-product: a cluster runtime stopped during its own activation rebuilt everything its teardown had removed, and a `stop()` issued from one of its callbacks was dropped on the floor. A verdict now sits at every zero-count site the ledger had left bare.
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-IFSC44DS.js";
10
+ } from "./chunk-LN5O3IR6.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -1996,7 +1996,9 @@ var ReplayManager = class {
1996
1996
  trace;
1997
1997
  onPersistenceError;
1998
1998
  onDispatchError;
1999
- /** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
1999
+ /** Bumped by exactly one statement, in `suspend()` below — which is where the
2000
+ * `generation !== this.retryGeneration` tests throughout this file look for a
2001
+ * cancellation. This class's own `stop()` does not touch the field. */
2000
2002
  retryGeneration = 0;
2001
2003
  pendingReplayPersistence = [];
2002
2004
  persistenceFlushScheduled = false;
@@ -2155,13 +2157,19 @@ var ReplayManager = class {
2155
2157
  this.scheduleRetentionCleanup(this.now() - this.retentionMs);
2156
2158
  }, this.retentionSweepMs);
2157
2159
  }
2158
- /** Stop the periodic retention sweep. */
2160
+ /** Stop the periodic retention sweep. Its only caller in `src/` is `suspend()`,
2161
+ * which chains it at its end — the class is internal (`ReplayManager` appears
2162
+ * nowhere in the barrel), so no consumer reaches this method directly. It
2163
+ * cancels no in-flight work: that is the `retryGeneration` bump in `suspend()`. */
2159
2164
  stop() {
2160
2165
  if (this.retentionTimer) clearInterval(this.retentionTimer);
2161
2166
  this.retentionTimer = null;
2162
2167
  }
2163
- /** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
2164
- * or stopped bus does not keep hammering the store) and stop the sweep. */
2168
+ /** Cancel in-flight persistence retries by superseding their generation, so a
2169
+ * hidden tab or a stopped bus does not keep hammering the store. This is the
2170
+ * field's only writer; the bus reaches it from its two `replayManager.suspend()`
2171
+ * call sites (the cluster's `onSuspend` handler, and `beginStop()`), and this
2172
+ * class's own `stop()` only clears the sweep timer. */
2165
2173
  suspend() {
2166
2174
  this.retryGeneration += 1;
2167
2175
  if (this.hydration) {
@@ -2335,7 +2343,7 @@ var ReplayManager = class {
2335
2343
  });
2336
2344
  }
2337
2345
  /** Run a persistence operation with exponential backoff on transient failure.
2338
- * Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
2346
+ * The `suspend()` bump of `retryGeneration` cancels the loop early; a
2339
2347
  * structurally failing operation throws after `persistenceRetryMaxAttempts`,
2340
2348
  * leaving the ring buffer intact so the bus keeps working. */
2341
2349
  async withPersistenceRetry(persistenceOperation, operation) {
@@ -2491,7 +2499,7 @@ var SDK_VERSION = (
2491
2499
  // declaration's type is `string | undefined` and the export's is `string`
2492
2500
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2493
2501
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2494
- true ? "0.21.24" : ""
2502
+ true ? "0.21.26" : ""
2495
2503
  );
2496
2504
 
2497
2505
  // src/core/data-bus.ts
@@ -3305,15 +3313,21 @@ var CrossTabDataBus = class {
3305
3313
  }
3306
3314
  /** Deliver a message to every local handler registered for its topic,
3307
3315
  * plus every handler registered with a wildcard subscription that matches
3308
- * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
3316
+ * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). Both sets are
3317
+ * collected before the first handler runs, so a publication is delivered to the
3318
+ * subscribers that existed when its delivery began — see the note in the body. */
3309
3319
  dispatch(message) {
3310
3320
  this.trace.recordDispatched(message.topic);
3311
- this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
3321
+ const exactHandlers = this.topicHandlers.get(message.topic);
3322
+ const delivered = exactHandlers ? Array.from(exactHandlers) : [];
3323
+ const wildcards = [];
3312
3324
  for (const [pattern, handlers] of this.topicHandlers) {
3313
3325
  if (pattern !== message.topic && topicMatchesPattern(pattern, message.topic)) {
3314
- this.invokeHandlers(handlers, (handler) => handler(message));
3326
+ for (const handler of handlers) wildcards.push(handler);
3315
3327
  }
3316
3328
  }
3329
+ this.invokeHandlers(delivered, (handler) => handler(message));
3330
+ this.invokeHandlers(wildcards, (handler) => handler(message));
3317
3331
  this.replayManager.record(message);
3318
3332
  }
3319
3333
  /**
@@ -3725,4 +3739,4 @@ export {
3725
3739
  parseDataBusPublication,
3726
3740
  selectWorkerBackend
3727
3741
  };
3728
- //# sourceMappingURL=chunk-IFSC44DS.js.map
3742
+ //# sourceMappingURL=chunk-LN5O3IR6.js.map