@pylonsync/sync 0.3.201 → 0.3.203

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.
@@ -0,0 +1,209 @@
1
+ // Unit tests for the SubscriptionCoordinator in isolation. Engine
2
+ // integration is covered by `round6-codex.test.ts` and the scenarios
3
+ // suite — these tests just pin the contract:
4
+ // - CRDT add/remove is refcounted; only the first add + last remove
5
+ // ship a WS frame
6
+ // - leader path registers via serverSubs; follower path broadcasts
7
+ // - forwarded sub-register / sub-unregister update the leader's
8
+ // forwarder + owner sets
9
+ // - scrubPeer unregisters when the last owner leaves
10
+
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { ServerSubscriptions } from "./server-subscriptions";
14
+ import { SubscriptionCoordinator } from "./subscription-coordinator";
15
+
16
+ function makeHarness(initialLeader = true) {
17
+ let isLeader = initialLeader;
18
+ const wsSent: unknown[] = [];
19
+ const broadcasts: unknown[] = [];
20
+ const serverSubs = new ServerSubscriptions((msg) => wsSent.push(msg));
21
+ const coord = new SubscriptionCoordinator(serverSubs, {
22
+ isLeader: () => isLeader,
23
+ broadcastToTabs: (payload) => broadcasts.push(payload),
24
+ });
25
+ return {
26
+ coord,
27
+ serverSubs,
28
+ wsSent,
29
+ broadcasts,
30
+ setLeader(v: boolean) {
31
+ isLeader = v;
32
+ },
33
+ };
34
+ }
35
+
36
+ describe("SubscriptionCoordinator CRDT subs", () => {
37
+ test("leader: first subscribeCrdt registers WS sub; second is a no-op", () => {
38
+ const h = makeHarness(true);
39
+ h.coord.subscribeCrdt("Todo", "r1");
40
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
41
+ const before = h.wsSent.length;
42
+ h.coord.subscribeCrdt("Todo", "r1"); // refcount → 2
43
+ expect(h.wsSent.length).toBe(before); // no new WS frame
44
+ });
45
+
46
+ test("leader: unsubscribe matches refcount; last call ships WS unsubscribe", () => {
47
+ const h = makeHarness(true);
48
+ h.coord.subscribeCrdt("Todo", "r1");
49
+ h.coord.subscribeCrdt("Todo", "r1");
50
+ h.coord.unsubscribeCrdt("Todo", "r1"); // refcount 2 → 1
51
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
52
+ h.coord.unsubscribeCrdt("Todo", "r1"); // refcount 1 → 0
53
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
54
+ });
55
+
56
+ test("unsubscribe more times than subscribe is a no-op", () => {
57
+ const h = makeHarness(true);
58
+ expect(() => h.coord.unsubscribeCrdt("Todo", "ghost")).not.toThrow();
59
+ });
60
+
61
+ test("follower: subscribeCrdt broadcasts sub-register; no serverSubs entry", () => {
62
+ const h = makeHarness(false);
63
+ h.coord.subscribeCrdt("Todo", "r1");
64
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
65
+ expect(h.broadcasts).toEqual([
66
+ {
67
+ type: "sub-register",
68
+ kind: "crdt",
69
+ key: "Todo\x00r1",
70
+ entity: "Todo",
71
+ rowId: "r1",
72
+ },
73
+ ]);
74
+ });
75
+
76
+ test("leader keeps WS sub alive while a follower still has it forwarded", () => {
77
+ const h = makeHarness(true);
78
+ h.coord.handleForwardedRegister(
79
+ {
80
+ type: "sub-register",
81
+ kind: "crdt",
82
+ key: "Todo\x00r1",
83
+ entity: "Todo",
84
+ rowId: "r1",
85
+ },
86
+ "follower-a",
87
+ );
88
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
89
+ // Leader's own subscribe doesn't re-send (already alive via fwd).
90
+ const before = h.wsSent.length;
91
+ h.coord.subscribeCrdt("Todo", "r1");
92
+ expect(h.wsSent.length).toBe(before);
93
+ // Leader unsubscribe with follower still wanting it keeps the WS sub alive.
94
+ h.coord.unsubscribeCrdt("Todo", "r1");
95
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
96
+ });
97
+ });
98
+
99
+ describe("SubscriptionCoordinator reactive subs", () => {
100
+ test("leader: subscribeReactive registers + tracks self in owner set", () => {
101
+ const h = makeHarness(true);
102
+ h.coord.subscribeReactive("sub-1", "myFn", { v: 1 }, () => {});
103
+ expect(h.serverSubs.has("sub-1")).toBe(true);
104
+ });
105
+
106
+ test("leader: unsubscribeReactive drops self; only unregisters when no other owner", () => {
107
+ const h = makeHarness(true);
108
+ h.coord.subscribeReactive("sub-1", "myFn", { v: 1 }, () => {});
109
+ // Follower also forwarded the same sub_id (different mounts in different tabs).
110
+ h.coord.handleForwardedRegister(
111
+ {
112
+ type: "sub-register",
113
+ kind: "reactive",
114
+ key: "sub-1",
115
+ sub_id: "sub-1",
116
+ fn_name: "myFn",
117
+ args: { v: 1 },
118
+ },
119
+ "follower-a",
120
+ );
121
+ h.coord.unsubscribeReactive("sub-1");
122
+ // Follower still owns it → still registered.
123
+ expect(h.serverSubs.has("sub-1")).toBe(true);
124
+ h.coord.handleForwardedUnregister(
125
+ {
126
+ type: "sub-unregister",
127
+ kind: "reactive",
128
+ key: "sub-1",
129
+ sub_id: "sub-1",
130
+ },
131
+ "follower-a",
132
+ );
133
+ // Now last owner left → unregistered.
134
+ expect(h.serverSubs.has("sub-1")).toBe(false);
135
+ });
136
+
137
+ test("inbound reactive-msg routes to the local handler", () => {
138
+ const h = makeHarness(true);
139
+ const received: unknown[] = [];
140
+ h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, (msg) =>
141
+ received.push(msg),
142
+ );
143
+ h.coord.handleReactiveMessage("sub-1", { kind: "result", result: 42 });
144
+ expect(received).toEqual([{ kind: "result", result: 42 }]);
145
+ });
146
+
147
+ test("follower: subscribeReactive broadcasts sub-register", () => {
148
+ const h = makeHarness(false);
149
+ h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, () => {});
150
+ expect(h.broadcasts).toEqual([
151
+ {
152
+ type: "sub-register",
153
+ kind: "reactive",
154
+ key: "sub-1",
155
+ sub_id: "sub-1",
156
+ fn_name: "fn",
157
+ args: { v: 1 },
158
+ },
159
+ ]);
160
+ });
161
+ });
162
+
163
+ describe("SubscriptionCoordinator scrubPeer + replays", () => {
164
+ test("scrubPeer removes a follower's CRDT entries and unregisters when last", () => {
165
+ const h = makeHarness(true);
166
+ h.coord.handleForwardedRegister(
167
+ {
168
+ type: "sub-register",
169
+ kind: "crdt",
170
+ key: "Todo\x00r1",
171
+ entity: "Todo",
172
+ rowId: "r1",
173
+ },
174
+ "follower-a",
175
+ );
176
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
177
+ h.coord.scrubPeer("follower-a");
178
+ expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
179
+ });
180
+
181
+ test("seedFromLocalInterest re-registers wanted subs on the new leader", () => {
182
+ const h = makeHarness(false);
183
+ // Mount a reactive sub as follower (broadcasts only).
184
+ h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, () => {});
185
+ expect(h.serverSubs.has("sub-1")).toBe(false);
186
+ // Promotion: now we're leader; seed should register via serverSubs.
187
+ h.setLeader(true);
188
+ h.coord.seedFromLocalInterest();
189
+ expect(h.serverSubs.has("sub-1")).toBe(true);
190
+ });
191
+
192
+ test("hasCrdtForwarders reflects forwarder presence", () => {
193
+ const h = makeHarness(true);
194
+ expect(h.coord.hasCrdtForwarders()).toBe(false);
195
+ h.coord.handleForwardedRegister(
196
+ {
197
+ type: "sub-register",
198
+ kind: "crdt",
199
+ key: "Todo\x00r1",
200
+ entity: "Todo",
201
+ rowId: "r1",
202
+ },
203
+ "follower-a",
204
+ );
205
+ expect(h.coord.hasCrdtForwarders()).toBe(true);
206
+ h.coord.scrubPeer("follower-a");
207
+ expect(h.coord.hasCrdtForwarders()).toBe(false);
208
+ });
209
+ });
@@ -0,0 +1,471 @@
1
+ // SubscriptionCoordinator — owns every "this tab wants live updates"
2
+ // subscription, regardless of kind (CRDT row, reactive query) or role
3
+ // (this tab is leader vs follower).
4
+ //
5
+ // Two kinds of subscriptions live here:
6
+ //
7
+ // 1. CRDT row subs (`subscribeCrdt(entity, rowId)`) — refcounted
8
+ // per-tab because N `useLoroDoc` components on the same row share
9
+ // one server subscription. The first add and last remove ship a
10
+ // WS frame; the in-between calls just bump the count.
11
+ //
12
+ // 2. Reactive query subs (`subscribeReactive(sub_id, fn_name, args, handler)`)
13
+ // — Convex-shape. The sub_id is per-mount; multiple mounts of the
14
+ // same query get different sub_ids. Each sub_id has at most one
15
+ // handler that fires for inbound `reactive-result` / `reactive-error`.
16
+ //
17
+ // Leader / follower split:
18
+ //
19
+ // - LEADER tabs own a real WebSocket and forward each sub to it via
20
+ // `serverSubs.register`. They also track which FOLLOWER tabs are
21
+ // interested in each key (the forwarder/owner sets), so the WS
22
+ // sub stays alive as long as any tab in the origin still wants it.
23
+ //
24
+ // - FOLLOWER tabs don't have a WS. They broadcast `sub-register` /
25
+ // `sub-unregister` envelopes over the BroadcastChannel; the leader
26
+ // picks those up and routes them through `handleForwardedRegister`
27
+ // / `handleForwardedUnregister`. Inbound `reactive-result` lands
28
+ // on the leader's WS, the leader broadcasts `reactive-msg` back
29
+ // across tabs, and `handleReactiveMessage` routes to the local
30
+ // handler on whichever tab originally subscribed.
31
+ //
32
+ // Lifecycle hooks (called by the engine):
33
+ //
34
+ // - `seedFromLocalInterest()` — when this tab settles as leader, register
35
+ // every locally-wanted sub with `serverSubs` so the next WS connect
36
+ // replays them.
37
+ // - `replayForwardedSubs()` — when a fresh leader asks for replay, every
38
+ // follower re-broadcasts its currently-wanted subs.
39
+ // - `scrubPeer(tabId)` — when a peer tab disappears (`bye`), drop its
40
+ // entries from forwarder/owner sets and unregister WS subs that
41
+ // are no longer wanted by anyone.
42
+
43
+ import type { ServerSubscriptions } from "./server-subscriptions";
44
+ import type { ReactiveMessage } from "./types";
45
+
46
+ /** Sentinel for "this leader tab subscribed locally" — used as a
47
+ * refcount-bearer key in reactive owner sets so the leader's own
48
+ * subs don't get conflated with forwarded follower subs. */
49
+ const OWN_TAB = "__self__";
50
+
51
+ /** Engine-side surface the coordinator depends on. Kept narrow so the
52
+ * coordinator's contract with the engine is explicit. */
53
+ export interface SubscriptionCoordinatorContext {
54
+ isLeader(): boolean;
55
+ broadcastToTabs(payload: unknown): void;
56
+ }
57
+
58
+ interface ReactiveSpec {
59
+ fn_name: string;
60
+ args: unknown;
61
+ }
62
+
63
+ export class SubscriptionCoordinator {
64
+ /** Per-row local refcount for CRDT subscriptions — N `useLoroDoc`
65
+ * consumers on the same `(entity, rowId)` in THIS tab share a
66
+ * single server subscription. Distinct from `reactiveSubOwners`:
67
+ * CRDT keys are per-row (many consumers per tab) while reactive
68
+ * sub_ids are per-consumer-instance (typically one). StrictMode
69
+ * double-subscribe still bumps the count by one each call; the
70
+ * matching double-unsubscribe early-returns when the count is
71
+ * already zero, so the math balances. */
72
+ private crdtSubscribers: Map<string, number> = new Map();
73
+
74
+ /** Per-row set of FOLLOWER tabIds that have forwarded a
75
+ * `sub-register` for this key. Leader-only. Used to:
76
+ * (a) skip the WS unsubscribe until both `crdtSubscribers`
77
+ * and this set are empty, and
78
+ * (b) skip the cross-tab binary broadcast when no follower
79
+ * cares about CRDT (saves bandwidth when the leader is
80
+ * the only CRDT consumer). */
81
+ private crdtForwarders: Map<string, Set<string>> = new Map();
82
+
83
+ /** Per-sub_id ownership set for reactive subscriptions on the
84
+ * leader: which tabs (self + forwarders) want this sub alive.
85
+ * A SET, not a count, so a follower crash + late `sub-unregister`
86
+ * storm can't underflow the count, and a remount/StrictMode
87
+ * double-subscribe from one tab still counts as one owner. */
88
+ private reactiveSubOwners: Map<string, Set<string>> = new Map();
89
+
90
+ /** Inbound message routing for reactive subscriptions — server pushes
91
+ * `reactive-result` / `reactive-error` envelopes keyed by sub_id and
92
+ * the hook's handler lives here. */
93
+ private reactiveHandlers: Map<string, (msg: ReactiveMessage) => void> =
94
+ new Map();
95
+
96
+ /** Specs (fn_name + args) for every reactive subscription this tab
97
+ * has registered, regardless of role. On promotion the new leader
98
+ * uses these to register with serverSubs; on a leader change while
99
+ * we stay a follower we use them to re-forward `sub-register` to
100
+ * the new leader. */
101
+ private wantedReactiveSpecs: Map<string, ReactiveSpec> = new Map();
102
+
103
+ constructor(
104
+ private readonly serverSubs: ServerSubscriptions,
105
+ private readonly ctx: SubscriptionCoordinatorContext,
106
+ ) {}
107
+
108
+ // ---- CRDT subscriptions ------------------------------------------------
109
+
110
+ /** Refcounted CRDT row subscribe. First subscriber for the row sends
111
+ * the WS frame (leader) or forwards to the leader (follower).
112
+ * Idempotent at the WS level: re-calling with no intervening
113
+ * unsubscribe just bumps the count. */
114
+ subscribeCrdt(entity: string, rowId: string): void {
115
+ const key = crdtKey(entity, rowId);
116
+ const prev = this.crdtSubscribers.get(key) ?? 0;
117
+ this.crdtSubscribers.set(key, prev + 1);
118
+ if (prev !== 0) return;
119
+ if (this.ctx.isLeader()) {
120
+ // Leader: only send the WS subscribe if no follower had already
121
+ // forwarded one (in which case the WS sub is already alive).
122
+ const hasFwd = (this.crdtForwarders.get(key)?.size ?? 0) > 0;
123
+ if (!hasFwd) {
124
+ this.serverSubs.register(key, {
125
+ type: "crdt-subscribe",
126
+ entity,
127
+ rowId,
128
+ });
129
+ }
130
+ } else {
131
+ // Follower: ask the leader to subscribe on our behalf. The leader
132
+ // echoes binary frames back over the broadcast channel so our
133
+ // local binaryHandlers fire normally.
134
+ this.ctx.broadcastToTabs({
135
+ type: "sub-register",
136
+ kind: "crdt",
137
+ key,
138
+ entity,
139
+ rowId,
140
+ });
141
+ }
142
+ }
143
+
144
+ /** Refcount-aware CRDT row unsubscribe. Last unsubscribe ships the
145
+ * WS frame; intermediate calls just decrement. Calling more times
146
+ * than `subscribeCrdt` is a no-op (StrictMode-safe). */
147
+ unsubscribeCrdt(entity: string, rowId: string): void {
148
+ const key = crdtKey(entity, rowId);
149
+ const prev = this.crdtSubscribers.get(key) ?? 0;
150
+ if (prev <= 0) return;
151
+ if (prev > 1) {
152
+ this.crdtSubscribers.set(key, prev - 1);
153
+ return;
154
+ }
155
+ this.crdtSubscribers.delete(key);
156
+ if (this.ctx.isLeader()) {
157
+ const remainingFwd = this.crdtForwarders.get(key)?.size ?? 0;
158
+ if (remainingFwd === 0 && this.serverSubs.has(key)) {
159
+ this.serverSubs.unregister(key, {
160
+ type: "crdt-unsubscribe",
161
+ entity,
162
+ rowId,
163
+ });
164
+ }
165
+ } else {
166
+ this.ctx.broadcastToTabs({
167
+ type: "sub-unregister",
168
+ kind: "crdt",
169
+ key,
170
+ entity,
171
+ rowId,
172
+ });
173
+ }
174
+ }
175
+
176
+ // ---- Reactive query subscriptions --------------------------------------
177
+
178
+ /** Register a reactive query subscription. The caller-minted `sub_id`
179
+ * is used by the React hook to dispatch result/error pushes to the
180
+ * right component.
181
+ *
182
+ * Idempotent: re-calling with the same `sub_id` replaces the prior
183
+ * handler + spec. Useful when args change and the hook re-registers
184
+ * — ServerSubscriptions re-sends on payload change, so the WS sees
185
+ * the new args. */
186
+ subscribeReactive(
187
+ sub_id: string,
188
+ fn_name: string,
189
+ args: unknown,
190
+ handler: (msg: ReactiveMessage) => void,
191
+ ): void {
192
+ this.reactiveHandlers.set(sub_id, handler);
193
+ this.wantedReactiveSpecs.set(sub_id, { fn_name, args });
194
+ if (!this.ctx.isLeader()) {
195
+ // Follower path: the WS lives on the leader. Forward the spec
196
+ // there; the leader registers with its own serverSubs and echoes
197
+ // inbound envelopes back to us via the channel.
198
+ this.ctx.broadcastToTabs({
199
+ type: "sub-register",
200
+ kind: "reactive",
201
+ key: sub_id,
202
+ sub_id,
203
+ fn_name,
204
+ args,
205
+ });
206
+ return;
207
+ }
208
+ let owners = this.reactiveSubOwners.get(sub_id);
209
+ if (!owners) {
210
+ owners = new Set();
211
+ this.reactiveSubOwners.set(sub_id, owners);
212
+ }
213
+ owners.add(OWN_TAB);
214
+ this.serverSubs.register(sub_id, {
215
+ type: "reactive-subscribe",
216
+ sub_id,
217
+ fn_name,
218
+ args,
219
+ });
220
+ }
221
+
222
+ /** Tear down a reactive subscription. No-op for unknown sub_ids —
223
+ * React StrictMode double-unmount doesn't error. */
224
+ unsubscribeReactive(sub_id: string): void {
225
+ if (!this.reactiveHandlers.has(sub_id)) return;
226
+ this.reactiveHandlers.delete(sub_id);
227
+ this.wantedReactiveSpecs.delete(sub_id);
228
+ if (!this.ctx.isLeader()) {
229
+ this.ctx.broadcastToTabs({
230
+ type: "sub-unregister",
231
+ kind: "reactive",
232
+ key: sub_id,
233
+ sub_id,
234
+ });
235
+ return;
236
+ }
237
+ const owners = this.reactiveSubOwners.get(sub_id);
238
+ if (owners) {
239
+ owners.delete(OWN_TAB);
240
+ if (owners.size === 0) {
241
+ this.reactiveSubOwners.delete(sub_id);
242
+ if (this.serverSubs.has(sub_id)) {
243
+ this.serverSubs.unregister(sub_id, {
244
+ type: "reactive-unsubscribe",
245
+ sub_id,
246
+ });
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // ---- Inbound from the multi-tab broker ---------------------------------
253
+
254
+ /** Follower → leader: register a WS subscription on the follower's
255
+ * behalf. Caller (engine) has already gated on `isLeader()`. */
256
+ handleForwardedRegister(
257
+ msg: Record<string, unknown>,
258
+ fromTabId: string,
259
+ ): void {
260
+ const kind = msg.kind as string;
261
+ if (kind === "crdt") {
262
+ const key = msg.key as string;
263
+ const entity = msg.entity as string;
264
+ const rowId = msg.rowId as string;
265
+ // Track the forwarding follower in a separate set (NOT the
266
+ // local refcount) — repeated sub-register from the same
267
+ // follower stays idempotent, and a follower crash before
268
+ // sub-unregister is cleaned up by `scrubPeer` when the
269
+ // broker's `onLeave` fires.
270
+ let fwd = this.crdtForwarders.get(key);
271
+ if (!fwd) {
272
+ fwd = new Set();
273
+ this.crdtForwarders.set(key, fwd);
274
+ }
275
+ fwd.add(fromTabId);
276
+ // Register if nothing else owned this key — local count and
277
+ // forwarder set together gate the WS subscribe.
278
+ const localCount = this.crdtSubscribers.get(key) ?? 0;
279
+ if (localCount === 0 && fwd.size === 1) {
280
+ this.serverSubs.register(key, {
281
+ type: "crdt-subscribe",
282
+ entity,
283
+ rowId,
284
+ });
285
+ }
286
+ return;
287
+ }
288
+ if (kind === "reactive") {
289
+ const sub_id = msg.sub_id as string;
290
+ const fn_name = msg.fn_name as string;
291
+ const args = msg.args;
292
+ let owners = this.reactiveSubOwners.get(sub_id);
293
+ if (!owners) {
294
+ owners = new Set();
295
+ this.reactiveSubOwners.set(sub_id, owners);
296
+ }
297
+ owners.add(fromTabId);
298
+ this.serverSubs.register(sub_id, {
299
+ type: "reactive-subscribe",
300
+ sub_id,
301
+ fn_name,
302
+ args,
303
+ });
304
+ }
305
+ }
306
+
307
+ /** Follower → leader: unregister a WS subscription on the follower's
308
+ * behalf. */
309
+ handleForwardedUnregister(
310
+ msg: Record<string, unknown>,
311
+ fromTabId: string,
312
+ ): void {
313
+ const kind = msg.kind as string;
314
+ if (kind === "crdt") {
315
+ const key = msg.key as string;
316
+ const entity = msg.entity as string;
317
+ const rowId = msg.rowId as string;
318
+ const fwd = this.crdtForwarders.get(key);
319
+ if (fwd) {
320
+ fwd.delete(fromTabId);
321
+ if (fwd.size === 0) this.crdtForwarders.delete(key);
322
+ }
323
+ const localCount = this.crdtSubscribers.get(key) ?? 0;
324
+ const remainingFwd = this.crdtForwarders.get(key)?.size ?? 0;
325
+ if (localCount === 0 && remainingFwd === 0 && this.serverSubs.has(key)) {
326
+ this.serverSubs.unregister(key, {
327
+ type: "crdt-unsubscribe",
328
+ entity,
329
+ rowId,
330
+ });
331
+ }
332
+ return;
333
+ }
334
+ if (kind === "reactive") {
335
+ const sub_id = msg.sub_id as string;
336
+ const owners = this.reactiveSubOwners.get(sub_id);
337
+ if (!owners) return;
338
+ owners.delete(fromTabId);
339
+ if (owners.size === 0) {
340
+ this.reactiveSubOwners.delete(sub_id);
341
+ if (this.serverSubs.has(sub_id)) {
342
+ this.serverSubs.unregister(sub_id, {
343
+ type: "reactive-unsubscribe",
344
+ sub_id,
345
+ });
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ /** Leader → follower: a `reactive-result` / `reactive-error` envelope
352
+ * arrived on the leader's WS. Route to the local handler if we
353
+ * registered the sub. */
354
+ handleReactiveMessage(sub_id: string, payload: ReactiveMessage): void {
355
+ const handler = this.reactiveHandlers.get(sub_id);
356
+ if (handler) handler(payload);
357
+ }
358
+
359
+ // ---- Leader / follower transitions -------------------------------------
360
+
361
+ /** Seed serverSubs with every subscription this tab currently wants.
362
+ * Called when this tab settles as leader (either at start() or via
363
+ * late promotion) so the next WS connect replays the bundle.
364
+ * Idempotent: serverSubs.register dedupes by payload equality. */
365
+ seedFromLocalInterest(): void {
366
+ for (const [sub_id, spec] of this.wantedReactiveSpecs) {
367
+ this.serverSubs.register(sub_id, {
368
+ type: "reactive-subscribe",
369
+ sub_id,
370
+ fn_name: spec.fn_name,
371
+ args: spec.args,
372
+ });
373
+ let owners = this.reactiveSubOwners.get(sub_id);
374
+ if (!owners) {
375
+ owners = new Set();
376
+ this.reactiveSubOwners.set(sub_id, owners);
377
+ }
378
+ owners.add(OWN_TAB);
379
+ }
380
+ for (const [key] of this.crdtSubscribers) {
381
+ const [entity, rowId] = key.split("\x00");
382
+ if (entity && rowId !== undefined) {
383
+ this.serverSubs.register(key, {
384
+ type: "crdt-subscribe",
385
+ entity,
386
+ rowId,
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ /** Re-broadcast every locally-wanted sub to the (new) leader.
393
+ * Triggered by `request-sub-replay` after a leader change while we
394
+ * stayed a follower. */
395
+ replayForwardedSubs(): void {
396
+ for (const [sub_id, spec] of this.wantedReactiveSpecs) {
397
+ this.ctx.broadcastToTabs({
398
+ type: "sub-register",
399
+ kind: "reactive",
400
+ key: sub_id,
401
+ sub_id,
402
+ fn_name: spec.fn_name,
403
+ args: spec.args,
404
+ });
405
+ }
406
+ for (const [key] of this.crdtSubscribers) {
407
+ const [entity, rowId] = key.split("\x00");
408
+ if (entity && rowId !== undefined) {
409
+ this.ctx.broadcastToTabs({
410
+ type: "sub-register",
411
+ kind: "crdt",
412
+ key,
413
+ entity,
414
+ rowId,
415
+ });
416
+ }
417
+ }
418
+ }
419
+
420
+ /** A peer tab disappeared (broker `bye` fired). Drop it from every
421
+ * forwarder/owner set; if a key drops to zero remaining owners
422
+ * (no local consumer, no forwarder) we unregister the WS sub so
423
+ * the server stops fanning at a tab that no longer exists. */
424
+ scrubPeer(tabId: string): void {
425
+ for (const [key, fwd] of this.crdtForwarders) {
426
+ if (!fwd.delete(tabId)) continue;
427
+ if (fwd.size === 0) {
428
+ this.crdtForwarders.delete(key);
429
+ const localCount = this.crdtSubscribers.get(key) ?? 0;
430
+ if (localCount === 0 && this.serverSubs.has(key)) {
431
+ const [entity, rowId] = key.split("\x00");
432
+ if (entity && rowId !== undefined) {
433
+ this.serverSubs.unregister(key, {
434
+ type: "crdt-unsubscribe",
435
+ entity,
436
+ rowId,
437
+ });
438
+ }
439
+ }
440
+ }
441
+ }
442
+ for (const [sub_id, owners] of this.reactiveSubOwners) {
443
+ if (!owners.delete(tabId)) continue;
444
+ if (owners.size === 0) {
445
+ this.reactiveSubOwners.delete(sub_id);
446
+ if (this.serverSubs.has(sub_id)) {
447
+ this.serverSubs.unregister(sub_id, {
448
+ type: "reactive-unsubscribe",
449
+ sub_id,
450
+ });
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ // ---- Predicates used by the engine -------------------------------------
457
+
458
+ /** True when at least one follower tab has forwarded a CRDT sub.
459
+ * The engine gates its binary-broadcast on this so we don't fan
460
+ * binary frames to every tab when no follower cares about CRDT. */
461
+ hasCrdtForwarders(): boolean {
462
+ return this.crdtForwarders.size > 0;
463
+ }
464
+ }
465
+
466
+ /** Internal CRDT key format: `${entity}\x00${rowId}`. Centralized so
467
+ * the engine's binary-frame parser (and any future routing logic)
468
+ * agrees with the coordinator on the wire shape. */
469
+ export function crdtKey(entity: string, rowId: string): string {
470
+ return `${entity}\x00${rowId}`;
471
+ }