@luckystack/sync 0.10.1 → 0.11.0

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,258 +1,18 @@
1
- # error-states
1
+ # Sync error states
2
2
 
3
- > Authoritative catalog of error codes emitted by `syncRequest` (client) and `handleSyncRequest` / `handleHttpSyncRequest` (server). Documents HTTP-status mapping, locale-aware message normalization, `errorParams` interpolation slots, the `rateLimitExceeded` hook payload, and registered error-tracker capture behavior.
3
+ A request failure, a local connection failure and a single recipient's failure are different outcomes. The originator acknowledgement reports server-handler success or failure, not the result of each recipient's filter; one rejected recipient must not prevent delivery to authorized peers. See `../src/handleSyncRequest.ts`, `../src/handleHttpSyncRequest.ts`, `../src/_shared/clientFanout.ts`.
4
4
 
5
- For the originator response envelope shape see [`./sync-request.md`](./sync-request.md). For the full lifecycle these errors slot into see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
5
+ Localize recipient errors using that recipient's locale, not the sender's. Do not expose raw exceptions or sensitive tokens in errors or logs. Offline queue overflow is a delivery failure, not a license to replay a mutation without rechecking authorization. For current envelopes and reason codes consult `../src/_shared/errorBuilders.ts` and `../src/syncRequest.ts`.
6
6
 
7
- ---
7
+ ## Which errors mean "nothing was saved"
8
8
 
9
- ## 1. Error envelope shape
9
+ Receiver policy and recipient resolution run before the `_server` handler on both transports (ADR 0063). A rejection at that point is a pre-commit rejection: nothing was persisted and a retry is safe. That covers a refused receiver (`sync.receiverNotAllowed`, `sync.notRoomMember`), an empty room (`sync.noReceiversFound`) and a failed cross-instance recipient lookup. The order exists because the framework cannot undo a handler's side effects; when the lookup ran after the handler, a caller was told `error` about a change that had been saved, retried, and applied it twice.
10
10
 
11
- Every sync error — whether produced client-side before sending or server-side at any stage — collapses to the same shape after `normalizeErrorResponse`:
11
+ - `sync.noReceiversFound` means "not saved, nobody was listening" — not "saved, nobody was listening". UI written against the older reading is wrong.
12
+ - An empty room is an error on every transport, including routed HTTP (ADR 0058). A silent success hid wrong room names, sockets that never re-joined after a reconnect and formatter mismatches; the error is the only runtime signal for that class of bug.
13
+ - A recipient-lookup failure reuses `sync.serverExecutionFailed`, the same code a throwing handler produces. Only the lookup case is guaranteed pre-commit, so the code alone does not prove nothing was saved. A lookup that times out usually means an instance on the same adapter channel did not answer, for example an environment sharing another environment's Redis; see `../../../docs/ARCHITECTURE_MULTI_INSTANCE.md`.
14
+ - `sync.notRoomMember` is tested from a different source per transport (ADR 0059). See `./room-fanout.md`.
12
15
 
13
- ```ts
14
- {
15
- status: 'error',
16
- message: string, // localized via preferredLocale / userLanguage
17
- errorCode: string, // see catalog below
18
- errorParams?: { key, value }[], // i18n interpolation slots
19
- httpStatus?: number, // inferred via defaultHttpStatusForResponse
20
- }
21
- ```
16
+ ## Locale coverage
22
17
 
