@luckystack/sync 0.1.4 → 0.1.6

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.
@@ -1,233 +1,233 @@
1
- # room-fanout
2
-
3
- > How `handleSyncRequest` resolves the `receiver` string to a set of Socket.io sockets, iterates them, and notifies hook consumers along the way. Covers the `'all'` sentinel, the per-token room convention, event-loop yielding for giant fanouts, the `preSyncFanout` / `postSyncFanout` hooks, and Redis-backed cross-instance fanout.
4
-
5
- For the originator's `receiver` argument see [`./sync-request.md`](./sync-request.md). For per-recipient handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
-
7
- ---
8
-
9
- ## 1. How `receiver` resolves to sockets
10
-
11
- `handleSyncRequest` branches on `receiver`:
12
-
13
- ```ts
14
- const sockets = receiver === 'all'
15
- ? ioInstance.sockets.sockets // Map<socketId, Socket>
16
- : ioInstance.sockets.adapter.rooms.get(receiver); // Set<socketId> | undefined
17
- ```
18
-
19
- - `receiver === 'all'` -> every connected socket on **this instance** (production with Redis adapter: every socket on every instance, see §6).
20
- - Any other string -> the Socket.io room with that name. If the room does not exist or is empty, `sockets` is `undefined` and the fanout fails with `sync.noReceiversFound`.
21
-
22
- There is no "broadcast to all but yourself" sentinel — pass `ignoreSelf: true` to skip the originator's own sockets. See [`./ignore-self.md`](./ignore-self.md).
23
-
24
- ---
25
-
26
- ## 2. Room conventions
27
-
28
- Rooms are managed by `@luckystack/core` (client) via `joinRoom(code)` / `leaveRoom(code)`. Conventions in production code:
29
-
30
- | Room name | Membership | Used for |
31
- |---|---|---|
32
- | `<sessionToken>` (auto-joined) | Every socket of one user | `streamTo(token, payload)` reaches all that user's devices. |
33
- | `<sharedCode>` (manual join) | Multiple users by app logic | Collab editors, multiplayer rooms, chat. |
34
- | `'all'` (sentinel, not a real room) | Every connected socket | Global broadcasts. Avoid in production. |
35
-
36
- ### Why every socket auto-joins a room named after its session token
37
-
38
- `@luckystack/server` joins each socket to a room with that socket's session token at connect time. This is what makes `streamTo(['userToken1', 'userToken2'], payload)` work — without it the framework would have to maintain a separate `tokenToSockets` map.
39
-
40
- Side effect: if a user is connected from three devices (three sockets, three different `socket.id`s, one session token), `streamTo` reaches all three because they all live in the same per-token room.
41
-
42
- ---
43
-
44
- ## 3. The fanout loop
45
-
46
- After auth, rate-limit, validation, and `_server` execution succeed, `handleSyncRequest` enters the per-recipient loop:
47
-
48
- ```
49
- preSyncFanout (stop signal aborts before any recipient is touched)
50
- |
51
- v
52
- for each socket in <resolved sockets>:
53
- yield every N (configurable)
54
- skip if ignoreSelf && token === recipientToken
55
- recipientCount++
56
- if _client exists:
57
- run clientHandler
58
- emit per-recipient result (success or normalized error)
59
- else:
60
- emit { serverOutput, clientOutput: {}, status: 'success', cb, fullName }
61
- |
62
- v
63
- postSyncFanout({ recipientCount, ...payload })
64
- ```
65
-
66
- Per-recipient errors do **not** abort the loop. A single recipient failing `_client` execution receives a `sync.clientExecutionFailed` frame; everyone else still receives their merged payload.
67
-
68
- ---
69
-
70
- ## 4. Event-loop yielding (`sync.fanoutYieldEvery`, `sync.fanoutYieldMs`)
71
-
72
- ```ts
73
- const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig().sync;
74
- let tempCount = 1;
75
- for (const socketEntry of sockets) {
76
- tempCount++;
77
- if (tempCount % fanoutYieldEvery === 0) {
78
- await new Promise(resolve => setTimeout(resolve, fanoutYieldMs));
79
- }
80
- // ... fanout to this recipient ...
81
- }
82
- ```
83
-
84
- Why: a `receiver: 'all'` fanout against thousands of sockets would otherwise block the event loop for the whole iteration. Yielding every `fanoutYieldEvery` recipients (default: see `projectConfig.sync.fanoutYieldEvery`) for `fanoutYieldMs` (default: a few ms) lets other socket events, API requests, and the heartbeat handlers run.
85
-
86
- Tuning:
87
-
88
- - **Higher `fanoutYieldEvery` + lower `fanoutYieldMs`** = faster fanout, less responsiveness for other requests.
89
- - **Lower `fanoutYieldEvery` + higher `fanoutYieldMs`** = smoother for concurrent traffic but slower fanout.
90
- - For typical room sizes (<100 recipients), the yield never triggers — defaults are tuned for `receiver: 'all'` worst cases.
91
-
92
- This loop only exists on the socket path (`handleSyncRequest`). The HTTP path (`handleHttpSyncRequest`) does not yield — HTTP requests are inherently isolated per Node.js handler invocation, and the per-instance fanout sits inside a single async block anyway.
93
-
94
- ---
95
-
96
- ## 5. Hooks dispatched during fanout
97
-
98
- ### `preSyncFanout`
99
-
100
- ```ts
101
- {
102
- routeName: string, // e.g. 'board/moveCard/v1'
103
- data: Record<string, unknown>, // clientInput
104
- user: SessionLayout | null, // sender's session
105
- receiver: string, // resolved roomCode or 'all'
106
- serverOutput: unknown, // what _server returned (minus status)
107
- }
108
- ```
109
-
110
- Fires **after** `_server` runs successfully and the recipient set is resolved, **before** any recipient receives the payload. Stop signal converts to an originator-side error envelope with the hook's `errorCode` / `httpStatus`. Use for:
111
-
112
- - "Don't fanout this mutation to a degraded region while we drain traffic."
113
- - "Throttle fanout-heavy routes during peak load."
114
- - "Inject a cross-room replication hop before the room receives the payload."
115
-
116
- ### `postSyncFanout`
117
-
118
- ```ts
119
- {
120
- routeName: string,
121
- data: Record<string, unknown>,
122
- user: SessionLayout | null,
123
- receiver: string,
124
- serverOutput: unknown,
125
- recipientCount: number, // actual number of sockets emitted to (NOT room size)
126
- }
127
- ```
128
-
129
- Fires after the last recipient's emit. Observation-only — there is no stop signal because the fanout has already happened. Use for:
130
-
131
- - Audit logs ("this mutation reached N viewers").
132
- - Metrics (`fanout_size_histogram.observe(recipientCount)`).
133
- - Cross-region eventual-consistency markers.
134
-
135
- ### `rateLimitExceeded`
136
-
137
- ```ts
138
- // Scope 'user' or 'route' (per-token / per-IP per-route bucket):
139
- {
140
- scope: 'user' | 'route',
141
- key: string, // 'token:<token>:sync:<route>' or 'ip:<ip>:sync:<route>'
142
- limit: number,
143
- windowMs: number,
144
- count: number,
145
- route: string,
146
- userId: string | undefined,
147
- }
148
-
149
- // Scope 'ip' (global per-IP cross-route bucket):
150
- {
151
- scope: 'ip',
152
- key: string, // 'ip:<ip>:sync:all'
153
- limit: number,
154
- windowMs: number,
155
- count: number,
156
- ip: string,
157
- }
158
- ```
159
-
160
- Fires before fanout begins, when either bucket rejects. Used to surface abusive senders and feed automated mitigation.
161
-
162
- ---
163
-
164
- ## 6. `recipientCount` vs raw room size
165
-
166
- `recipientCount` differs from `sockets.size` in three cases:
167
-
168
- 1. **`ignoreSelf: true`** — every socket whose extracted token matches the sender's is skipped. If a user has 3 sockets in the room and triggered the sync themselves, `recipientCount` is `size - 3`.
169
- 2. **Sockets disappearing mid-fanout** — `ioInstance.sockets.sockets.get(socketId)` can return `undefined` between resolving the room set and emitting (the socket disconnected). Those iterations `continue` without bumping `recipientCount`.
170
- 3. **HTTP transport** — the HTTP path's `_client` execution `continue`s without bumping `recipientCount` only for per-recipient failures it could not recover from; the socket path always bumps for handled (success or error) recipients and only skips disappeared sockets.
171
-
172
- This is why the hook payload exposes `recipientCount` instead of a raw room size — observers want the count that actually saw the payload.
173
-
174
- ---
175
-
176
- ## 7. Cross-instance fanout via Redis adapter
177
-
178
- Single-instance fanout is built into Socket.io. **Cross-instance fanout requires the Redis adapter.** Without it, a `broadcastStream` on instance A reaches sockets on instance A only; sockets on instance B (same room name, different Node process) get nothing.
179
-
180
- `@luckystack/server` wires the Redis adapter when `REDIS_URL` is set and the Socket.io Redis adapter peer is installed. With it:
181
-
182
- - `io.to(roomCode).emit(...)` reaches every socket in the room **across every instance**.
183
- - `ioInstance.sockets.adapter.rooms.get(roomCode)` still only sees the local instance's members — the Redis adapter handles the cross-instance fanout transparently at the emit layer.
184
-
185
- This means `handleSyncRequest`'s **per-recipient `_client` execution only runs against local sockets**. If you need a `_client` handler to execute on every recipient regardless of which instance they're connected to, either:
186
-
187
- 1. Pin sticky sessions so a given room's sockets all land on the same instance (simplest), OR
188
- 2. Wire your own cross-instance per-recipient runner (rare, advanced).
189
-
190
- For the common case (broadcast a single `serverOutput` to everyone in the room), the Redis adapter is sufficient. See [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md) for the adapter setup.
191
-
192
- ---
193
-
194
- ## 8. `sync.noReceiversFound`
195
-
196
- Triggered when the resolved `sockets` is `undefined` or falsy:
197
-
198
- - The room name was misspelled.
199
- - Every member already disconnected before the request reached the fanout step.
200
- - A bug had the client `joinRoom`-ing under a different name than the sender's `receiver` argument.
201
- - `receiver: 'all'` while no sockets are connected at all (development edge case).
202
-
203
- Surfaced to the originator as:
204
-
205
- ```ts
206
- { status: 'error', errorCode: 'sync.noReceiversFound', message: '<localized>', httpStatus: 404 }
207
- ```
208
-
209
- Default `httpStatus` mapping for this code comes from `defaultHttpStatusForResponse` in `@luckystack/core`.
210
-
211
- This is not necessarily a bug — sending to an empty room is legal if the sender doesn't yet know the room is empty. UI typically treats `sync.noReceiversFound` as "your mutation succeeded server-side (the `_server` already ran and persisted state); just nobody happened to be listening". `_server`'s mutations are NOT rolled back when there are zero recipients.
212
-
213
- ---
214
-
215
- ## 9. Sanity-check checklist
216
-
217
- - Are sockets joining the right room? `socket.rooms` on the recipient side lists every room they're in (including the auto-joined `<sessionToken>` room and `<socket.id>` self-room).
218
- - Is the Redis adapter wired in production? `getIoInstance().of('/').adapter` should be `RedisAdapter`, not `Adapter`.
219
- - Are giant fanouts hot in the profiler? Bump `fanoutYieldEvery` higher OR move to per-token rooms instead of `receiver: 'all'`.
220
- - Is `recipientCount` consistently below room size? Probably `ignoreSelf: true` + multi-tab usage. Expected; not a bug.
221
-
222
- ---
223
-
224
- ## 10. Related
225
-
226
- - Originator API: [`./sync-request.md`](./sync-request.md)
227
- - Handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
228
- - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
229
- - Streaming fanout: [`./streaming.md`](./streaming.md)
230
- - Error catalog (including `sync.noReceiversFound`): [`./error-states.md`](./error-states.md)
231
- - Socket.io + Redis adapter: [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md)
232
- - Hook payload shapes: `@luckystack/core` `HookPayloads`
233
- - Config: `projectConfig.sync.fanoutYieldEvery`, `projectConfig.sync.fanoutYieldMs`
1
+ # room-fanout
2
+
3
+ > How `handleSyncRequest` resolves the `receiver` string to a set of Socket.io sockets, iterates them, and notifies hook consumers along the way. Covers the `'all'` sentinel, the per-token room convention, event-loop yielding for giant fanouts, the `preSyncFanout` / `postSyncFanout` hooks, and Redis-backed cross-instance fanout.
4
+
5
+ For the originator's `receiver` argument see [`./sync-request.md`](./sync-request.md). For per-recipient handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
+
7
+ ---
8
+
9
+ ## 1. How `receiver` resolves to sockets
10
+
11
+ `handleSyncRequest` branches on `receiver`:
12
+
13
+ ```ts
14
+ const sockets = receiver === 'all'
15
+ ? ioInstance.sockets.sockets // Map<socketId, Socket>
16
+ : ioInstance.sockets.adapter.rooms.get(receiver); // Set<socketId> | undefined
17
+ ```
18
+
19
+ - `receiver === 'all'` -> every connected socket on **this instance** (production with Redis adapter: every socket on every instance, see §6).
20
+ - Any other string -> the Socket.io room with that name. If the room does not exist or is empty, `sockets` is `undefined` and the fanout fails with `sync.noReceiversFound`.
21
+
22
+ There is no "broadcast to all but yourself" sentinel — pass `ignoreSelf: true` to skip the originator's own sockets. See [`./ignore-self.md`](./ignore-self.md).
23
+
24
+ ---
25
+
26
+ ## 2. Room conventions
27
+
28
+ Rooms are managed by `@luckystack/core` (client) via `joinRoom(code)` / `leaveRoom(code)`. Conventions in production code:
29
+
30
+ | Room name | Membership | Used for |
31
+ |---|---|---|
32
+ | `<sessionToken>` (auto-joined) | Every socket of one user | `streamTo(token, payload)` reaches all that user's devices. |
33
+ | `<sharedCode>` (manual join) | Multiple users by app logic | Collab editors, multiplayer rooms, chat. |
34
+ | `'all'` (sentinel, not a real room) | Every connected socket | Global broadcasts. Avoid in production. |
35
+
36
+ ### Why every socket auto-joins a room named after its session token
37
+
38
+ `@luckystack/server` joins each socket to a room with that socket's session token at connect time. This is what makes `streamTo(['userToken1', 'userToken2'], payload)` work — without it the framework would have to maintain a separate `tokenToSockets` map.
39
+
40
+ Side effect: if a user is connected from three devices (three sockets, three different `socket.id`s, one session token), `streamTo` reaches all three because they all live in the same per-token room.
41
+
42
+ ---
43
+
44
+ ## 3. The fanout loop
45
+
46
+ After auth, rate-limit, validation, and `_server` execution succeed, `handleSyncRequest` enters the per-recipient loop:
47
+
48
+ ```
49
+ preSyncFanout (stop signal aborts before any recipient is touched)
50
+ |
51
+ v
52
+ for each socket in <resolved sockets>:
53
+ yield every N (configurable)
54
+ skip if ignoreSelf && token === recipientToken
55
+ recipientCount++
56
+ if _client exists:
57
+ run clientHandler
58
+ emit per-recipient result (success or normalized error)
59
+ else:
60
+ emit { serverOutput, clientOutput: {}, status: 'success', cb, fullName }
61
+ |
62
+ v
63
+ postSyncFanout({ recipientCount, ...payload })
64
+ ```
65
+
66
+ Per-recipient errors do **not** abort the loop. A single recipient failing `_client` execution receives a `sync.clientExecutionFailed` frame; everyone else still receives their merged payload.
67
+
68
+ ---
69
+
70
+ ## 4. Event-loop yielding (`sync.fanoutYieldEvery`, `sync.fanoutYieldMs`)
71
+
72
+ ```ts
73
+ const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig().sync;
74
+ let tempCount = 1;
75
+ for (const socketEntry of sockets) {
76
+ tempCount++;
77
+ if (tempCount % fanoutYieldEvery === 0) {
78
+ await new Promise(resolve => setTimeout(resolve, fanoutYieldMs));
79
+ }
80
+ // ... fanout to this recipient ...
81
+ }
82
+ ```
83
+
84
+ Why: a `receiver: 'all'` fanout against thousands of sockets would otherwise block the event loop for the whole iteration. Yielding every `fanoutYieldEvery` recipients (default: see `projectConfig.sync.fanoutYieldEvery`) for `fanoutYieldMs` (default: a few ms) lets other socket events, API requests, and the heartbeat handlers run.
85
+
86
+ Tuning:
87
+
88
+ - **Higher `fanoutYieldEvery` + lower `fanoutYieldMs`** = faster fanout, less responsiveness for other requests.
89
+ - **Lower `fanoutYieldEvery` + higher `fanoutYieldMs`** = smoother for concurrent traffic but slower fanout.
90
+ - For typical room sizes (<100 recipients), the yield never triggers — defaults are tuned for `receiver: 'all'` worst cases.
91
+
92
+ This loop only exists on the socket path (`handleSyncRequest`). The HTTP path (`handleHttpSyncRequest`) does not yield — HTTP requests are inherently isolated per Node.js handler invocation, and the per-instance fanout sits inside a single async block anyway.
93
+
94
+ ---
95
+
96
+ ## 5. Hooks dispatched during fanout
97
+
98
+ ### `preSyncFanout`
99
+
100
+ ```ts
101
+ {
102
+ routeName: string, // e.g. 'board/moveCard/v1'
103
+ data: Record<string, unknown>, // clientInput
104
+ user: SessionLayout | null, // sender's session
105
+ receiver: string, // resolved roomCode or 'all'
106
+ serverOutput: unknown, // what _server returned (minus status)
107
+ }
108
+ ```
109
+
110
+ Fires **after** `_server` runs successfully and the recipient set is resolved, **before** any recipient receives the payload. Stop signal converts to an originator-side error envelope with the hook's `errorCode` / `httpStatus`. Use for:
111
+
112
+ - "Don't fanout this mutation to a degraded region while we drain traffic."
113
+ - "Throttle fanout-heavy routes during peak load."
114
+ - "Inject a cross-room replication hop before the room receives the payload."
115
+
116
+ ### `postSyncFanout`
117
+
118
+ ```ts
119
+ {
120
+ routeName: string,
121
+ data: Record<string, unknown>,
122
+ user: SessionLayout | null,
123
+ receiver: string,
124
+ serverOutput: unknown,
125
+ recipientCount: number, // actual number of sockets emitted to (NOT room size)
126
+ }
127
+ ```
128
+
129
+ Fires after the last recipient's emit. Observation-only — there is no stop signal because the fanout has already happened. Use for:
130
+
131
+ - Audit logs ("this mutation reached N viewers").
132
+ - Metrics (`fanout_size_histogram.observe(recipientCount)`).
133
+ - Cross-region eventual-consistency markers.
134
+
135
+ ### `rateLimitExceeded`
136
+
137
+ ```ts
138
+ // Scope 'user' or 'route' (per-token / per-IP per-route bucket):
139
+ {
140
+ scope: 'user' | 'route',
141
+ key: string, // 'token:<token>:sync:<route>' or 'ip:<ip>:sync:<route>'
142
+ limit: number,
143
+ windowMs: number,
144
+ count: number,
145
+ route: string,
146
+ userId: string | undefined,
147
+ }
148
+
149
+ // Scope 'ip' (global per-IP cross-route bucket):
150
+ {
151
+ scope: 'ip',
152
+ key: string, // 'ip:<ip>:sync:all'
153
+ limit: number,
154
+ windowMs: number,
155
+ count: number,
156
+ ip: string,
157
+ }
158
+ ```
159
+
160
+ Fires before fanout begins, when either bucket rejects. Used to surface abusive senders and feed automated mitigation.
161
+
162
+ ---
163
+
164
+ ## 6. `recipientCount` vs raw room size
165
+
166
+ `recipientCount` differs from `sockets.size` in three cases:
167
+
168
+ 1. **`ignoreSelf: true`** — every socket whose extracted token matches the sender's is skipped. If a user has 3 sockets in the room and triggered the sync themselves, `recipientCount` is `size - 3`.
169
+ 2. **Sockets disappearing mid-fanout** — `ioInstance.sockets.sockets.get(socketId)` can return `undefined` between resolving the room set and emitting (the socket disconnected). Those iterations `continue` without bumping `recipientCount`.
170
+ 3. **HTTP transport** — the HTTP path's `_client` execution `continue`s without bumping `recipientCount` only for per-recipient failures it could not recover from; the socket path always bumps for handled (success or error) recipients and only skips disappeared sockets.
171
+
172
+ This is why the hook payload exposes `recipientCount` instead of a raw room size — observers want the count that actually saw the payload.
173
+
174
+ ---
175
+
176
+ ## 7. Cross-instance fanout via Redis adapter
177
+
178
+ Single-instance fanout is built into Socket.io. **Cross-instance fanout requires the Redis adapter.** Without it, a `broadcastStream` on instance A reaches sockets on instance A only; sockets on instance B (same room name, different Node process) get nothing.
179
+
180
+ `@luckystack/server` wires the Redis adapter when `REDIS_URL` is set and the Socket.io Redis adapter peer is installed. With it:
181
+
182
+ - `io.to(roomCode).emit(...)` reaches every socket in the room **across every instance**.
183
+ - `ioInstance.sockets.adapter.rooms.get(roomCode)` still only sees the local instance's members — the Redis adapter handles the cross-instance fanout transparently at the emit layer.
184
+
185
+ This means `handleSyncRequest`'s **per-recipient `_client` execution only runs against local sockets**. If you need a `_client` handler to execute on every recipient regardless of which instance they're connected to, either:
186
+
187
+ 1. Pin sticky sessions so a given room's sockets all land on the same instance (simplest), OR
188
+ 2. Wire your own cross-instance per-recipient runner (rare, advanced).
189
+
190
+ For the common case (broadcast a single `serverOutput` to everyone in the room), the Redis adapter is sufficient. See [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md) for the adapter setup.
191
+
192
+ ---
193
+
194
+ ## 8. `sync.noReceiversFound`
195
+
196
+ Triggered when the resolved `sockets` is `undefined` or falsy:
197
+
198
+ - The room name was misspelled.
199
+ - Every member already disconnected before the request reached the fanout step.
200
+ - A bug had the client `joinRoom`-ing under a different name than the sender's `receiver` argument.
201
+ - `receiver: 'all'` while no sockets are connected at all (development edge case).
202
+
203
+ Surfaced to the originator as:
204
+
205
+ ```ts
206
+ { status: 'error', errorCode: 'sync.noReceiversFound', message: '<localized>', httpStatus: 404 }
207
+ ```
208
+
209
+ Default `httpStatus` mapping for this code comes from `defaultHttpStatusForResponse` in `@luckystack/core`.
210
+
211
+ This is not necessarily a bug — sending to an empty room is legal if the sender doesn't yet know the room is empty. UI typically treats `sync.noReceiversFound` as "your mutation succeeded server-side (the `_server` already ran and persisted state); just nobody happened to be listening". `_server`'s mutations are NOT rolled back when there are zero recipients.
212
+
213
+ ---
214
+
215
+ ## 9. Sanity-check checklist
216
+
217
+ - Are sockets joining the right room? `socket.rooms` on the recipient side lists every room they're in (including the auto-joined `<sessionToken>` room and `<socket.id>` self-room).
218
+ - Is the Redis adapter wired in production? `getIoInstance().of('/').adapter` should be `RedisAdapter`, not `Adapter`.
219
+ - Are giant fanouts hot in the profiler? Bump `fanoutYieldEvery` higher OR move to per-token rooms instead of `receiver: 'all'`.
220
+ - Is `recipientCount` consistently below room size? Probably `ignoreSelf: true` + multi-tab usage. Expected; not a bug.
221
+
222
+ ---
223
+
224
+ ## 10. Related
225
+
226
+ - Originator API: [`./sync-request.md`](./sync-request.md)
227
+ - Handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
228
+ - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
229
+ - Streaming fanout: [`./streaming.md`](./streaming.md)
230
+ - Error catalog (including `sync.noReceiversFound`): [`./error-states.md`](./error-states.md)
231
+ - Socket.io + Redis adapter: [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md)
232
+ - Hook payload shapes: `@luckystack/core` `HookPayloads`
233
+ - Config: `projectConfig.sync.fanoutYieldEvery`, `projectConfig.sync.fanoutYieldMs`