@luckystack/sync 0.1.0 → 0.1.2

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,321 +1,321 @@
1
- # server-vs-client-handlers
2
-
3
- > A sync route is **two files**: a mandatory `_server_v{N}.ts` that runs **once per request** to validate and produce `serverOutput`, and an **optional** `_client_v{N}.ts` that runs **once per recipient socket** for per-target filtering, per-client auth, or producing a custom `clientOutput`. This doc covers the contract of both files and the rules for when to skip the `_client` file entirely.
4
-
5
- For the originator-side call signature see [`./sync-request.md`](./sync-request.md). For fanout mechanics see [`./room-fanout.md`](./room-fanout.md).
6
-
7
- ---
8
-
9
- ## 1. The two-file model
10
-
11
- Given the file-based router (see `/docs/ARCHITECTURE_ROUTING.md`):
12
-
13
- ```
14
- src/board/_sync/moveCard_server_v1.ts <- REQUIRED: validates + produces serverOutput (runs once)
15
- src/board/_sync/moveCard_client_v1.ts <- OPTIONAL: per-recipient logic (runs once per socket in the room)
16
- ```
17
-
18
- Route name: `board/moveCard` at version `v1`. The framework loads both files via `getRuntimeSyncMaps()` and keys them as `board/moveCard/v1_server` and `board/moveCard/v1_client`.
19
-
20
- A route is valid if **at least one** of the two files exists. If neither exists, `handleSyncRequest` rejects with `sync.notFound`.
21
-
22
- ---
23
-
24
- ## 2. `_server_v{N}.ts` contract — runs ONCE per request
25
-
26
- ```ts
27
- import type { SessionLayout, Functions } from '@luckystack/core';
28
- import type { AuthProps } from '@luckystack/login';
29
-
30
- export const auth: AuthProps = { login: true };
31
-
32
- export interface SyncParams {
33
- clientInput: { /* validated against generated Zod schema */ };
34
- user: SessionLayout | null; // null only when auth.login === false
35
- functions: Functions; // tryCatch, db, redis, notify, ...
36
- roomCode: string; // the `receiver` from syncRequest
37
- stream: (payload?: Record<string, unknown>) => void; // originator only
38
- broadcastStream: (payload?: Record<string, unknown>) => void; // entire room
39
- streamTo: (tokens: string | string[], payload?: Record<string, unknown>) => void;
40
- }
41
-
42
- export const main = async ({ clientInput, user, functions, roomCode, broadcastStream }: SyncParams) => {
43
- // 1. Mutate persistent state.
44
- await functions.db.card.update({ where: { id: clientInput.cardId }, data: { laneId: clientInput.toLane } });
45
-
46
- // 2. Optionally stream progress / tokens / diffs (broadcastStream goes to everyone in roomCode).
47
- // 3. Return the success envelope. Everything except `status` becomes `serverOutput` for the recipients.
48
- return { status: 'success', cardId: clientInput.cardId, movedBy: user!.id };
49
- };
50
- ```
51
-
52
- Key rules:
53
-
54
- - **`auth: AuthProps` is mandatory.** Drives the framework's auth gate (`auth.login` -> require session, `auth.additional` -> run `validateRequest` predicates). See `@luckystack/login` `AuthProps`.
55
- - **Return must include `status: 'success' | 'error'`.** Anything else collapses to `sync.invalidServerResponse`. The rest of the object is forwarded as `serverOutput` (after stripping `status`).
56
- - **Streaming primitives are received as params.** See [`./streaming.md`](./streaming.md) for the audience matrix.
57
- - **Never `try/catch` manually.** Wrap async ops in `functions.tryCatch` (or the destructured `tryCatch` from `functions`) — the framework already wraps the whole `main()` in its own `tryCatch` for Sentry capture, but per-operation `tryCatch` lets you surface targeted error codes.
58
-
59
- ---
60
-
61
- ## 3. `_client_v{N}.ts` contract — runs ONCE per recipient socket
62
-
63
- ```ts
64
- import type { Functions } from '@luckystack/core';
65
-
66
- export interface SyncClientParams {
67
- clientInput: { /* same shape as _server */ };
68
- token: string | null; // recipient's session token (NOT the sender's)
69
- functions: Functions;
70
- serverOutput: unknown; // exactly what _server returned (minus `status`)
71
- roomCode: string;
72
- stream: (payload?: Record<string, unknown>) => void; // per-recipient stream
73
- }
74
-
75
- export const main = async ({ clientInput, token, functions, serverOutput, roomCode }: SyncClientParams) => {
76
- // 1. Optionally fetch this recipient's session — DO NOT receive `user`, only `token`.
77
- // The framework deliberately does not pre-resolve sessions per recipient because
78
- // most clients do not need them. Resolving N sessions for a 50-user room is wasteful.
79
- const recipient = await functions.session.getSession(token);
80
- if (!recipient) return { status: 'success' }; // anonymous viewer — leave as-is
81
-
82
- // 2. Filter / brand / translate based on the recipient.
83
- if (recipient.role === 'guest' && serverOutput.privateNotes) {
84
- return { status: 'success', visibleNotes: null }; // strip the private field
85
- }
86
-
87
- // 3. Return a `clientOutput` envelope. Same status rules as _server.
88
- return { status: 'success', visibleNotes: serverOutput.privateNotes };
89
- };
90
- ```
91
-
92
- Key rules:
93
-
94
- - **No `user` param.** Receives `token` instead. Call `functions.session.getSession(token)` only when you actually need the session — most `_client` files don't.
95
- - **Cannot validate or reject the request.** Validation already happened in `_server`. `_client` only customizes per-recipient output. Its error path emits `sync.clientExecutionFailed` / `sync.invalidClientResponse` / `sync.clientRejected` to that specific recipient, but does NOT abort the fanout to the others.
96
- - **No `broadcastStream` / `streamTo`.** A per-recipient handler cannot fanout to other recipients — that contract is server-scope only.
97
-
98
- ---
99
-
100
- ## 4. When to create `_client_v{N}.ts`
101
-
102
- | Goal | Need `_client`? | Why |
103
- |---|---|---|
104
- | Mutate state, tell the room what changed | No | `serverOutput` reaches every recipient unchanged. |
105
- | Hide a field from non-owner viewers | Yes | Per-recipient filter. |
106
- | Brand the payload (translate strings, swap CDN host, inject feature flags) | Yes | Per-recipient customization. |
107
- | Per-recipient auth (e.g. "guests get a redacted version") | Yes | Use `token` -> `getSession` -> branch. |
108
- | Per-recipient stream (different chunks per viewer) | Yes | Only `_client` has the per-target `stream(...)` primitive. |
109
- | Just return `{ status: 'success' }` | **No — delete the file** | Pure overhead: framework already emits success with `clientOutput: {}` when `_client` is absent. |
110
-
111
- ### The "empty client" anti-pattern
112
-
113
- ```ts
114
- // BAD — adds avoidable per-recipient await + tryCatch + emit overhead for every socket in the room.
115
- export const main = async () => ({ status: 'success' });
116
- ```
117
-
118
- If the file would only return `{ status: 'success' }`, leave it out. The framework's no-client branch emits:
119
-
120
- ```ts
121
- {
122
- cb,
123
- fullName: resolvedName,
124
- serverOutput,
125
- clientOutput: {},
126
- message: `${resolvedName} sync success`,
127
- status: 'success',
128
- }
129
- ```
130
-
131
- …directly to each recipient with zero per-recipient handler invocation. That path is materially cheaper, especially when `receiver: 'all'`.
132
-
133
- Rule of thumb: a `_client` file exists **only** when it makes a decision the server cannot make once.
134
-
135
- ---
136
-
137
- ## 5. Full lifecycle of `handleSyncRequest`
138
-
139
- ```
140
- incoming msg (Socket.io 'sync' event)
141
- |
142
- v
143
- 1. validate msg shape -> sync.invalidRequest
144
- 2. parseTransportRouteName(name) -> routing.invalidServiceRouteName
145
- 3. validate cb -> sync.invalidCallback
146
- 4. validate receiver -> sync.missingReceiver
147
- 5. getSession(token) -> setSentryUser(...)
148
- 6. getRuntimeSyncMaps()
149
- no _server AND no _client -> sync.notFound
150
- |
151
- v
152
- 7. If _server exists:
153
- AuthProps gate (auth.login) -> auth.required
154
- validateRequest(auth.additional) -> auth.forbidden
155
- 8. dispatchHook('preSyncAuthorize') -> stop signal becomes error envelope
156
- 9. applySyncRateLimits() -> sync.rateLimitExceeded
157
- 10. If _server exists:
158
- validateInputByType(clientInput) -> sync.invalidInputType
159
- tryCatch(serverMain(...)) -> sync.serverExecutionFailed
160
- status !== 'success' | 'error' -> sync.invalidServerResponse
161
- status === 'error' -> server's errorCode (normalized)
162
- status === 'success' -> serverOutput = result (minus status)
163
- 11. Resolve recipients:
164
- receiver === 'all' -> io.sockets.sockets (Map of every connected socket)
165
- otherwise -> io.sockets.adapter.rooms.get(receiver) (Set of IDs)
166
- no sockets found -> sync.noReceiversFound
167
- 12. dispatchHook('preSyncFanout') -> stop signal becomes error envelope
168
- 13. Per-recipient loop (with periodic event-loop yield):
169
- if (ignoreSelf && token === recipientToken) continue
170
- recipientCount++
171
- if (_client exists):
172
- tryCatch(clientHandler(...)) -> sync.clientExecutionFailed to that recipient
173
- status='error' -> normalized error to that recipient
174
- status='success' -> emit { serverOutput, clientOutput, ... }
175
- else:
176
- emit { serverOutput, clientOutput: {}, ... }
177
- 14. dispatchHook('postSyncFanout', { recipientCount })
178
- 15. ack originator: emit(buildSyncResponseEventName(responseIndex), { status, result: serverOutput })
179
- ```
180
-
181
- Per-recipient failures inside step 13 do **not** abort the fanout — every other recipient still receives the merged payload. The originator's ack always reflects the `_server` outcome, never the per-recipient outcomes.
182
-
183
- ---
184
-
185
- ## 6. Failure-mode matrix
186
-
187
- | Stage | Trigger | Error code | Visibility |
188
- |---|---|---|---|
189
- | Msg shape | Non-object `msg` | `sync.invalidRequest` | originator ack |
190
- | Routing | Bad `name` shape | `routing.invalidServiceRouteName` | originator ack |
191
- | Routing | No `cb` | `sync.invalidCallback` | originator ack |
192
- | Routing | No receiver | `sync.missingReceiver` | originator ack |
193
- | Route lookup | No `_server` AND no `_client` | `sync.notFound` | originator ack |
194
- | Auth | `auth.login` + no session | `auth.required` | originator ack |
195
- | Auth | `validateRequest` reject | `auth.forbidden` (or specific predicate code) | originator ack |
196
- | Auth | `preSyncAuthorize` stop | hook's `errorCode` | originator ack |
197
- | Rate limit | Per-route or per-IP bucket | `sync.rateLimitExceeded` | originator ack |
198
- | Validation | Zod fail | `sync.invalidInputType` | originator ack |
199
- | Server execution | Thrown | `sync.serverExecutionFailed` | originator ack |
200
- | Server return | `status: 'error'` from `_server` | route-supplied `errorCode` | originator ack |
201
- | Server return | Anything other than `success`/`error` | `sync.invalidServerResponse` | originator ack |
202
- | Fanout | No sockets in room | `sync.noReceiversFound` | originator ack |
203
- | Fanout | `preSyncFanout` stop | hook's `errorCode` | originator ack |
204
- | Per-recipient | `_client` thrown | `sync.clientExecutionFailed` | that recipient only |
205
- | Per-recipient | `_client` returned `status: 'error'` | route code or `sync.clientRejected` | that recipient only |
206
- | Per-recipient | `_client` returned non-success | `sync.invalidClientResponse` | that recipient only |
207
-
208
- Full catalog including HTTP-status mapping in [`./error-states.md`](./error-states.md).
209
-
210
- ---
211
-
212
- ## 7. Example A — server-only sync (no `_client`)
213
-
214
- ```ts
215
- // src/notifications/_sync/markRead_server_v1.ts
216
- export const auth = { login: true };
217
-
218
- export interface SyncParams {
219
- clientInput: { notificationId: string };
220
- user: SessionLayout;
221
- functions: Functions;
222
- roomCode: string;
223
- }
224
-
225
- export const main = async ({ clientInput, user, functions }: SyncParams) => {
226
- await functions.db.notification.update({
227
- where: { id: clientInput.notificationId, userId: user.id },
228
- data: { readAt: new Date() },
229
- });
230
- return { status: 'success', notificationId: clientInput.notificationId };
231
- };
232
- ```
233
-
234
- No `_client` file. Every recipient in `roomCode` receives `{ serverOutput: { notificationId }, clientOutput: {} }`.
235
-
236
- ---
237
-
238
- ## 8. Example B — server + client per-recipient filter
239
-
240
- ```ts
241
- // src/board/_sync/showCard_server_v1.ts
242
- export const auth = { login: true };
243
-
244
- export const main = async ({ clientInput, functions }: SyncParams) => {
245
- const card = await functions.db.card.findUnique({ where: { id: clientInput.cardId } });
246
- if (!card) return { status: 'error', errorCode: 'board.cardNotFound' };
247
- return { status: 'success', card };
248
- };
249
- ```
250
-
251
- ```ts
252
- // src/board/_sync/showCard_client_v1.ts — strip privateNotes from non-owners
253
- export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
254
- const recipient = await functions.session.getSession(token);
255
- if (!recipient) return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
256
-
257
- if (recipient.id !== serverOutput.card.ownerId) {
258
- return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
259
- }
260
- return { status: 'success', card: serverOutput.card };
261
- };
262
- ```
263
-
264
- Recipients see:
265
-
266
- - Owner: `{ serverOutput, clientOutput: { card: { privateNotes: '...' } } }`
267
- - Anyone else: `{ serverOutput, clientOutput: { card: { privateNotes: null } } }`
268
-
269
- Note: `serverOutput` is the same payload for every recipient — only `clientOutput` differs. The client-side handler chooses which to render.
270
-
271
- ---
272
-
273
- ## 9. Example C — server + client custom `clientOutput` (translation)
274
-
275
- ```ts
276
- // src/chat/_sync/announce_server_v1.ts
277
- export const main = async ({ clientInput }: SyncParams) => ({
278
- status: 'success',
279
- i18nKey: clientInput.i18nKey,
280
- params: clientInput.params,
281
- });
282
- ```
283
-
284
- ```ts
285
- // src/chat/_sync/announce_client_v1.ts — pre-translate per recipient locale
286
- export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
287
- const recipient = await functions.session.getSession(token);
288
- const locale = recipient?.language ?? 'en';
289
- const text = functions.translator.translate({ key: serverOutput.i18nKey, params: serverOutput.params, locale });
290
- return { status: 'success', text };
291
- };
292
- ```
293
-
294
- This is the canonical reason to add `_client`: the translation cost is per-locale, the source key is shared, and centralizing it in `_client` keeps the room mutation single-source.
295
-
296
- ---
297
-
298
- ## 10. Related hooks
299
-
300
- | Hook | Stage | Stop semantics |
301
- |---|---|---|
302
- | `preSyncAuthorize` | After `AuthProps` gate, before rate-limit | Stop -> originator error envelope with hook's `errorCode` |
303
- | `preSyncFanout` | After `_server` runs, before any recipient receives | Stop -> originator error envelope; nobody gets the payload |
304
- | `postSyncFanout` | After all recipients emitted | Observation-only; receives `recipientCount` |
305
- | `rateLimitExceeded` | When per-route or per-IP bucket rejects | Observation-only |
306
- | `preSyncStream` / `postSyncStream` | Per stream chunk emit | Observation-only |
307
-
308
- Hook payload shapes live in `@luckystack/core` (`HookPayloads`).
309
-
310
- ---
311
-
312
- ## 11. Related
313
-
314
- - Originator API: [`./sync-request.md`](./sync-request.md)
315
- - Streaming primitives: [`./streaming.md`](./streaming.md)
316
- - Room mechanics + fanout hooks: [`./room-fanout.md`](./room-fanout.md)
317
- - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
318
- - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
319
- - Version coexistence: [`./version-policy.md`](./version-policy.md)
320
- - Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md)
321
- - Routing conventions: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md)
1
+ # server-vs-client-handlers
2
+
3
+ > A sync route is **two files**: a mandatory `_server_v{N}.ts` that runs **once per request** to validate and produce `serverOutput`, and an **optional** `_client_v{N}.ts` that runs **once per recipient socket** for per-target filtering, per-client auth, or producing a custom `clientOutput`. This doc covers the contract of both files and the rules for when to skip the `_client` file entirely.
4
+
5
+ For the originator-side call signature see [`./sync-request.md`](./sync-request.md). For fanout mechanics see [`./room-fanout.md`](./room-fanout.md).
6
+
7
+ ---
8
+
9
+ ## 1. The two-file model
10
+
11
+ Given the file-based router (see `/docs/ARCHITECTURE_ROUTING.md`):
12
+
13
+ ```
14
+ src/board/_sync/moveCard_server_v1.ts <- REQUIRED: validates + produces serverOutput (runs once)
15
+ src/board/_sync/moveCard_client_v1.ts <- OPTIONAL: per-recipient logic (runs once per socket in the room)
16
+ ```
17
+
18
+ Route name: `board/moveCard` at version `v1`. The framework loads both files via `getRuntimeSyncMaps()` and keys them as `board/moveCard/v1_server` and `board/moveCard/v1_client`.
19
+
20
+ A route is valid if **at least one** of the two files exists. If neither exists, `handleSyncRequest` rejects with `sync.notFound`.
21
+
22
+ ---
23
+
24
+ ## 2. `_server_v{N}.ts` contract — runs ONCE per request
25
+
26
+ ```ts
27
+ import type { SessionLayout, Functions } from '@luckystack/core';
28
+ import type { AuthProps } from '@luckystack/login';
29
+
30
+ export const auth: AuthProps = { login: true };
31
+
32
+ export interface SyncParams {
33
+ clientInput: { /* validated against generated Zod schema */ };
34
+ user: SessionLayout | null; // null only when auth.login === false
35
+ functions: Functions; // tryCatch, db, redis, notify, ...
36
+ roomCode: string; // the `receiver` from syncRequest
37
+ stream: (payload?: Record<string, unknown>) => void; // originator only
38
+ broadcastStream: (payload?: Record<string, unknown>) => void; // entire room
39
+ streamTo: (tokens: string | string[], payload?: Record<string, unknown>) => void;
40
+ }
41
+
42
+ export const main = async ({ clientInput, user, functions, roomCode, broadcastStream }: SyncParams) => {
43
+ // 1. Mutate persistent state.
44
+ await functions.db.card.update({ where: { id: clientInput.cardId }, data: { laneId: clientInput.toLane } });
45
+
46
+ // 2. Optionally stream progress / tokens / diffs (broadcastStream goes to everyone in roomCode).
47
+ // 3. Return the success envelope. Everything except `status` becomes `serverOutput` for the recipients.
48
+ return { status: 'success', cardId: clientInput.cardId, movedBy: user!.id };
49
+ };
50
+ ```
51
+
52
+ Key rules:
53
+
54
+ - **`auth: AuthProps` is mandatory.** Drives the framework's auth gate (`auth.login` -> require session, `auth.additional` -> run `validateRequest` predicates). See `@luckystack/login` `AuthProps`.
55
+ - **Return must include `status: 'success' | 'error'`.** Anything else collapses to `sync.invalidServerResponse`. The rest of the object is forwarded as `serverOutput` (after stripping `status`).
56
+ - **Streaming primitives are received as params.** See [`./streaming.md`](./streaming.md) for the audience matrix.
57
+ - **Never `try/catch` manually.** Wrap async ops in `functions.tryCatch` (or the destructured `tryCatch` from `functions`) — the framework already wraps the whole `main()` in its own `tryCatch` for Sentry capture, but per-operation `tryCatch` lets you surface targeted error codes.
58
+
59
+ ---
60
+
61
+ ## 3. `_client_v{N}.ts` contract — runs ONCE per recipient socket
62
+
63
+ ```ts
64
+ import type { Functions } from '@luckystack/core';
65
+
66
+ export interface SyncClientParams {
67
+ clientInput: { /* same shape as _server */ };
68
+ token: string | null; // recipient's session token (NOT the sender's)
69
+ functions: Functions;
70
+ serverOutput: unknown; // exactly what _server returned (minus `status`)
71
+ roomCode: string;
72
+ stream: (payload?: Record<string, unknown>) => void; // per-recipient stream
73
+ }
74
+
75
+ export const main = async ({ clientInput, token, functions, serverOutput, roomCode }: SyncClientParams) => {
76
+ // 1. Optionally fetch this recipient's session — DO NOT receive `user`, only `token`.
77
+ // The framework deliberately does not pre-resolve sessions per recipient because
78
+ // most clients do not need them. Resolving N sessions for a 50-user room is wasteful.
79
+ const recipient = await functions.session.getSession(token);
80
+ if (!recipient) return { status: 'success' }; // anonymous viewer — leave as-is
81
+
82
+ // 2. Filter / brand / translate based on the recipient.
83
+ if (recipient.role === 'guest' && serverOutput.privateNotes) {
84
+ return { status: 'success', visibleNotes: null }; // strip the private field
85
+ }
86
+
87
+ // 3. Return a `clientOutput` envelope. Same status rules as _server.
88
+ return { status: 'success', visibleNotes: serverOutput.privateNotes };
89
+ };
90
+ ```
91
+
92
+ Key rules:
93
+
94
+ - **No `user` param.** Receives `token` instead. Call `functions.session.getSession(token)` only when you actually need the session — most `_client` files don't.
95
+ - **Cannot validate or reject the request.** Validation already happened in `_server`. `_client` only customizes per-recipient output. Its error path emits `sync.clientExecutionFailed` / `sync.invalidClientResponse` / `sync.clientRejected` to that specific recipient, but does NOT abort the fanout to the others.
96
+ - **No `broadcastStream` / `streamTo`.** A per-recipient handler cannot fanout to other recipients — that contract is server-scope only.
97
+
98
+ ---
99
+
100
+ ## 4. When to create `_client_v{N}.ts`
101
+
102
+ | Goal | Need `_client`? | Why |
103
+ |---|---|---|
104
+ | Mutate state, tell the room what changed | No | `serverOutput` reaches every recipient unchanged. |
105
+ | Hide a field from non-owner viewers | Yes | Per-recipient filter. |
106
+ | Brand the payload (translate strings, swap CDN host, inject feature flags) | Yes | Per-recipient customization. |
107
+ | Per-recipient auth (e.g. "guests get a redacted version") | Yes | Use `token` -> `getSession` -> branch. |
108
+ | Per-recipient stream (different chunks per viewer) | Yes | Only `_client` has the per-target `stream(...)` primitive. |
109
+ | Just return `{ status: 'success' }` | **No — delete the file** | Pure overhead: framework already emits success with `clientOutput: {}` when `_client` is absent. |
110
+
111
+ ### The "empty client" anti-pattern
112
+
113
+ ```ts
114
+ // BAD — adds avoidable per-recipient await + tryCatch + emit overhead for every socket in the room.
115
+ export const main = async () => ({ status: 'success' });
116
+ ```
117
+
118
+ If the file would only return `{ status: 'success' }`, leave it out. The framework's no-client branch emits:
119
+
120
+ ```ts
121
+ {
122
+ cb,
123
+ fullName: resolvedName,
124
+ serverOutput,
125
+ clientOutput: {},
126
+ message: `${resolvedName} sync success`,
127
+ status: 'success',
128
+ }
129
+ ```
130
+
131
+ …directly to each recipient with zero per-recipient handler invocation. That path is materially cheaper, especially when `receiver: 'all'`.
132
+
133
+ Rule of thumb: a `_client` file exists **only** when it makes a decision the server cannot make once.
134
+
135
+ ---
136
+
137
+ ## 5. Full lifecycle of `handleSyncRequest`
138
+
139
+ ```
140
+ incoming msg (Socket.io 'sync' event)
141
+ |
142
+ v
143
+ 1. validate msg shape -> sync.invalidRequest
144
+ 2. parseTransportRouteName(name) -> routing.invalidServiceRouteName
145
+ 3. validate cb -> sync.invalidCallback
146
+ 4. validate receiver -> sync.missingReceiver
147
+ 5. getSession(token) -> setSentryUser(...)
148
+ 6. getRuntimeSyncMaps()
149
+ no _server AND no _client -> sync.notFound
150
+ |
151
+ v
152
+ 7. If _server exists:
153
+ AuthProps gate (auth.login) -> auth.required
154
+ validateRequest(auth.additional) -> auth.forbidden
155
+ 8. dispatchHook('preSyncAuthorize') -> stop signal becomes error envelope
156
+ 9. applySyncRateLimits() -> sync.rateLimitExceeded
157
+ 10. If _server exists:
158
+ validateInputByType(clientInput) -> sync.invalidInputType
159
+ tryCatch(serverMain(...)) -> sync.serverExecutionFailed
160
+ status !== 'success' | 'error' -> sync.invalidServerResponse
161
+ status === 'error' -> server's errorCode (normalized)
162
+ status === 'success' -> serverOutput = result (minus status)
163
+ 11. Resolve recipients:
164
+ receiver === 'all' -> io.sockets.sockets (Map of every connected socket)
165
+ otherwise -> io.sockets.adapter.rooms.get(receiver) (Set of IDs)
166
+ no sockets found -> sync.noReceiversFound
167
+ 12. dispatchHook('preSyncFanout') -> stop signal becomes error envelope
168
+ 13. Per-recipient loop (with periodic event-loop yield):
169
+ if (ignoreSelf && token === recipientToken) continue
170
+ recipientCount++
171
+ if (_client exists):
172
+ tryCatch(clientHandler(...)) -> sync.clientExecutionFailed to that recipient
173
+ status='error' -> normalized error to that recipient
174
+ status='success' -> emit { serverOutput, clientOutput, ... }
175
+ else:
176
+ emit { serverOutput, clientOutput: {}, ... }
177
+ 14. dispatchHook('postSyncFanout', { recipientCount })
178
+ 15. ack originator: emit(buildSyncResponseEventName(responseIndex), { status, result: serverOutput })
179
+ ```
180
+
181
+ Per-recipient failures inside step 13 do **not** abort the fanout — every other recipient still receives the merged payload. The originator's ack always reflects the `_server` outcome, never the per-recipient outcomes.
182
+
183
+ ---
184
+
185
+ ## 6. Failure-mode matrix
186
+
187
+ | Stage | Trigger | Error code | Visibility |
188
+ |---|---|---|---|
189
+ | Msg shape | Non-object `msg` | `sync.invalidRequest` | originator ack |
190
+ | Routing | Bad `name` shape | `routing.invalidServiceRouteName` | originator ack |
191
+ | Routing | No `cb` | `sync.invalidCallback` | originator ack |
192
+ | Routing | No receiver | `sync.missingReceiver` | originator ack |
193
+ | Route lookup | No `_server` AND no `_client` | `sync.notFound` | originator ack |
194
+ | Auth | `auth.login` + no session | `auth.required` | originator ack |
195
+ | Auth | `validateRequest` reject | `auth.forbidden` (or specific predicate code) | originator ack |
196
+ | Auth | `preSyncAuthorize` stop | hook's `errorCode` | originator ack |
197
+ | Rate limit | Per-route or per-IP bucket | `sync.rateLimitExceeded` | originator ack |
198
+ | Validation | Zod fail | `sync.invalidInputType` | originator ack |
199
+ | Server execution | Thrown | `sync.serverExecutionFailed` | originator ack |
200
+ | Server return | `status: 'error'` from `_server` | route-supplied `errorCode` | originator ack |
201
+ | Server return | Anything other than `success`/`error` | `sync.invalidServerResponse` | originator ack |
202
+ | Fanout | No sockets in room | `sync.noReceiversFound` | originator ack |
203
+ | Fanout | `preSyncFanout` stop | hook's `errorCode` | originator ack |
204
+ | Per-recipient | `_client` thrown | `sync.clientExecutionFailed` | that recipient only |
205
+ | Per-recipient | `_client` returned `status: 'error'` | route code or `sync.clientRejected` | that recipient only |
206
+ | Per-recipient | `_client` returned non-success | `sync.invalidClientResponse` | that recipient only |
207
+
208
+ Full catalog including HTTP-status mapping in [`./error-states.md`](./error-states.md).
209
+
210
+ ---
211
+
212
+ ## 7. Example A — server-only sync (no `_client`)
213
+
214
+ ```ts
215
+ // src/notifications/_sync/markRead_server_v1.ts
216
+ export const auth = { login: true };
217
+
218
+ export interface SyncParams {
219
+ clientInput: { notificationId: string };
220
+ user: SessionLayout;
221
+ functions: Functions;
222
+ roomCode: string;
223
+ }
224
+
225
+ export const main = async ({ clientInput, user, functions }: SyncParams) => {
226
+ await functions.db.notification.update({
227
+ where: { id: clientInput.notificationId, userId: user.id },
228
+ data: { readAt: new Date() },
229
+ });
230
+ return { status: 'success', notificationId: clientInput.notificationId };
231
+ };
232
+ ```
233
+
234
+ No `_client` file. Every recipient in `roomCode` receives `{ serverOutput: { notificationId }, clientOutput: {} }`.
235
+
236
+ ---
237
+
238
+ ## 8. Example B — server + client per-recipient filter
239
+
240
+ ```ts
241
+ // src/board/_sync/showCard_server_v1.ts
242
+ export const auth = { login: true };
243
+
244
+ export const main = async ({ clientInput, functions }: SyncParams) => {
245
+ const card = await functions.db.card.findUnique({ where: { id: clientInput.cardId } });
246
+ if (!card) return { status: 'error', errorCode: 'board.cardNotFound' };
247
+ return { status: 'success', card };
248
+ };
249
+ ```
250
+
251
+ ```ts
252
+ // src/board/_sync/showCard_client_v1.ts — strip privateNotes from non-owners
253
+ export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
254
+ const recipient = await functions.session.getSession(token);
255
+ if (!recipient) return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
256
+
257
+ if (recipient.id !== serverOutput.card.ownerId) {
258
+ return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
259
+ }
260
+ return { status: 'success', card: serverOutput.card };
261
+ };
262
+ ```
263
+
264
+ Recipients see:
265
+
266
+ - Owner: `{ serverOutput, clientOutput: { card: { privateNotes: '...' } } }`
267
+ - Anyone else: `{ serverOutput, clientOutput: { card: { privateNotes: null } } }`
268
+
269
+ Note: `serverOutput` is the same payload for every recipient — only `clientOutput` differs. The client-side handler chooses which to render.
270
+
271
+ ---
272
+
273
+ ## 9. Example C — server + client custom `clientOutput` (translation)
274
+
275
+ ```ts
276
+ // src/chat/_sync/announce_server_v1.ts
277
+ export const main = async ({ clientInput }: SyncParams) => ({
278
+ status: 'success',
279
+ i18nKey: clientInput.i18nKey,
280
+ params: clientInput.params,
281
+ });
282
+ ```
283
+
284
+ ```ts
285
+ // src/chat/_sync/announce_client_v1.ts — pre-translate per recipient locale
286
+ export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
287
+ const recipient = await functions.session.getSession(token);
288
+ const locale = recipient?.language ?? 'en';
289
+ const text = functions.translator.translate({ key: serverOutput.i18nKey, params: serverOutput.params, locale });
290
+ return { status: 'success', text };
291
+ };
292
+ ```
293
+
294
+ This is the canonical reason to add `_client`: the translation cost is per-locale, the source key is shared, and centralizing it in `_client` keeps the room mutation single-source.
295
+
296
+ ---
297
+
298
+ ## 10. Related hooks
299
+
300
+ | Hook | Stage | Stop semantics |
301
+ |---|---|---|
302
+ | `preSyncAuthorize` | After `AuthProps` gate, before rate-limit | Stop -> originator error envelope with hook's `errorCode` |
303
+ | `preSyncFanout` | After `_server` runs, before any recipient receives | Stop -> originator error envelope; nobody gets the payload |
304
+ | `postSyncFanout` | After all recipients emitted | Observation-only; receives `recipientCount` |
305
+ | `rateLimitExceeded` | When per-route or per-IP bucket rejects | Observation-only |
306
+ | `preSyncStream` / `postSyncStream` | Per stream chunk emit | Observation-only |
307
+
308
+ Hook payload shapes live in `@luckystack/core` (`HookPayloads`).
309
+
310
+ ---
311
+
312
+ ## 11. Related
313
+
314
+ - Originator API: [`./sync-request.md`](./sync-request.md)
315
+ - Streaming primitives: [`./streaming.md`](./streaming.md)
316
+ - Room mechanics + fanout hooks: [`./room-fanout.md`](./room-fanout.md)
317
+ - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
318
+ - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
319
+ - Version coexistence: [`./version-policy.md`](./version-policy.md)
320
+ - Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md)
321
+ - Routing conventions: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md)