@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.
@@ -318,6 +318,80 @@ export class LocalStore {
318
318
  this.notify();
319
319
  }
320
320
 
321
+ /**
322
+ * Apply a reconcile result against the local replica. Reconcile is
323
+ * "the server says these are the canonical rows right now" — it does
324
+ * NOT carry per-event seqs. Distinct from `applyChange` (which
325
+ * threads server-issued change events through a monotonic seq
326
+ * guard + cursor advance).
327
+ *
328
+ * `tombstoneSeq` is the cursor at the start of the reconcile fetch.
329
+ * - Upserts skip rows whose tombstone is fresher (would be stale
330
+ * resurrections of a later delete).
331
+ * - Removals tombstone at `tombstoneSeq` so a later legitimate
332
+ * re-create (with a strictly greater seq) flows through.
333
+ *
334
+ * Cursor is NOT advanced — reconcile fabricates no real seqs, so
335
+ * the engine's cursor stays pinned to the change-log position the
336
+ * reconcile started from.
337
+ */
338
+ async applyReconcileBatch(
339
+ entity: string,
340
+ upserts: Row[],
341
+ removalIds: string[],
342
+ tombstoneSeq: number,
343
+ ): Promise<void> {
344
+ if (!this.tables.has(entity)) this.tables.set(entity, new Map());
345
+ const table = this.tables.get(entity)!;
346
+ const applied: Array<{ id: string; row: Row }> = [];
347
+ for (const row of upserts) {
348
+ const id = (row as { id?: unknown }).id;
349
+ if (typeof id !== "string" || id.length === 0) continue;
350
+ // Treat the upsert as effective at `tombstoneSeq + 1`. Anything
351
+ // tombstoned at a HIGHER seq is fresher and wins; the upsert is
352
+ // a stale view that pre-dated the delete.
353
+ if (this.isTombstoned(entity, id, tombstoneSeq + 1)) continue;
354
+ const merged = { ...row, id };
355
+ table.set(id, merged);
356
+ applied.push({ id, row: merged });
357
+ }
358
+ const removed: string[] = [];
359
+ for (const id of removalIds) {
360
+ if (this.reconcileRemove(entity, id, tombstoneSeq)) {
361
+ removed.push(id);
362
+ }
363
+ }
364
+ if (applied.length > 0 || removed.length > 0) this.notify();
365
+ // Persist sequentially so disk order matches memory order — same
366
+ // discipline as applyChangesAsync.
367
+ if (this._persistFn) {
368
+ for (const { id, row } of applied) {
369
+ const ev: ChangeEvent = {
370
+ seq: tombstoneSeq + 1,
371
+ entity,
372
+ row_id: id,
373
+ kind: "insert",
374
+ data: row,
375
+ timestamp: "",
376
+ };
377
+ const result = this._persistFn(ev);
378
+ if (result instanceof Promise) await result;
379
+ }
380
+ for (const id of removed) {
381
+ const ev: ChangeEvent = {
382
+ seq: tombstoneSeq,
383
+ entity,
384
+ row_id: id,
385
+ kind: "delete",
386
+ data: undefined as unknown as Row,
387
+ timestamp: "",
388
+ };
389
+ const result = this._persistFn(ev);
390
+ if (result instanceof Promise) await result;
391
+ }
392
+ }
393
+ }
394
+
321
395
  /**
322
396
  * Drop every table + tombstone in-place, then notify. Used by the
323
397
  * sync engine's `resetReplica()` on identity flip (token or tenant
@@ -0,0 +1,173 @@
1
+ // Unit tests for MultiTabOrchestrator. Drives the dispatch table by
2
+ // calling handleMessage() directly — same path the BroadcastChannel's
3
+ // onmessage hits in production. The broker itself is stubbed so the
4
+ // election + heartbeat machinery doesn't fire during these tests.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
9
+ import { ServerSubscriptions } from "./server-subscriptions";
10
+ import { SubscriptionCoordinator } from "./subscription-coordinator";
11
+ import type { ChangeEvent, ResolvedSession } from "./types";
12
+
13
+ function makeRig(opts: { isLeader?: boolean } = {}) {
14
+ const events: { kind: string; args: unknown[] }[] = [];
15
+ const record = (kind: string) =>
16
+ (...args: unknown[]) => {
17
+ events.push({ kind, args });
18
+ };
19
+ const serverSubs = new ServerSubscriptions(() => {});
20
+ const subs = new SubscriptionCoordinator(serverSubs, {
21
+ isLeader: () => opts.isLeader ?? true,
22
+ broadcastToTabs: () => {},
23
+ });
24
+ const orch = new MultiTabOrchestrator(
25
+ { enabled: false, appName: "test" },
26
+ subs,
27
+ {
28
+ onInitialLeader: record("initialLeader"),
29
+ onLatePromote: record("latePromote"),
30
+ onDemote: record("demote"),
31
+ onAppliedReceived: record("applied"),
32
+ onReconciledReceived: record("reconciled"),
33
+ onResetReceived: record("reset"),
34
+ onSessionReceived: record("session"),
35
+ onMutationsForwarded: record("mutationsForwarded"),
36
+ onMutationsAcked: record("mutationsAcked"),
37
+ onMutationsFailed: record("mutationsFailed"),
38
+ onBinaryReceived: record("binary"),
39
+ onPeerLeft: record("peerLeft"),
40
+ },
41
+ );
42
+ return { orch, events, serverSubs, subs };
43
+ }
44
+
45
+ describe("MultiTabOrchestrator dispatch", () => {
46
+ test("applied envelope fires onAppliedReceived with changes + cursor", () => {
47
+ const { orch, events } = makeRig();
48
+ const change: ChangeEvent = {
49
+ seq: 7,
50
+ entity: "Todo",
51
+ row_id: "r1",
52
+ kind: "insert",
53
+ data: { id: "r1" },
54
+ timestamp: "",
55
+ };
56
+ orch.handleMessage(
57
+ { type: "applied", changes: [change], targetCursor: { last_seq: 7 } },
58
+ "leader-x",
59
+ );
60
+ expect(events.length).toBe(1);
61
+ expect(events[0].kind).toBe("applied");
62
+ const [changes, cursor] = events[0].args as [ChangeEvent[], { last_seq: number }];
63
+ expect(changes[0].seq).toBe(7);
64
+ expect(cursor.last_seq).toBe(7);
65
+ });
66
+
67
+ test("session envelope fires onSessionReceived", () => {
68
+ const { orch, events } = makeRig();
69
+ const resolved: ResolvedSession = {
70
+ userId: "u1",
71
+ tenantId: "org-a",
72
+ isAdmin: false,
73
+ roles: [],
74
+ };
75
+ orch.handleMessage({ type: "session", resolved }, "leader-x");
76
+ expect(events.length).toBe(1);
77
+ expect(events[0].kind).toBe("session");
78
+ expect(events[0].args[0]).toEqual(resolved);
79
+ });
80
+
81
+ test("mutations envelope only fires onMutationsForwarded on the leader", () => {
82
+ const follower = makeRig({ isLeader: false });
83
+ follower.orch.handleMessage(
84
+ { type: "mutations", ops: [] },
85
+ "follower-x",
86
+ );
87
+ // No event because this rig isn't leader. The orchestrator's
88
+ // leader gate filtered it out.
89
+ expect(follower.events.length).toBe(0);
90
+ });
91
+
92
+ test("mutations-acked + mutations-failed both fire their hooks", () => {
93
+ const { orch, events } = makeRig();
94
+ orch.handleMessage({ type: "mutations-acked", opIds: ["a", "b"] }, "x");
95
+ orch.handleMessage(
96
+ { type: "mutations-failed", ops: [{ opId: "c", error: "bad" }] },
97
+ "x",
98
+ );
99
+ expect(events.map((e) => e.kind)).toEqual([
100
+ "mutationsAcked",
101
+ "mutationsFailed",
102
+ ]);
103
+ expect((events[0].args[0] as string[])[0]).toBe("a");
104
+ expect((events[1].args[0] as { opId: string; error: string }[])[0].opId).toBe(
105
+ "c",
106
+ );
107
+ });
108
+
109
+ test("sub-register and sub-unregister route directly to SubscriptionCoordinator", async () => {
110
+ const { orch, serverSubs, events } = makeRig();
111
+ // Sub-register / sub-unregister are leader-gated inside the
112
+ // orchestrator (followers can't act on a peer's request). Init
113
+ // the orchestrator so it observes itself as leader.
114
+ await orch.init();
115
+ events.length = 0; // drop the initialLeader event from init
116
+ orch.handleMessage(
117
+ {
118
+ type: "sub-register",
119
+ kind: "crdt",
120
+ key: "Todo\x00r1",
121
+ entity: "Todo",
122
+ rowId: "r1",
123
+ },
124
+ "follower-1",
125
+ );
126
+ expect(serverSubs.has("Todo\x00r1")).toBe(true);
127
+ // Engine hooks were NOT fired — subscription dispatch bypasses them.
128
+ expect(events.length).toBe(0);
129
+ orch.handleMessage(
130
+ {
131
+ type: "sub-unregister",
132
+ kind: "crdt",
133
+ key: "Todo\x00r1",
134
+ entity: "Todo",
135
+ rowId: "r1",
136
+ },
137
+ "follower-1",
138
+ );
139
+ expect(serverSubs.has("Todo\x00r1")).toBe(false);
140
+ });
141
+
142
+ test("binary envelope fires onBinaryReceived with the bytes", () => {
143
+ const { orch, events } = makeRig();
144
+ const bytes = new Uint8Array([1, 2, 3]);
145
+ orch.handleMessage({ type: "binary", bytes }, "leader-x");
146
+ expect(events.length).toBe(1);
147
+ expect(events[0].kind).toBe("binary");
148
+ expect((events[0].args[0] as Uint8Array)[0]).toBe(1);
149
+ });
150
+
151
+ test("reset envelope fires onResetReceived", () => {
152
+ const { orch, events } = makeRig();
153
+ orch.handleMessage({ type: "reset" }, "leader-x");
154
+ expect(events.map((e) => e.kind)).toEqual(["reset"]);
155
+ });
156
+
157
+ test("unknown envelope is a silent no-op", () => {
158
+ const { orch, events } = makeRig();
159
+ expect(() =>
160
+ orch.handleMessage({ type: "bogus-type-no-one-knows" }, "x"),
161
+ ).not.toThrow();
162
+ expect(events.length).toBe(0);
163
+ });
164
+ });
165
+
166
+ describe("MultiTabOrchestrator init", () => {
167
+ test("multiTab:false short-circuits to sole-leader", async () => {
168
+ const { orch, events } = makeRig();
169
+ const leader = await orch.init();
170
+ expect(leader).toBe(true);
171
+ expect(events.map((e) => e.kind)).toContain("initialLeader");
172
+ });
173
+ });
@@ -0,0 +1,366 @@
1
+ // MultiTabOrchestrator — owns the cross-tab coordination protocol.
2
+ //
3
+ // Responsibilities:
4
+ // - Bring up the BroadcastChannel-backed broker and run the
5
+ // election protocol via `MultiTabBroker`.
6
+ // - Route inbound BroadcastChannel messages through the right hook
7
+ // on the engine OR directly through the SubscriptionCoordinator
8
+ // when the case is purely a subscription concern.
9
+ // - Provide typed broadcast helpers for outbound fanout (applied
10
+ // changes, session updates, reset, mutations, reactive results,
11
+ // binary frames, sub-replay requests).
12
+ //
13
+ // Why it lives outside the SyncEngine: the engine used to own the
14
+ // broker field, the isLeader flag, the 11-case `handleMultiTabMessage`
15
+ // switch, and every `broadcastToTabs` call. That bundle is its own
16
+ // concern — the cross-tab protocol — and pulling it out lets the
17
+ // engine focus on data-plane work (pull / push / reconcile / apply /
18
+ // subscribe). The engine still owns the data semantics behind each
19
+ // message; the orchestrator just decides "who sees what and when."
20
+ //
21
+ // Leader/follower bookkeeping: the orchestrator owns `isLeader` and
22
+ // notifies the engine via hooks when it flips. The engine keeps a
23
+ // mirror flag (`isMultiTabLeader`) so the existing call sites that
24
+ // gate on leadership don't need to round-trip into the orchestrator
25
+ // for every check.
26
+
27
+ import { MultiTabBroker } from "./multi-tab";
28
+ import type { PendingMutation } from "./mutation-queue";
29
+ import type { SubscriptionCoordinator } from "./subscription-coordinator";
30
+ import type {
31
+ ChangeEvent,
32
+ ReactiveMessage,
33
+ ResolvedSession,
34
+ Row,
35
+ SyncCursor,
36
+ } from "./types";
37
+
38
+ /** ms the orchestrator waits for the broker's first election to
39
+ * settle before forcing the leader role if no peer claimed it. */
40
+ const ELECTION_SETTLE_MAX_MS = 400;
41
+
42
+ /** Hooks the engine registers to receive inbound multi-tab events.
43
+ * Cases that purely concern subscriptions are NOT routed through
44
+ * these — the orchestrator dispatches them directly to its
45
+ * `SubscriptionCoordinator` reference. */
46
+ export interface MultiTabOrchestratorHooks {
47
+ /** Initial promotion (first election settles with this tab as
48
+ * leader). Fired before the engine's start() proceeds past
49
+ * `initMultiTab()`. */
50
+ onInitialLeader(): void;
51
+ /** Late promotion: the previous leader dropped while we were a
52
+ * follower. Engine performs recovery (replay subs, pull, drain
53
+ * the mutation queue, start the transport). */
54
+ onLatePromote(): void;
55
+ /** Demotion: another tab took over as leader. Engine tears down
56
+ * its transport. */
57
+ onDemote(): void;
58
+ /** Inbound applied-change batch from the current leader. Engine
59
+ * enqueues for apply with `fromBroadcast: true` so the queue
60
+ * doesn't re-broadcast. */
61
+ onAppliedReceived(
62
+ changes: ChangeEvent[],
63
+ targetCursor: SyncCursor | undefined,
64
+ ): void;
65
+ /** Inbound reconcile batch from the current leader. */
66
+ onReconciledReceived(
67
+ entity: string,
68
+ upserts: Row[],
69
+ removalIds: string[],
70
+ tombstoneSeq: number,
71
+ ): void;
72
+ /** Replica reset broadcast — identity flip happened on the leader. */
73
+ onResetReceived(): void;
74
+ /** Resolved session update from the leader. Engine funnels through
75
+ * its session chain so concurrent triggers commit in order. */
76
+ onSessionReceived(resolved: ResolvedSession): void;
77
+ /** Follower → leader: ops the follower wants pushed. Only fires
78
+ * when this tab is leader. */
79
+ onMutationsForwarded(ops: PendingMutation[]): void;
80
+ /** Leader → follower: op_ids that were successfully pushed. */
81
+ onMutationsAcked(opIds: string[]): void;
82
+ /** Leader → follower: op_ids that failed server-side validation. */
83
+ onMutationsFailed(ops: { opId: string; error: string }[]): void;
84
+ /** Leader → follower: a binary frame from the WS. Engine routes
85
+ * to its local binary handlers. */
86
+ onBinaryReceived(bytes: Uint8Array): void;
87
+ /** A peer tab disappeared (broker observed `bye`). Engine and
88
+ * SubscriptionCoordinator both clean up state for the departed tab. */
89
+ onPeerLeft(tabId: string): void;
90
+ }
91
+
92
+ export interface MultiTabOrchestratorConfig {
93
+ /** When false, multi-tab coordination is disabled; this tab acts
94
+ * as a sole leader. */
95
+ enabled?: boolean;
96
+ /** App name used to derive the BroadcastChannel name. Defaults to
97
+ * "default" — apps that share an origin can pin their own name
98
+ * to avoid cross-talk between unrelated installations. */
99
+ appName?: string;
100
+ }
101
+
102
+ export class MultiTabOrchestrator {
103
+ private broker: MultiTabBroker | null = null;
104
+ private _isLeader = false;
105
+ private settled = false;
106
+
107
+ constructor(
108
+ private readonly config: MultiTabOrchestratorConfig,
109
+ private readonly subscriptions: SubscriptionCoordinator,
110
+ private readonly hooks: MultiTabOrchestratorHooks,
111
+ ) {}
112
+
113
+ // ---- Lifecycle ---------------------------------------------------------
114
+
115
+ /** Bring up the broker and run the initial election. Resolves when
116
+ * the election has settled (either onPromote fired, or the
117
+ * settle timer expired without a peer claiming leadership).
118
+ * Returns true if this tab is now the leader. */
119
+ async init(): Promise<boolean> {
120
+ if (this.config.enabled === false) {
121
+ this._isLeader = true;
122
+ this.settled = true;
123
+ this.hooks.onInitialLeader();
124
+ return true;
125
+ }
126
+ if (!MultiTabBroker.available()) {
127
+ this._isLeader = true;
128
+ this.settled = true;
129
+ this.hooks.onInitialLeader();
130
+ return true;
131
+ }
132
+ const channelName = `pylon:${this.config.appName ?? "default"}:multitab`;
133
+ this.broker = new MultiTabBroker();
134
+ await new Promise<void>((resolve) => {
135
+ const finish = () => {
136
+ if (this.settled) return;
137
+ this.settled = true;
138
+ resolve();
139
+ };
140
+ this.broker!.start(channelName, {
141
+ onPromote: () => {
142
+ this._isLeader = true;
143
+ if (!this.settled) {
144
+ // Initial promotion fired before the settle timer — the
145
+ // engine's start() proceeds straight into the leader path.
146
+ this.hooks.onInitialLeader();
147
+ finish();
148
+ } else {
149
+ // Late promotion: the previous leader dropped after our
150
+ // initial settle. Engine recovers (pull, replay subs,
151
+ // drain queued mutations, start transport).
152
+ this.hooks.onLatePromote();
153
+ }
154
+ },
155
+ onDemote: () => {
156
+ this._isLeader = false;
157
+ this.hooks.onDemote();
158
+ },
159
+ onAppMessage: (payload, from) =>
160
+ this.handleMessage(payload, from.tabId),
161
+ onLeave: (tabId) => {
162
+ // Drop forwarded-sub state for the departed tab AND notify
163
+ // the engine in case it wants to do anything else (the
164
+ // engine's current hook just delegates to subscriptions,
165
+ // but the seam is here for future cleanup needs).
166
+ if (this._isLeader) this.subscriptions.scrubPeer(tabId);
167
+ this.hooks.onPeerLeft(tabId);
168
+ },
169
+ });
170
+ // Bound the settle wait: if no peer claims leader within the
171
+ // election window, this tab takes the role.
172
+ setTimeout(() => {
173
+ if (this.settled) return;
174
+ if (this.broker!.isLeader()) {
175
+ this._isLeader = true;
176
+ this.hooks.onInitialLeader();
177
+ }
178
+ finish();
179
+ }, ELECTION_SETTLE_MAX_MS);
180
+ });
181
+ return this._isLeader;
182
+ }
183
+
184
+ stop(): void {
185
+ if (this.broker) {
186
+ this.broker.stop();
187
+ this.broker = null;
188
+ }
189
+ }
190
+
191
+ // ---- Predicates --------------------------------------------------------
192
+
193
+ isLeader(): boolean {
194
+ return this._isLeader;
195
+ }
196
+
197
+ // ---- Outbound broadcasts ----------------------------------------------
198
+
199
+ /** No-op when the broker isn't running. The engine uses this for
200
+ * envelope shapes the orchestrator doesn't have first-class
201
+ * helpers for (currently none — kept as an escape hatch). */
202
+ broadcastRaw(payload: unknown): void {
203
+ this.broker?.broadcastApp(payload);
204
+ }
205
+
206
+ broadcastApplied(changes: ChangeEvent[], targetCursor?: SyncCursor): void {
207
+ this.broadcastRaw({ type: "applied", changes, targetCursor });
208
+ }
209
+
210
+ broadcastReconciled(
211
+ entity: string,
212
+ upserts: Row[],
213
+ removalIds: string[],
214
+ tombstoneSeq: number,
215
+ ): void {
216
+ this.broadcastRaw({
217
+ type: "reconciled",
218
+ entity,
219
+ upserts,
220
+ removalIds,
221
+ tombstoneSeq,
222
+ });
223
+ }
224
+
225
+ broadcastReset(): void {
226
+ this.broadcastRaw({ type: "reset" });
227
+ }
228
+
229
+ broadcastSession(resolved: ResolvedSession): void {
230
+ this.broadcastRaw({ type: "session", resolved });
231
+ }
232
+
233
+ /** Follower → leader: forward our pending batch. The leader's
234
+ * engine handles it via `onMutationsForwarded`. */
235
+ forwardMutations(ops: PendingMutation[]): void {
236
+ this.broadcastRaw({ type: "mutations", ops });
237
+ }
238
+
239
+ /** Leader → followers: applied op_ids. Followers mark applied + clear. */
240
+ broadcastMutationsAcked(opIds: string[]): void {
241
+ this.broadcastRaw({ type: "mutations-acked", opIds });
242
+ }
243
+
244
+ /** Leader → followers: per-op failures with error strings. */
245
+ broadcastMutationsFailed(ops: { opId: string; error: string }[]): void {
246
+ this.broadcastRaw({ type: "mutations-failed", ops });
247
+ }
248
+
249
+ /** Leader → followers: a reactive-result / reactive-error landed on
250
+ * the WS. Routed by sub_id to whatever tab owns the local handler. */
251
+ broadcastReactiveMessage(sub_id: string, payload: ReactiveMessage): void {
252
+ this.broadcastRaw({ type: "reactive-msg", sub_id, payload });
253
+ }
254
+
255
+ /** Leader → followers: a binary CRDT frame from the WS. Only
256
+ * meaningful when at least one follower has forwarded a CRDT
257
+ * sub — caller (engine) gates on `subscriptions.hasCrdtForwarders()`. */
258
+ broadcastBinary(bytes: Uint8Array): void {
259
+ this.broadcastRaw({ type: "binary", bytes });
260
+ }
261
+
262
+ /** New leader → followers: re-forward your active sub-registers. */
263
+ requestSubReplay(): void {
264
+ this.broadcastRaw({ type: "request-sub-replay" });
265
+ }
266
+
267
+ // ---- Inbound dispatch --------------------------------------------------
268
+
269
+ /** Public for tests + future debugging. Drives the same path the
270
+ * BroadcastChannel's onmessage hits, so a test can simulate any
271
+ * inbound envelope without standing up a real channel. The engine
272
+ * itself never calls this directly — it goes through the broker. */
273
+ handleMessage(payload: unknown, fromTabId: string): void {
274
+ if (!payload || typeof payload !== "object") return;
275
+ const msg = payload as { type?: string } & Record<string, unknown>;
276
+ switch (msg.type) {
277
+ case "applied": {
278
+ const changes = msg.changes as ChangeEvent[] | undefined;
279
+ const targetCursor = msg.targetCursor as SyncCursor | undefined;
280
+ if (Array.isArray(changes) || targetCursor) {
281
+ this.hooks.onAppliedReceived(changes ?? [], targetCursor);
282
+ }
283
+ break;
284
+ }
285
+ case "reconciled": {
286
+ const entity = msg.entity as string | undefined;
287
+ const upserts = msg.upserts as Row[] | undefined;
288
+ const removalIds = msg.removalIds as string[] | undefined;
289
+ const tombstoneSeq = msg.tombstoneSeq as number | undefined;
290
+ if (
291
+ typeof entity === "string" &&
292
+ Array.isArray(upserts) &&
293
+ Array.isArray(removalIds) &&
294
+ typeof tombstoneSeq === "number"
295
+ ) {
296
+ this.hooks.onReconciledReceived(
297
+ entity,
298
+ upserts,
299
+ removalIds,
300
+ tombstoneSeq,
301
+ );
302
+ }
303
+ break;
304
+ }
305
+ case "reset": {
306
+ this.hooks.onResetReceived();
307
+ break;
308
+ }
309
+ case "session": {
310
+ const resolved = msg.resolved as ResolvedSession | undefined;
311
+ if (resolved) this.hooks.onSessionReceived(resolved);
312
+ break;
313
+ }
314
+ case "mutations": {
315
+ // Follower → leader. Only the leader acts on these.
316
+ if (!this._isLeader) return;
317
+ const ops = msg.ops as PendingMutation[] | undefined;
318
+ if (Array.isArray(ops)) this.hooks.onMutationsForwarded(ops);
319
+ break;
320
+ }
321
+ case "mutations-acked": {
322
+ const opIds = msg.opIds as string[] | undefined;
323
+ if (Array.isArray(opIds)) this.hooks.onMutationsAcked(opIds);
324
+ break;
325
+ }
326
+ case "mutations-failed": {
327
+ const ops = msg.ops as
328
+ | { opId: string; error: string }[]
329
+ | undefined;
330
+ if (Array.isArray(ops)) this.hooks.onMutationsFailed(ops);
331
+ break;
332
+ }
333
+ case "sub-register": {
334
+ // Follower → leader. Leader-only — no work to do on a follower
335
+ // because it can't act on a peer's request.
336
+ if (!this._isLeader) return;
337
+ this.subscriptions.handleForwardedRegister(msg, fromTabId);
338
+ break;
339
+ }
340
+ case "sub-unregister": {
341
+ if (!this._isLeader) return;
342
+ this.subscriptions.handleForwardedUnregister(msg, fromTabId);
343
+ break;
344
+ }
345
+ case "reactive-msg": {
346
+ const sub_id = msg.sub_id as string;
347
+ const payload = msg.payload as ReactiveMessage;
348
+ this.subscriptions.handleReactiveMessage(sub_id, payload);
349
+ break;
350
+ }
351
+ case "binary": {
352
+ const bytes = msg.bytes as Uint8Array | undefined;
353
+ if (bytes instanceof Uint8Array) this.hooks.onBinaryReceived(bytes);
354
+ break;
355
+ }
356
+ case "request-sub-replay": {
357
+ // Followers respond. A leader receiving its own broadcast (or
358
+ // an old broadcast post-promotion) ignores — its serverSubs
359
+ // already has the bundle.
360
+ if (this._isLeader) return;
361
+ this.subscriptions.replayForwardedSubs();
362
+ break;
363
+ }
364
+ }
365
+ }
366
+ }