@luckystack/sync 0.1.1 → 0.1.3

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,252 +1,252 @@
1
- # error-states
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 Sentry capture behavior.
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).
6
-
7
- ---
8
-
9
- ## 1. Error envelope shape
10
-
11
- Every sync error — whether produced client-side before sending or server-side at any stage — collapses to the same shape after `normalizeErrorResponse`:
12
-
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
- ```
22
-
23
- `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The default i18n catalog ships built-in keys for every framework code; project-defined codes use whatever the project i18n 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.rateLimitExceeded` | Per-route or per-IP rate-limit bucket rejected | `429` | `[{ key: 'seconds', value: <retry in> }]` |
60
- | `sync.invalidInputType` | `validateInputByType` rejected (Zod schema mismatch) | `400` | `[{ key: 'message', value: <readable reason> }]` |
61
- | `sync.serverExecutionFailed` | `_server`'s `main(...)` threw (caught by `tryCatch`) | `500` | — |
62
- | `<route-supplied>` (e.g. `board.cardNotFound`) | `_server` returned `{ status: 'error', errorCode }` | `defaultHttpStatusForResponse` or as-provided | as-provided |
63
- | `sync.invalidServerResponse` | `_server` returned a value with `status` other than `'success'` / `'error'` | `500` | — |
64
- | `sync.noReceiversFound` | Resolved sockets is `undefined` or empty | `404` | — |
65
- | `<hook-supplied>` | `preSyncAuthorize` / `preSyncFanout` stop signal | from hook | from hook |
66
-
67
- The `preSyncAuthorize` and `preSyncFanout` hooks can stop with any `errorCode` they choose; the framework normalizes the message via `normalizeErrorResponse` using the originator's locale.
68
-
69
- ---
70
-
71
- ## 4. Server-side error codes (per-recipient, fanout step)
72
-
73
- 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.
74
-
75
- | `errorCode` | Trigger | `httpStatus` | `errorParams` |
76
- |---|---|---|---|
77
- | `sync.clientExecutionFailed` | `_client`'s `main(...)` threw (caught by per-recipient `tryCatch`) | `500` | — |
78
- | `<route-supplied>` (e.g. `chat.translationFailed`) | `_client` returned `{ status: 'error', errorCode }` | as-provided | as-provided |
79
- | `sync.clientRejected` | `_client` returned `{ status: 'error' }` without an `errorCode` | `500` (default) | — |
80
- | `sync.invalidClientResponse` | `_client` returned something other than `'success'` / `'error'` | `500` | — |
81
-
82
- 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.
83
-
84
- ---
85
-
86
- ## 5. HTTP-status mapping
87
-
88
- `httpStatus` defaults via `defaultHttpStatusForResponse(errorCode)` in `@luckystack/core`. Default rules (paraphrased — see core for exact source):
89
-
90
- - `auth.required` -> `401`
91
- - `auth.forbidden` and similar role/policy codes -> `403`
92
- - Validation failures (`*.invalidRequest`, `*.invalidInputType`, `*.invalidName`, `routing.invalidServiceRouteName`) -> `400`
93
- - Not-found (`*.notFound`, `sync.noReceiversFound`) -> `404`
94
- - Rate limit (`*.rateLimitExceeded`) -> `429`
95
- - Execution failures (`*.ExecutionFailed`, `*.invalid*Response`) -> `500`
96
-
97
- 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.
98
-
99
- ---
100
-
101
- ## 6. `errorParams` localization slots
102
-
103
- `errorParams` is an array of `{ key, value }` pairs interpolated into the i18n message template. Framework codes use:
104
-
105
- - `sync.rateLimitExceeded` -> `[{ key: 'seconds', value: <number> }]` — the seconds until the next request is allowed. Template: "Rate limited, retry in {{seconds}}s".
106
- - `sync.invalidInputType` -> `[{ key: 'message', value: <Zod reason> }]` — the Zod error message. Template: "Input validation failed: {{message}}".
107
- - `sync.failedRequest` (dev notification) -> `[{ key: 'name', ...}, { key: 'message', ...}]`. Template: "Sync {{name}} failed: {{message}}".
108
- - `routing.invalidServiceRouteName` -> `[{ key: 'name', value: <bad input> }]`. Template: "Invalid service route name: {{name}}".
109
-
110
- Project codes can carry whatever slots their templates declare. The normalizer doesn't enforce a schema — it interpolates blindly.
111
-
112
- ---
113
-
114
- ## 7. `rateLimitExceeded` hook payload
115
-
116
- Fired whenever a rate-limit bucket rejects a sync request. The payload differs by `scope`:
117
-
118
- ### Scope `'user'` or `'route'` (per-route bucket, identified by token or by IP)
119
-
120
- ```ts
121
- {
122
- scope: 'user' | 'route',
123
- key: string, // 'token:<token>:sync:<routeName>' or 'ip:<ip>:sync:<routeName>'
124
- limit: number, // projectConfig.rateLimiting.defaultApiLimit
125
- windowMs: number, // projectConfig.rateLimiting.windowMs
126
- count: number, // limit + 1 (the request that tipped over)
127
- route: string, // e.g. 'board/moveCard/v1'
128
- userId: string | undefined,
129
- }
130
- ```
131
-
132
- `scope: 'user'` when the request carried a session token; `scope: 'route'` when it was anonymous (only an IP).
133
-
134
- ### Scope `'ip'` (global per-IP bucket, across all sync routes)
135
-
136
- ```ts
137
- {
138
- scope: 'ip',
139
- key: string, // 'ip:<ip>:sync:all'
140
- limit: number, // projectConfig.rateLimiting.defaultIpLimit
141
- windowMs: number,
142
- count: number,
143
- ip: string,
144
- }
145
- ```
146
-
147
- The hook is observation-only — there is no stop signal. The fanout has already been aborted by the time the hook fires.
148
-
149
- Typical consumer: a mitigation system that bans the offending IP / user after N hook fires within a window.
150
-
151
- ---
152
-
153
- ## 8. Locale resolution
154
-
155
- Both transports localize error messages through the same chain:
156
-
157
- 1. **`preferredLocale`** — first non-empty match from headers:
158
- - Socket: `socket.handshake.headers['x-language']` -> `socket.handshake.headers['accept-language']`.
159
- - HTTP: `xLanguageHeader` argument -> `acceptLanguageHeader` argument.
160
- 2. **`userLanguage`** — `user.language` from the resolved session.
161
- 3. **Fallback** — `projectConfig.defaultLanguage`.
162
-
163
- `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.
164
-
165
- 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.
166
-
167
- ---
168
-
169
- ## 9. Sentry capture via `tryCatch`
170
-
171
- 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.
172
-
173
- Captured automatically:
174
-
175
- - `_server` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'server', userId, receiver, transport: 'socket' | 'http' }`.
176
- - `_client` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'client', sourceUserId, targetToken, receiver, transport }`.
177
-
178
- NOT captured (returned as `status: 'error'` envelopes only):
179
-
180
- - Validation failures (`sync.invalidInputType`) — these are caller errors, not server bugs.
181
- - Auth rejects — these are policy outcomes.
182
- - Rate-limit rejects — these are noise.
183
- - `_server` / `_client` returning `{ status: 'error' }` voluntarily — the handler made a domain decision; nothing threw.
184
-
185
- If you need to also Sentry-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`.
186
-
187
- ---
188
-
189
- ## 10. Offline queue overflow flow
190
-
191
- When the socket is offline AND `dropPolicy: 'reject'` is active:
192
-
193
- ```
194
- syncRequest({ ..., offlineDropPolicy: 'reject' })
195
- -> canSendNow(socket) === false
196
- -> enqueueSyncRequest({ id, key, run, createdAt, dropPolicy: 'reject' })
197
- -> queue full
198
- -> enqueueSyncRequest returns false
199
- -> resolve({
200
- status: 'error',
201
- errorCode: 'offline.queueFull',
202
- message: <localized>,
203
- httpStatus: undefined,
204
- })
205
- ```
206
-
207
- 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).
208
-
209
- `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".
210
-
211
- ---
212
-
213
- ## 11. Dev-only side effects
214
-
215
- When `projectConfig.logging.devLogs === true`:
216
-
217
- - Local validation failures (`sync.invalidName`, `sync.missingReceiver`, etc.) log via `getLogger().error(...)`.
218
- - Server returning `{ status: 'error' }` logs the normalized message at `getLogger().error(...)`.
219
- - Auth rejects, validation rejects, etc., warn-log at `getLogger().warn(...)`.
220
-
221
- When `projectConfig.logging.devNotifications === true` AND on the client:
222
-
223
- - Most local validation failures call `notify.error({ key: '<errorCode>' })`.
224
-
225
- These are dev hints, not production behavior. Toggle both to `false` for production to avoid leaking diagnostic info into user-facing notifications.
226
-
227
- ---
228
-
229
- ## 12. Quick lookup by symptom
230
-
231
- | Symptom | Most likely code | Where to look |
232
- |---|---|---|
233
- | `syncRequest` returns immediately with error | `sync.missingReceiver`, `sync.invalidVersion`, `sync.ioUnavailable` | Client-side validation, §2 |
234
- | Request reached server but auth rejected | `auth.required`, `auth.forbidden` | `_server`'s `auth` export, [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md) |
235
- | 429 rate-limit responses | `sync.rateLimitExceeded` | `projectConfig.rateLimiting` |
236
- | Server-side schema fail | `sync.invalidInputType` | `_server`'s `SyncParams.clientInput` interface + generated Zod schema |
237
- | Handler threw | `sync.serverExecutionFailed` / `sync.clientExecutionFailed` | Sentry breadcrumb |
238
- | Domain logic rejected | `<route-supplied errorCode>` | The `_server` or `_client` file's return path |
239
- | Empty room | `sync.noReceiversFound` | `receiver` argument; check room membership |
240
- | Offline + caller didn't await | `offline.queueFull` | Bump `offlineQueue.maxSize` or switch `dropPolicy` |
241
-
242
- ---
243
-
244
- ## 13. Related
245
-
246
- - Originator response envelope: [`./sync-request.md`](./sync-request.md)
247
- - Pipeline / lifecycle: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
248
- - Rate limiting hook + buckets: [`./room-fanout.md`](./room-fanout.md) §5
249
- - Hook payload shapes: `@luckystack/core` `HookPayloads`
250
- - Status-code mapping source: `@luckystack/core` `defaultHttpStatusForResponse`
251
- - Locale extraction: `@luckystack/core` `extractLanguageFromHeader`, `normalizeErrorResponse`
252
- - Project config: `projectConfig.rateLimiting`, `projectConfig.offlineQueue`, `projectConfig.logging`
1
+ # error-states
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 Sentry capture behavior.
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).
6
+
7
+ ---
8
+
9
+ ## 1. Error envelope shape
10
+
11
+ Every sync error — whether produced client-side before sending or server-side at any stage — collapses to the same shape after `normalizeErrorResponse`:
12
+
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
+ ```
22
+
23
+ `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The default i18n catalog ships built-in keys for every framework code; project-defined codes use whatever the project i18n 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.rateLimitExceeded` | Per-route or per-IP rate-limit bucket rejected | `429` | `[{ key: 'seconds', value: <retry in> }]` |
60
+ | `sync.invalidInputType` | `validateInputByType` rejected (Zod schema mismatch) | `400` | `[{ key: 'message', value: <readable reason> }]` |
61
+ | `sync.serverExecutionFailed` | `_server`'s `main(...)` threw (caught by `tryCatch`) | `500` | — |
62
+ | `<route-supplied>` (e.g. `board.cardNotFound`) | `_server` returned `{ status: 'error', errorCode }` | `defaultHttpStatusForResponse` or as-provided | as-provided |
63
+ | `sync.invalidServerResponse` | `_server` returned a value with `status` other than `'success'` / `'error'` | `500` | — |
64
+ | `sync.noReceiversFound` | Resolved sockets is `undefined` or empty | `404` | — |
65
+ | `<hook-supplied>` | `preSyncAuthorize` / `preSyncFanout` stop signal | from hook | from hook |
66
+
67
+ The `preSyncAuthorize` and `preSyncFanout` hooks can stop with any `errorCode` they choose; the framework normalizes the message via `normalizeErrorResponse` using the originator's locale.
68
+
69
+ ---
70
+
71
+ ## 4. Server-side error codes (per-recipient, fanout step)
72
+
73
+ 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.
74
+
75
+ | `errorCode` | Trigger | `httpStatus` | `errorParams` |
76
+ |---|---|---|---|
77
+ | `sync.clientExecutionFailed` | `_client`'s `main(...)` threw (caught by per-recipient `tryCatch`) | `500` | — |
78
+ | `<route-supplied>` (e.g. `chat.translationFailed`) | `_client` returned `{ status: 'error', errorCode }` | as-provided | as-provided |
79
+ | `sync.clientRejected` | `_client` returned `{ status: 'error' }` without an `errorCode` | `500` (default) | — |
80
+ | `sync.invalidClientResponse` | `_client` returned something other than `'success'` / `'error'` | `500` | — |
81
+
82
+ 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.
83
+
84
+ ---
85
+
86
+ ## 5. HTTP-status mapping
87
+
88
+ `httpStatus` defaults via `defaultHttpStatusForResponse(errorCode)` in `@luckystack/core`. Default rules (paraphrased — see core for exact source):
89
+
90
+ - `auth.required` -> `401`
91
+ - `auth.forbidden` and similar role/policy codes -> `403`
92
+ - Validation failures (`*.invalidRequest`, `*.invalidInputType`, `*.invalidName`, `routing.invalidServiceRouteName`) -> `400`
93
+ - Not-found (`*.notFound`, `sync.noReceiversFound`) -> `404`
94
+ - Rate limit (`*.rateLimitExceeded`) -> `429`
95
+ - Execution failures (`*.ExecutionFailed`, `*.invalid*Response`) -> `500`
96
+
97
+ 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.
98
+
99
+ ---
100
+
101
+ ## 6. `errorParams` localization slots
102
+
103
+ `errorParams` is an array of `{ key, value }` pairs interpolated into the i18n message template. Framework codes use:
104
+
105
+ - `sync.rateLimitExceeded` -> `[{ key: 'seconds', value: <number> }]` — the seconds until the next request is allowed. Template: "Rate limited, retry in {{seconds}}s".
106
+ - `sync.invalidInputType` -> `[{ key: 'message', value: <Zod reason> }]` — the Zod error message. Template: "Input validation failed: {{message}}".
107
+ - `sync.failedRequest` (dev notification) -> `[{ key: 'name', ...}, { key: 'message', ...}]`. Template: "Sync {{name}} failed: {{message}}".
108
+ - `routing.invalidServiceRouteName` -> `[{ key: 'name', value: <bad input> }]`. Template: "Invalid service route name: {{name}}".
109
+
110
+ Project codes can carry whatever slots their templates declare. The normalizer doesn't enforce a schema — it interpolates blindly.
111
+
112
+ ---
113
+
114
+ ## 7. `rateLimitExceeded` hook payload
115
+
116
+ Fired whenever a rate-limit bucket rejects a sync request. The payload differs by `scope`:
117
+
118
+ ### Scope `'user'` or `'route'` (per-route bucket, identified by token or by IP)
119
+
120
+ ```ts
121
+ {
122
+ scope: 'user' | 'route',
123
+ key: string, // 'token:<token>:sync:<routeName>' or 'ip:<ip>:sync:<routeName>'
124
+ limit: number, // projectConfig.rateLimiting.defaultApiLimit
125
+ windowMs: number, // projectConfig.rateLimiting.windowMs
126
+ count: number, // limit + 1 (the request that tipped over)
127
+ route: string, // e.g. 'board/moveCard/v1'
128
+ userId: string | undefined,
129
+ }
130
+ ```
131
+
132
+ `scope: 'user'` when the request carried a session token; `scope: 'route'` when it was anonymous (only an IP).
133
+
134
+ ### Scope `'ip'` (global per-IP bucket, across all sync routes)
135
+
136
+ ```ts
137
+ {
138
+ scope: 'ip',
139
+ key: string, // 'ip:<ip>:sync:all'
140
+ limit: number, // projectConfig.rateLimiting.defaultIpLimit
141
+ windowMs: number,
142
+ count: number,
143
+ ip: string,
144
+ }
145
+ ```
146
+
147
+ The hook is observation-only — there is no stop signal. The fanout has already been aborted by the time the hook fires.
148
+
149
+ Typical consumer: a mitigation system that bans the offending IP / user after N hook fires within a window.
150
+
151
+ ---
152
+
153
+ ## 8. Locale resolution
154
+
155
+ Both transports localize error messages through the same chain:
156
+
157
+ 1. **`preferredLocale`** — first non-empty match from headers:
158
+ - Socket: `socket.handshake.headers['x-language']` -> `socket.handshake.headers['accept-language']`.
159
+ - HTTP: `xLanguageHeader` argument -> `acceptLanguageHeader` argument.
160
+ 2. **`userLanguage`** — `user.language` from the resolved session.
161
+ 3. **Fallback** — `projectConfig.defaultLanguage`.
162
+
163
+ `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.
164
+
165
+ 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.
166
+
167
+ ---
168
+
169
+ ## 9. Sentry capture via `tryCatch`
170
+
171
+ 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.
172
+
173
+ Captured automatically:
174
+
175
+ - `_server` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'server', userId, receiver, transport: 'socket' | 'http' }`.
176
+ - `_client` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'client', sourceUserId, targetToken, receiver, transport }`.
177
+
178
+ NOT captured (returned as `status: 'error'` envelopes only):
179
+
180
+ - Validation failures (`sync.invalidInputType`) — these are caller errors, not server bugs.
181
+ - Auth rejects — these are policy outcomes.
182
+ - Rate-limit rejects — these are noise.
183
+ - `_server` / `_client` returning `{ status: 'error' }` voluntarily — the handler made a domain decision; nothing threw.
184
+
185
+ If you need to also Sentry-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`.
186
+
187
+ ---
188
+
189
+ ## 10. Offline queue overflow flow
190
+
191
+ When the socket is offline AND `dropPolicy: 'reject'` is active:
192
+
193
+ ```
194
+ syncRequest({ ..., offlineDropPolicy: 'reject' })
195
+ -> canSendNow(socket) === false
196
+ -> enqueueSyncRequest({ id, key, run, createdAt, dropPolicy: 'reject' })
197
+ -> queue full
198
+ -> enqueueSyncRequest returns false
199
+ -> resolve({
200
+ status: 'error',
201
+ errorCode: 'offline.queueFull',
202
+ message: <localized>,
203
+ httpStatus: undefined,
204
+ })
205
+ ```
206
+
207
+ 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).
208
+
209
+ `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".
210
+
211
+ ---
212
+
213
+ ## 11. Dev-only side effects
214
+
215
+ When `projectConfig.logging.devLogs === true`:
216
+
217
+ - Local validation failures (`sync.invalidName`, `sync.missingReceiver`, etc.) log via `getLogger().error(...)`.
218
+ - Server returning `{ status: 'error' }` logs the normalized message at `getLogger().error(...)`.
219
+ - Auth rejects, validation rejects, etc., warn-log at `getLogger().warn(...)`.
220
+
221
+ When `projectConfig.logging.devNotifications === true` AND on the client:
222
+
223
+ - Most local validation failures call `notify.error({ key: '<errorCode>' })`.
224
+
225
+ These are dev hints, not production behavior. Toggle both to `false` for production to avoid leaking diagnostic info into user-facing notifications.
226
+
227
+ ---
228
+
229
+ ## 12. Quick lookup by symptom
230
+
231
+ | Symptom | Most likely code | Where to look |
232
+ |---|---|---|
233
+ | `syncRequest` returns immediately with error | `sync.missingReceiver`, `sync.invalidVersion`, `sync.ioUnavailable` | Client-side validation, §2 |
234
+ | Request reached server but auth rejected | `auth.required`, `auth.forbidden` | `_server`'s `auth` export, [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md) |
235
+ | 429 rate-limit responses | `sync.rateLimitExceeded` | `projectConfig.rateLimiting` |
236
+ | Server-side schema fail | `sync.invalidInputType` | `_server`'s `SyncParams.clientInput` interface + generated Zod schema |
237
+ | Handler threw | `sync.serverExecutionFailed` / `sync.clientExecutionFailed` | Sentry breadcrumb |
238
+ | Domain logic rejected | `<route-supplied errorCode>` | The `_server` or `_client` file's return path |
239
+ | Empty room | `sync.noReceiversFound` | `receiver` argument; check room membership |
240
+ | Offline + caller didn't await | `offline.queueFull` | Bump `offlineQueue.maxSize` or switch `dropPolicy` |
241
+
242
+ ---
243
+
244
+ ## 13. Related
245
+
246
+ - Originator response envelope: [`./sync-request.md`](./sync-request.md)
247
+ - Pipeline / lifecycle: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
248
+ - Rate limiting hook + buckets: [`./room-fanout.md`](./room-fanout.md) §5
249
+ - Hook payload shapes: `@luckystack/core` `HookPayloads`
250
+ - Status-code mapping source: `@luckystack/core` `defaultHttpStatusForResponse`
251
+ - Locale extraction: `@luckystack/core` `extractLanguageFromHeader`, `normalizeErrorResponse`
252
+ - Project config: `projectConfig.rateLimiting`, `projectConfig.offlineQueue`, `projectConfig.logging`