@pylonsync/sync 0.3.213 → 0.3.216

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,197 @@
1
+ // Integration tests for the engine's WS room subscription path.
2
+ // Exercises subscribeRoom / unsubscribeRoom against the in-process
3
+ // TestServer:
4
+ // - subscribeRoom emits `room-subscribe` over the WS exactly once,
5
+ // refcounted across multiple subscribers
6
+ // - inbound `room-snapshot` populates the local registry + fires
7
+ // subscriber callbacks
8
+ // - inbound `room-update` action:join mutates the cached members
9
+ // - WS reconnect resends `room-subscribe` for every active room
10
+ // - last unsubscribe emits `room-unsubscribe`
11
+ // - inbound `error { code: NOT_IN_ROOM, room }` surfaces via the
12
+ // registry's error slot
13
+ //
14
+ // All harness-level — no real network. The TestServer's `pushToUser`
15
+ // + WS subprotocol bearer threading do the work.
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+
19
+ import { createTestEnv, type TestEnv } from "./test-harness";
20
+
21
+ function isRoomSubscribe(msg: unknown, room: string): boolean {
22
+ return (
23
+ typeof msg === "object" &&
24
+ msg !== null &&
25
+ (msg as Record<string, unknown>).type === "room-subscribe" &&
26
+ (msg as Record<string, unknown>).room === room
27
+ );
28
+ }
29
+
30
+ function isRoomUnsubscribe(msg: unknown, room: string): boolean {
31
+ return (
32
+ typeof msg === "object" &&
33
+ msg !== null &&
34
+ (msg as Record<string, unknown>).type === "room-unsubscribe" &&
35
+ (msg as Record<string, unknown>).room === room
36
+ );
37
+ }
38
+
39
+ function countWs(env: TestEnv, predicate: (msg: unknown) => boolean): number {
40
+ return env.server.receivedWsMessages.filter((m) => predicate(m.msg)).length;
41
+ }
42
+
43
+ describe("engine WS room subscriptions", () => {
44
+ let env: TestEnv;
45
+
46
+ beforeEach(() => {
47
+ env = createTestEnv();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ await env.dispose();
52
+ });
53
+
54
+ test("first subscribeRoom emits room-subscribe; second is dedup'd", async () => {
55
+ env.signIn({ userId: "u1" });
56
+ await env.start();
57
+ env.engine.subscribeRoom("channel:foo", () => {});
58
+ env.engine.subscribeRoom("channel:foo", () => {});
59
+ await env.flush();
60
+ expect(countWs(env, (m) => isRoomSubscribe(m, "channel:foo"))).toBe(1);
61
+ });
62
+
63
+ test("inbound room-snapshot fires subscriber callback with members", async () => {
64
+ env.signIn({ userId: "u1" });
65
+ await env.start();
66
+ let callbackCount = 0;
67
+ env.engine.subscribeRoom("channel:foo", () => {
68
+ callbackCount += 1;
69
+ });
70
+ await env.flush();
71
+ env.server.pushToUser("u1", {
72
+ type: "room-snapshot",
73
+ room: "channel:foo",
74
+ members: [
75
+ { user_id: "alice", joined_at: "t0" },
76
+ { user_id: "bob", joined_at: "t1" },
77
+ ],
78
+ });
79
+ await env.flush();
80
+ expect(callbackCount).toBeGreaterThanOrEqual(1);
81
+ const members = env.engine.getRoomMembers("channel:foo");
82
+ expect(members).toHaveLength(2);
83
+ });
84
+
85
+ test("inbound room-update action:join mutates cached members", async () => {
86
+ env.signIn({ userId: "u1" });
87
+ await env.start();
88
+ env.engine.subscribeRoom("channel:foo", () => {});
89
+ await env.flush();
90
+ env.server.pushToUser("u1", {
91
+ type: "room-snapshot",
92
+ room: "channel:foo",
93
+ members: [{ user_id: "alice", joined_at: "t0" }],
94
+ });
95
+ env.server.pushToUser("u1", {
96
+ type: "room-update",
97
+ room: "channel:foo",
98
+ action: "join",
99
+ member: { user_id: "bob", joined_at: "t1" },
100
+ });
101
+ await env.flush();
102
+ const members = env.engine.getRoomMembers("channel:foo");
103
+ expect(members?.map((m) => m.user_id).sort()).toEqual(["alice", "bob"]);
104
+ });
105
+
106
+ test("last unsubscribeRoom emits room-unsubscribe + clears registry entry", async () => {
107
+ env.signIn({ userId: "u1" });
108
+ await env.start();
109
+ const unsub1 = env.engine.subscribeRoom("channel:foo", () => {});
110
+ const unsub2 = env.engine.subscribeRoom("channel:foo", () => {});
111
+ await env.flush();
112
+ unsub1();
113
+ expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(0);
114
+ unsub2();
115
+ await env.flush();
116
+ expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(1);
117
+ expect(env.engine.getRoomMembers("channel:foo")).toBeNull();
118
+ });
119
+
120
+ test("inbound error { code: NOT_IN_ROOM } surfaces via getRoomError", async () => {
121
+ env.signIn({ userId: "u1" });
122
+ await env.start();
123
+ let callbackFired = false;
124
+ env.engine.subscribeRoom("channel:foo", () => {
125
+ callbackFired = true;
126
+ });
127
+ await env.flush();
128
+ env.server.pushToUser("u1", {
129
+ type: "error",
130
+ code: "NOT_IN_ROOM",
131
+ room: "channel:foo",
132
+ message: "not a member",
133
+ });
134
+ await env.flush();
135
+ expect(callbackFired).toBe(true);
136
+ expect(env.engine.getRoomError("channel:foo")).toEqual({
137
+ code: "NOT_IN_ROOM",
138
+ message: "not a member",
139
+ });
140
+ });
141
+
142
+ test("isWebSocketConnected reflects WS open state", async () => {
143
+ env.signIn({ userId: "u1" });
144
+ await env.start();
145
+ expect(env.engine.isWebSocketConnected()).toBe(true);
146
+ expect(env.engine.getActiveTransportType()).toBe("websocket");
147
+ });
148
+
149
+ test("WS reconnect resends room-subscribe for every active room", async () => {
150
+ // Tight reconnect budget so the test runs in a couple hundred ms
151
+ // rather than seconds. Picks a short base delay; the full-jitter
152
+ // formula caps the first attempt at random(0, 50ms).
153
+ const reconnectEnv = createTestEnv({ reconnectDelay: 50 });
154
+ try {
155
+ reconnectEnv.signIn({ userId: "u1" });
156
+ await reconnectEnv.start();
157
+ reconnectEnv.engine.subscribeRoom("channel:a", () => {});
158
+ reconnectEnv.engine.subscribeRoom("channel:b", () => {});
159
+ await reconnectEnv.flush();
160
+ expect(
161
+ countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:a")),
162
+ ).toBe(1);
163
+ expect(
164
+ countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:b")),
165
+ ).toBe(1);
166
+ // Force the WS to drop. The engine schedules a reconnect via its
167
+ // backoff, opens a new socket, fires onConnected → rooms.replay().
168
+ const closed = reconnectEnv.transport.closeLatestWs();
169
+ expect(closed).toBe(true);
170
+ await reconnectEnv.flush(200);
171
+ expect(reconnectEnv.transport.wsConnectCount()).toBeGreaterThanOrEqual(2);
172
+ // Replay: each active room got a fresh room-subscribe.
173
+ expect(
174
+ countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:a")),
175
+ ).toBe(2);
176
+ expect(
177
+ countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:b")),
178
+ ).toBe(2);
179
+ } finally {
180
+ await reconnectEnv.dispose();
181
+ }
182
+ });
183
+
184
+ test("StrictMode-style double-subscribe to same room only ships one wire frame", async () => {
185
+ env.signIn({ userId: "u1" });
186
+ await env.start();
187
+ // Simulate StrictMode: mount, mount again, unmount, unmount.
188
+ const u1 = env.engine.subscribeRoom("channel:foo", () => {});
189
+ const u2 = env.engine.subscribeRoom("channel:foo", () => {});
190
+ await env.flush();
191
+ expect(countWs(env, (m) => isRoomSubscribe(m, "channel:foo"))).toBe(1);
192
+ u1();
193
+ u2();
194
+ await env.flush();
195
+ expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(1);
196
+ });
197
+ });
@@ -0,0 +1,189 @@
1
+ // Unit tests for RoomSubscriptions. Pins the contract the engine
2
+ // depends on:
3
+ // - register/unregister refcount → first add sends room-subscribe,
4
+ // last remove sends room-unsubscribe
5
+ // - applySnapshot / applyUpdate / applyError mutate cached state and
6
+ // pulse subscribers
7
+ // - replay() resends room-subscribe for every active room (used by
8
+ // the engine's onConnected hook so reconnect resyncs membership)
9
+ // - sendWs returning false (no WS) doesn't break refcount semantics
10
+ //
11
+ // The engine integration (multi-tab forwarding, fanout) is covered in
12
+ // the scenarios suite — these tests pin the registry in isolation so
13
+ // regressions surface at the layer they originated.
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+
17
+ import { RoomSubscriptions } from "./room-subscriptions";
18
+
19
+ function makeHarness() {
20
+ const sent: unknown[] = [];
21
+ // Default to "ws is open" so the refcount path actually ships
22
+ // subscribe / unsubscribe frames; individual tests override.
23
+ let wsOpen = true;
24
+ const rooms = new RoomSubscriptions((msg) => {
25
+ sent.push(msg);
26
+ return wsOpen;
27
+ });
28
+ return {
29
+ rooms,
30
+ sent,
31
+ setWsOpen(v: boolean) {
32
+ wsOpen = v;
33
+ },
34
+ };
35
+ }
36
+
37
+ describe("RoomSubscriptions: refcount + wire frames", () => {
38
+ test("first register ships room-subscribe; second is a no-op", () => {
39
+ const h = makeHarness();
40
+ h.rooms.register("channel:foo", () => {});
41
+ expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:foo" }]);
42
+ h.rooms.register("channel:foo", () => {});
43
+ expect(h.sent.length).toBe(1); // no second subscribe
44
+ });
45
+
46
+ test("last unregister ships room-unsubscribe; mid-stream calls are no-ops on wire", () => {
47
+ const h = makeHarness();
48
+ const unsub1 = h.rooms.register("channel:foo", () => {});
49
+ const unsub2 = h.rooms.register("channel:foo", () => {});
50
+ unsub1();
51
+ expect(h.sent.length).toBe(1); // still just the initial subscribe
52
+ unsub2();
53
+ expect(h.sent).toEqual([
54
+ { type: "room-subscribe", room: "channel:foo" },
55
+ { type: "room-unsubscribe", room: "channel:foo" },
56
+ ]);
57
+ });
58
+
59
+ test("unregisterRoom force-tears-down regardless of refcount", () => {
60
+ const h = makeHarness();
61
+ h.rooms.register("channel:foo", () => {});
62
+ h.rooms.register("channel:foo", () => {});
63
+ h.rooms.unregisterRoom("channel:foo");
64
+ expect(h.sent).toEqual([
65
+ { type: "room-subscribe", room: "channel:foo" },
66
+ { type: "room-unsubscribe", room: "channel:foo" },
67
+ ]);
68
+ expect(h.rooms.has("channel:foo")).toBe(false);
69
+ });
70
+
71
+ test("sendWs returning false (WS closed / follower) still refcounts correctly", () => {
72
+ const h = makeHarness();
73
+ h.setWsOpen(false);
74
+ const unsub = h.rooms.register("channel:foo", () => {});
75
+ // The send happened (registry doesn't care if it landed); it just
76
+ // didn't reach the wire. Engine's replay() on reconnect handles the
77
+ // catch-up.
78
+ expect(h.sent.length).toBe(1);
79
+ unsub();
80
+ expect(h.sent.length).toBe(2); // unsubscribe still queued
81
+ });
82
+ });
83
+
84
+ describe("RoomSubscriptions: snapshot / update / error", () => {
85
+ test("applySnapshot stores members and pulses every subscriber", () => {
86
+ const h = makeHarness();
87
+ let calls = 0;
88
+ h.rooms.register("channel:foo", () => {
89
+ calls += 1;
90
+ });
91
+ h.rooms.applySnapshot("channel:foo", [
92
+ { user_id: "u1", joined_at: "t1" },
93
+ { user_id: "u2", joined_at: "t2" },
94
+ ]);
95
+ expect(h.rooms.members("channel:foo")).toHaveLength(2);
96
+ expect(calls).toBe(1);
97
+ });
98
+
99
+ test("members() returns null before any snapshot lands", () => {
100
+ const h = makeHarness();
101
+ h.rooms.register("channel:foo", () => {});
102
+ expect(h.rooms.members("channel:foo")).toBeNull();
103
+ });
104
+
105
+ test("applyUpdate join inserts a member; idempotent on duplicate user_id", () => {
106
+ const h = makeHarness();
107
+ h.rooms.register("channel:foo", () => {});
108
+ h.rooms.applySnapshot("channel:foo", []);
109
+ h.rooms.applyUpdate(
110
+ "channel:foo",
111
+ "join",
112
+ { user_id: "u1", joined_at: "t1" },
113
+ undefined,
114
+ );
115
+ h.rooms.applyUpdate(
116
+ "channel:foo",
117
+ "join",
118
+ { user_id: "u1", joined_at: "t1" },
119
+ undefined,
120
+ );
121
+ expect(h.rooms.members("channel:foo")).toHaveLength(1);
122
+ });
123
+
124
+ test("applyUpdate leave removes the matching user", () => {
125
+ const h = makeHarness();
126
+ h.rooms.register("channel:foo", () => {});
127
+ h.rooms.applySnapshot("channel:foo", [
128
+ { user_id: "u1", joined_at: "t1" },
129
+ { user_id: "u2", joined_at: "t2" },
130
+ ]);
131
+ h.rooms.applyUpdate(
132
+ "channel:foo",
133
+ "leave",
134
+ { user_id: "u1", joined_at: "t1" },
135
+ undefined,
136
+ );
137
+ expect(h.rooms.members("channel:foo")).toEqual([
138
+ { user_id: "u2", joined_at: "t2" },
139
+ ]);
140
+ });
141
+
142
+ test("applyError stores the error code and surfaces via error()", () => {
143
+ const h = makeHarness();
144
+ let calls = 0;
145
+ h.rooms.register("channel:foo", () => {
146
+ calls += 1;
147
+ });
148
+ h.rooms.applyError("channel:foo", { code: "NOT_IN_ROOM" });
149
+ expect(h.rooms.error("channel:foo")).toEqual({ code: "NOT_IN_ROOM" });
150
+ expect(calls).toBe(1);
151
+ });
152
+
153
+ test("late register on an already-snapshotted room pulses the new sub immediately", () => {
154
+ const h = makeHarness();
155
+ h.rooms.register("channel:foo", () => {});
156
+ h.rooms.applySnapshot("channel:foo", [
157
+ { user_id: "u1", joined_at: "t1" },
158
+ ]);
159
+ let latePulseCount = 0;
160
+ h.rooms.register("channel:foo", () => {
161
+ latePulseCount += 1;
162
+ });
163
+ expect(latePulseCount).toBe(1); // fired synchronously
164
+ });
165
+ });
166
+
167
+ describe("RoomSubscriptions: replay on reconnect", () => {
168
+ test("replay() resends room-subscribe for every active room", () => {
169
+ const h = makeHarness();
170
+ h.rooms.register("channel:a", () => {});
171
+ h.rooms.register("channel:b", () => {});
172
+ h.sent.length = 0;
173
+ h.rooms.replay();
174
+ expect(h.sent).toEqual([
175
+ { type: "room-subscribe", room: "channel:a" },
176
+ { type: "room-subscribe", room: "channel:b" },
177
+ ]);
178
+ });
179
+
180
+ test("replay() skips fully-unregistered rooms", () => {
181
+ const h = makeHarness();
182
+ const unsub = h.rooms.register("channel:a", () => {});
183
+ h.rooms.register("channel:b", () => {});
184
+ unsub();
185
+ h.sent.length = 0;
186
+ h.rooms.replay();
187
+ expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:b" }]);
188
+ });
189
+ });
@@ -0,0 +1,301 @@
1
+ // RoomSubscriptions — client-side registry for WS-pushed room membership.
2
+ //
3
+ // The server (>=v0.3.214) accepts `{ type: "room-subscribe", room }` and
4
+ // `{ type: "room-unsubscribe", room }` control frames, and pushes back
5
+ // `room-snapshot` (members list) + `room-update` (join/leave/presence/
6
+ // broadcast deltas). This registry owns the client-side bookkeeping:
7
+ //
8
+ // - refcount per roomId so N `useRoom` mounts of the same channel
9
+ // produce exactly one `room-subscribe` on the wire (the first add)
10
+ // and one `room-unsubscribe` (the last remove). Mirrors the dedup
11
+ // story `useRoom`'s `__roomRegistryInternals` already enforced at
12
+ // the React layer — but moved INTO the engine because the WS sub
13
+ // is the engine's connection, not React's.
14
+ //
15
+ // - per-room subscriber callbacks. Server pushes are O(rooms), but
16
+ // each push has to fan out to every component that subscribed to
17
+ // that room. We notify them all when the snapshot or update lands.
18
+ //
19
+ // - cached `members` snapshot so a late subscriber (mounting after
20
+ // the initial snapshot already landed) gets the current state on
21
+ // `register()` without waiting for the next push.
22
+ //
23
+ // - replay-on-reconnect. Server clears its per-client room
24
+ // subscription state on disconnect; on reconnect, we resend a
25
+ // `room-subscribe` for every roomId that still has subscribers.
26
+ //
27
+ // Why a separate registry from ServerSubscriptions:
28
+ // ServerSubscriptions is a write-only replay registry — it remembers
29
+ // the SUBSCRIBE message and re-sends it on reconnect. Rooms need
30
+ // more than that: per-room state (the members snapshot), per-room
31
+ // callbacks, and per-room error surfacing. Mixing those concerns
32
+ // into ServerSubscriptions would dilute its single responsibility.
33
+ // Both are used together by the engine: ServerSubscriptions handles
34
+ // the replay primitive; RoomSubscriptions is the consumer that
35
+ // keys into it for rooms.
36
+ //
37
+ // Multi-tab note: only the leader tab opens a WS, so only the leader
38
+ // runs this registry against a real socket. Follower tabs broadcast
39
+ // `room-sub-register` envelopes to the leader, the leader keeps a
40
+ // per-room follower set so the WS sub stays alive while any tab in
41
+ // the origin still wants it, and inbound `room-snapshot`/`room-update`
42
+ // land on the leader's WS and get fanned out cross-tab so each
43
+ // follower's local registry routes to its own React subscribers.
44
+ // The leader-side fanout / follower-side mirror plumbing lives on
45
+ // the engine + orchestrator; this module is leader-local state.
46
+
47
+ export interface RoomMember {
48
+ user_id: string;
49
+ joined_at: string;
50
+ data?: Record<string, unknown>;
51
+ }
52
+
53
+ /** Reason codes the SDK exposes on a room subscription error. */
54
+ export type RoomErrorCode = "NOT_IN_ROOM" | "UNKNOWN";
55
+
56
+ export interface RoomError {
57
+ code: RoomErrorCode;
58
+ message?: string;
59
+ }
60
+
61
+ /** Callback fired when a room's membership snapshot OR error state
62
+ * changes. The same callback receives every transition for the room —
63
+ * snapshots overwrite state, updates mutate it, and errors set the
64
+ * `error` slot. Subscribers read the latest values via the getters on
65
+ * the entry; this fires purely as a "something changed" pulse so
66
+ * React hooks can re-render. */
67
+ export type RoomSubscriber = () => void;
68
+
69
+ interface RoomEntry {
70
+ roomId: string;
71
+ /** Current members snapshot (post-snapshot/update). `null` until the
72
+ * first snapshot lands — distinct from "empty room" which is `[]`.
73
+ * React hooks distinguish loading vs empty using this. */
74
+ members: RoomMember[] | null;
75
+ /** Latest error from the server (NOT_IN_ROOM). Cleared when a fresh
76
+ * snapshot lands. */
77
+ error: RoomError | null;
78
+ /** Number of `register()` calls that haven't been balanced by
79
+ * `unregister()`. The first add ships `room-subscribe`; the last
80
+ * remove ships `room-unsubscribe`. */
81
+ refs: number;
82
+ /** Subscriber callbacks — one per mounted React hook. */
83
+ subs: Set<RoomSubscriber>;
84
+ }
85
+
86
+ export class RoomSubscriptions {
87
+ private readonly rooms: Map<string, RoomEntry> = new Map();
88
+
89
+ /** Caller-supplied uplink to the WS. The engine routes through its
90
+ * active transport; this module stays transport-agnostic.
91
+ * Returns true when the message reached the wire (transport is open),
92
+ * false otherwise — RoomSubscriptions uses this to decide whether
93
+ * to surface "not connected yet" to the registry's callers.
94
+ * No-op transports (followers, no WS yet) return false; the engine
95
+ * hides the broadcast/leader split behind this hook. */
96
+ constructor(private readonly sendWs: (msg: unknown) => boolean) {}
97
+
98
+ /** Register a subscriber for `roomId`. First add ships the WS
99
+ * `room-subscribe`; subsequent adds just bump the refcount and
100
+ * deliver the cached snapshot to the new subscriber's callback.
101
+ *
102
+ * Returns an unsubscribe function that decrements the refcount on
103
+ * call. The last unsubscribe ships `room-unsubscribe`, clears the
104
+ * entry, and the next register() for the same room is a fresh
105
+ * start.
106
+ *
107
+ * Idempotent w.r.t. wire frames: a re-subscribe with no intervening
108
+ * full unsubscribe doesn't re-send `room-subscribe` to the server. */
109
+ register(roomId: string, subscriber: RoomSubscriber): () => void {
110
+ let entry = this.rooms.get(roomId);
111
+ const isFirst = !entry;
112
+ if (!entry) {
113
+ entry = {
114
+ roomId,
115
+ members: null,
116
+ error: null,
117
+ refs: 0,
118
+ subs: new Set(),
119
+ };
120
+ this.rooms.set(roomId, entry);
121
+ }
122
+ entry.refs += 1;
123
+ entry.subs.add(subscriber);
124
+
125
+ if (isFirst) {
126
+ // First subscriber — ask the server to start pushing updates.
127
+ // `sendWs` returns false when the transport isn't open yet (or
128
+ // we're a follower); that's fine — `replay()` on reconnect /
129
+ // promotion will re-send the subscribe.
130
+ this.sendWs({ type: "room-subscribe", room: roomId });
131
+ } else if (entry.members !== null || entry.error !== null) {
132
+ // Late subscriber on an already-active room. Fire one tick so
133
+ // the new callback observes the cached snapshot / error
134
+ // immediately instead of waiting for the next push.
135
+ try {
136
+ subscriber();
137
+ } catch (err) {
138
+ console.warn("[sync] room subscriber threw on initial notify:", err);
139
+ }
140
+ }
141
+
142
+ return () => this.unregisterOne(roomId, subscriber);
143
+ }
144
+
145
+ /** Decrement the refcount for one subscriber. Internal — the
146
+ * `register()` returned unsubscribe routes here. Last out ships
147
+ * `room-unsubscribe`. */
148
+ private unregisterOne(roomId: string, subscriber: RoomSubscriber): void {
149
+ const entry = this.rooms.get(roomId);
150
+ if (!entry) return;
151
+ entry.subs.delete(subscriber);
152
+ entry.refs -= 1;
153
+ if (entry.refs > 0) return;
154
+ this.rooms.delete(roomId);
155
+ this.sendWs({ type: "room-unsubscribe", room: roomId });
156
+ }
157
+
158
+ /** Force a full teardown of one room regardless of refcount. Used by
159
+ * the engine's leader-handoff and the hook's manual `leave()` path.
160
+ * Notifies every remaining subscriber via the standard pulse (so
161
+ * they observe `members === null` and re-render as disconnected). */
162
+ unregisterRoom(roomId: string): void {
163
+ const entry = this.rooms.get(roomId);
164
+ if (!entry) return;
165
+ this.rooms.delete(roomId);
166
+ this.sendWs({ type: "room-unsubscribe", room: roomId });
167
+ }
168
+
169
+ /** Snapshot push from the server: full membership for the room.
170
+ * Overwrites any cached state, clears any prior error, and pulses
171
+ * every subscriber callback. */
172
+ applySnapshot(roomId: string, members: RoomMember[]): void {
173
+ const entry = this.rooms.get(roomId);
174
+ if (!entry) return;
175
+ entry.members = members;
176
+ entry.error = null;
177
+ this.notify(entry);
178
+ }
179
+
180
+ /** Incremental update from the server. `action` mirrors the server's
181
+ * RoomEvent variants — join / leave / presence / broadcast. The
182
+ * registry mutates the cached `members` snapshot in-place so a
183
+ * re-render after the update sees the right shape without waiting
184
+ * for the next snapshot. `broadcast` updates don't touch members
185
+ * (no peer joined/left) but still fan out to listeners — Future
186
+ * work: expose a separate broadcast channel. For now the React
187
+ * hook only needs the membership delta. */
188
+ applyUpdate(
189
+ roomId: string,
190
+ action: "join" | "leave" | "presence" | "broadcast",
191
+ member: RoomMember | undefined,
192
+ _data: unknown,
193
+ ): void {
194
+ const entry = this.rooms.get(roomId);
195
+ if (!entry) return;
196
+ // If we haven't received a snapshot yet, seed an empty list so
197
+ // the in-place mutation paths below have something to mutate.
198
+ if (entry.members === null) entry.members = [];
199
+ switch (action) {
200
+ case "join": {
201
+ if (!member) break;
202
+ // Idempotent insert: server may re-send a join on flapping
203
+ // connections; collapse by user_id.
204
+ const filtered = entry.members.filter(
205
+ (m) => m.user_id !== member.user_id,
206
+ );
207
+ filtered.push(member);
208
+ entry.members = filtered;
209
+ break;
210
+ }
211
+ case "leave": {
212
+ if (!member) break;
213
+ entry.members = entry.members.filter(
214
+ (m) => m.user_id !== member.user_id,
215
+ );
216
+ break;
217
+ }
218
+ case "presence": {
219
+ if (!member) break;
220
+ // Swap the matching member's data while preserving join order.
221
+ entry.members = entry.members.map((m) =>
222
+ m.user_id === member.user_id ? { ...m, ...member } : m,
223
+ );
224
+ break;
225
+ }
226
+ case "broadcast": {
227
+ // No membership delta. Still pulse subscribers so apps that
228
+ // want to react to broadcast traffic can read it via a future
229
+ // dedicated callback. The current React hook ignores this.
230
+ break;
231
+ }
232
+ }
233
+ entry.error = null;
234
+ this.notify(entry);
235
+ }
236
+
237
+ /** Server pushed `{ type: "error", code, room }` after a subscribe.
238
+ * Record the error on the room and pulse subscribers — the React
239
+ * hook surfaces it via the `error` return slot. We do NOT
240
+ * unregister automatically: the user genuinely isn't in the room
241
+ * and the SDK shouldn't retry, but the registry entry stays so
242
+ * React unmount still ships a `room-unsubscribe` (server is
243
+ * idempotent for unknown subs). */
244
+ applyError(roomId: string, error: RoomError): void {
245
+ const entry = this.rooms.get(roomId);
246
+ if (!entry) return;
247
+ entry.error = error;
248
+ this.notify(entry);
249
+ }
250
+
251
+ /** Read the cached snapshot for a room without subscribing. The
252
+ * React hook uses this in its initial-state effect so a re-mount
253
+ * inside the same registry lifecycle picks up the current members
254
+ * on tick zero. Returns `null` when the snapshot hasn't landed
255
+ * yet — DISTINCT from `[]` ("empty room"). */
256
+ members(roomId: string): RoomMember[] | null {
257
+ return this.rooms.get(roomId)?.members ?? null;
258
+ }
259
+
260
+ /** Latest error for a room (null if none). */
261
+ error(roomId: string): RoomError | null {
262
+ return this.rooms.get(roomId)?.error ?? null;
263
+ }
264
+
265
+ /** Is there at least one local subscriber for `roomId`. Used by the
266
+ * hook's polling-fallback path to decide whether to keep polling. */
267
+ has(roomId: string): boolean {
268
+ return this.rooms.has(roomId);
269
+ }
270
+
271
+ /** Every room currently tracked. Used by `replay()` and by the
272
+ * multi-tab seed-on-promotion. */
273
+ roomIds(): string[] {
274
+ return Array.from(this.rooms.keys());
275
+ }
276
+
277
+ /** Resend `room-subscribe` for every active room. Called by the
278
+ * engine's `onConnected` hook after the WS reopens — the server
279
+ * forgets per-client subs across disconnects, so without this
280
+ * resync the first push would never arrive. */
281
+ replay(): void {
282
+ for (const roomId of this.rooms.keys()) {
283
+ this.sendWs({ type: "room-subscribe", room: roomId });
284
+ }
285
+ }
286
+
287
+ /** Test/diagnostics: total active rooms. */
288
+ size(): number {
289
+ return this.rooms.size;
290
+ }
291
+
292
+ private notify(entry: RoomEntry): void {
293
+ for (const cb of entry.subs) {
294
+ try {
295
+ cb();
296
+ } catch (err) {
297
+ console.warn("[sync] room subscriber threw:", err);
298
+ }
299
+ }
300
+ }
301
+ }