23
- `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The framework itself ships no message catalog: the `sync.*` keys live in the scaffold's `src/_locales/*.json`, and `translate()` returns the raw key when a key is missing — so a code that is not in your locale files surfaces as `sync.someCode` in the UI rather than as a sentence. The canonical list of every `sync.*` code the framework can emit (both transports plus the server's HTTP sync route, e.g. `sync.disabled`, `sync.methodNotAllowed`, `sync.requestTimeout`) is exported as `SYNC_ERROR_CODES` (with the type `SyncErrorCode`) from both `@luckystack/sync` and `@luckystack/sync/client`. Write a parity test of your locale files against that array instead of retyping the tables below — the framework's own test keeps the scaffold locales in parity the same way. Project-defined codes use whatever the project catalog defines.
24
-
25
- ---
26
-
27
- ## 2. Client-side error codes
28
-
29
- Produced by `syncRequest` BEFORE the message ever hits the wire.
30
-
31
- | `errorCode` | Trigger | `httpStatus` (default) | `errorParams` |
32
- |---|---|---|---|
33
- | `sync.invalidName` | `name` is missing or not a string | n/a (local) | — |
34
- | `routing.invalidServiceRouteName` | `name` doesn't match `<page>/<route>` shape | n/a (local) | `[{ key: 'name', value: <input name> }]` |
35
- | `sync.invalidVersion` | `version` is missing or not a string | n/a (local) | — |
36
- | `sync.missingReceiver` | `receiver` is missing or empty after trim | n/a (local) | — |
37
- | `sync.ioUnavailable` | Socket never initialized or `waitForSocket()` timed out | n/a (local) | — |
38
- | `offline.queueFull` | Socket offline + queue full + `dropPolicy: 'reject'` | n/a (local) | — |
39
- | `sync.failedRequest` | Catch-all fallback for malformed ack (used in dev notification) | n/a (local) | `[{ key: 'name', ...}, { key: 'message', ...}]` |
40
- | `sync.invalidServerResponse` | Server ack missing `status: 'success'` and no recognized error code | inherited from server response (if any) | — |
41
-
42
- Local errors never travel over the wire — they short-circuit inside `syncRequest`'s promise body and resolve immediately with the error envelope. `httpStatus` doesn't apply (there was no HTTP).
43
-
44
- ---
45
-
46
- ## 3. Server-side error codes (originator-visible)
47
-
48
- Emitted by `handleSyncRequest` / `handleHttpSyncRequest` to the originator's ack channel (`buildSyncResponseEventName(responseIndex)` for socket, HTTP response body for HTTP).
49
-
50
- | `errorCode` | Trigger | `httpStatus` | `errorParams` |
51
- |---|---|---|---|
52
- | `sync.invalidRequest` | `msg` is not an object, or `name`/`data` missing or wrong type | `400` | — |
53
- | `routing.invalidServiceRouteName` | `parseTransportRouteName` failed | `400` | `[{ key: 'name', value: <input name> }]` |
54
- | `sync.invalidCallback` | `cb` is missing or not a string | `400` | — |
55
- | `sync.missingReceiver` | `receiver` is empty after trim | `400` | — |
56
- | `sync.notFound` | Neither `_server_v{N}` nor `_client_v{N}` exists for this route | `404` | — |
57
- | `auth.required` | `auth.login === true` on `_server` and no session resolved | `401` | — |
58
- | `auth.forbidden` (or route-specific code from `validateRequest`) | `validateRequest(auth.additional)` rejected | `403` (default; some predicates override) | predicate-defined |
59
- | `sync.receiverNotAllowed` | `receiver: 'all'` requested while `sync.allowClientReceiverAll` is `false` | `403` | — |
60
- | `sync.notRoomMember` | `sync.requireRoomMembership` is on and the requester is not a member of `receiver` — socket: physical membership of the originating socket; HTTP: logical membership from the session's `roomCodes` (+ its own token-room); an anonymous HTTP caller fails closed. See `/docs/ARCHITECTURE_SYNC.md` → "Room membership is transport-specific" | `403` | — |
61
- | `sync.rateLimitExceeded` | Per-route or per-IP rate-limit bucket rejected | `429` | `[{ key: 'seconds', value: <retry in> }]` |
62
- | `sync.invalidInputType` | `validateInputByType` rejected (Zod schema mismatch) | `400` | `[{ key: 'message', value: <readable reason> }]` |
63
- | `sync.serverExecutionFailed` | `_server`'s `main(...)` threw (caught by `tryCatch`) | `500` | — |
64
- | `<route-supplied>` (e.g. `board.cardNotFound`) | `_server` returned `{ status: 'error', errorCode }` | `defaultHttpStatusForResponse` or as-provided | as-provided |
65
- | `sync.invalidServerResponse` | `_server` returned a value with `status` other than `'success'` / `'error'` | `500` | — |
66
- | `sync.noReceiversFound` | The cross-instance recipient list for `receiver` is empty. Both transports since 0.10.0 (the HTTP path used to answer `success` with zero recipients). Recipients are resolved BEFORE `_server` runs, so nothing has been persisted — a retry is safe | `404` | — |
67
- | `<hook-supplied>` | `preSyncAuthorize` / `preSyncFanout` stop signal | from hook | from hook |
68
-
69
- The `preSyncAuthorize` and `preSyncFanout` hooks can stop with any `errorCode` they choose; the framework normalizes the message via `normalizeErrorResponse` using the originator's locale.
70
-
71
- ---
72
-
73
- ## 4. Server-side error codes (per-recipient, fanout step)
74
-
75
- Emitted to a **single recipient** when `_client_v{N}.ts` execution fails for them only. These do NOT propagate back to the originator and do NOT abort the fanout loop.
76
-
77
- | `errorCode` | Trigger | `httpStatus` | `errorParams` |
78
- |---|---|---|---|
79
- | `sync.clientExecutionFailed` | `_client`'s `main(...)` threw (caught by per-recipient `tryCatch`) | `500` | — |
80
- | `<route-supplied>` (e.g. `chat.translationFailed`) | `_client` returned `{ status: 'error', errorCode }` | as-provided | as-provided |
81
- | `sync.clientRejected` | `_client` returned `{ status: 'error' }` without an `errorCode` | `500` (default) | — |
82
- | `sync.invalidClientResponse` | `_client` returned something other than `'success'` / `'error'` | `500` | — |
83
-
84
- Recipients see these on their own `socketEventNames.sync` channel as if they were normal sync frames, just with `status: 'error'` set. Subscribers in `upsertSyncEventCallback` that branch on `status` will see the error variant.
85
-
86
- ---
87
-
88
- ## 5. HTTP-status mapping
89
-
90
- `httpStatus` defaults via `defaultHttpStatusForResponse(errorCode)` in `@luckystack/core`. Default rules (paraphrased — see core for exact source):
91
-
92
- - `auth.required` -> `401`
93
- - `auth.forbidden` and similar role/policy codes, plus the receiver-policy codes `sync.receiverNotAllowed` / `sync.notRoomMember` -> `403`
94
- - Validation failures (`*.invalidRequest`, `*.invalidInputType`, `*.invalidName`, `routing.invalidServiceRouteName`) -> `400`
95
- - Not-found (`*.notFound`, `sync.noReceiversFound`) -> `404`
96
- - Rate limit (`*.rateLimitExceeded`) -> `429`
97
- - Execution failures (`*.ExecutionFailed`, `*.invalid*Response`) -> `500`
98
-
99
- A hook or route can override the default by returning `httpStatus: <number>` in its stop signal or error payload. Custom codes inherit the fallback `500` unless `defaultHttpStatusForResponse` is extended in core.
100
-
101
- ---
102
-
103
- ## 6. `errorParams` localization slots
104
-
105
- `errorParams` is an array of `{ key, value }` pairs interpolated into the i18n message template. Framework codes use:
106
-
107
- - `sync.rateLimitExceeded` -> `[{ key: 'seconds', value: <number> }]` — the seconds until the next request is allowed. Template: "Rate limited, retry in {{seconds}}s".
108
- - `sync.invalidInputType` -> `[{ key: 'message', value: <Zod reason> }]` — the Zod error message. Template: "Input validation failed: {{message}}".
109
- - `sync.failedRequest` (dev notification) -> `[{ key: 'name', ...}, { key: 'message', ...}]`. Template: "Sync {{name}} failed: {{message}}".
110
- - `routing.invalidServiceRouteName` -> `[{ key: 'name', value: <bad input> }]`. Template: "Invalid service route name: {{name}}".
111
-
112
- Project codes can carry whatever slots their templates declare. The normalizer doesn't enforce a schema — it interpolates blindly.
113
-
114
- ---
115
-
116
- ## 7. `rateLimitExceeded` hook payload
117
-
118
- Fired whenever a rate-limit bucket rejects a sync request. The payload differs by `scope`:
119
-
120
- ### Scope `'user'` or `'ip'` (per-route bucket, identified by token or by IP)
121
-
122
- ```ts
123
- {
124
- scope: 'user' | 'ip',
125
- key: string, // 'token:<token>:sync:<routeName>' or 'ip:<ip>:sync:<routeName>'
126
- limit: number, // projectConfig.rateLimiting.defaultApiLimit
127
- windowMs: number, // projectConfig.rateLimiting.windowMs
128
- count: number, // limit + 1 (the request that tipped over)
129
- route: string, // e.g. 'board/moveCard/v1' — marks this a per-route bucket
130
- userId: string | undefined,
131
- ip: string | undefined, // set when anonymous (no token), else undefined
132
- }
133
- ```
134
-
135
- `scope: 'user'` when the request carried a session token; `scope: 'ip'` when it was anonymous (the per-route bucket is then IP-keyed). The `route` field stays set in both cases to distinguish this per-route bucket from the global `:sync:all` IP bucket below (parity with the API handler).
136
-
137
- ### Scope `'ip'` (global per-IP bucket, across all sync routes)
138
-
139
- ```ts
140
- {
141
- scope: 'ip',
142
- key: string, // 'ip:<ip>:sync:all'
143
- limit: number, // projectConfig.rateLimiting.defaultIpLimit
144
- windowMs: number,
145
- count: number,
146
- ip: string,
147
- }
148
- ```
149
-
150
- The hook is observation-only — there is no stop signal. The fanout has already been aborted by the time the hook fires.
151
-
152
- Typical consumer: a mitigation system that bans the offending IP / user after N hook fires within a window.
153
-
154
- ---
155
-
156
- ## 8. Locale resolution
157
-
158
- Both transports localize error messages through the same chain:
159
-
160
- 1. **`preferredLocale`** — first non-empty match from headers:
161
- - Socket: `socket.handshake.headers['x-language']` -> `socket.handshake.headers['accept-language']`.
162
- - HTTP: `xLanguageHeader` argument -> `acceptLanguageHeader` argument.
163
- 2. **`userLanguage`** — `user.language` from the resolved session.
164
- 3. **Fallback** — `projectConfig.defaultLanguage`.
165
-
166
- `extractLanguageFromHeader` accepts either a string or `string[]` (Node's headers can be either), trims whitespace, and picks the first listed language. The chain doesn't combine locales — first non-empty wins.
167
-
168
- Recipients (per-recipient errors during fanout) use **their own** headers, not the sender's. That's why `handleSyncRequest`'s per-recipient error path re-extracts `tempSocket.handshake.headers` for each recipient.
169
-
170
- ---
171
-
172
- ## 9. Error-tracker capture via `tryCatch`
173
-
174
- Every `_server` and `_client` execution is wrapped in `tryCatch(..., undefined, { handler, sync, stage, userId, ... })`. The fourth argument is the breadcrumb context that gets attached when `tryCatch` captures an exception.
175
-
176
- Captured automatically:
177
-
178
- - `_server` thrown errors -> a breadcrumb/event for the registered tracker(s): `{ handler: 'handleSyncRequest', sync: <route>, stage: 'server', userId, receiver, transport: 'socket' | 'http' }`.
179
- - `_client` thrown errors -> a breadcrumb/event for the registered tracker(s): `{ handler: 'handleSyncRequest', sync: <route>, stage: 'client', sourceUserId, targetToken, receiver, transport }`.
180
-
181
- NOT captured (returned as `status: 'error'` envelopes only):
182
-
183
- - Validation failures (`sync.invalidInputType`) — these are caller errors, not server bugs.
184
- - Auth rejects — these are policy outcomes.
185
- - Rate-limit rejects — these are noise.
186
- - `_server` / `_client` returning `{ status: 'error' }` voluntarily — the handler made a domain decision; nothing threw.
187
-
188
- If you need to capture a voluntary error (e.g. "this code path should never trigger in production"), do it explicitly inside the handler via `@luckystack/error-tracking`'s `captureException`; it fans out to the registered tracker(s).
189
-
190
- ---
191
-
192
- ## 10. Offline queue overflow flow
193
-
194
- When the socket is offline AND `dropPolicy: 'reject'` is active:
195
-
196
- ```
197
- syncRequest({ ..., offlineDropPolicy: 'reject' })
198
- -> canSendNow(socket) === false
199
- -> enqueueSyncRequest({ id, key, run, createdAt, dropPolicy: 'reject' })
200
- -> queue full
201
- -> enqueueSyncRequest returns false
202
- -> resolve({
203
- status: 'error',
204
- errorCode: 'offline.queueFull',
205
- message: <localized>,
206
- httpStatus: undefined,
207
- })
208
- ```
209
-
210
- With `dropPolicy: 'drop-oldest'` or `'drop-newest'`, the queue policy handles eviction silently and the caller's promise stays pending until the network comes back (then resolves on replay).
211
-
212
- `offline.queueFull` is the only error code that fires when there is no server interaction at all — it's purely a client-side admission of "we cannot promise to deliver this if we accept it".
213
-
214
- ---
215
-
216
- ## 11. Dev-only side effects
217
-
218
- When `projectConfig.logging.devLogs === true`:
219
-
220
- - Local validation failures (`sync.invalidName`, `sync.missingReceiver`, etc.) log via `getLogger().error(...)`.
221
- - Server returning `{ status: 'error' }` logs the normalized message at `getLogger().error(...)`.
222
- - Auth rejects, validation rejects, etc., warn-log at `getLogger().warn(...)`.
223
-
224
- When `projectConfig.logging.devNotifications === true` AND on the client:
225
-
226
- - Most local validation failures call `notify.error({ key: '<errorCode>' })`.
227
-
228
- These are dev hints, not production behavior. Toggle both to `false` for production to avoid leaking diagnostic info into user-facing notifications.
229
-
230
- ---
231
-
232
- ## 12. Quick lookup by symptom
233
-
234
- | Symptom | Most likely code | Where to look |
235
- |---|---|---|
236
- | `syncRequest` returns immediately with error | `sync.missingReceiver`, `sync.invalidVersion`, `sync.ioUnavailable` | Client-side validation, §2 |
237
- | Request reached server but auth rejected | `auth.required`, `auth.forbidden` | `_server`'s `auth` export, [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md) |
238
- | 429 rate-limit responses | `sync.rateLimitExceeded` | `projectConfig.rateLimiting` |
239
- | Server-side schema fail | `sync.invalidInputType` | `_server`'s `SyncParams.clientInput` interface + generated Zod schema |
240
- | Handler threw | `sync.serverExecutionFailed` / `sync.clientExecutionFailed` | Registered error-tracker capture |
241
- | Domain logic rejected | `<route-supplied errorCode>` | The `_server` or `_client` file's return path |
242
- | Receiver rejected before `_server` ran (403) | `sync.receiverNotAllowed`, `sync.notRoomMember` | `projectConfig.sync.allowClientReceiverAll` / `requireRoomMembership`; on `routed-http` membership is the session's `roomCodes`, not the socket — `/docs/ARCHITECTURE_SYNC.md` |
243
- | Empty room (both transports; before `_server` ran, nothing persisted) | `sync.noReceiversFound` | `receiver` argument; whether any socket actually (re)joined the room — [`./room-fanout.md`](./room-fanout.md) §8 |
244
- | Recipient lookup failed (both transports; before `_server` ran, nothing persisted) | `sync.serverExecutionFailed` | The Redis adapter's `fetchSockets()` timed out: an instance on the same adapter key + Redis did not answer — `/docs/ARCHITECTURE_MULTI_INSTANCE.md` pitfalls (shared Redis across environments) |
245
- | Offline + caller didn't await | `offline.queueFull` | Bump `offlineQueue.maxSize` or switch `dropPolicy` |
246
-
247
- ---
248
-
249
- ## 13. Related
250
-
251
- - Originator response envelope: [`./sync-request.md`](./sync-request.md)
252
- - Pipeline / lifecycle: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
253
- - Rate limiting hook + buckets: [`./room-fanout.md`](./room-fanout.md) §5
254
- - Hook payload shapes: `@luckystack/core` `HookPayloads`
255
- - Status-code mapping source: `@luckystack/core` `defaultHttpStatusForResponse`
256
- - Canonical code list for locale parity tests: `SYNC_ERROR_CODES` / `SyncErrorCode` (`@luckystack/sync` and `@luckystack/sync/client`)
257
- - Locale extraction: `@luckystack/core` `extractLanguageFromHeader`, `normalizeErrorResponse`
258
- - Project config: `projectConfig.rateLimiting`, `projectConfig.offlineQueue`, `projectConfig.logging`
18
+ The framework ships no message catalog. The `sync.*` keys live in the project's locale files, and a missing key appears in the UI as the raw code. The authoritative list of every `sync.*` code either transport can emit is `SYNC_ERROR_CODES` (`../src/_shared/errorCodes.ts`, exported from both the server and `/client` entries). Keep locale files complete with a parity test against that list rather than a hand-copied table.
@@ -1,162 +1,5 @@
1
- # ignore-self
1
+ # Ignoring the sender in fanout
2
2
 
3
- > `ignoreSelf: true` on `syncRequest` tells `handleSyncRequest` to skip every recipient socket whose session token matches the sender's. The check is **token-based, not socket-id-based** — multi-tab users skip ALL of their own tabs. The originator's ack response still arrives.
3
+ Optimistic interfaces have already applied their own change. Suppressing the echoed broadcast avoids needless re-rendering or flicker, but must not suppress the originator's direct acknowledgement: that response carries the server-authoritative result and lets the request complete. The sender can still be in the target room; ignore-self concerns the event audience, not authorization. Source: `../src/_shared/clientFanout.ts`, `../src/syncRequest.ts`.
4
4
 
5
- For the full `syncRequest` signature see [`./sync-request.md`](./sync-request.md). For the fanout loop's surrounding mechanics see [`./room-fanout.md`](./room-fanout.md).
6
-
7
- ---
8
-
9
- ## 1. What the flag actually does
10
-
11
- The originator passes `ignoreSelf: true`. Inside the per-recipient loop:
12
-
13
- ```ts
14
- const tempToken = extractTokenFromSocket(tempSocket);
15
-
16
- if (ignoreSelf && typeof ignoreSelf === 'boolean' && token === tempToken) {
17
- continue;
18
- }
19
-
20
- recipientCount++;
21
- ```
22
-
23
- `token` is the sender's session token (resolved from their socket at the top of `handleSyncRequest`). `tempToken` is the recipient's session token (extracted from THAT socket via `extractTokenFromSocket`).
24
-
25
- When the two match:
26
-
27
- - That recipient is **not** emitted to.
28
- - That recipient does **not** count toward `recipientCount` (so `postSyncFanout` sees a number that reflects actual sends).
29
- - The fanout loop continues immediately to the next socket.
30
-
31
- The originator still receives:
32
-
33
- - The normal ack via `buildSyncResponseEventName(responseIndex)` (the success/error envelope with `serverOutput`).
34
- - Any chunks emitted via the originator-targeted `stream(payload)` (those flow on the originator's unicast progress channel, not the broadcast room).
35
-
36
- What the originator does NOT receive when `ignoreSelf: true`:
37
-
38
- - The room frame (the `{ serverOutput, clientOutput, ... status: 'success' }` payload that `upsertSyncEventCallback` consumes).
39
- - `broadcastStream` and `streamTo` chunks targeted at their own token room.
40
-
41
- ---
42
-
43
- ## 2. When to use `ignoreSelf: true`
44
-
45
- The canonical case: **optimistic UI**. The sender already applied the local change before calling `syncRequest`, so receiving the broadcast back just causes a no-op re-render (and risks visual flicker if the local optimistic state and the broadcast payload differ subtly).
46
-
47
- ```ts
48
- // Local update (optimistic):
49
- setCards(prev => moveCardLocally(prev, cardId, toLane));
50
-
51
- // Tell everyone else:
52
- await syncRequest({
53
- name: 'board/moveCard',
54
- version: 'v1',
55
- data: { cardId, toLane },
56
- receiver: roomCode,
57
- ignoreSelf: true,
58
- });
59
- ```
60
-
61
- Other use cases:
62
-
63
- - **Drift-tolerant counters / cursor positions / typing indicators** — the sender's state is authoritative locally; rebroadcasting to them is pure waste.
64
- - **Chat message sends** — the sender already rendered "you said …" in the input box; the broadcast is for everyone else.
65
- - **Acknowledgment-pattern sends** — the sender awaits the ack to know it persisted; they don't need a separate "your message arrived" frame.
66
-
67
- ---
68
-
69
- ## 3. When to leave `ignoreSelf: false` (or omit it)
70
-
71
- Default to `false` (or just don't set it) when **the sender needs to receive the server's authoritative version** to reconcile their optimistic guess.
72
-
73
- - **Server-assigned IDs** — the optimistic version had a temp UUID, the broadcast payload has the real DB primary key. The sender needs to listen.
74
- - **Server-computed derived fields** — timestamps (`createdAt`), running totals (`balance`), search scores, anything the client could not have known.
75
- - **Cross-validation refresh** — the sender wants to confirm "yes, my mutation actually landed in the shape I expected" via the broadcast.
76
- - **Recipients are different users** — when the sender is NOT a member of the room they're sending to (e.g. an admin pushing an announcement), `ignoreSelf` is irrelevant. Leave it false.
77
-
78
- If you're unsure, leave it `false`. Receiving an extra broadcast frame is cheap; **missing one is expensive** because you can't easily detect the absence.
79
-
80
- ---
81
-
82
- ## 4. Edge case: multiple sockets per user (tabs, devices)
83
-
84
- `ignoreSelf` compares **session tokens**, not socket IDs. If a user has 3 tabs open:
85
-
86
- - Tab A initiates `syncRequest({ ignoreSelf: true })`.
87
- - Tabs A, B, and C are all in the room (they all auto-joined the `<sessionToken>` room AND any shared room they're members of).
88
- - All three tabs share the same session token.
89
-
90
- Result: **all three tabs skip the broadcast**, not just Tab A.
91
-
92
- This is usually what you want — when you optimistically update a piece of state in any one tab, the framework's `SessionProvider` typically broadcasts the change to all tabs of the same user via the per-token room separately. But it's worth being aware of: `ignoreSelf` is a per-user filter, not a per-socket filter.
93
-
94
- If you ever genuinely want "skip Tab A but notify Tabs B and C", you have to model that explicitly — either with separate logical session tokens per tab (atypical) or by leaving `ignoreSelf: false` and de-duplicating client-side.
95
-
96
- ---
97
-
98
- ## 5. Interaction with `recipientCount`
99
-
100
- `recipientCount` is what `postSyncFanout` reports. Skipped recipients (whether via `ignoreSelf` or because the socket disappeared mid-fanout) are NOT counted.
101
-
102
- Sample timeline:
103
-
104
- - Room has 5 sockets, 3 of which belong to the sender's session token.
105
- - `syncRequest({ ignoreSelf: true })` from one of the sender's sockets.
106
- - Fanout skips all 3 of the sender's sockets.
107
- - `recipientCount === 2`.
108
- - `postSyncFanout` payload: `{ ..., recipientCount: 2 }`.
109
-
110
- This makes metrics observe "actual sends", not "room size" — useful for "how many viewers did this update actually reach".
111
-
112
- ---
113
-
114
- ## 6. Interaction with streaming
115
-
116
- `broadcastStream` and `streamTo` happen **inside** `_server`'s `main()`, BEFORE the fanout loop reaches the skip step. The skip is per-final-emit only.
117
-
118
- Implication: a `broadcastStream` call sends chunks to **every socket in the room INCLUDING the sender's own sockets**, regardless of whether the final ack-step fanout will skip them.
119
-
120
- If you need to exclude the sender from broadcast streaming too, use `streamTo` with an explicit list that excludes the sender's token:
121
-
122
- ```ts
123
- // inside _server_v1.ts main()
124
- const others = (await functions.session.getRoomMembers(roomCode)).filter(t => t !== user.id);
125
- streamTo(others, { chunk: piece });
126
- ```
127
-
128
- This isn't a common need — typically the sender genuinely does want to see their own LLM tokens stream because they initiated the conversation. But it's available.
129
-
130
- ---
131
-
132
- ## 7. Why the originator still gets the ack with `ignoreSelf: true`
133
-
134
- The ack response (`buildSyncResponseEventName(responseIndex)`) is the protocol-level "your request succeeded / failed" envelope. It is **never** a fanout payload — it's a direct unicast `socket.emit` to the originator's responseIndex channel, and it carries `serverOutput` so the originator can reconcile their optimistic state with the server's authoritative version.
135
-
136
- `ignoreSelf` only affects the **room fanout step**. The ack is wired through a separate channel that bypasses the fanout entirely.
137
-
138
- This is critical: the originator's `syncRequest` `await` would never resolve if `ignoreSelf` suppressed the ack. The split between "broadcast payload" and "originator ack" is what makes the optimistic-UI pattern usable.
139
-
140
- ---
141
-
142
- ## 8. Quick reference
143
-
144
- | Behavior with `ignoreSelf: true` | |
145
- |---|---|
146
- | Sender's tabs skip the broadcast frame | yes |
147
- | Sender receives ack with `serverOutput` | yes |
148
- | Sender receives originator-targeted `stream(...)` chunks | yes |
149
- | Sender receives `broadcastStream(...)` chunks | **yes — broadcastStream runs in `_server` before skip** |
150
- | Sender receives `streamTo(...)` chunks (when token included) | **yes — same reason** |
151
- | Other tabs of same user skipped too | yes — token match, not socket-id match |
152
- | `recipientCount` counts skipped sockets | no |
153
- | Same flag works on HTTP transport | yes — `handleHttpSyncRequest` has the same skip block |
154
-
155
- ---
156
-
157
- ## 9. Related
158
-
159
- - Originator API: [`./sync-request.md`](./sync-request.md)
160
- - Fanout mechanics: [`./room-fanout.md`](./room-fanout.md)
161
- - Stream audience matrix: [`./streaming.md`](./streaming.md)
162
- - Server handler params (`user.id` vs sender's token): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
5
+ Multiple tabs or devices may share an identity, so distinguish originator socket from other recipients rather than interpreting this option as 'exclude this user from all delivery'. Stream audience rules are separate: see `./streaming.md`.